58 Commits

Author SHA1 Message Date
Torsten Schulz (local)
49e7255062 Enhance CSV saving functionality by adding token retrieval from authorization header if not present in cookies. Update tests to validate CSV saving for users with 'vorstand' role.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-18 13:12:32 +01:00
Torsten Schulz (local)
74246e6b08 Implement status toggle functionality for contact requests, updating the status display and adding error handling. Enhance the UI with a new button for marking requests as completed or reopening them.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-11 21:16:03 +01:00
Torsten Schulz (local)
6230c96bc9 Refactor links section to use dynamic rendering with computed properties, enhancing maintainability and scalability. Add new 'Links' tab in CMS for better navigation.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-03-04 16:05:34 +01:00
Torsten Schulz (local)
3fb40bd87d Erweitere die Navigation um einen neuen Link zu "Links" und aktualisiere die Logik zur Bestimmung des aktuellen Submenüs, um die neue Route zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-03-04 14:53:11 +01:00
Torsten Schulz (local)
46c2c14ae8 Füge Unterstützung für Kontaktanfragen hinzu, einschließlich neuer Routen und Berechtigungen für Trainer und Vorstand. Aktualisiere E-Mail-Versandlogik, um Anfragen an alle relevanten Empfänger weiterzuleiten.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-26 14:28:54 +01:00
Torsten Schulz (local)
ff8c1970df Ersetze Willkommensnachricht durch Geburtstags-Widget mit dynamischer Anzeige der nächsten Geburtstage
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 16:39:52 +01:00
Torsten Schulz (local)
8347a86727 Entferne die JSON-Darstellung des Mitglieds aus der Mitgliederansicht
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2026-02-14 16:26:58 +01:00
Torsten Schulz (local)
9a6d32dcb3 Füge ESM-Importe und Skriptbeschreibung für das Aufteilen von Namen in Benutzer- und Bewerbungsdateien hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 16:25:29 +01:00
Torsten Schulz (local)
161618f6fb Füge Skripte zum Aufteilen von Namen in firstName und lastName für Mitglieder und Bewerbungen hinzu, einschließlich Backup-Funktionalität.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 15:58:11 +01:00
Torsten Schulz (local)
0b3fba44a4 Füge Skript zum Aufteilen von Namen in firstName und lastName für Benutzer hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 15:50:37 +01:00
Torsten Schulz (local)
d35e1c9a3e Füge Vorname und Nachname in das Registrierungsformular und die Mitgliederverwaltung ein
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 15:48:56 +01:00
Torsten Schulz (local)
528353132a Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:45:51 +01:00
Torsten Schulz (local)
cd5e5cd781 Füge die Anzeige der Mitgliederdaten im JSON-Format in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:42:58 +01:00
Torsten Schulz (local)
ebbffcc5c4 Füge die Anzeige des Mitgliedsnamens in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:38:39 +01:00
Torsten Schulz (local)
5c760d7fa8 Füge Sichtbarkeits-Flags für E-Mail und Telefon in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-14 03:31:28 +01:00
Torsten Schulz (local)
d40073ac7b Füge Sichtbarkeits-Flags für E-Mail, Telefon, Adresse und Geburtstag in der Mitgliederansicht hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 03:23:59 +01:00
Torsten Schulz (local)
b25cf13d3c Füge Sichtbarkeits-Flags für Mitglieder hinzu, um die Anzeige von E-Mail, Telefon, Adresse und Geburtstag zu steuern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:16:28 +01:00
Torsten Schulz (local)
3287102761 Füge Vorname und Nachname zu den Mitgliederdaten hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2026-02-14 03:08:19 +01:00
Torsten Schulz (local)
08624cabbe Verbessere die Sichtbarkeit von Mitgliederdaten, indem das Geburtsdatum im Edit-Formular hinzugefügt wird
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 03:05:15 +01:00
Torsten Schulz (local)
d37f182928 Füge Skript hinzu, um Sichtbarkeitsflags für Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-14 02:58:30 +01:00
Torsten Schulz (local)
79c45be7c7 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:52:44 +01:00
Torsten Schulz (local)
d52f3ffc8d Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 02:50:57 +01:00
Torsten Schulz (local)
64baaf8535 Füge Skript hinzu, um das Sichtbarkeitsflag für Geburtstage aller Mitglieder auf true zu setzen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:48:30 +01:00
Torsten Schulz (local)
e665495003 Verbessere die Sortierlogik in der Mitgliederliste für Namen, Nachnamen und Geburtstage mit robusteren Vergleichen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:42:43 +01:00
Torsten Schulz (local)
8f444c59eb Füge Sortieroptionen zur Mitgliederliste hinzu und verbessere die Sortierung nach Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:36:26 +01:00
Torsten Schulz (local)
8117335af9 Entferne die Sortieroptionen und passe die Mitgliederanzeige an, um die Sortierung direkt aus der Mitgliederliste zu entfernen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2026-02-14 02:36:19 +01:00
Torsten Schulz (local)
85ec99b08c Optimiere das Template der Mitgliederliste durch Entfernen von überflüssigem Code und verbessere die Sortieroptionen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
2026-02-14 02:28:40 +01:00
Torsten Schulz (local)
04571e6444 Verbessere die Struktur des Templates in der Mitgliederliste und füge Sortieroptionen hinzu
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-14 02:22:52 +01:00
Torsten Schulz (local)
5799f97570 Entferne überflüssige Zeile im Template der Mitgliederliste
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 02:17:27 +01:00
Torsten Schulz (local)
8ab08f4c09 Füge Header und Sortieroptionen zur Mitgliederliste hinzu
Some checks are pending
Code Analysis (JS/Vue) / analyze (push) Has started running
2026-02-14 02:16:36 +01:00
Torsten Schulz (local)
fcf3168692 Entferne überflüssige geschweifte Klammer in der formatDate-Funktion
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 56s
2026-02-14 02:13:25 +01:00
Torsten Schulz (local)
cfd209d7ee Filtere den Admin-Account aus der Mitgliederliste heraus, um die Sichtbarkeit zu verbessern
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 02:07:45 +01:00
Torsten Schulz (local)
ee1709ffb2 Füge Sortieroptionen für Mitgliederliste hinzu und implementiere Sortierlogik nach Name, Nachname und Geburtstag
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:06:36 +01:00
Torsten Schulz (local)
8bb02b6e4a Füge dotenv-Konfiguration zum Skript hinzu, um Umgebungsvariablen zu laden
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-14 02:00:39 +01:00
Torsten Schulz (local)
7a20af2772 Füge active-Feld zu Mitgliedsdaten hinzu und implementiere Skript zum Aktivieren aller Mitglieder
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2026-02-14 01:54:39 +01:00
Torsten Schulz (local)
3e610e68b6 Füge Debug-Logs hinzu, um alle geladenen Mitglieder (decryptet) anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
2026-02-14 01:47:20 +01:00
Torsten Schulz (local)
485b21c13e Füge Diagnose-Skript hinzu, um Mitglieder aus members.json mit Status und Sichtbarkeit anzuzeigen
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
2026-02-14 01:43:18 +01:00
Torsten Schulz (local)
08b1edc354 Füge Skript zum Re-Encryptieren von Klartext-Mitgliedsanträgen hinzu; implementiere Backup-Funktion und Fehlerbehandlung
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-14 01:37:42 +01:00
Torsten Schulz (local)
6e297c682c Füge Geburtstags-Widget hinzu und implementiere Geburtstagsladefunktion; erweitere Sichtbarkeitseinstellungen für Geburtstage in Profil und API
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
2026-02-13 17:27:27 +01:00
Torsten Schulz (local)
3d3e22bb1b Implementiere zentralen E-Mail-Service für Registrierungsbenachrichtigungen und entferne veralteten Code
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-11 15:41:03 +01:00
Torsten Schulz (local)
d18b671532 Ändere Sichtbarkeitseinstellungen für Mitglieder: Standardmäßig sichtbar für alle eingeloggten Mitglieder, es sei denn, sie sind explizit verborgen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:37:13 +01:00
Torsten Schulz (local)
af3c0164ef Füge Sichtbarkeitsoptionen für Mitglieder und registrierte Benutzer hinzu; aktualisiere die Sichtbarkeitseinstellungen basierend auf Benutzerpräferenzen in der Mitgliederabfrage und dem Sichtbarkeits-Skript.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 50s
2026-02-11 14:25:49 +01:00
Torsten Schulz (local)
c681194462 Make visibility opt-in by default; coerce visibility booleans; only 'vorstand' overrides
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 14:10:54 +01:00
Torsten Schulz (local)
141a15a6cb Respect per-user visibility; only 'vorstand' overrides visibility; UI shows contactHidden per-member
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:27:24 +01:00
Torsten (PC)
ce5915a3bc fixed .gitignore
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2026-02-11 13:08:07 +01:00
Torsten Schulz (local)
677140bd33 Füge Sichtbarkeitspräferenzen für Mitgliederprofile hinzu: Ermögliche Benutzern, ihre E-Mail, Telefonnummer und Adresse für andere eingeloggte Mitglieder sichtbar zu machen. Aktualisiere die API, um diese Einstellungen zu respektieren und bei der Profildatenrückgabe zu berücksichtigen.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 13:04:45 +01:00
Torsten Schulz (local)
8a1e309eba Verbessere Mitgliederabfrage: Filtere manuelle Mitglieder nach aktiven/akzeptierten Status und entferne nicht benötigte Datenschutzlogik.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 44s
2026-02-11 12:49:14 +01:00
Torsten Schulz (local)
0d533710cd Refactor file handling to prioritize internal data directories for backups and uploads; enhance error handling and logging for metadata and CSV operations.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 47s
2026-02-11 11:42:24 +01:00
Torsten Schulz (local)
0fcf6ced0e Galerie: proxy + previews; uploads internal; membership PDF storage hardened; migration/preview scripts
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
2026-02-11 10:02:33 +01:00
Torsten Schulz (local)
9c1bcba713 Refactor Galerie component to use image IDs for keys and update image loading logic; add new scripts for generating previews and migrating public gallery to metadata with authentication checks. 2026-02-09 14:31:46 +01:00
Torsten Schulz (local)
74b28bbc49 Mitgliedschaft: 'Online ansehen' auf /verein/satzung verlinken 2026-02-09 11:42:03 +01:00
Torsten Schulz (local)
905e02debf Update CMS navigation links and remove membership application page
This commit modifies the Navigation component and the CMS index page to replace the "Mitglieder" link with "Mitgliederverwaltung" and updates the corresponding route. Additionally, it removes the outdated "mitgliedschaftsantraege" page, streamlining the CMS structure and improving user navigation.
2026-02-09 09:58:46 +01:00
Torsten Schulz (local)
80c2b0bfeb 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.
2026-02-09 09:37:11 +01:00
Torsten Schulz (local)
33ef5cda5f Improve Satzung content loading and HTML conversion process
This commit ensures that the Satzung content is loaded as a string, enhancing reliability. Additionally, it refines the HTML conversion function by improving the handling of line breaks, merging related lines, and removing empty paragraphs. These changes enhance the overall quality and readability of the generated HTML content.
2026-02-06 13:35:20 +01:00
Torsten Schulz (local)
f7fe8595a1 Add WYSIWYG text editor for Satzung content management
This commit introduces a new WYSIWYG text editor for editing the Satzung text directly within the CMS. It includes functionality for saving the edited content and displays a success or error message based on the save operation's outcome. Additionally, the layout has been updated to improve the presentation of the PDF upload section and current PDF information.
2026-02-06 12:57:51 +01:00
Torsten Schulz (local)
581e80bbc3 Enhance HTML conversion for Satzung uploads by removing page numbers and improving list handling
This commit updates the text-to-HTML conversion function to remove page numbers and footers from the extracted text. It also introduces enhanced handling for enumerated lists, allowing for better formatting of items with specific patterns (e.g., a), b), c)). These changes improve the overall quality and readability of the generated HTML content.
2026-02-06 11:58:23 +01:00
Torsten Schulz (local)
78aec7ce57 Implement PDF text extraction and HTML conversion in Satzung upload process
This commit introduces a new mechanism for extracting text from uploaded PDF files using pdftotext, followed by a basic plausibility check of the extracted content. If the text meets the criteria, it is converted to HTML format and stored in the configuration, replacing the previous static content handling. This enhancement improves the accuracy and reliability of the Satzung content management.
2026-02-06 11:39:41 +01:00
Torsten Schulz (local)
7346e84abd Refactor PDF text extraction and update configuration handling in Satzung upload process
This commit removes the PDF text extraction logic and replaces it with a fallback mechanism that retains existing content or provides a neutral message. The configuration update now only sets the PDF path without automatically generating HTML content, improving clarity and maintaining the integrity of the existing data.
2026-02-06 10:55:41 +01:00
87 changed files with 6046 additions and 4109 deletions

4
.gitignore vendored
View File

@@ -154,3 +154,7 @@ server/data/**
!server/data/.gitkeep
public/data/**
public/uploads/**
backups/*
public/data
server/data
public/uploads

View File

@@ -202,7 +202,7 @@
</p>
</form>
<p class="mt-4 text-sm text-gray-600 text-center">
Ihre Nachricht wird direkt an j.dichmann@gmx.de gesendet
Ihre Nachricht wird an den Vorstand und die Trainer weitergeleitet
</p>
</div>
</div>

View File

@@ -18,12 +18,12 @@
<div class="grid sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2">
<div
v-for="image in images"
:key="image.filename"
:key="image.id"
class="group relative w-20 h-20 rounded-md overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 cursor-pointer"
@click="openLightbox(image)"
>
<img
:src="`/galerie/${image.filename}`"
:src="getPreviewUrl(image)"
:alt="image.title"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
>
@@ -49,7 +49,7 @@
<X :size="24" />
</button>
<img
:src="`/galerie/${lightboxImage.filename}`"
:src="getOriginalUrl(lightboxImage)"
:alt="lightboxImage.title"
class="max-w-[80vw] max-h-[80vh] object-contain rounded-lg"
@click.stop
@@ -66,16 +66,19 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { X } from 'lucide-vue-next'
const images = ref([])
const lightboxImage = ref(null)
const getPreviewUrl = (img) => `/api/media/galerie/${img.id}?preview=true`
const getOriginalUrl = (img) => `/api/media/galerie/${img.id}`
const loadImages = async () => {
try {
const response = await $fetch('/api/galerie')
images.value = response || []
const response = await $fetch('/api/galerie/list')
images.value = response?.images || []
} catch (error) {
console.error('Fehler beim Laden der Galerie-Bilder:', error)
images.value = []

View File

@@ -109,7 +109,7 @@
</a>
<span class="text-sm text-gray-500">oder</span>
<NuxtLink
to="/satzung"
to="/verein/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
>
<Eye

View File

@@ -109,7 +109,7 @@
</a>
<span class="text-sm text-gray-500">oder</span>
<NuxtLink
to="/satzung"
to="/verein/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
>
<Eye

View File

@@ -36,7 +36,7 @@
<button
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
:class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''"
:class="(route.path.startsWith('/verein/') || route.path.startsWith('/vorstand') || route.path.startsWith('/vereinsmeisterschaften') || route.path.startsWith('/links') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''"
@click="toggleSubmenu('verein')"
>
Verein
@@ -177,6 +177,13 @@
>
Galerie
</NuxtLink>
<NuxtLink
to="/links"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Links
</NuxtLink>
</template>
<!-- Mannschaften Submenu -->
@@ -299,6 +306,16 @@
Newsletter
</NuxtLink>
</template>
<template v-if="canAccessContactRequests">
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/cms/kontaktanfragen"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Kontaktanfragen
</NuxtLink>
</template>
<template v-if="isAdmin">
<div class="h-3 w-px bg-primary-700" />
<div class="relative inline-block">
@@ -336,32 +353,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,32 +375,25 @@
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
Sportbetrieb
</NuxtLink>
<NuxtLink
to="/cms/mannschaften"
to="/cms/mitgliederverwaltung"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Mannschaften
Mitgliederverwaltung
</NuxtLink>
<NuxtLink
to="/cms/spielplaene"
to="/cms/kontaktanfragen"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Spielpläne
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/mitglieder"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Mitglieder
Kontaktanfragen
</NuxtLink>
<div class="border-t border-gray-700 my-1" />
<NuxtLink
@@ -414,13 +403,6 @@
>
Einstellungen
</NuxtLink>
<NuxtLink
to="/cms/mitgliedschaftsantraege"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Mitgliedschaftsanträge
</NuxtLink>
<NuxtLink
to="/cms/benutzer"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@@ -539,6 +521,13 @@
>
Galerie
</NuxtLink>
<NuxtLink
to="/links"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Links
</NuxtLink>
<NuxtLink
to="/newsletter/subscribe"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@@ -749,6 +738,16 @@
Newsletter
</NuxtLink>
</template>
<template v-if="canAccessContactRequests && !isAdmin">
<div class="border-t border-primary-700/20 my-2" />
<NuxtLink
to="/cms/kontaktanfragen"
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"
>
Kontaktanfragen
</NuxtLink>
</template>
<template v-if="isAdmin">
<div class="border-t border-primary-700/20 my-2" />
<NuxtLink
@@ -773,60 +772,32 @@
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
Sportbetrieb
</NuxtLink>
<NuxtLink
to="/cms/mannschaften"
to="/cms/mitgliederverwaltung"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Mannschaften
Mitgliederverwaltung
</NuxtLink>
<NuxtLink
to="/cms/spielplaene"
to="/cms/kontaktanfragen"
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
Kontaktanfragen
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/mitglieder"
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"
>
Mitglieder
</NuxtLink>
<NuxtLink
to="/cms/ueber-uns"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Über uns
</NuxtLink>
<NuxtLink
to="/cms/geschichte"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Geschichte
</NuxtLink>
<NuxtLink
to="/cms/tt-regeln"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
TT-Regeln
</NuxtLink>
<NuxtLink
to="/cms/satzung"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Satzung
Inhalte
</NuxtLink>
<NuxtLink
to="/cms/vereinsmeisterschaften"
@@ -842,13 +813,6 @@
>
Einstellungen
</NuxtLink>
<NuxtLink
to="/cms/mitgliedschaftsantraege"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Mitgliedschaftsanträge
</NuxtLink>
<NuxtLink
v-if="getAuthStore()?.hasAnyRole('admin', 'vorstand')"
to="/cms/benutzer"
@@ -909,11 +873,16 @@ const canAccessNewsletter = computed(() => {
const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
})
const canAccessContactRequests = computed(() => {
const store = getAuthStore()
return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false
})
// Automatisches Setzen des Submenus basierend auf der Route
const currentSubmenu = computed(() => {
const path = route.path
if (path.startsWith('/verein/') || path.startsWith('/vorstand') ||
path.startsWith('/links') ||
path.startsWith('/vereinsmeisterschaften')) {
return 'verein'
}
@@ -1033,7 +1002,7 @@ const toggleSubmenu = (menu) => {
if (menu === 'newsletter' && !path.startsWith('/newsletter')) {
navigateTo('/newsletter/subscribe')
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften')) {
} else if (menu === 'verein' && !path.startsWith('/verein/') && !path.startsWith('/vorstand') && !path.startsWith('/vereinsmeisterschaften') && !path.startsWith('/links')) {
navigateTo('/verein/ueber-uns')
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
navigateTo('/mannschaften')

View File

@@ -1,13 +1,10 @@
<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">
<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
</h1>
<div class="space-x-3">
</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"
@@ -15,17 +12,10 @@
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">
<!-- 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
@@ -127,12 +117,9 @@
</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">
<!-- 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"
@@ -140,26 +127,17 @@
/>
</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
const html = data?.seiten?.geschichte || ''
if (editor.value) editor.value.innerHTML = html
}
async function save() {
@@ -169,11 +147,11 @@ async function save() {
try {
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
// Modal nicht verfügbar, ignorieren
// Modal nicht verfügbar
}
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
// Modal nicht verfügbar, ignorieren
// Modal nicht verfügbar
}
}
}
@@ -241,24 +219,19 @@ function insertHistoryTemplate(type) {
break
}
// Editor fokussieren
editorElement.focus()
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// Prüfen ob der Cursor im Editor ist
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
// Aktuelles Element finden
let currentElement = range.commonAncestorContainer
// Falls es ein Text-Node ist, zum Parent-Element gehen
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement
}
// Zum Geschichts-Abschnitt navigieren (div mit border-l-4 border-primary-600)
let sectionElement = currentElement
while (sectionElement && sectionElement !== editorElement) {
if (sectionElement.classList &&
@@ -273,11 +246,9 @@ function insertHistoryTemplate(type) {
sectionElement.classList.contains('border-l-4') &&
sectionElement.classList.contains('border-primary-600')) {
// Wir sind in einem Geschichts-Abschnitt - neuen Abschnitt danach einfügen
const tempDiv = document.createElement('div')
tempDiv.innerHTML = template
// Suche nach dem ersten Element-Node (nicht Text-Node)
let newSection = null
for (let i = 0; i < tempDiv.childNodes.length; i++) {
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
@@ -287,14 +258,12 @@ function insertHistoryTemplate(type) {
}
if (newSection) {
// Nach dem aktuellen Abschnitt einfügen
if (sectionElement.nextSibling) {
sectionElement.parentElement.insertBefore(newSection, sectionElement.nextSibling)
} else {
sectionElement.parentElement.appendChild(newSection)
}
// Cursor in das neue Element setzen
const newRange = document.createRange()
const titleElement = newSection.querySelector('h3')
if (titleElement) {
@@ -303,13 +272,10 @@ function insertHistoryTemplate(type) {
selection.removeAllRanges()
selection.addRange(newRange)
}
} else {
console.error('No valid element found in template');
}
} else {
// Kein Geschichts-Abschnitt gefunden - suche nach dem nächsten Geschichts-Abschnitt
let nextSection = null
let walker = document.createTreeWalker(
const walker = document.createTreeWalker(
editorElement,
NodeFilter.SHOW_ELEMENT,
{
@@ -324,7 +290,6 @@ function insertHistoryTemplate(type) {
}
)
// Finde den ersten Geschichts-Abschnitt nach dem aktuellen Element
let node = walker.nextNode()
while (node) {
if (currentElement.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
@@ -337,7 +302,6 @@ function insertHistoryTemplate(type) {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = template
// Suche nach dem ersten Element-Node (nicht Text-Node)
let newSection = null
for (let i = 0; i < tempDiv.childNodes.length; i++) {
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
@@ -348,14 +312,11 @@ function insertHistoryTemplate(type) {
if (newSection) {
if (nextSection) {
// Vor dem nächsten Geschichts-Abschnitt einfügen
nextSection.parentElement.insertBefore(newSection, nextSection)
} else {
// Kein nächster Abschnitt gefunden - am Ende einfügen
editorElement.appendChild(newSection)
}
// Cursor in das neue Element setzen
const newRange = document.createRange()
const titleElement = newSection.querySelector('h3')
if (titleElement) {
@@ -364,16 +325,12 @@ function insertHistoryTemplate(type) {
selection.removeAllRanges()
selection.addRange(newRange)
}
} else {
console.error('No valid element found in template');
}
}
} else {
// Cursor ist nicht im Editor - Template am Ende einfügen
editorElement.innerHTML += template
}
} else {
// Keine Auswahl - Template am Ende einfügen
editorElement.innerHTML += template
}
}
@@ -382,34 +339,27 @@ function deleteCurrentSection() {
const editorElement = editor.value
if (!editorElement) return
// Editor fokussieren
editorElement.focus()
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// Prüfen ob der Cursor im Editor ist
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
// Aktuelles Element finden
let currentElement = range.commonAncestorContainer
// Falls es ein Text-Node ist, zum Parent-Element gehen
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement
}
// Zum Geschichts-Abschnitt navigieren (div mit border-l-4 border-primary-600)
let sectionElement = currentElement
while (sectionElement && !(sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600'))) {
sectionElement = sectionElement.parentElement
}
if (sectionElement && sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600')) {
// Geschichts-Abschnitt gefunden - löschen
sectionElement.remove()
// Cursor in das nächste Element setzen
const nextElement = editorElement.querySelector('.border-l-4.border-primary-600')
if (nextElement) {
const titleElement = nextElement.querySelector('h3')

303
components/cms/CmsLinks.vue Normal file
View File

@@ -0,0 +1,303 @@
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">
Links bearbeiten
</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="saving"
@click="save"
>
{{ saving ? 'Speichert...' : 'Speichern' }}
</button>
</div>
<p class="text-sm text-gray-500 mb-6">
Diese Übersicht wird auf der öffentlichen Seite als Karten dargestellt.
</p>
<div class="space-y-6">
<div
v-for="(section, sectionIndex) in sections"
:key="section.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center gap-3 mb-4">
<input
v-model="section.title"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Block-Titel"
>
<button
type="button"
class="px-3 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeSection(sectionIndex)"
>
Block löschen
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, itemIndex) in section.items"
:key="item.id"
class="grid md:grid-cols-12 gap-2"
>
<input
v-model="item.label"
type="text"
class="md:col-span-4 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Link-Text"
>
<input
v-model="item.href"
type="url"
class="md:col-span-5 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
>
<input
v-model="item.description"
type="text"
class="md:col-span-2 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Beschreibung (optional)"
>
<button
type="button"
class="md:col-span-1 px-2 py-2 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
@click="removeItem(sectionIndex, itemIndex)"
>
X
</button>
</div>
</div>
<div class="mt-3">
<button
type="button"
class="px-3 py-2 text-sm bg-gray-100 text-gray-800 rounded-lg hover:bg-gray-200"
@click="addItem(sectionIndex)"
>
Link hinzufügen
</button>
</div>
</div>
</div>
<div class="mt-6">
<button
type="button"
class="px-4 py-2 text-sm bg-primary-100 text-primary-800 rounded-lg hover:bg-primary-200"
@click="addSection"
>
Block hinzufügen
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const saving = ref(false)
const sections = ref([])
function createId() {
const c = globalThis?.crypto
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const defaultSections = [
{
id: createId(),
title: 'Ergebnisse & Portale',
items: [
{ id: createId(), label: 'MyTischtennis.de', href: 'http://www.mytischtennis.de/public/home', description: '(offizielle QTTR-Werte)' },
{ id: createId(), label: 'Click-tt Ergebnisse', href: 'http://httv.click-tt.de/', description: '(offizieller Ergebnisdienst HTTV)' },
{ id: createId(), label: 'Tischtennis Pur - das Tischtennis Portal', href: 'https://www.tischtennis-pur.de/', description: '(Informationen, Blogs, Fachbeiträge, Tipps)' },
{ id: createId(), label: 'Liveticker 2. und 3. TT-Bundesliga', href: 'https://ticker.tt-news.com/', description: '' }
]
},
{
id: createId(),
title: 'Verbände',
items: [
{ id: createId(), label: 'Hessischer Tischtennisverband (HTTV)', href: 'http://www.httv.de/', description: '' },
{ id: createId(), label: 'Deutscher Tischtennisbund (DTTB)', href: 'http://www.tischtennis.de/aktuelles/', description: '' },
{ id: createId(), label: 'European Table Tennis Union (ETTU)', href: 'http://www.ettu.org/', description: '' },
{ id: createId(), label: 'International Table Tennis Federation (ITTF)', href: 'https://www.ittf.com/', description: '' }
]
},
{
id: createId(),
title: 'Regionale Links',
items: [
{ id: createId(), label: 'Stadt Frankfurt', href: 'http://www.frankfurt.de/', description: '' },
{ id: createId(), label: 'Vereinsring Harheim', href: 'http://www.harheim.com/', description: '' }
]
},
{
id: createId(),
title: 'Partner & Vereine',
items: [
{ id: createId(), label: 'TTC OE Bad Homburg', href: 'http://www.ttcoe.de/', description: '' },
{ id: createId(), label: 'SpVgg Steinkirchen e.V.', href: 'https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis', description: '' },
{ id: createId(), label: 'Ergebnisse SpVgg Steinkirchen', href: 'https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/', description: '' }
]
}
]
function createHtmlFromSections(inputSections) {
const safeSections = Array.isArray(inputSections) ? inputSections : []
return safeSections
.filter((s) => s.title && Array.isArray(s.items) && s.items.length > 0)
.map((section) => {
const itemsHtml = section.items
.filter((item) => item.label && item.href)
.map((item) => {
const description = item.description ? ` ${item.description}` : ''
return `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${description}</li>`
})
.join('')
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
})
.join('\n')
}
function normalizeSections(rawSections) {
if (!Array.isArray(rawSections) || rawSections.length === 0) {
return JSON.parse(JSON.stringify(defaultSections))
}
return rawSections.map((section) => ({
id: section.id || createId(),
title: section.title || '',
items: Array.isArray(section.items)
? section.items.map((item) => ({
id: item.id || createId(),
label: item.label || '',
href: item.href || '',
description: item.description || ''
}))
: []
}))
}
function stripTags(html) {
if (!html) return ''
return String(html)
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.trim()
}
function parseLinksHtml(html) {
if (!html || typeof html !== 'string') return []
const sectionsParsed = []
const sectionPattern = /<h2[^>]*>([\s\S]*?)<\/h2>\s*<ul[^>]*>([\s\S]*?)<\/ul>/gi
let sectionMatch
while ((sectionMatch = sectionPattern.exec(html)) !== null) {
const title = stripTags(sectionMatch[1])
const ulContent = sectionMatch[2] || ''
const itemPattern = /<li[^>]*>([\s\S]*?)<\/li>/gi
const items = []
let itemMatch
while ((itemMatch = itemPattern.exec(ulContent)) !== null) {
const liHtml = itemMatch[1] || ''
const anchorMatch = liHtml.match(/<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
const href = anchorMatch ? String(anchorMatch[1]).trim() : ''
const label = anchorMatch ? stripTags(anchorMatch[2]) : stripTags(liHtml)
let description = ''
if (anchorMatch) {
description = stripTags(liHtml.replace(anchorMatch[0], ''))
}
if (label || href || description) {
items.push({
id: createId(),
label,
href,
description
})
}
}
if (title || items.length > 0) {
sectionsParsed.push({
id: createId(),
title,
items
})
}
}
return sectionsParsed
}
function addSection() {
sections.value.push({
id: createId(),
title: '',
items: [{ id: createId(), label: '', href: '', description: '' }]
})
}
function removeSection(index) {
sections.value.splice(index, 1)
}
function addItem(sectionIndex) {
sections.value[sectionIndex].items.push({
id: createId(),
label: '',
href: '',
description: ''
})
}
function removeItem(sectionIndex, itemIndex) {
sections.value[sectionIndex].items.splice(itemIndex, 1)
}
async function load() {
try {
const current = await $fetch('/api/config')
const configured = current?.seiten?.linksStructured
if (Array.isArray(configured) && configured.length > 0) {
sections.value = normalizeSections(configured)
return
}
const html = current?.seiten?.links
const parsed = parseLinksHtml(html)
sections.value = normalizeSections(parsed)
} catch {
sections.value = JSON.parse(JSON.stringify(defaultSections))
}
}
async function save() {
saving.value = true
try {
const current = await $fetch('/api/config')
const cleanedSections = normalizeSections(sections.value)
const linksHtml = createHtmlFromSections(cleanedSections)
const updated = {
...current,
seiten: {
...(current?.seiten || {}),
linksStructured: cleanedSections,
links: linksHtml
}
}
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Links erfolgreich gespeichert.') } catch {}
} catch (error) {
const msg = error?.data?.message || 'Fehler beim Speichern der Links'
try { window.showErrorModal && window.showErrorModal('Fehler', msg) } catch {}
} finally {
saving.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,245 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">Mannschaften verwalten</h2>
<div class="w-24 h-1 bg-primary-600" />
</div>
<button class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">
<Plus :size="20" class="mr-2" /> Mannschaft hinzufügen
</button>
</div>
<div v-if="isLoading" class="flex items-center justify-center py-12"><Loader2 :size="40" class="animate-spin text-primary-600" /></div>
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaft</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liga</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Staffelleiter</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaftsführer</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spieler</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(mannschaft, index) in mannschaften" :key="index" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ mannschaft.mannschaft }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.liga }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.staffelleiter }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.mannschaftsfuehrer }}</td>
<td class="px-4 py-3 text-sm text-gray-600"><div class="max-w-xs truncate">{{ getSpielerListe(mannschaft).join(', ') || '-' }}</div></td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(mannschaft, index)"><Pencil :size="18" /></button>
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(mannschaft, index)"><Trash2 :size="18" /></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!isLoading && mannschaften.length === 0" class="bg-white rounded-xl shadow-lg p-12 text-center">
<Users :size="48" class="text-gray-400 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Mannschaften vorhanden</h3>
<p class="text-gray-600 mb-6">Fügen Sie die erste Mannschaft hinzu.</p>
<button class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">Mannschaft hinzufügen</button>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200">
<h2 class="text-2xl font-display font-bold text-gray-900">{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}</h2>
</div>
<form class="p-6 space-y-4" @submit.prevent="saveMannschaft">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
<input v-model="formData.mannschaft" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
<input v-model="formData.liga" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
<input v-model="formData.staffelleiter" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
<input v-model="formData.telefon" type="tel" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
<input v-model="formData.heimspieltag" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
<input v-model="formData.spielsystem" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
<input v-model="formData.mannschaftsfuehrer" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
<div class="space-y-2">
<div v-if="formData.spielerListe.length === 0" class="text-sm text-gray-500">Noch keine Spieler eingetragen.</div>
<div v-for="(spieler, index) in formData.spielerListe" :key="spieler.id" class="px-3 py-2 border border-gray-200 rounded-lg bg-white">
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
<input v-model="spieler.name" type="text" class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Spielername" :disabled="isSaving">
<div class="flex items-center gap-1">
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach oben" :disabled="isSaving || index === 0" @click="moveSpielerUp(index)"><ChevronUp :size="18" /></button>
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach unten" :disabled="isSaving || index === formData.spielerListe.length - 1" @click="moveSpielerDown(index)"><ChevronDown :size="18" /></button>
<button type="button" class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Spieler entfernen" :disabled="isSaving" @click="removeSpieler(spieler.id)"><Trash2 :size="18" /></button>
</div>
<div class="flex items-center gap-2">
<select v-model="moveTargetBySpielerId[spieler.id]" class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1" title="Mannschaft auswählen">
<option v-for="t in mannschaftenSelectOptions" :key="t" :value="t">{{ t }}</option>
</select>
<button type="button" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)" title="In ausgewählte Mannschaft verschieben" @click="moveSpielerToMannschaft(spieler.id)"><ArrowRight :size="18" /></button>
</div>
</div>
</div>
</div>
<div class="mt-3 flex items-center justify-between">
<button type="button" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors" :disabled="isSaving" @click="addSpieler()"><Plus :size="18" class="mr-2" /> Spieler hinzufügen</button>
<p class="text-xs text-gray-500">Reihenfolge per / ändern.</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
<input v-model="formData.weitere_informationen_link" type="url" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="https://..." :disabled="isSaving">
</div>
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"><AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}</div>
<div class="flex justify-end space-x-4 pt-4">
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving"><Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span></button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
const isLoading = ref(true)
const isSaving = ref(false)
const mannschaften = ref([])
const showModal = ref(false)
const errorMessage = ref('')
const isEditing = ref(false)
const editingIndex = ref(-1)
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
const moveTargetBySpielerId = ref({})
const pendingSpielerNamesByTeamIndex = ref({})
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
function newSpielerItem(name = '') { return { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, name } }
function parseSpielerString(s) { if (!s) return []; return String(s).split(';').map(x => x.trim()).filter(Boolean).map(name => newSpielerItem(name)) }
function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') }
function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') }
async function fetchCsvText(url) {
const attempt = async () => { const r = await fetch(`${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.text() }
try { return await attempt() } catch { await new Promise(r => setTimeout(r, 150)); return await attempt() }
}
const mannschaftenSelectOptions = computed(() => {
const current = (formData.value.mannschaft || '').trim()
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
return [...new Set([current, ...names])].filter(Boolean)
})
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
function getPendingSpielerNamesForTeamIndex(teamIndex) {
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
pendingSpielerNamesByTeamIndex.value[teamIndex] = [...list]; return pendingSpielerNamesByTeamIndex.value[teamIndex]
}
const loadMannschaften = async () => {
isLoading.value = true
try {
const csv = await fetchCsvText('/api/mannschaften')
const lines = csv.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) { mannschaften.value = []; return }
mannschaften.value = lines.slice(1).map(line => {
const values = []; let current = ''; let inQuotes = false
for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }
values.push(current.trim())
if (values.length < 10) return null
return { mannschaft: values[0]?.trim() || '', liga: values[1]?.trim() || '', staffelleiter: values[2]?.trim() || '', telefon: values[3]?.trim() || '', heimspieltag: values[4]?.trim() || '', spielsystem: values[5]?.trim() || '', mannschaftsfuehrer: values[6]?.trim() || '', spieler: values[7]?.trim() || '', weitere_informationen_link: values[8]?.trim() || '', letzte_aktualisierung: values[9]?.trim() || '' }
}).filter(m => m !== null && m.mannschaft !== '')
} catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false }
}
const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') }
const openAddModal = () => { formData.value = { mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
const openEditModal = (mannschaft, index) => {
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
const currentTeam = (formData.value.mannschaft || '').trim()
for (const s of formData.value.spielerListe) { moveTargetBySpielerId.value[s.id] = currentTeam }
}
const addSpieler = () => { const item = newSpielerItem(''); formData.value.spielerListe.push(item); moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim() }
const removeSpieler = (id) => { const idx = formData.value.spielerListe.findIndex(s => s.id === id); if (idx === -1) return; formData.value.spielerListe.splice(idx, 1); if (moveTargetBySpielerId.value[id]) delete moveTargetBySpielerId.value[id] }
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
const canMoveSpieler = (id) => { const t = (moveTargetBySpielerId.value[id] || '').trim(); const c = (formData.value.mannschaft || '').trim(); return Boolean(t) && Boolean(c) && t !== c }
const moveSpielerToMannschaft = (spielerId) => {
if (!isEditing.value || editingIndex.value < 0) return
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return }
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return }
formData.value.spielerListe.splice(idx, 1)
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
delete moveTargetBySpielerId.value[spielerId]
}
const saveMannschaft = async () => {
isSaving.value = true; errorMessage.value = ''
try {
const spielerString = serializeSpielerList(formData.value.spielerListe)
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
if (touchedTeamIndexes.length > 0) { const ts = nowIsoDate(); for (const idxStr of touchedTeamIndexes) { const idx = Number(idxStr); if (!Number.isFinite(idx)) continue; const existing = mannschaften.value[idx]; if (!existing) continue; mannschaften.value[idx] = { ...existing, spieler: serializeSpielerNames(pendingSpielerNamesByTeamIndex.value[idx]), letzte_aktualisierung: ts } } }
await saveCSV(); closeModal(); await loadMannschaften()
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gespeichert')
} catch (error) { console.error('Fehler:', error); errorMessage.value = error?.data?.statusMessage || error?.statusMessage || error?.data?.message || 'Fehler beim Speichern.'; if (window.showErrorModal) window.showErrorModal('Fehler', errorMessage.value) } finally { isSaving.value = false }
}
const saveCSV = async () => {
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
const rows = mannschaften.value.map(m => {
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung)].join(',')
})
await $fetch('/api/cms/save-csv', { method: 'POST', body: { filename: 'mannschaften.csv', content: [header, ...rows].join('\n') } })
}
const confirmDelete = (mannschaft, index) => {
if (window.showConfirmModal) {
window.showConfirmModal('Mannschaft löschen', `Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`, async () => {
try { mannschaften.value.splice(index, 1); await saveCSV(); await loadMannschaften(); window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gelöscht') } catch (error) { console.error('Fehler:', error); window.showErrorModal('Fehler', 'Fehler beim Löschen der Mannschaft') }
})
} else { if (confirm(`Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) { mannschaften.value.splice(index, 1); saveCSV(); loadMannschaften() } }
}
onMounted(() => { loadMannschaften().catch(() => {}) })
</script>

View File

@@ -0,0 +1,936 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
Mitgliederliste
</h2>
<div class="w-24 h-1 bg-primary-600 mb-4" />
</div>
<div class="flex items-center space-x-3">
<button
class="flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold rounded-lg transition-colors"
@click="viewMode = viewMode === 'cards' ? 'table' : 'cards'"
>
<component
:is="viewMode === 'cards' ? Table2 : Grid3x3"
:size="20"
class="mr-2"
/>
{{ viewMode === 'cards' ? 'Tabelle' : 'Karten' }}
</button>
<button
v-if="canEdit"
class="flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"
@click="showBulkImportModal = true"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Bulk-Import
</button>
<button
v-if="canEdit"
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<UserPlus
:size="20"
class="mr-2"
/>
Mitglied hinzufügen
</button>
</div>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- Table View -->
<div
v-else-if="viewMode === 'table'"
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
E-Mail
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Telefon
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th
v-if="canEdit"
class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="member in members"
:key="member.id"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
{{ member.name }}
</div>
<div
v-if="member.notes"
class="text-xs text-gray-500"
>
{{ member.notes }}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData">
<a
v-if="member.email"
:href="`mailto:${member.email}`"
class="text-sm text-primary-600 hover:text-primary-800"
>
{{ member.email }}
</a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template>
<span
v-else
class="text-sm text-gray-400"
>Nur für Vorstand</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData">
<a
v-if="member.phone"
:href="`tel:${member.phone}`"
class="text-sm text-primary-600 hover:text-primary-800"
>
{{ member.phone }}
</a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template>
<span
v-else
class="text-sm text-gray-400"
>Nur für Vorstand</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<button
v-if="canEdit"
:class="[
'px-2 py-1 text-xs font-medium rounded-full transition-colors',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
title="Klicken zum Umschalten"
@click="toggleMannschaftsspieler(member)"
>
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</button>
<span
v-else
:class="[
'px-2 py-1 text-xs font-medium rounded-full',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600'
]"
>
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center space-x-2">
<span
v-if="member.hasLogin"
class="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
>
Login
</span>
<span
:class="member.source === 'manual' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'"
class="px-2 py-1 text-xs font-medium rounded-full"
>
{{ member.source === 'manual' ? 'Manuell' : 'System' }}
</span>
</div>
</td>
<td
v-if="canEdit"
class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium"
>
<div
v-if="member.editable"
class="flex justify-end space-x-2"
>
<button
class="text-blue-600 hover:text-blue-900"
title="Bearbeiten"
@click="openEditModal(member)"
>
<Edit :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(member)"
>
<Trash2 :size="18" />
</button>
</div>
<span
v-else
class="text-gray-400 text-xs"
>Nicht editierbar</span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="members.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Mitglieder gefunden.
</div>
</div>
<!-- Cards View -->
<div
v-else
class="space-y-4"
>
<div
v-for="member in members"
:key="member.id"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center mb-2">
<h3 class="text-xl font-semibold text-gray-900">
{{ member.name }}
</h3>
<span
v-if="member.hasLogin"
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
>
Hat Login
</span>
<span
v-if="member.source === 'manual'"
class="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded-full"
>
Manuell
</span>
<span
v-else
class="ml-2 px-2 py-1 bg-purple-100 text-purple-800 text-xs font-medium rounded-full"
>
Aus Login-System
</span>
<button
v-if="canEdit"
:class="[
'ml-2 px-2 py-1 text-xs font-medium rounded-full transition-colors',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
title="Klicken zum Umschalten"
@click="toggleMannschaftsspieler(member)"
>
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</button>
<span
v-else
:class="[
'ml-2 px-2 py-1 text-xs font-medium rounded-full',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600'
]"
>
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</span>
</div>
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
<template v-if="canViewContactData">
<div
v-if="member.email"
class="flex items-center"
>
<Mail
:size="16"
class="mr-2 text-primary-600"
/>
<a
:href="`mailto:${member.email}`"
class="hover:text-primary-600"
>{{ member.email }}</a>
</div>
<div
v-if="member.phone"
class="flex items-center"
>
<Phone
:size="16"
class="mr-2 text-primary-600"
/>
<a
:href="`tel:${member.phone}`"
class="hover:text-primary-600"
>{{ member.phone }}</a>
</div>
</template>
<div
v-else
class="col-span-2 flex items-center text-gray-500 text-sm italic"
>
<Mail
:size="16"
class="mr-2"
/>
Kontaktdaten nur für Vorstand sichtbar
</div>
<div
v-if="member.address"
class="flex items-start col-span-2"
>
<MapPin
:size="16"
class="mr-2 text-primary-600 mt-0.5"
/>
<span>{{ member.address }}</span>
</div>
<div
v-if="member.notes"
class="flex items-start col-span-2"
>
<FileText
:size="16"
class="mr-2 text-primary-600 mt-0.5"
/>
<span>{{ member.notes }}</span>
</div>
<div
v-if="member.lastLogin"
class="flex items-center col-span-2 text-sm text-gray-500"
>
<Clock
:size="16"
class="mr-2"
/>
Letzter Login: {{ formatDate(member.lastLogin) }}
</div>
</div>
</div>
<div
v-if="canEdit && member.editable"
class="flex space-x-2 ml-4"
>
<button
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Bearbeiten"
@click="openEditModal(member)"
>
<Edit :size="20" />
</button>
<button
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Löschen"
@click="confirmDelete(member)"
>
<Trash2 :size="20" />
</button>
</div>
</div>
</div>
<div
v-if="members.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Mitglieder gefunden.
</div>
</div>
<!-- Edit/Add Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
{{ editingMember ? 'Mitglied bearbeiten' : 'Mitglied hinzufügen' }}
</h2>
<form
class="space-y-4"
@submit.prevent="saveMember"
>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Vorname *</label>
<input
v-model="formData.firstName"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nachname *</label>
<input
v-model="formData.lastName"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Geburtsdatum *</label>
<input
v-model="formData.geburtsdatum"
type="date"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
<p class="text-xs text-gray-500 mt-1">
Wird zur eindeutigen Identifizierung benötigt
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
<input
v-model="formData.email"
type="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
<input
v-model="formData.phone"
type="tel"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Adresse</label>
<input
v-model="formData.address"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Notizen</label>
<textarea
v-model="formData.notes"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
/>
</div>
<div class="flex items-center">
<input
id="isMannschaftsspieler"
v-model="formData.isMannschaftsspieler"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving"
>
<label
for="isMannschaftsspieler"
class="ml-2 block text-sm font-medium text-gray-700"
>
Mannschaftsspieler
</label>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/>
{{ errorMessage }}
</div>
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
</div>
<!-- Bulk Import Modal -->
<div
v-if="showBulkImportModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeBulkImportModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Bulk-Import von Mitgliedern
</h2>
<!-- CSV Upload Section -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">CSV-Datei hochladen</label>
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
@click="triggerBulkFileInput"
@dragover.prevent
@dragenter.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleBulkFileDrop"
>
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p class="text-lg font-medium text-gray-900 mb-2">
CSV-Datei hochladen
</p>
<p class="text-sm text-gray-600 mb-4">
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
</p>
<p
v-if="bulkSelectedFile"
class="text-sm text-primary-600 font-medium"
>
{{ bulkSelectedFile.name }}
</p>
</div>
<input
ref="bulkFileInput"
type="file"
accept=".csv"
class="hidden"
@change="handleBulkFileSelect"
>
</div>
<!-- CSV Format Info -->
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-lg mb-6">
<h4 class="text-sm font-medium text-blue-800 mb-2">
Erwartetes CSV-Format:
</h4>
<div class="text-xs text-blue-700 space-y-1">
<p> Erste Zeile: Spaltenüberschriften (firstName, lastName, geburtsdatum, email, phone, address, notes)</p>
<p> <strong>Pflichtfelder:</strong> firstName, lastName, geburtsdatum</p>
<p> <strong>Geburtsdatum:</strong> Format YYYY-MM-DD (z.B. 1990-01-15)</p>
<p> Trennzeichen: Komma (,) oder Semikolon (;)</p>
</div>
</div>
<!-- Preview Section -->
<div
v-if="bulkPreviewData.length > 0"
class="mb-6"
>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Vorschau ({{ bulkPreviewData.length }} Einträge)
</h3>
<div class="max-h-64 overflow-y-auto border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Vorname</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nachname</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Geburtsdatum</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">E-Mail</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(row, index) in bulkPreviewData.slice(0, 10)"
:key="index"
class="hover:bg-gray-50"
>
<td class="px-3 py-2">{{ row.firstName || '-' }}</td>
<td class="px-3 py-2">{{ row.lastName || '-' }}</td>
<td class="px-3 py-2">{{ row.geburtsdatum || '-' }}</td>
<td class="px-3 py-2">{{ row.email || '-' }}</td>
</tr>
</tbody>
</table>
<div
v-if="bulkPreviewData.length > 10"
class="px-3 py-2 text-xs text-gray-500 bg-gray-50 text-center"
>
... und {{ bulkPreviewData.length - 10 }} weitere
</div>
</div>
</div>
<!-- Import Results -->
<div
v-if="bulkImportResults"
class="mb-6"
>
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Import-Ergebnisse</h3>
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ bulkImportResults.summary.imported }}</div>
<div class="text-sm text-gray-600">Importiert</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-yellow-600">{{ bulkImportResults.summary.duplicates }}</div>
<div class="text-sm text-gray-600">Duplikate</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ bulkImportResults.summary.errors }}</div>
<div class="text-sm text-gray-600">Fehler</div>
</div>
</div>
<div v-if="bulkImportResults.results.duplicates.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Duplikate:</h4>
<div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="dup in bulkImportResults.results.duplicates" :key="dup.index">
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
</div>
</div>
</div>
<div v-if="bulkImportResults.results.errors.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Fehler:</h4>
<div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="err in bulkImportResults.results.errors" :key="err.index">
Zeile {{ err.index }}: {{ err.error }}
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isBulkImporting"
@click="closeBulkImportModal"
>
Schließen
</button>
<button
:disabled="!bulkPreviewData.length || isBulkImporting"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center disabled:bg-gray-400"
@click="processBulkImport"
>
<Loader2
v-if="isBulkImporting"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isBulkImporting ? 'Importiert...' : 'Importieren' }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next'
const authStore = useAuthStore()
const isLoading = ref(true)
const isSaving = ref(false)
const members = ref([])
const showModal = ref(false)
const editingMember = ref(null)
const errorMessage = ref('')
const viewMode = ref('cards')
// Bulk import state
const showBulkImportModal = ref(false)
const bulkFileInput = ref(null)
const bulkSelectedFile = ref(null)
const bulkPreviewData = ref([])
const isBulkImporting = ref(false)
const bulkImportResults = ref(null)
const isDragOver = ref(false)
const formData = ref({
firstName: '',
lastName: '',
geburtsdatum: '',
email: '',
phone: '',
address: '',
notes: '',
isMannschaftsspieler: false
})
const canEdit = computed(() => {
return authStore.hasAnyRole('admin', 'vorstand')
})
const canViewContactData = computed(() => {
return authStore.hasRole('vorstand')
})
const loadMembers = async () => {
isLoading.value = true
try {
const response = await $fetch('/api/members')
members.value = response.members
} catch (error) {
console.error('Fehler beim Laden der Mitglieder:', error)
} finally {
isLoading.value = false
}
}
const openAddModal = () => {
editingMember.value = null
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false }
showModal.value = true
errorMessage.value = ''
}
const openEditModal = (member) => {
editingMember.value = member
formData.value = {
firstName: member.firstName || '',
lastName: member.lastName || '',
geburtsdatum: member.geburtsdatum || '',
email: member.email || '',
phone: member.phone || '',
address: member.address || '',
notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true
}
showModal.value = true
errorMessage.value = ''
}
const closeModal = () => {
showModal.value = false
editingMember.value = null
errorMessage.value = ''
}
const saveMember = async () => {
isSaving.value = true
errorMessage.value = ''
try {
await $fetch('/api/members', {
method: 'POST',
body: { id: editingMember.value?.id, ...formData.value }
})
closeModal()
await loadMembers()
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Mitglied erfolgreich gespeichert.')
} catch (error) {
console.error('Fehler beim Speichern:', error)
const errorMsg = error.data?.message || error.message || 'Fehler beim Speichern des Mitglieds.'
errorMessage.value = errorMsg
if ((error.statusCode === 409 || error.status === 409) && window.showErrorModal) {
window.showErrorModal('Duplikat gefunden', errorMsg)
}
} finally {
isSaving.value = false
}
}
const toggleMannschaftsspieler = async (member) => {
try {
const response = await $fetch('/api/members/toggle-mannschaftsspieler', {
method: 'POST',
body: { memberId: member.id }
})
member.isMannschaftsspieler = response.isMannschaftsspieler
await loadMembers()
} catch (error) {
console.error('Fehler beim Umschalten des Mannschaftsspieler-Status:', error)
if (window.showErrorModal) window.showErrorModal('Fehler', error.data?.message || 'Fehler beim Umschalten des Status.')
}
}
const confirmDelete = async (member) => {
window.showConfirmModal('Mitglied löschen', `Möchten Sie "${member.name}" wirklich löschen?`, async () => {
try {
await $fetch('/api/members', { method: 'DELETE', body: { id: member.id } })
await loadMembers()
window.showSuccessModal('Erfolg', 'Mitglied wurde erfolgreich gelöscht')
} catch (error) {
console.error('Fehler beim Löschen:', error)
window.showErrorModal('Fehler', 'Fehler beim Löschen des Mitglieds')
}
})
}
const formatDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// Bulk import functions
const triggerBulkFileInput = () => { bulkFileInput.value?.click() }
const handleBulkFileSelect = (event) => { const file = event.target.files?.[0]; if (file) processBulkCSV(file) }
const handleBulkFileDrop = (event) => { isDragOver.value = false; const file = event.dataTransfer?.files?.[0]; if (file && file.type === 'text/csv') processBulkCSV(file) }
const processBulkCSV = async (file) => {
bulkSelectedFile.value = file
bulkImportResults.value = null
try {
const text = await file.text()
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) { window.showErrorModal('Fehler', 'CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten'); return }
const parseCSVLine = (line) => {
const tabCount = (line.match(/\t/g) || []).length
const semicolonCount = (line.match(/;/g) || []).length
const delimiter = tabCount > semicolonCount ? '\t' : (semicolonCount > 0 ? ';' : ',')
return line.split(delimiter).map(value => value.trim().replace(/^"|"$/g, ''))
}
const headers = parseCSVLine(lines[0]).map(h => h.toLowerCase())
const firstNameIdx = headers.findIndex(h => h.includes('firstname') || h.includes('vorname'))
const lastNameIdx = headers.findIndex(h => h.includes('lastname') || h.includes('nachname'))
const geburtsdatumIdx = headers.findIndex(h => h.includes('geburtsdatum') || h.includes('birthdate') || h.includes('geburt'))
const emailIdx = headers.findIndex(h => h.includes('email') || h.includes('e-mail'))
const phoneIdx = headers.findIndex(h => h.includes('phone') || h.includes('telefon') || h.includes('tel'))
const addressIdx = headers.findIndex(h => h.includes('address') || h.includes('adresse'))
const notesIdx = headers.findIndex(h => h.includes('note') || h.includes('notiz') || h.includes('bemerkung'))
if (firstNameIdx === -1 || lastNameIdx === -1 || geburtsdatumIdx === -1) { window.showErrorModal('Fehler', 'CSV muss Spalten für firstName, lastName und geburtsdatum enthalten'); return }
bulkPreviewData.value = lines.slice(1).map((line) => {
const values = parseCSVLine(line)
return {
firstName: values[firstNameIdx] || '',
lastName: values[lastNameIdx] || '',
geburtsdatum: values[geburtsdatumIdx] || '',
email: emailIdx !== -1 ? (values[emailIdx] || '') : '',
phone: phoneIdx !== -1 ? (values[phoneIdx] || '') : '',
address: addressIdx !== -1 ? (values[addressIdx] || '') : '',
notes: notesIdx !== -1 ? (values[notesIdx] || '') : ''
}
}).filter(row => row.firstName && row.lastName && row.geburtsdatum)
} catch (error) {
console.error('Fehler beim Parsen der CSV:', error)
window.showErrorModal('Fehler', 'Fehler beim Lesen der CSV-Datei: ' + error.message)
}
}
const processBulkImport = async () => {
if (!bulkPreviewData.value.length) return
isBulkImporting.value = true
bulkImportResults.value = null
try {
const response = await $fetch('/api/members/bulk', { method: 'POST', body: { members: bulkPreviewData.value } })
bulkImportResults.value = response
if (response.summary.imported > 0) {
await loadMembers()
window.showSuccessModal('Import erfolgreich', `${response.summary.imported} Mitglieder wurden erfolgreich importiert.`)
}
} catch (error) {
console.error('Fehler beim Bulk-Import:', error)
window.showErrorModal('Import-Fehler', error.data?.message || error.message || 'Fehler beim Import')
} finally {
isBulkImporting.value = false
}
}
const closeBulkImportModal = () => {
showBulkImportModal.value = false
bulkSelectedFile.value = null
bulkPreviewData.value = []
bulkImportResults.value = null
isDragOver.value = false
if (bulkFileInput.value) bulkFileInput.value.value = ''
}
onMounted(() => { loadMembers() })
</script>

View File

@@ -0,0 +1,307 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900">
Mitgliedschaftsanträge
</h2>
<button
:disabled="loading"
class="px-3 py-1.5 sm:px-4 sm:py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white text-sm sm:text-base font-medium rounded-lg transition-colors"
@click="refreshApplications"
>
{{ loading ? 'Lädt...' : 'Aktualisieren' }}
</button>
</div>
<!-- Loading State -->
<div
v-if="loading"
class="text-center py-12"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto" />
<p class="mt-4 text-gray-600">
Lade Anträge...
</p>
</div>
<!-- Applications List -->
<div
v-else
class="space-y-6"
>
<div
v-for="application in applications"
:key="application.id"
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
>
<!-- Application Header -->
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ application.personalData.vorname }} {{ application.personalData.nachname }}
</h3>
<p class="text-sm text-gray-600">
Eingereicht: {{ formatDate(application.timestamp) }}
</p>
</div>
<div class="flex items-center space-x-3">
<span
:class="[
'px-3 py-1 rounded-full text-sm font-medium',
getStatusClass(application.status)
]"
>
{{ getStatusText(application.status) }}
</span>
<div class="flex space-x-2">
<button
class="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="viewApplication(application)"
>
Anzeigen
</button>
<button
v-if="application.metadata.pdfGenerated"
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors flex items-center"
@click="downloadPDF(application.id)"
>
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
PDF
</button>
<button
v-if="application.status === 'pending'"
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
@click="approveApplication(application.id)"
>
Genehmigen
</button>
<button
v-if="application.status === 'pending'"
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="rejectApplication(application.id)"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
<!-- Application Details -->
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
<p v-if="application.personalData.telefon_privat"><strong>Telefon:</strong> {{ application.personalData.telefon_privat }}</p>
<p v-if="application.personalData.telefon_mobil"><strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}</p>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>PDF:</strong> {{ application.metadata.pdfGenerated ? 'Generiert' : 'Nicht verfügbar' }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Application Detail Modal -->
<div
v-if="selectedApplication"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
</h2>
<button
class="text-gray-400 hover:text-gray-600"
@click="closeModal"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3>
<div class="space-y-2 text-sm">
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
<p v-if="selectedApplication.personalData.telefon_privat"><strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}</p>
<p v-if="selectedApplication.personalData.telefon_mobil"><strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}</p>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
<div class="space-y-2 text-sm">
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ selectedApplication.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>Eingereicht:</strong> {{ formatDate(selectedApplication.timestamp) }}</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeModal">Schließen</button>
<button
v-if="selectedApplication.metadata.pdfGenerated"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
@click="downloadPDF(selectedApplication.id)"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
PDF herunterladen
</button>
<button
v-if="selectedApplication.status === 'pending'"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
@click="approveApplication(selectedApplication.id)"
>
Genehmigen
</button>
<button
v-if="selectedApplication.status === 'pending'"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
@click="rejectApplication(selectedApplication.id)"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const applications = ref([])
const loading = ref(false)
const selectedApplication = ref(null)
const hasApplications = computed(() => applications.value.length > 0)
const isReady = computed(() => !loading.value)
defineExpose({ hasApplications, isReady })
const loadApplications = async () => {
loading.value = true
try {
const response = await $fetch('/api/membership/applications')
applications.value = response
} catch (error) {
console.error('Fehler beim Laden der Anträge:', error)
window.showErrorModal('Fehler', 'Fehler beim Laden der Anträge')
} finally {
loading.value = false
}
}
const refreshApplications = () => { loadApplications() }
const viewApplication = (application) => { selectedApplication.value = application }
const closeModal = () => { selectedApplication.value = null }
const approveApplication = async (id) => {
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich genehmigen?', async () => {
try {
await $fetch('/api/membership/update-status', { method: 'PUT', body: { id, status: 'approved' } })
await loadApplications()
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich genehmigt')
} catch (error) {
console.error('Fehler beim Genehmigen:', error)
window.showErrorModal('Fehler', 'Fehler beim Genehmigen des Antrags')
}
})
}
const rejectApplication = async (id) => {
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich ablehnen?', async () => {
try {
await $fetch('/api/membership/update-status', { method: 'PUT', body: { id, status: 'rejected' } })
await loadApplications()
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich abgelehnt')
} catch (error) {
console.error('Fehler beim Ablehnen:', error)
window.showErrorModal('Fehler', 'Fehler beim Ablehnen des Antrags')
}
})
}
const downloadPDF = async (id) => {
try {
const filename = `beitrittserklärung_${id}.pdf`
const response = await fetch(`/uploads/${filename}`)
if (!response.ok) throw new Error('PDF nicht gefunden')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
window.showSuccessModal('Erfolg', 'PDF wurde erfolgreich heruntergeladen')
} catch (error) {
console.error('Fehler beim Herunterladen:', error)
window.showErrorModal('Fehler', 'Fehler beim Herunterladen des PDFs')
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const getStatusClass = (status) => {
switch (status) {
case 'pending': return 'bg-yellow-100 text-yellow-800'
case 'approved': return 'bg-green-100 text-green-800'
case 'rejected': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
const getStatusText = (status) => {
switch (status) {
case 'pending': return 'Ausstehend'
case 'approved': return 'Genehmigt'
case 'rejected': return 'Abgelehnt'
default: return 'Unbekannt'
}
}
onMounted(() => { loadApplications() })
</script>

View File

@@ -0,0 +1,278 @@
<template>
<div>
<!-- 1. PDF-Upload -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">
PDF-Upload
</h2>
<form
enctype="multipart/form-data"
class="space-y-4"
@submit.prevent="uploadPdf"
>
<div>
<label
for="pdf-file"
class="block text-sm font-medium text-gray-700 mb-2"
>
Neue Satzung hochladen (PDF)
</label>
<input
id="pdf-file"
ref="fileInput"
type="file"
accept=".pdf"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
@change="handleFileSelect"
>
<p class="mt-1 text-sm text-gray-500">
Nur PDF-Dateien bis 10MB sind erlaubt
</p>
</div>
<button
type="submit"
:disabled="!selectedFile || uploading"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
v-if="uploading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{{ uploading ? 'Wird hochgeladen...' : 'PDF hochladen' }}
</button>
</form>
</div>
<!-- 2. Aktuelle PDF-Information -->
<div
v-if="currentPdfUrl"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"
>
<h2 class="text-xl font-semibold mb-4">
Aktuelle Satzung (PDF)
</h2>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p class="text-gray-600">
PDF-Datei verfügbar
</p>
<a
:href="currentPdfUrl"
target="_blank"
class="text-primary-600 hover:text-primary-700 font-medium"
>
Satzung anzeigen
</a>
</div>
<div class="text-sm text-gray-500">
Zuletzt aktualisiert: {{ lastUpdated }}
</div>
</div>
</div>
<!-- 3. Textfassung (WYSIWYG) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">
Textfassung für die Website
</h2>
<button
type="button"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
:disabled="savingText"
@click="saveText"
>
<svg
v-if="savingText"
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{{ savingText ? 'Speichert...' : 'Text speichern' }}
</button>
</div>
<p class="text-sm text-gray-500 mb-4">
Diese HTML-Fassung wird auf der Seite Verein Satzung" angezeigt. Die PDF-Version bleibt die rechtlich verbindliche Fassung.
</p>
<RichTextEditor
v-model="satzungContent"
label="Satzung (HTML-Version)"
/>
</div>
<div
v-if="message"
class="mt-4 p-4 rounded-lg"
:class="messageType === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'"
>
{{ message }}
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import RichTextEditor from '~/components/RichTextEditor.vue'
const fileInput = ref(null)
const selectedFile = ref(null)
const uploading = ref(false)
const currentPdfUrl = ref('')
const lastUpdated = ref('')
const message = ref('')
const messageType = ref('')
const satzungContent = ref('')
const savingText = ref(false)
async function loadCurrentSatzung() {
try {
const data = await $fetch('/api/config')
const satzung = data?.seiten?.satzung
if (satzung?.pdfUrl) {
currentPdfUrl.value = satzung.pdfUrl
lastUpdated.value = new Date().toLocaleDateString('de-DE')
}
if (satzung?.content) {
const content = typeof satzung.content === 'string' ? satzung.content : String(satzung.content || '')
satzungContent.value = content
} else {
satzungContent.value = ''
}
} catch (e) {
console.error('Fehler beim Laden der aktuellen Satzung:', e)
satzungContent.value = ''
}
}
function handleFileSelect(event) {
const file = event.target.files[0]
if (file) {
if (file.type !== 'application/pdf') {
message.value = 'Bitte wählen Sie eine PDF-Datei aus'
messageType.value = 'error'
return
}
if (file.size > 10 * 1024 * 1024) {
message.value = 'Die Datei ist zu groß (max. 10MB)'
messageType.value = 'error'
return
}
selectedFile.value = file
message.value = ''
}
}
async function uploadPdf() {
if (!selectedFile.value) return
uploading.value = true
message.value = ''
try {
const formData = new FormData()
formData.append('pdf', selectedFile.value)
const result = await $fetch('/api/cms/satzung-upload', {
method: 'POST',
body: formData
})
message.value = result.message
messageType.value = 'success'
await loadCurrentSatzung()
selectedFile.value = null
if (fileInput.value) fileInput.value.value = ''
} catch (error) {
message.value = error.data?.message || 'Fehler beim Hochladen der PDF-Datei'
messageType.value = 'error'
} finally {
uploading.value = false
}
}
async function saveText() {
savingText.value = true
message.value = ''
try {
const current = await $fetch('/api/config')
const currentSeiten = current.seiten || {}
const currentSatzung = currentSeiten.satzung || {}
const updated = {
...current,
seiten: {
...currentSeiten,
satzung: {
...currentSatzung,
content: satzungContent.value || ''
}
}
}
await $fetch('/api/config', {
method: 'PUT',
body: updated
})
message.value = 'Satzungstext erfolgreich gespeichert'
messageType.value = 'success'
try {
window.showSuccessModal && window.showSuccessModal('Erfolg', 'Satzungstext erfolgreich gespeichert.')
} catch {
// Modal optional
}
} catch (error) {
const errMsg = error?.data?.message || 'Fehler beim Speichern des Satzungstextes'
message.value = errMsg
messageType.value = 'error'
try {
window.showErrorModal && window.showErrorModal('Fehler', errMsg)
} catch {
// optional
}
} finally {
savingText.value = false
}
}
onMounted(loadCurrentSatzung)
</script>

View File

@@ -0,0 +1,194 @@
<template>
<div>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h2>
<div class="space-x-3">
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" @click="showUploadModal = true">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
CSV hochladen
</button>
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base" @click="save">Speichern</button>
</div>
</div>
<!-- CSV Upload Section -->
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h3>
<div v-if="currentFile" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div><p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p><p class="text-xs text-green-600">{{ currentFile.size }} bytes</p></div>
</div>
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="removeFile">Entfernen</button>
</div>
</div>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer" :class="{ 'border-primary-400 bg-primary-50': isDragOver }" @click="triggerFileInput" @dragover.prevent @dragenter.prevent @drop.prevent="handleFileDrop">
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p>
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p>
</div>
<input ref="fileInput" type="file" accept=".csv" class="hidden" @change="handleFileSelect">
</div>
<!-- Column Selection -->
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8">
<h3 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h3>
<p class="text-sm text-gray-600 mb-6">Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:</p>
<div class="space-y-4">
<div v-for="(header, index) in csvHeaders" :key="index" class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50">
<div class="flex items-center">
<input :id="`column-${index}`" v-model="selectedColumns[index]" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded">
<label :for="`column-${index}`" class="ml-3 text-sm font-medium text-gray-900">{{ header }}</label>
</div>
<div class="text-xs text-gray-500">{{ getColumnPreview(index) }}</div>
</div>
</div>
<div class="mt-6 flex justify-between items-center">
<div class="text-sm text-gray-600">{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt</div>
<div class="space-x-3">
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="selectAllColumns">Alle auswählen</button>
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="deselectAllColumns">Alle abwählen</button>
<button class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="suggestHalleColumns">Halle-Spalten vorschlagen</button>
<button :disabled="selectedColumnsCount === 0" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="confirmColumnSelection">Auswahl bestätigen</button>
</div>
</div>
</div>
<!-- Data Preview -->
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900">Datenvorschau</h3>
<div class="flex space-x-2">
<button class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="exportCSV">CSV exportieren</button>
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="clearData">Daten löschen</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"><tr><th v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)" :key="index" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ header }}</th></tr></thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)" :key="rowIndex" :class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen</div>
<div class="mt-4 text-sm text-gray-600"><p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p></div>
</div>
<!-- Empty State -->
<div v-if="csvData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg">
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<p class="text-gray-600">Keine CSV-Daten geladen.</p>
<p class="text-sm text-gray-500 mt-2">Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.</p>
</div>
<!-- Upload Modal -->
<div v-if="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="closeUploadModal">
<div class="bg-white rounded-lg max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">CSV-Datei hochladen</h3>
<div class="space-y-4">
<div><label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input ref="modalFileInput" type="file" accept=".csv" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" @change="handleModalFileSelect"></div>
<div v-if="selectedFile" class="p-3 bg-gray-50 rounded-lg"><p class="text-sm text-gray-700"><strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}</p><p class="text-xs text-gray-500">{{ selectedFile.size }} bytes</p></div>
<div class="bg-blue-50 p-4 rounded-lg"><h4 class="text-sm font-medium text-blue-800 mb-2">Erwartetes CSV-Format:</h4><div class="text-xs text-blue-700 space-y-1"><p> Erste Zeile: Spaltenüberschriften</p><p> Trennzeichen: Komma (,)</p></div></div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeUploadModal">Abbrechen</button>
<button :disabled="!selectedFile" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="processSelectedFile">Hochladen</button>
</div>
</div>
</div>
<!-- Processing Modal -->
<div v-if="isProcessing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3>
<p class="text-sm text-gray-600">{{ processingMessage }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
const fileInput = ref(null)
const modalFileInput = ref(null)
const showUploadModal = ref(false)
const isProcessing = ref(false)
const processingMessage = ref('')
const isDragOver = ref(false)
const currentFile = ref(null)
const selectedFile = ref(null)
const csvData = ref([])
const csvHeaders = ref([])
const selectedColumns = ref([])
const columnsSelected = ref(false)
const filteredCsvData = ref([])
const filteredCsvHeaders = ref([])
const triggerFileInput = () => { fileInput.value?.click() }
const handleFileSelect = (event) => { const file = event.target.files[0]; if (file) processFile(file) }
const handleModalFileSelect = (event) => { selectedFile.value = event.target.files[0] }
const handleFileDrop = (event) => { isDragOver.value = false; const file = event.dataTransfer.files[0]; if (file && file.type === 'text/csv') processFile(file) }
const processFile = async (file) => {
isProcessing.value = true; processingMessage.value = 'Datei wird gelesen...'
try {
const text = await file.text(); processingMessage.value = 'CSV wird geparst...'
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten')
const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) }
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false
currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified }
processingMessage.value = 'Verarbeitung abgeschlossen!'
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
} catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false }
}
const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) }
const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' }
const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length)
const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' }
const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
const confirmColumnSelection = () => { const si = selectedColumns.value.map((s, i) => s ? i : -1).filter(i => i !== -1); filteredCsvHeaders.value = si.map(i => csvHeaders.value[i]); filteredCsvData.value = csvData.value.map(row => si.map(i => row[i])); columnsSelected.value = true }
const suggestHalleColumns = () => { csvHeaders.value.forEach((header, index) => { const h = header.toLowerCase(); if (h.includes('halle') || h.includes('strasse') || h.includes('plz') || h.includes('ort')) selectedColumns.value[index] = true }) }
const clearData = () => { if (confirm('Möchten Sie alle Daten wirklich löschen?')) removeFile() }
const exportCSV = () => {
const d = columnsSelected.value ? filteredCsvData.value : csvData.value; const h = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
if (d.length === 0) return
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `spielplan_export_${new Date().toISOString().split('T')[0]}.csv`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a)
}
const save = async () => {
const d = columnsSelected.value ? filteredCsvData.value : csvData.value; const h = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
if (d.length === 0) { alert('Keine Daten zum Speichern vorhanden.'); return }
try {
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
const response = await fetch('/api/cms/save-csv', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: 'spielplan.csv', content: csv }) })
if (response.ok) alert('Spielplan erfolgreich gespeichert!'); else alert('Fehler beim Speichern!')
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
}
const closeUploadModal = () => { showUploadModal.value = false; selectedFile.value = null; if (modalFileInput.value) modalFileInput.value.value = '' }
onMounted(() => {
(async () => {
try {
const response = await fetch('/data/spielplan.csv'); if (!response.ok) return; const text = await response.text()
const lines = text.split('\n').filter(line => line.trim() !== ''); if (lines.length < 2) return
const parseCSVLine = (line) => { const values = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }; values.push(current.trim()); return values }
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
currentFile.value = { name: 'spielplan.csv', size: text.length, lastModified: null }
} catch { /* ignore */ }
})()
})
</script>

View File

@@ -0,0 +1,188 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
Termine verwalten
</h2>
<div class="w-24 h-1 bg-primary-600" />
</div>
<button
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<Plus :size="20" class="mr-2" />
Termin hinzufügen
</button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<Loader2 :size="40" class="animate-spin text-primary-600" />
</div>
<!-- Termine Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uhrzeit</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="termin in termine" :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`" class="hover:bg-gray-50">
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ formatDate(termin.datum) }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ termin.uhrzeit || '-' }}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ termin.titel }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ termin.beschreibung || '-' }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
:class="{
'bg-blue-100 text-blue-800': termin.kategorie === 'Training',
'bg-green-100 text-green-800': termin.kategorie === 'Punktspiel',
'bg-purple-100 text-purple-800': termin.kategorie === 'Turnier',
'bg-yellow-100 text-yellow-800': termin.kategorie === 'Veranstaltung',
'bg-gray-100 text-gray-800': termin.kategorie === 'Sonstiges'
}"
class="px-2 py-1 text-xs font-medium rounded-full"
>{{ termin.kategorie }}</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(termin)"><Pencil :size="18" /></button>
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(termin)"><Trash2 :size="18" /></button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">Keine Termine vorhanden.</div>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}</h2>
<form class="space-y-4" @submit.prevent="saveTermin">
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
<input v-model="formData.datum" type="date" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
<input v-model="formData.uhrzeit" type="time" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
<select v-model="formData.kategorie" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
<option value="Training">Training</option>
<option value="Punktspiel">Punktspiel</option>
<option value="Turnier">Turnier</option>
<option value="Veranstaltung">Veranstaltung</option>
<option value="Sonstiges">Sonstiges</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
<input v-model="formData.titel" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea v-model="formData.beschreibung" rows="3" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving" />
</div>
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm">
<AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}
</div>
<div class="flex justify-end space-x-4 pt-4">
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving">
<Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" />
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-vue-next'
const isLoading = ref(true)
const isSaving = ref(false)
const termine = ref([])
const showModal = ref(false)
const errorMessage = ref('')
const isEditing = ref(false)
const originalTermin = ref(null)
const formData = ref({ datum: '', titel: '', beschreibung: '', kategorie: 'Sonstiges', uhrzeit: '' })
const loadTermine = async () => {
isLoading.value = true
try {
const response = await $fetch('/api/termine-manage')
termine.value = response.termine
} catch (error) {
console.error('Fehler beim Laden der Termine:', error)
} finally {
isLoading.value = false
}
}
const openAddModal = () => {
formData.value = { datum: '', titel: '', beschreibung: '', kategorie: 'Sonstiges', uhrzeit: '' }
showModal.value = true; errorMessage.value = ''; isEditing.value = false; originalTermin.value = null
}
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; originalTermin.value = null }
const saveTermin = async () => {
isSaving.value = true; errorMessage.value = ''
try {
if (isEditing.value && originalTermin.value) {
const params = new URLSearchParams({ datum: originalTermin.value.datum, uhrzeit: originalTermin.value.uhrzeit || '', titel: originalTermin.value.titel, beschreibung: originalTermin.value.beschreibung || '', kategorie: originalTermin.value.kategorie || 'Sonstiges' })
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
}
await $fetch('/api/termine-manage', { method: 'POST', body: formData.value })
closeModal(); await loadTermine()
} catch (error) {
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Termins.'
} finally {
isSaving.value = false
}
}
const openEditModal = (termin) => {
formData.value = { datum: termin.datum || '', uhrzeit: termin.uhrzeit || '', titel: termin.titel || '', beschreibung: termin.beschreibung || '', kategorie: termin.kategorie || 'Sonstiges' }
originalTermin.value = { ...termin }; isEditing.value = true; showModal.value = true; errorMessage.value = ''
}
const confirmDelete = async (termin) => {
window.showConfirmModal('Termin löschen', `Möchten Sie den Termin "${termin.titel}" wirklich löschen?`, async () => {
try {
const params = new URLSearchParams({ datum: termin.datum, uhrzeit: termin.uhrzeit || '', titel: termin.titel, beschreibung: termin.beschreibung || '', kategorie: termin.kategorie || 'Sonstiges' })
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
await loadTermine(); window.showSuccessModal('Erfolg', 'Termin wurde erfolgreich gelöscht')
} catch (error) {
console.error('Fehler beim Löschen:', error); window.showErrorModal('Fehler', 'Fehler beim Löschen des Termins')
}
})
}
const formatDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
onMounted(() => { loadTermine() })
</script>

View File

@@ -0,0 +1,183 @@
<template>
<div>
<!-- Header with save button -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
TT-Regeln bearbeiten
</h2>
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div>
<!-- Toolbar -->
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
<!-- Formatierung -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('bold')"><strong>B</strong></button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('italic')"><em>I</em></button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(1)">H1</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(2)">H2</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(3)">H3</button>
</div>
<!-- Listen -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertUnorderedList')"></button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertOrderedList')">1.</button>
</div>
<!-- Schnellzugriff für Regeln -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertRuleTemplate('generic')">Neue Regel</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertRuleTemplate('basic')">Grundregel</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertRuleTemplate('penalty')">Strafregel</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertRuleTemplate('service')">Aufschlag</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentRule()">Regel löschen</button>
</div>
<!-- Weitere Tools -->
<div class="flex items-center gap-1">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button>
</div>
</div>
</div>
<!-- Hilfe-Sektion -->
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-lg font-semibold text-blue-900 mb-2">So arbeiten Sie mit Regel-Kästchen:</h3>
<div class="text-sm text-blue-800 space-y-2">
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
<ul class="ml-4 space-y-1">
<li> <span class="bg-gray-100 px-2 py-1 rounded text-xs">Neue Regel</span> - Graues Kästchen</li>
<li> <span class="bg-blue-100 px-2 py-1 rounded text-xs">Grundregel</span> - Blaues Kästchen</li>
<li> <span class="bg-green-100 px-2 py-1 rounded text-xs">Strafregel</span> - Grünes Kästchen</li>
<li> <span class="bg-yellow-100 px-2 py-1 rounded text-xs">Aufschlag</span> - Gelbes Kästchen</li>
</ul>
<p><strong>2. Kästchen löschen:</strong> Klicken Sie in ein Kästchen und dann auf <span class="bg-red-100 px-2 py-1 rounded text-xs">Regel löschen</span></p>
<p><strong>3. Kästchen bearbeiten:</strong> Klicken Sie direkt in die Texte und bearbeiten Sie sie</p>
</div>
</div>
<!-- Editor -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4">
<div
ref="editor"
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
contenteditable
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const editor = ref(null)
async function load() {
const data = await $fetch('/api/config')
const html = data?.seiten?.ttRegeln || ''
if (editor.value) editor.value.innerHTML = html
}
async function save() {
const html = editor.value?.innerHTML || ''
const current = await $fetch('/api/config')
const updated = { ...current, seiten: { ...(current.seiten || {}), ttRegeln: html } }
try {
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Regeln erfolgreich gespeichert!') } catch { /* */ }
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch { /* */ }
}
}
function format(cmd) { document.execCommand(cmd, false, null) }
function formatHeader(level) { document.execCommand('formatBlock', false, 'H' + level) }
function createLink() { const url = prompt('URL eingeben:'); if (url) document.execCommand('createLink', false, url) }
function removeFormat() { document.execCommand('removeFormat', false, null) }
function insertRuleTemplate(type) {
const editorElement = editor.value
if (!editorElement) return
const templates = {
generic: '<div class="text-center p-6 bg-gray-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Regel</h3><p class="text-gray-600 text-sm">[Regeltext hier eingeben]</p></div>',
basic: '<div class="text-center p-6 bg-blue-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Grundregel</h3><p class="text-gray-600 text-sm"><strong>Regel:</strong> [Regeltext hier eingeben]<br><strong>Beschreibung:</strong> [Detaillierte Beschreibung]<br><strong>Anwendung:</strong> [Wann gilt diese Regel?]</p></div>',
penalty: '<div class="text-center p-6 bg-green-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Strafregel</h3><p class="text-gray-600 text-sm"><strong>Verstoß:</strong> [Was ist der Verstoß?]<br><strong>Strafe:</strong> [Welche Strafe wird verhängt?]<br><strong>Häufigkeit:</strong> [Bei wiederholten Verstößen?]</p></div>',
service: '<div class="text-center p-6 bg-yellow-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Aufschlagregel</h3><p class="text-gray-600 text-sm"><strong>Regel:</strong> [Aufschlagregel hier eingeben]<br><strong>Technik:</strong> [Wie muss der Aufschlag ausgeführt werden?]<br><strong>Fehler:</strong> [Was gilt als Fehler?]</p></div>'
}
const template = templates[type] || templates.generic
editorElement.focus()
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
let currentElement = range.commonAncestorContainer
if (currentElement.nodeType === Node.TEXT_NODE) currentElement = currentElement.parentElement
let targetContainer = currentElement
while (targetContainer && !targetContainer.classList.contains('grid')) {
targetContainer = targetContainer.parentElement
}
if (targetContainer && targetContainer.classList.contains('md:grid-cols-2') && targetContainer.classList.contains('lg:grid-cols-3') && targetContainer.classList.contains('gap-6')) {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = template
let newCard = null
for (let i = 0; i < tempDiv.childNodes.length; i++) {
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) { newCard = tempDiv.childNodes[i]; break }
}
if (newCard) {
targetContainer.appendChild(newCard)
const newRange = document.createRange()
const titleElement = newCard.querySelector('h3')
if (titleElement) { newRange.setStart(titleElement, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange) }
}
} else {
editorElement.innerHTML += template
}
} else {
editorElement.innerHTML += template
}
} else {
editorElement.innerHTML += template
}
}
function deleteCurrentRule() {
const editorElement = editor.value
if (!editorElement) return
editorElement.focus()
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
let currentElement = range.commonAncestorContainer
if (currentElement.nodeType === Node.TEXT_NODE) currentElement = currentElement.parentElement
let cardElement = currentElement
while (cardElement && !cardElement.classList.contains('text-center')) { cardElement = cardElement.parentElement }
if (cardElement && cardElement.classList.contains('text-center')) {
cardElement.remove()
const gridContainer = editorElement.querySelector('.grid')
if (gridContainer && gridContainer.children.length > 0) {
const firstCard = gridContainer.firstElementChild
const titleElement = firstCard.querySelector('h3')
if (titleElement) {
const newRange = document.createRange()
newRange.setStart(titleElement, 0); newRange.collapse(true)
selection.removeAllRanges(); selection.addRange(newRange)
}
}
}
}
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,133 @@
<template>
<div>
<!-- Header with save button -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
Über uns bearbeiten
</h2>
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div>
<!-- Toolbar -->
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('bold')"
>
<strong>B</strong>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('italic')"
>
<em>I</em>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(1)"
>
H1
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(2)"
>
H2
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(3)"
>
H3
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertUnorderedList')"
>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertOrderedList')"
>
1.
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="createLink()"
>
Link
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="removeFormat()"
>
Clear
</button>
</div>
</div>
<!-- Editor -->
<div class="bg-white rounded-b-lg shadow-sm border border-t-0 border-gray-200 p-3 sm:p-4">
<div
ref="editor"
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
contenteditable
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const editor = ref(null)
async function load() {
const data = await $fetch('/api/config')
const html = data?.seiten?.ueberUns || ''
if (editor.value) editor.value.innerHTML = html
}
async function save() {
const html = editor.value?.innerHTML || ''
const current = await $fetch('/api/config')
const updated = { ...current, seiten: { ...(current.seiten || {}), ueberUns: html } }
try {
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
// Modal nicht verfügbar
}
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
// Modal nicht verfügbar
}
}
}
function format(cmd) {
document.execCommand(cmd, false, null)
}
function formatHeader(level) {
document.execCommand('formatBlock', false, 'H' + level)
}
function createLink() {
const url = prompt('URL eingeben:')
if (!url) return
document.execCommand('createLink', false, url)
}
function removeFormat() {
document.execCommand('removeFormat', false, null)
}
onMounted(load)
</script>

View File

@@ -100,6 +100,18 @@ if ls public/data/*.csv >/dev/null 2>&1; then
else
echo " No public CSVs to backup (public/data/*.csv not found)"
fi
# Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
elif ls public/data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
else
echo " No public CSVs to backup (server/data/public-data or public/data not found)"
fi
# 2. Handle local changes and Git Pull
echo "2. Handling local changes and pulling latest from git..."
@@ -158,6 +170,38 @@ if [ -d ".output" ]; then
if [ -d ".output" ]; then
echo "ERROR: .output konnte auch nach erneutem Versuch nicht gelöscht werden!"
echo "Bitte manuell prüfen und löschen: rm -rf .output"
if ls "$BACKUP_DIR/public-data"/*.csv >/dev/null 2>&1; then
# Restore into internal storage (server/data/public-data)
mkdir -p server/data/public-data
for csv_file in "$BACKUP_DIR/public-data"/*.csv; do
filename=$(basename "$csv_file")
cp -f "$csv_file" "server/data/public-data/$filename"
if [ -f "server/data/public-data/$filename" ]; then
backup_size=$(stat -f%z "$csv_file" 2>/dev/null || stat -c%s "$csv_file" 2>/dev/null || echo "0")
restored_size=$(stat -f%z "server/data/public-data/$filename" 2>/dev/null || stat -c%s "server/data/public-data/$filename" 2>/dev/null || echo "0")
if [ "$backup_size" = "$restored_size" ] && [ "$backup_size" != "0" ]; then
echo " \u2713 Restored server/data/public-data/$filename from backup ($backup_size bytes)"
else
echo " \u26a0 WARNING: server/data/public-data/$filename size mismatch (Backup: $backup_size, Restored: $restored_size)"
fi
else
echo " \u274c ERROR: Konnte server/data/public-data/$filename nicht wiederherstellen!"
fi
done
echo " \u2713 All public-data files restored into server/data/public-data ($BACKUP_DIR/public-data)."
# Optional: synchronize internal public-data into public/data for legacy builds
# This uses the project's sync script and forces overwrite in public/data.
if command -v node >/dev/null 2>&1 && [ -f scripts/sync-public-data.js ]; then
echo " Synchronizing server/data/public-data -> public/data (using scripts/sync-public-data.js --force)"
node scripts/sync-public-data.js --force || echo " WARNING: sync script failed"
else
echo " Note: To publish CSVs to public/data run: node scripts/sync-public-data.js --force"
fi
else
echo " No public CSVs to restore"
fi
exit 1
fi
fi

View File

@@ -92,12 +92,17 @@ else
exit 1
fi
if ls public/data/*.csv >/dev/null 2>&1; then
# Prefer internal public-data under server/data/public-data for backups; fallback to legacy public/data
if ls server/data/public-data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a server/data/public-data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up server/data/public-data/*.csv -> $BACKUP_DIR/public-data/"
elif ls public/data/*.csv >/dev/null 2>&1; then
mkdir -p "$BACKUP_DIR/public-data"
cp -a public/data/*.csv "$BACKUP_DIR/public-data/"
echo " Backed up public/data/*.csv -> $BACKUP_DIR/public-data/"
else
echo " No public CSVs to backup (public/data/*.csv not found)"
echo " No public CSVs to backup (server/data/public-data or public/data not found)"
fi
# 2. Handle local changes and Git Pull
@@ -310,38 +315,33 @@ echo " Restored server/data from backup ($BACKUP_DIR/server-data)."
# Stelle alle CSVs wieder her
if ls "$BACKUP_DIR/public-data"/*.csv >/dev/null 2>&1; then
mkdir -p public/data
# WICHTIG: Überschreibe auch Dateien, die aus dem Git-Repository kommen
# Verwende cp mit -f (force) um sicherzustellen, dass Backup-Dateien Vorrang haben
# Restore into internal storage (server/data/public-data)
mkdir -p server/data/public-data
for csv_file in "$BACKUP_DIR/public-data"/*.csv; do
filename=$(basename "$csv_file")
# Überschreibe explizit, auch wenn Datei bereits existiert
cp -f "$csv_file" "public/data/$filename"
# Stelle sicher, dass die Datei wirklich überschrieben wurde
if [ -f "public/data/$filename" ]; then
# Prüfe, ob die Datei wirklich vom Backup kommt (Größenvergleich)
cp -f "$csv_file" "server/data/public-data/$filename"
if [ -f "server/data/public-data/$filename" ]; then
backup_size=$(stat -f%z "$csv_file" 2>/dev/null || stat -c%s "$csv_file" 2>/dev/null || echo "0")
restored_size=$(stat -f%z "public/data/$filename" 2>/dev/null || stat -c%s "public/data/$filename" 2>/dev/null || echo "0")
restored_size=$(stat -f%z "server/data/public-data/$filename" 2>/dev/null || stat -c%s "server/data/public-data/$filename" 2>/dev/null || echo "0")
if [ "$backup_size" = "$restored_size" ] && [ "$backup_size" != "0" ]; then
echo " Restored public/data/$filename from backup ($backup_size bytes)"
echo " \u2713 Restored server/data/public-data/$filename from backup ($backup_size bytes)"
else
echo " WARNING: public/data/$filename Größe stimmt nicht überein (Backup: $backup_size, Restored: $restored_size)"
echo " \u26a0 WARNING: server/data/public-data/$filename size mismatch (Backup: $backup_size, Restored: $restored_size)"
fi
else
echo " ERROR: Konnte public/data/$filename nicht wiederherstellen!"
echo " \u274c ERROR: Konnte server/data/public-data/$filename nicht wiederherstellen!"
fi
done
echo " All public/data/*.csv files restored from backup ($BACKUP_DIR/public-data)."
echo " \u2713 All public-data files restored into server/data/public-data ($BACKUP_DIR/public-data)."
# Zusätzliche Sicherheit: Entferne public/data Dateien aus Git-Index, falls sie getrackt sind
# (nach dem Restore, damit sie nicht beim nächsten git reset überschrieben werden)
if git ls-files --error-unmatch public/data/*.csv >/dev/null 2>&1; then
echo " WARNING: public/data/*.csv Dateien sind noch im Git getrackt!"
echo " Entferne sie aus dem Git-Index (Dateien bleiben erhalten)..."
git rm --cached public/data/*.csv 2>/dev/null || true
echo " ✓ public/data/*.csv aus Git-Index entfernt"
# Optional: synchronize internal public-data into public/data for legacy builds
# This uses the project's sync script and forces overwrite in public/data.
if command -v node >/dev/null 2>&1 && [ -f scripts/sync-public-data.js ]; then
echo " Synchronizing server/data/public-data -> public/data (using scripts/sync-public-data.js --force)"
node scripts/sync-public-data.js --force || echo " WARNING: sync script failed"
else
echo " Note: To publish CSVs to public/data run: node scripts/sync-public-data.js --force"
fi
else
echo " No public CSVs to restore"

View File

@@ -19,12 +19,17 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
if (to.path.startsWith('/cms')) {
const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : [])
const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')
const canAccessContactRequests = roles.includes('admin') || roles.includes('vorstand') || roles.includes('trainer')
// Newsletter-Seite nur für Newsletter-Rolle, Admin oder Vorstand
if (to.path.startsWith('/cms/newsletter')) {
if (!hasAccess) {
return navigateTo('/mitgliederbereich')
}
} else if (to.path.startsWith('/cms/kontaktanfragen')) {
if (!canAccessContactRequests) {
return navigateTo('/mitgliederbereich')
}
} else {
// Andere CMS-Seiten nur für Admin oder Vorstand
const isAdmin = roles.includes('admin') || roles.includes('vorstand')

12
package-lock.json generated
View File

@@ -9581,9 +9581,9 @@
}
},
"node_modules/nanotar": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/nanotar/-/nanotar-0.2.0.tgz",
"integrity": "sha512-9ca1h0Xjvo9bEkE4UOxgAzLV0jHKe6LMaxo37ND2DAhhAtd0j8pR1Wxz+/goMrZO8AEZTWCmyaOsFI/W5AdpCQ==",
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/nanotar/-/nanotar-0.2.1.tgz",
"integrity": "sha512-MUrzzDUcIOPbv7ubhDV/L4CIfVTATd9XhDE2ixFeCrM5yp9AlzUpn91JrnN0HD6hksdxvz9IW9aKANz0Bta0GA==",
"license": "MIT"
},
"node_modules/natural-compare": {
@@ -11377,9 +11377,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -12,6 +12,9 @@
"start": "nuxt start --port 3100",
"postinstall": "nuxt prepare",
"test": "vitest run",
"check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js",
"test:watch": "vitest watch",
"lint": "eslint . --fix"
},

View File

@@ -70,6 +70,9 @@
<option value="newsletter">
Newsletter
</option>
<option value="trainer">
Trainer
</option>
</select>
<!-- Approve Button -->
@@ -177,10 +180,11 @@
'bg-red-100 text-red-800': role === 'admin',
'bg-blue-100 text-blue-800': role === 'vorstand',
'bg-green-100 text-green-800': role === 'newsletter',
'bg-amber-100 text-amber-800': role === 'trainer',
'bg-gray-100 text-gray-800': role === 'mitglied'
}"
>
{{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : 'Mitglied' }}
{{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : role === 'trainer' ? 'Trainer' : 'Mitglied' }}
</span>
</div>
<button
@@ -280,6 +284,15 @@
>
<span class="ml-2 text-sm text-gray-700">Newsletter</span>
</label>
<label class="flex items-center">
<input
v-model="selectedRoles"
type="checkbox"
value="trainer"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<span class="ml-2 text-sm text-gray-700">Trainer</span>
</label>
<label class="flex items-center">
<input
v-model="selectedRoles"

View File

@@ -324,6 +324,14 @@
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
<input
v-model="trainer.email"
type="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Zusatzinfo</label>
<div class="flex space-x-2">
@@ -668,6 +676,7 @@ const addTrainer = () => {
name: '',
lizenz: '',
schwerpunkt: '',
email: '',
zusatz: ''
})
}

View File

@@ -7,9 +7,29 @@
<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 -->
<!-- Geburtstage Widget -->
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center">
<Calendar :size="20" class="text-pink-600" />
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Geburtstage (nächste 4 Wochen)</h2>
</div>
<div v-if="loadingBirthdays" class="text-sm text-gray-500">Lade...</div>
<ul v-else class="space-y-2">
<li v-for="b in birthdays" :key="b.name + b.dayMonth" class="flex items-center justify-between p-3 border border-gray-100 rounded-lg">
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ b.name }}</div>
<div class="text-xs text-gray-600">{{ b.dayMonth }}</div>
</div>
<div class="text-sm text-gray-500">{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}</div>
</li>
<li v-if="birthdays.length === 0" class="text-sm text-gray-600">Keine Geburtstage in den nächsten 4 Wochen.</li>
</ul>
</div>
<!-- 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 +40,11 @@
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Über uns
Inhalte
</h2>
</div>
<p class="text-gray-600">
Seite Über uns" bearbeiten (WYSIWYG)
</p>
</NuxtLink>
<!-- Geschichte -->
<NuxtLink
to="/cms/geschichte"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center group-hover:bg-amber-600 transition-colors">
<Newspaper
:size="24"
class="text-amber-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Geschichte
</h2>
</div>
<p class="text-gray-600">
Vereinsgeschichte bearbeiten (WYSIWYG)
</p>
</NuxtLink>
<!-- TT-Regeln -->
<NuxtLink
to="/cms/tt-regeln"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center group-hover:bg-red-600 transition-colors">
<Newspaper
:size="24"
class="text-red-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
TT-Regeln
</h2>
</div>
<p class="text-gray-600">
Tischtennis-Regeln bearbeiten (WYSIWYG)
</p>
</NuxtLink>
<!-- Satzung -->
<NuxtLink
to="/cms/satzung"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-slate-100 rounded-lg flex items-center justify-center group-hover:bg-slate-600 transition-colors">
<svg
class="w-6 h-6 text-slate-600 group-hover:text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Satzung
</h2>
</div>
<p class="text-gray-600">
Satzung als PDF hochladen
Über uns, Geschichte, TT-Regeln &amp; Satzung
</p>
</NuxtLink>
<!-- News -->
@@ -120,9 +68,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,38 +81,17 @@
/>
</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
Termine, Mannschaften &amp; Spielpläne
</p>
</NuxtLink>
<!-- Mannschaften -->
<!-- Mitgliederverwaltung (gruppiert) -->
<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
</p>
</NuxtLink>
<!-- Mitglieder -->
<NuxtLink
to="/mitgliederbereich/mitglieder"
to="/cms/mitgliederverwaltung"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
>
<div class="flex items-center mb-4">
@@ -175,11 +102,32 @@
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Mitglieder
Mitgliederverwaltung
</h2>
</div>
<p class="text-gray-600">
Mitgliederliste bearbeiten
Mitgliederliste &amp; Mitgliedschaftsanträge
</p>
</NuxtLink>
<!-- Kontaktanfragen -->
<NuxtLink
to="/cms/kontaktanfragen"
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-emerald-100 rounded-lg flex items-center justify-center group-hover:bg-emerald-600 transition-colors">
<Mail
:size="24"
class="text-emerald-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Kontaktanfragen
</h2>
</div>
<p class="text-gray-600">
Kontaktformular-Anfragen einsehen und beantworten
</p>
</NuxtLink>
@@ -252,10 +200,31 @@
</template>
<script setup>
import { Newspaper, Calendar, Users, UserCog, Settings, Layout } from 'lucide-vue-next'
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail } from 'lucide-vue-next'
import { ref, onMounted } from 'vue'
const authStore = useAuthStore()
const birthdays = ref([])
const loadingBirthdays = ref(true)
const loadBirthdays = async () => {
loadingBirthdays.value = true
try {
const res = await $fetch('/api/birthdays')
birthdays.value = res.birthdays || []
} catch (e) {
console.error('Fehler beim Laden der Geburtstage', e)
birthdays.value = []
} finally {
loadingBirthdays.value = false
}
}
onMounted(() => {
loadBirthdays()
})
definePageMeta({
middleware: 'auth',
layout: 'default'

64
pages/cms/inhalte.vue Normal file
View File

@@ -0,0 +1,64 @@
<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'" />
<CmsLinks v-if="activeTab === 'links'" />
</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'
import CmsLinks from '~/components/cms/CmsLinks.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' },
{ id: 'links', label: 'Links' }
]
</script>

View File

@@ -0,0 +1,255 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-4xl font-display font-bold text-gray-900">
Kontaktanfragen
</h1>
<div class="w-24 h-1 bg-primary-600 mt-4" />
</div>
<button
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
:disabled="isLoading"
@click="loadRequests"
>
{{ isLoading ? 'Lädt...' : 'Aktualisieren' }}
</button>
</div>
<div class="mb-4 flex items-center justify-end">
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
v-model="showAnswered"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
>
Bearbeitete Anfragen anzeigen
</label>
</div>
<div v-if="isLoading" class="text-center py-12 text-gray-600">
Lade Kontaktanfragen...
</div>
<div v-else-if="filteredRequests.length === 0" class="bg-white rounded-xl shadow p-8 text-center text-gray-600">
{{ showAnswered ? 'Aktuell liegen keine Kontaktanfragen vor.' : 'Aktuell liegen keine offenen Kontaktanfragen vor.' }}
</div>
<div v-else class="space-y-4">
<div
v-for="request in filteredRequests"
:key="request.id"
class="bg-white rounded-xl shadow border border-gray-100"
>
<div class="p-5 border-b border-gray-100 flex items-start justify-between gap-4">
<div>
<p class="text-lg font-semibold text-gray-900">
{{ request.subject }}
</p>
<p class="text-sm text-gray-600">
Von {{ request.name }} ({{ request.email }}){{ request.phone ? ` · ${request.phone}` : '' }}
</p>
<p class="text-xs text-gray-500 mt-1">
Eingegangen: {{ formatDate(request.createdAt) }}
</p>
</div>
<span
class="px-2.5 py-1 rounded-full text-xs font-semibold"
:class="request.status === 'beantwortet' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
>
{{ request.status === 'beantwortet' ? 'Erledigt' : 'Offen' }}
</span>
</div>
<div class="p-5">
<p class="text-gray-800 whitespace-pre-wrap">
{{ request.message }}
</p>
<div v-if="Array.isArray(request.replies) && request.replies.length > 0" class="mt-5 border-t border-gray-100 pt-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">
Antworten
</h3>
<div class="space-y-2">
<div
v-for="reply in request.replies"
:key="reply.id"
class="bg-gray-50 rounded-lg p-3"
>
<p class="text-xs text-gray-500 mb-1">
{{ formatDate(reply.createdAt) }}{{ reply.responderEmail ? ` · ${reply.responderEmail}` : '' }}
</p>
<p class="text-sm text-gray-800 whitespace-pre-wrap">
{{ reply.message }}
</p>
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
:disabled="togglingId === request.id"
@click="toggleStatus(request)"
>
{{ togglingId === request.id ? '…' : (request.status === 'beantwortet' ? 'Wieder öffnen' : 'Als erledigt markieren') }}
</button>
<button
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
@click="openReplyModal(request)"
>
Antworten
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="replyModalOpen && selectedRequest"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeReplyModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-2">
Antwort senden
</h2>
<p class="text-sm text-gray-600 mb-4">
An: {{ selectedRequest.email }}<br>
Betreff: <strong>Aw: {{ selectedRequest.subject }}</strong>
</p>
<textarea
v-model="replyText"
rows="8"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600"
placeholder="Ihre Antwort..."
/>
<div v-if="errorMessage" class="mt-3 text-sm text-red-600">
{{ errorMessage }}
</div>
<div class="mt-5 flex justify-end gap-3">
<button
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
:disabled="isSendingReply"
@click="closeReplyModal"
>
Abbrechen
</button>
<button
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
:disabled="isSendingReply || !replyText.trim()"
@click="sendReply"
>
{{ isSendingReply ? 'Sende...' : 'Antwort senden' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const requests = ref([])
const isLoading = ref(false)
const replyModalOpen = ref(false)
const selectedRequest = ref(null)
const replyText = ref('')
const isSendingReply = ref(false)
const errorMessage = ref('')
const showAnswered = ref(false)
const togglingId = ref(null)
const filteredRequests = computed(() => {
if (showAnswered.value) return requests.value
return requests.value.filter((request) => request.status !== 'beantwortet')
})
const formatDate = (value) => {
if (!value) return '-'
return new Date(value).toLocaleString('de-DE')
}
const loadRequests = async () => {
isLoading.value = true
try {
requests.value = await $fetch('/api/cms/contact-requests')
} catch (error) {
console.error('Fehler beim Laden der Kontaktanfragen:', error)
requests.value = []
} finally {
isLoading.value = false
}
}
const openReplyModal = (request) => {
selectedRequest.value = request
replyText.value = ''
errorMessage.value = ''
replyModalOpen.value = true
}
const closeReplyModal = () => {
replyModalOpen.value = false
selectedRequest.value = null
replyText.value = ''
errorMessage.value = ''
}
const toggleStatus = async (request) => {
togglingId.value = request.id
try {
await $fetch(`/api/cms/contact-requests/${request.id}/toggle-status`, {
method: 'PATCH'
})
await loadRequests()
} catch (error) {
console.error('Fehler beim Umschalten des Status:', error)
if (window.showErrorModal) {
window.showErrorModal('Fehler', error?.data?.statusMessage || 'Status konnte nicht geändert werden.')
}
} finally {
togglingId.value = null
}
}
const sendReply = async () => {
if (!selectedRequest.value) return
const text = replyText.value.trim()
if (!text) return
isSendingReply.value = true
errorMessage.value = ''
try {
await $fetch(`/api/cms/contact-requests/${selectedRequest.value.id}/reply`, {
method: 'POST',
body: { message: text }
})
closeReplyModal()
await loadRequests()
if (window.showSuccessModal) {
window.showSuccessModal('Erfolg', 'Antwort wurde erfolgreich versendet.')
}
} catch (error) {
console.error('Fehler beim Senden der Antwort:', error)
errorMessage.value = error?.data?.statusMessage || error?.data?.message || 'Antwort konnte nicht gesendet werden.'
} finally {
isSendingReply.value = false
}
}
onMounted(loadRequests)
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'Kontaktanfragen - CMS - Harheimer TC'
})
</script>

View File

@@ -1,836 +0,0 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
Mannschaften verwalten
</h1>
<div class="w-24 h-1 bg-primary-600 mb-4" />
</div>
<button
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<Plus
:size="20"
class="mr-2"
/>
Mannschaft hinzufügen
</button>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- Mannschaften Table -->
<div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Liga
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Staffelleiter
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaftsführer
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Spieler
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(mannschaft, index) in mannschaften"
:key="index"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ mannschaft.mannschaft }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.liga }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.staffelleiter }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.mannschaftsfuehrer }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
<div class="max-w-xs truncate">
{{ getSpielerListe(mannschaft).join(', ') || '-' }}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button
class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(mannschaft, index)"
>
<Pencil
:size="18"
/>
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(mannschaft, index)"
>
<Trash2
:size="18"
/>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Empty State -->
<div
v-if="!isLoading && mannschaften.length === 0"
class="bg-white rounded-xl shadow-lg p-12 text-center"
>
<Users
:size="48"
class="text-gray-400 mx-auto mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Keine Mannschaften vorhanden
</h3>
<p class="text-gray-600 mb-6">
Fügen Sie die erste Mannschaft hinzu.
</p>
<button
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
Mannschaft hinzufügen
</button>
</div>
</div>
<!-- Add/Edit Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200">
<h2 class="text-2xl font-display font-bold text-gray-900">
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
</h2>
</div>
<form
class="p-6 space-y-4"
@submit.prevent="saveMannschaft"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Mannschaft *
</label>
<input
v-model="formData.mannschaft"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Liga *
</label>
<input
v-model="formData.liga"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Staffelleiter
</label>
<input
v-model="formData.staffelleiter"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Telefon
</label>
<input
v-model="formData.telefon"
type="tel"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Heimspieltag
</label>
<input
v-model="formData.heimspieltag"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Spielsystem
</label>
<input
v-model="formData.spielsystem"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Mannschaftsführer
</label>
<input
v-model="formData.mannschaftsfuehrer"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Spieler
</label>
<div class="space-y-2">
<div
v-if="formData.spielerListe.length === 0"
class="text-sm text-gray-500"
>
Noch keine Spieler eingetragen.
</div>
<div
v-for="(spieler, index) in formData.spielerListe"
:key="spieler.id"
class="px-3 py-2 border border-gray-200 rounded-lg bg-white"
>
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
<input
v-model="spieler.name"
type="text"
class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Spielername"
:disabled="isSaving"
>
<!-- Reihenfolge -->
<div class="flex items-center gap-1">
<button
type="button"
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach oben"
:disabled="isSaving || index === 0"
@click="moveSpielerUp(index)"
>
<ChevronUp :size="18" />
</button>
<button
type="button"
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach unten"
:disabled="isSaving || index === formData.spielerListe.length - 1"
@click="moveSpielerDown(index)"
>
<ChevronDown :size="18" />
</button>
<button
type="button"
class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Spieler entfernen"
:disabled="isSaving"
@click="removeSpieler(spieler.id)"
>
<Trash2 :size="18" />
</button>
</div>
<!-- Verschieben (kompakt in gleicher Zeile) -->
<div class="flex items-center gap-2">
<select
v-model="moveTargetBySpielerId[spieler.id]"
class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1"
title="Mannschaft auswählen"
>
<option
v-for="t in mannschaftenSelectOptions"
:key="t"
:value="t"
>
{{ t }}
</option>
</select>
<button
type="button"
class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)"
@click="moveSpielerToMannschaft(spieler.id)"
title="In ausgewählte Mannschaft verschieben"
>
<ArrowRight :size="18" />
</button>
</div>
</div>
</div>
</div>
<div class="mt-3 flex items-center justify-between">
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors"
:disabled="isSaving"
@click="addSpieler()"
>
<Plus
:size="18"
class="mr-2"
/>
Spieler hinzufügen
</button>
<p class="text-xs text-gray-500">
Reihenfolge per / ändern. Verschieben nur bei bestehenden Mannschaften.
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Weitere Informationen (Link)
</label>
<input
v-model="formData.weitere_informationen_link"
type="url"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
:disabled="isSaving"
>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/>
{{ errorMessage }}
</div>
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
const isLoading = ref(true)
const isSaving = ref(false)
const mannschaften = ref([])
const showModal = ref(false)
const errorMessage = ref('')
const isEditing = ref(false)
const editingIndex = ref(-1)
const formData = ref({
mannschaft: '',
liga: '',
staffelleiter: '',
telefon: '',
heimspieltag: '',
spielsystem: '',
mannschaftsfuehrer: '',
spielerListe: [],
weitere_informationen_link: '',
letzte_aktualisierung: ''
})
// Für Verschieben-UI (Combobox pro Spieler)
const moveTargetBySpielerId = ref({})
// Pending-Änderungen für andere Teams (wird erst beim Speichern angewendet)
const pendingSpielerNamesByTeamIndex = ref({}) // { [index: number]: string[] }
function nowIsoDate() {
return new Date().toISOString().split('T')[0]
}
function newSpielerItem(name = '') {
return {
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
name
}
}
function parseSpielerString(spielerString) {
if (!spielerString) return []
return String(spielerString)
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(name => newSpielerItem(name))
}
function serializeSpielerList(spielerListe) {
return (spielerListe || [])
.map(s => (s?.name || '').trim())
.filter(Boolean)
.join('; ')
}
function serializeSpielerNames(spielerNames) {
return (spielerNames || [])
.map(s => String(s || '').trim())
.filter(Boolean)
.join('; ')
}
async function fetchCsvText(url) {
const attempt = async () => {
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
const response = await fetch(withBuster, { cache: 'no-store' })
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.text()
}
try {
return await attempt()
} catch (e) {
// 1 Retry: hilft bei Firefox NS_ERROR_NET_PARTIAL_TRANSFER direkt nach Speichern
await new Promise(resolve => setTimeout(resolve, 150))
return await attempt()
}
}
const mannschaftenSelectOptions = computed(() => {
const current = (formData.value.mannschaft || '').trim()
const names = mannschaften.value
.map(m => (m?.mannschaft || '').trim())
.filter(Boolean)
return [...new Set([current, ...names])].filter(Boolean)
})
function resetSpielerDraftState() {
moveTargetBySpielerId.value = {}
pendingSpielerNamesByTeamIndex.value = {}
}
function getPendingSpielerNamesForTeamIndex(teamIndex) {
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) {
return pendingSpielerNamesByTeamIndex.value[teamIndex]
}
const existing = mannschaften.value[teamIndex]
const list = existing ? getSpielerListe(existing) : []
pendingSpielerNamesByTeamIndex.value[teamIndex] = [...list]
return pendingSpielerNamesByTeamIndex.value[teamIndex]
}
const loadMannschaften = async () => {
isLoading.value = true
try {
const csv = await fetchCsvText('/api/mannschaften')
const lines = csv.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) {
mannschaften.value = []
return
}
mannschaften.value = lines.slice(1).map(line => {
// CSV-Parser: Respektiert Anführungszeichen
const values = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
if (values.length < 10) return null
return {
mannschaft: values[0]?.trim() || '',
liga: values[1]?.trim() || '',
staffelleiter: values[2]?.trim() || '',
telefon: values[3]?.trim() || '',
heimspieltag: values[4]?.trim() || '',
spielsystem: values[5]?.trim() || '',
mannschaftsfuehrer: values[6]?.trim() || '',
spieler: values[7]?.trim() || '',
weitere_informationen_link: values[8]?.trim() || '',
letzte_aktualisierung: values[9]?.trim() || ''
}
}).filter(mannschaft => mannschaft !== null && mannschaft.mannschaft !== '')
} catch (error) {
console.error('Fehler beim Laden der Mannschaften:', error)
errorMessage.value = 'Fehler beim Laden der Mannschaften'
throw error
} finally {
isLoading.value = false
}
}
const getSpielerListe = (mannschaft) => {
if (!mannschaft.spieler) return []
return mannschaft.spieler.split(';').map(s => s.trim()).filter(s => s !== '')
}
const openAddModal = () => {
formData.value = {
mannschaft: '',
liga: '',
staffelleiter: '',
telefon: '',
heimspieltag: '',
spielsystem: '',
mannschaftsfuehrer: '',
spielerListe: [],
weitere_informationen_link: '',
letzte_aktualisierung: nowIsoDate()
}
showModal.value = true
errorMessage.value = ''
isEditing.value = false
editingIndex.value = -1
resetSpielerDraftState()
}
const closeModal = () => {
showModal.value = false
errorMessage.value = ''
isEditing.value = false
editingIndex.value = -1
resetSpielerDraftState()
}
const openEditModal = (mannschaft, index) => {
formData.value = {
mannschaft: mannschaft.mannschaft || '',
liga: mannschaft.liga || '',
staffelleiter: mannschaft.staffelleiter || '',
telefon: mannschaft.telefon || '',
heimspieltag: mannschaft.heimspieltag || '',
spielsystem: mannschaft.spielsystem || '',
mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '',
spielerListe: parseSpielerString(mannschaft.spieler || ''),
weitere_informationen_link: mannschaft.weitere_informationen_link || '',
letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate()
}
isEditing.value = true
editingIndex.value = index
showModal.value = true
errorMessage.value = ''
resetSpielerDraftState()
// Pro Spieler: aktuelle Mannschaft vorauswählen
const currentTeam = (formData.value.mannschaft || '').trim()
for (const s of formData.value.spielerListe) {
moveTargetBySpielerId.value[s.id] = currentTeam
}
}
const addSpieler = () => {
const item = newSpielerItem('')
formData.value.spielerListe.push(item)
moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim()
}
const removeSpieler = (spielerId) => {
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId)
if (idx === -1) return
formData.value.spielerListe.splice(idx, 1)
if (moveTargetBySpielerId.value[spielerId]) {
delete moveTargetBySpielerId.value[spielerId]
}
}
const moveSpielerUp = (index) => {
if (index <= 0) return
const arr = formData.value.spielerListe
const item = arr[index]
arr.splice(index, 1)
arr.splice(index - 1, 0, item)
}
const moveSpielerDown = (index) => {
const arr = formData.value.spielerListe
if (index < 0 || index >= arr.length - 1) return
const item = arr[index]
arr.splice(index, 1)
arr.splice(index + 1, 0, item)
}
const canMoveSpieler = (spielerId) => {
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim()
const currentTeam = (formData.value.mannschaft || '').trim()
return Boolean(targetName) && Boolean(currentTeam) && targetName !== currentTeam
}
const moveSpielerToMannschaft = (spielerId) => {
if (!isEditing.value || editingIndex.value < 0) return
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim()
if (!targetName) return
const targetIndex = mannschaften.value.findIndex((m, idx) => {
if (idx === editingIndex.value) return false
return (m?.mannschaft || '').trim() === targetName
})
if (targetIndex === -1) {
errorMessage.value = 'Ziel-Mannschaft nicht gefunden. Bitte aus der Liste auswählen.'
return
}
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId)
if (idx === -1) return
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim()
if (!spielerName) {
errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'
return
}
// Entfernen aus aktueller Mannschaft
formData.value.spielerListe.splice(idx, 1)
// Hinzufügen zur Ziel-Mannschaft (pending; wird erst beim Speichern geschrieben)
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex)
pendingList.push(spielerName)
// UI zurücksetzen
delete moveTargetBySpielerId.value[spielerId]
}
const saveMannschaft = async () => {
isSaving.value = true
errorMessage.value = ''
try {
const spielerString = serializeSpielerList(formData.value.spielerListe)
const updated = {
mannschaft: formData.value.mannschaft || '',
liga: formData.value.liga || '',
staffelleiter: formData.value.staffelleiter || '',
telefon: formData.value.telefon || '',
heimspieltag: formData.value.heimspieltag || '',
spielsystem: formData.value.spielsystem || '',
mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '',
spieler: spielerString,
weitere_informationen_link: formData.value.weitere_informationen_link || '',
letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate()
}
if (isEditing.value && editingIndex.value >= 0) {
// Aktualisiere bestehende Mannschaft
mannschaften.value[editingIndex.value] = { ...updated }
} else {
// Füge neue Mannschaft hinzu
mannschaften.value.push({ ...updated })
}
// Pending-Verschiebungen anwenden (andere Mannschaften)
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
if (touchedTeamIndexes.length > 0) {
const ts = nowIsoDate()
for (const idxStr of touchedTeamIndexes) {
const idx = Number(idxStr)
if (!Number.isFinite(idx)) continue
const existing = mannschaften.value[idx]
if (!existing) continue
const pendingNames = pendingSpielerNamesByTeamIndex.value[idx]
mannschaften.value[idx] = {
...existing,
spieler: serializeSpielerNames(pendingNames),
letzte_aktualisierung: ts
}
}
}
// Speichere als CSV
await saveCSV()
closeModal()
await loadMannschaften()
if (window.showSuccessModal) {
window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gespeichert')
}
} catch (error) {
console.error('Fehler beim Speichern:', error)
errorMessage.value = error?.data?.statusMessage || error?.statusMessage || error?.data?.message || 'Fehler beim Speichern der Mannschaft.'
if (window.showErrorModal) {
window.showErrorModal('Fehler', errorMessage.value)
}
} finally {
isSaving.value = false
}
}
const saveCSV = async () => {
// CSV-Header
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
// CSV-Zeilen generieren
const rows = mannschaften.value.map(m => {
// Escape-Werte, die Kommas oder Anführungszeichen enthalten
const escapeCSV = (value) => {
if (!value) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
return [
escapeCSV(m.mannschaft),
escapeCSV(m.liga),
escapeCSV(m.staffelleiter),
escapeCSV(m.telefon),
escapeCSV(m.heimspieltag),
escapeCSV(m.spielsystem),
escapeCSV(m.mannschaftsfuehrer),
escapeCSV(m.spieler),
escapeCSV(m.weitere_informationen_link),
escapeCSV(m.letzte_aktualisierung)
].join(',')
})
const csvContent = [header, ...rows].join('\n')
// Speichere über API
await $fetch('/api/cms/save-csv', {
method: 'POST',
body: {
filename: 'mannschaften.csv',
content: csvContent
}
})
}
const confirmDelete = (mannschaft, index) => {
if (window.showConfirmModal) {
window.showConfirmModal('Mannschaft löschen', `Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`, async () => {
try {
mannschaften.value.splice(index, 1)
await saveCSV()
await loadMannschaften()
window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gelöscht')
} catch (error) {
console.error('Fehler beim Löschen:', error)
window.showErrorModal('Fehler', 'Fehler beim Löschen der Mannschaft')
}
})
} else {
// Fallback ohne Modal
if (confirm(`Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) {
mannschaften.value.splice(index, 1)
saveCSV()
loadMannschaften()
}
}
}
onMounted(() => {
loadMannschaften().catch(() => {})
})
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'Mannschaften verwalten - Harheimer TC',
})
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Mitgliederverwaltung</h1>
<p class="mt-1 text-sm text-gray-500">Anträge und Mitgliederliste verwalten</p>
</div>
<!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) -->
<div v-show="antraegeRef?.hasApplications" class="mb-10">
<CmsMitgliedschaftsantraege ref="antraegeRef" />
</div>
<div v-if="antraegeRef?.hasApplications" class="border-t border-gray-300 mb-10" />
<!-- Mitgliederliste darunter -->
<CmsMitglieder />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import CmsMitglieder from '~/components/cms/CmsMitglieder.vue'
import CmsMitgliedschaftsantraege from '~/components/cms/CmsMitgliedschaftsantraege.vue'
const antraegeRef = ref(null)
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'Mitgliederverwaltung CMS'
})
</script>

View File

@@ -1,420 +0,0 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Fixed Header -->
<div class="fixed top-16 left-0 right-0 bg-white shadow-sm border-b border-gray-200 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between py-3 sm:py-4">
<h1 class="text-xl sm:text-3xl font-bold text-gray-900">
Mitgliedschaftsanträge
</h1>
<button
:disabled="loading"
class="px-3 py-1.5 sm:px-4 sm:py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white text-sm sm:text-base font-medium rounded-lg transition-colors"
@click="refreshApplications"
>
{{ loading ? 'Lädt...' : 'Aktualisieren' }}
</button>
</div>
</div>
</div>
<!-- Content -->
<div class="pt-20 sm:pt-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Loading State -->
<div
v-if="loading"
class="text-center py-12"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto" />
<p class="mt-4 text-gray-600">
Lade Anträge...
</p>
</div>
<!-- Empty State -->
<div
v-else-if="applications.length === 0"
class="text-center py-12"
>
<div class="text-gray-400 text-6xl mb-4">
📋
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Keine Anträge vorhanden
</h3>
<p class="text-gray-600">
Es wurden noch keine Mitgliedschaftsanträge eingereicht.
</p>
</div>
<!-- Applications List -->
<div
v-else
class="space-y-6"
>
<div
v-for="application in applications"
:key="application.id"
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
>
<!-- Application Header -->
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ application.personalData.vorname }} {{ application.personalData.nachname }}
</h3>
<p class="text-sm text-gray-600">
Eingereicht: {{ formatDate(application.timestamp) }}
</p>
</div>
<div class="flex items-center space-x-3">
<!-- Status Badge -->
<span
:class="[
'px-3 py-1 rounded-full text-sm font-medium',
getStatusClass(application.status)
]"
>
{{ getStatusText(application.status) }}
</span>
<!-- Actions -->
<div class="flex space-x-2">
<button
class="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="viewApplication(application)"
>
Anzeigen
</button>
<button
v-if="application.metadata.pdfGenerated"
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors flex items-center"
@click="downloadPDF(application.id)"
>
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
PDF
</button>
<button
v-if="application.status === 'pending'"
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
@click="approveApplication(application.id)"
>
Genehmigen
</button>
<button
v-if="application.status === 'pending'"
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="rejectApplication(application.id)"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
<!-- Application Details -->
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">
Kontaktdaten
</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
<p v-if="application.personalData.telefon_privat">
<strong>Telefon:</strong> {{ application.personalData.telefon_privat }}
</p>
<p v-if="application.personalData.telefon_mobil">
<strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}
</p>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">
Antragsdetails
</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>PDF:</strong> {{ application.metadata.pdfGenerated ? 'Generiert' : 'Nicht verfügbar' }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Application Detail Modal -->
<div
v-if="selectedApplication"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
</h2>
<button
class="text-gray-400 hover:text-gray-600"
@click="closeModal"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Personal Data -->
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">
Persönliche Daten
</h3>
<div class="space-y-2 text-sm">
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
<p v-if="selectedApplication.personalData.telefon_privat">
<strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}
</p>
<p v-if="selectedApplication.personalData.telefon_mobil">
<strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}
</p>
</div>
</div>
<!-- Application Details -->
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">
Antragsdetails
</h3>
<div class="space-y-2 text-sm">
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ selectedApplication.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>Eingereicht:</strong> {{ formatDate(selectedApplication.timestamp) }}</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<button
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeModal"
>
Schließen
</button>
<button
v-if="selectedApplication.metadata.pdfGenerated"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
@click="downloadPDF(selectedApplication.id)"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
PDF herunterladen
</button>
<button
v-if="selectedApplication.status === 'pending'"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
@click="approveApplication(selectedApplication.id)"
>
Genehmigen
</button>
<button
v-if="selectedApplication.status === 'pending'"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
@click="rejectApplication(selectedApplication.id)"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const applications = ref([])
const loading = ref(false)
const selectedApplication = ref(null)
const loadApplications = async () => {
loading.value = true
try {
const response = await $fetch('/api/membership/applications')
applications.value = response
} catch (error) {
console.error('Fehler beim Laden der Anträge:', error)
window.showErrorModal('Fehler', 'Fehler beim Laden der Anträge')
} finally {
loading.value = false
}
}
const refreshApplications = () => {
loadApplications()
}
const viewApplication = (application) => {
selectedApplication.value = application
}
const closeModal = () => {
selectedApplication.value = null
}
const approveApplication = async (id) => {
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich genehmigen?', async () => {
try {
await $fetch('/api/membership/update-status', {
method: 'PUT',
body: { id, status: 'approved' }
})
await loadApplications()
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich genehmigt')
} catch (error) {
console.error('Fehler beim Genehmigen:', error)
window.showErrorModal('Fehler', 'Fehler beim Genehmigen des Antrags')
}
})
}
const rejectApplication = async (id) => {
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich ablehnen?', async () => {
try {
await $fetch('/api/membership/update-status', {
method: 'PUT',
body: { id, status: 'rejected' }
})
await loadApplications()
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich abgelehnt')
} catch (error) {
console.error('Fehler beim Ablehnen:', error)
window.showErrorModal('Fehler', 'Fehler beim Ablehnen des Antrags')
}
})
}
const downloadPDF = async (id) => {
try {
const filename = `beitrittserklärung_${id}.pdf`
// Direkter Download über die öffentliche Uploads-Route
const response = await fetch(`/uploads/${filename}`)
if (!response.ok) {
throw new Error('PDF nicht gefunden')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
window.showSuccessModal('Erfolg', 'PDF wurde erfolgreich heruntergeladen')
} catch (error) {
console.error('Fehler beim Herunterladen:', error)
window.showErrorModal('Fehler', 'Fehler beim Herunterladen des PDFs')
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getStatusClass = (status) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800'
case 'approved':
return 'bg-green-100 text-green-800'
case 'rejected':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getStatusText = (status) => {
switch (status) {
case 'pending':
return 'Ausstehend'
case 'approved':
return 'Genehmigt'
case 'rejected':
return 'Abgelehnt'
default:
return 'Unbekannt'
}
}
onMounted(() => {
loadApplications()
})
useHead({
title: 'Mitgliedschaftsanträge - CMS - Harheimer TC',
})
</script>

View File

@@ -1,191 +0,0 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl sm:text-4xl font-display font-bold text-gray-900">
Satzung verwalten
</h1>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">
PDF-Upload
</h2>
<form
enctype="multipart/form-data"
class="space-y-4"
@submit.prevent="uploadPdf"
>
<div>
<label
for="pdf-file"
class="block text-sm font-medium text-gray-700 mb-2"
>
Neue Satzung hochladen (PDF)
</label>
<input
id="pdf-file"
ref="fileInput"
type="file"
accept=".pdf"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
@change="handleFileSelect"
>
<p class="mt-1 text-sm text-gray-500">
Nur PDF-Dateien bis 10MB sind erlaubt
</p>
</div>
<button
type="submit"
:disabled="!selectedFile || uploading"
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
v-if="uploading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{{ uploading ? 'Wird hochgeladen...' : 'PDF hochladen' }}
</button>
</form>
</div>
<div
v-if="currentPdfUrl"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"
>
<h2 class="text-xl font-semibold mb-4">
Aktuelle Satzung
</h2>
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600">
PDF-Datei verfügbar
</p>
<a
:href="currentPdfUrl"
target="_blank"
class="text-primary-600 hover:text-primary-700 font-medium"
>
Satzung anzeigen
</a>
</div>
<div class="text-sm text-gray-500">
Zuletzt aktualisiert: {{ lastUpdated }}
</div>
</div>
</div>
<div
v-if="message"
class="mt-4 p-4 rounded-lg"
:class="messageType === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'"
>
{{ message }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'CMS: Satzung' })
const fileInput = ref(null)
const selectedFile = ref(null)
const uploading = ref(false)
const currentPdfUrl = ref('')
const lastUpdated = ref('')
const message = ref('')
const messageType = ref('')
async function loadCurrentSatzung() {
try {
const data = await $fetch('/api/config')
const satzung = data?.seiten?.satzung
if (satzung?.pdfUrl) {
currentPdfUrl.value = satzung.pdfUrl
// Einfache Zeitstempel-Simulation
lastUpdated.value = new Date().toLocaleDateString('de-DE')
}
} catch (e) {
console.error('Fehler beim Laden der aktuellen Satzung:', e)
}
}
function handleFileSelect(event) {
const file = event.target.files[0]
if (file) {
if (file.type !== 'application/pdf') {
message.value = 'Bitte wählen Sie eine PDF-Datei aus'
messageType.value = 'error'
return
}
if (file.size > 10 * 1024 * 1024) {
message.value = 'Die Datei ist zu groß (max. 10MB)'
messageType.value = 'error'
return
}
selectedFile.value = file
message.value = ''
}
}
async function uploadPdf() {
if (!selectedFile.value) return
uploading.value = true
message.value = ''
try {
const formData = new FormData()
formData.append('pdf', selectedFile.value)
const result = await $fetch('/api/cms/satzung-upload', {
method: 'POST',
body: formData
})
message.value = result.message
messageType.value = 'success'
// Aktuelle Satzung neu laden
await loadCurrentSatzung()
// Formular zurücksetzen
selectedFile.value = null
if (fileInput.value) fileInput.value.value = ''
} catch (error) {
message.value = error.data?.message || 'Fehler beim Hochladen der PDF-Datei'
messageType.value = 'error'
} finally {
uploading.value = false
}
}
onMounted(loadCurrentSatzung)
</script>

View File

@@ -1,682 +0,0 @@
<template>
<div class="min-h-full bg-gray-50">
<!-- Fixed Header below navigation -->
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
Spielpläne bearbeiten
</h1>
<div class="space-x-3">
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
@click="showUploadModal = true"
>
<svg
class="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
CSV hochladen
</button>
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div>
</div>
</div>
</div>
<!-- Content with top padding -->
<div class="pt-20 pb-16">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- CSV Upload Section -->
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
Vereins-Spielplan (CSV)
</h2>
<!-- Current File Info -->
<div
v-if="currentFile"
class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg
class="w-5 h-5 text-green-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm font-medium text-green-800">
{{ currentFile.name }}
</p>
<p class="text-xs text-green-600">
{{ currentFile.size }} bytes, {{ currentFile.lastModified ? new Date(currentFile.lastModified).toLocaleDateString('de-DE') : 'Unbekannt' }}
</p>
</div>
</div>
<button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="removeFile"
>
Entfernen
</button>
</div>
</div>
<!-- Upload Area -->
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
@click="triggerFileInput"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
>
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p class="text-lg font-medium text-gray-900 mb-2">
CSV-Datei hochladen
</p>
<p class="text-sm text-gray-600 mb-4">
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
</p>
<p class="text-xs text-gray-500">
Unterstützte Formate: .csv
</p>
</div>
<input
ref="fileInput"
type="file"
accept=".csv"
class="hidden"
@change="handleFileSelect"
>
</div>
<!-- Column Selection -->
<div
v-if="csvData.length > 0 && !columnsSelected"
class="bg-white rounded-xl shadow-lg p-6 mb-8"
>
<h2 class="text-xl font-semibold text-gray-900 mb-4">
Spalten auswählen
</h2>
<p class="text-sm text-gray-600 mb-6">
Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:
</p>
<div class="space-y-4">
<div
v-for="(header, index) in csvHeaders"
:key="index"
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<input
:id="`column-${index}`"
v-model="selectedColumns[index]"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<label
:for="`column-${index}`"
class="ml-3 text-sm font-medium text-gray-900"
>
{{ header }}
</label>
</div>
<div class="text-xs text-gray-500">
{{ getColumnPreview(index) }}
</div>
</div>
</div>
<div class="mt-6 flex justify-between items-center">
<div class="text-sm text-gray-600">
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
</div>
<div class="space-x-3">
<button
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="selectAllColumns"
>
Alle auswählen
</button>
<button
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="deselectAllColumns"
>
Alle abwählen
</button>
<button
class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="suggestHalleColumns"
>
Halle-Spalten vorschlagen
</button>
<button
:disabled="selectedColumnsCount === 0"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="confirmColumnSelection"
>
Auswahl bestätigen
</button>
</div>
</div>
</div>
<!-- Data Preview -->
<div
v-if="csvData.length > 0 && columnsSelected"
class="bg-white rounded-xl shadow-lg p-6"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">
Datenvorschau
</h2>
<div class="flex space-x-2">
<button
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="exportCSV"
>
CSV exportieren
</button>
<button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="clearData"
>
Daten löschen
</button>
</div>
</div>
<!-- Data Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)"
:key="index"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{{ header }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)"
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
>
<td
v-for="(cell, cellIndex) in row"
:key="cellIndex"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="(columnsSelected ? filteredCsvData : csvData).length > 10"
class="mt-4 text-center text-sm text-gray-600"
>
Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen
</div>
<div class="mt-4 text-sm text-gray-600">
<p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p>
<p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p>
</div>
</div>
<!-- Empty State -->
<div
v-else
class="text-center py-12 bg-white rounded-xl shadow-lg"
>
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-gray-600">
Keine CSV-Daten geladen.
</p>
<p class="text-sm text-gray-500 mt-2">
Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.
</p>
</div>
</div>
</div>
<!-- Upload Modal -->
<div
v-if="showUploadModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeUploadModal"
>
<div class="bg-white rounded-lg max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
CSV-Datei hochladen
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label>
<input
ref="modalFileInput"
type="file"
accept=".csv"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@change="handleModalFileSelect"
>
</div>
<div
v-if="selectedFile"
class="p-3 bg-gray-50 rounded-lg"
>
<p class="text-sm text-gray-700">
<strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}
</p>
<p class="text-xs text-gray-500">
{{ selectedFile.size }} bytes
</p>
</div>
<div class="bg-blue-50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-blue-800 mb-2">
Erwartetes CSV-Format:
</h4>
<div class="text-xs text-blue-700 space-y-1">
<p> Erste Zeile: Spaltenüberschriften</p>
<p> Spalten: Datum, Mannschaft, Gegner, Ort, Uhrzeit, etc.</p>
<p> Trennzeichen: Komma (,)</p>
<p> Text in Anführungszeichen bei Sonderzeichen</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeUploadModal"
>
Abbrechen
</button>
<button
:disabled="!selectedFile"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="processSelectedFile"
>
Hochladen
</button>
</div>
</div>
</div>
<!-- Processing Modal -->
<div
v-if="isProcessing"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">
Verarbeitung läuft...
</h3>
<p class="text-sm text-gray-600">
{{ processingMessage }}
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'CMS: Spielpläne' })
const fileInput = ref(null)
const modalFileInput = ref(null)
const showUploadModal = ref(false)
const isProcessing = ref(false)
const processingMessage = ref('')
const isDragOver = ref(false)
const currentFile = ref(null)
const selectedFile = ref(null)
const csvData = ref([])
const csvHeaders = ref([])
const selectedColumns = ref([])
const columnsSelected = ref(false)
const filteredCsvData = ref([])
const filteredCsvHeaders = ref([])
const triggerFileInput = () => {
fileInput.value?.click()
}
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
processFile(file)
}
}
const handleModalFileSelect = (event) => {
selectedFile.value = event.target.files[0]
}
const handleFileDrop = (event) => {
isDragOver.value = false
const file = event.dataTransfer.files[0]
if (file && file.type === 'text/csv') {
processFile(file)
}
}
const processFile = async (file) => {
isProcessing.value = true
processingMessage.value = 'Datei wird gelesen...'
try {
const text = await file.text()
processingMessage.value = 'CSV wird geparst...'
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) {
throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten')
}
// CSV-Parser: Automatische Erkennung von Trennzeichen (Tab oder Semikolon)
const parseCSVLine = (line) => {
// Prüfe ob Tab oder Semikolon häufiger vorkommt
const tabCount = (line.match(/\t/g) || []).length
const semicolonCount = (line.match(/;/g) || []).length
const delimiter = tabCount > semicolonCount ? '\t' : ';'
return line.split(delimiter).map(value => value.trim())
}
// Header-Zeile parsen
csvHeaders.value = parseCSVLine(lines[0])
// Datenzeilen parsen
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
// Spaltenauswahl initialisieren (alle ausgewählt)
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
columnsSelected.value = false
// Datei-Info speichern
currentFile.value = {
name: file.name,
size: file.size,
lastModified: file.lastModified
}
processingMessage.value = 'Verarbeitung abgeschlossen!'
setTimeout(() => {
isProcessing.value = false
showUploadModal.value = false
}, 1000)
} catch (error) {
console.error('Fehler beim Verarbeiten der CSV-Datei:', error)
alert('Fehler beim Verarbeiten der CSV-Datei: ' + error.message)
isProcessing.value = false
}
}
const processSelectedFile = () => {
if (selectedFile.value) {
processFile(selectedFile.value)
}
}
const removeFile = () => {
currentFile.value = null
csvData.value = []
csvHeaders.value = []
selectedColumns.value = []
columnsSelected.value = false
filteredCsvData.value = []
filteredCsvHeaders.value = []
if (fileInput.value) {
fileInput.value.value = ''
}
}
// Computed properties for column selection
const selectedColumnsCount = computed(() => {
return selectedColumns.value.filter(selected => selected).length
})
const getColumnPreview = (index) => {
if (csvData.value.length === 0) return 'Keine Daten'
const sampleValues = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== '')
return sampleValues.length > 0 ? `Beispiel: ${sampleValues.join(', ')}` : 'Leere Spalte'
}
const selectAllColumns = () => {
selectedColumns.value = selectedColumns.value.map(() => true)
}
const deselectAllColumns = () => {
selectedColumns.value = selectedColumns.value.map(() => false)
}
const confirmColumnSelection = () => {
// Filtere Daten basierend auf ausgewählten Spalten
const selectedIndices = selectedColumns.value.map((selected, index) => selected ? index : -1).filter(index => index !== -1)
filteredCsvHeaders.value = selectedIndices.map(index => csvHeaders.value[index])
filteredCsvData.value = csvData.value.map(row => selectedIndices.map(index => row[index]))
columnsSelected.value = true
}
const suggestHalleColumns = () => {
// Automatisch Halle-Spalten vorschlagen
csvHeaders.value.forEach((header, index) => {
const headerLower = header.toLowerCase()
if (headerLower.includes('halle') ||
headerLower.includes('strasse') ||
headerLower.includes('plz') ||
headerLower.includes('ort')) {
selectedColumns.value[index] = true
}
})
}
const clearData = () => {
if (confirm('Möchten Sie alle Daten wirklich löschen?')) {
removeFile()
}
}
const exportCSV = () => {
const dataToExport = columnsSelected.value ? filteredCsvData.value : csvData.value
const headersToExport = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
if (dataToExport.length === 0) return
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
const csvContent = [
headersToExport.join(';'),
...dataToExport.map(row => row.join(';'))
].join('\n')
// Download
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `spielplan_export_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
}
const save = async () => {
const dataToSave = columnsSelected.value ? filteredCsvData.value : csvData.value
const headersToSave = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
if (dataToSave.length === 0) {
alert('Keine Daten zum Speichern vorhanden.')
return
}
try {
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
const csvContent = [
headersToSave.join(';'),
...dataToSave.map(row => row.join(';'))
].join('\n')
// CSV speichern
const response = await fetch('/api/cms/save-csv', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
filename: 'spielplan.csv',
content: csvContent
})
})
if (response.ok) {
alert('Spielplan erfolgreich gespeichert!')
} else {
alert('Fehler beim Speichern des Spielplans!')
}
} catch (error) {
console.error('Fehler beim Speichern:', error)
alert('Fehler beim Speichern des Spielplans!')
}
}
const closeUploadModal = () => {
showUploadModal.value = false
selectedFile.value = null
if (modalFileInput.value) {
modalFileInput.value.value = ''
}
}
// Drag and Drop Events
const _handleDragEnter = () => {
isDragOver.value = true
}
const _handleDragLeave = () => {
isDragOver.value = false
}
onMounted(() => {
// Load existing data if available
loadExistingData()
})
const loadExistingData = async () => {
try {
const response = await fetch('/data/spielplan.csv')
if (response.ok) {
const text = await response.text()
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length >= 2) {
// Parse existing CSV
const parseCSVLine = (line) => {
const values = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
return values
}
csvHeaders.value = parseCSVLine(lines[0])
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
currentFile.value = {
name: 'spielplan.csv',
size: text.length,
lastModified: null
}
}
}
} catch {
// Fehler beim Laden der Datei, ignorieren
}
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Sportbetrieb verwalten</h1>
<p class="mt-1 text-sm text-gray-500">Termine, Mannschaften und Spielpläne pflegen</p>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
:class="activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</nav>
</div>
<!-- Tab Content -->
<div>
<CmsTermine v-if="activeTab === 'termine'" />
<CmsMannschaften v-if="activeTab === 'mannschaften'" />
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import CmsTermine from '~/components/cms/CmsTermine.vue'
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'Sportbetrieb verwalten CMS'
})
const activeTab = ref('termine')
const tabs = [
{ id: 'termine', label: 'Termine' },
{ id: 'mannschaften', label: 'Mannschaften' },
{ id: 'spielplaene', label: 'Spielpläne' }
]
</script>

View File

@@ -1,391 +0,0 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
Termine verwalten
</h1>
<div class="w-24 h-1 bg-primary-600 mb-4" />
</div>
<button
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<Plus
:size="20"
class="mr-2"
/>
Termin hinzufügen
</button>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- Termine Table -->
<div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Datum
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Uhrzeit
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Titel
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Beschreibung
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kategorie
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="termin in termine"
:key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(termin.datum) }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ termin.uhrzeit || '-' }}
</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ termin.titel }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ termin.beschreibung || '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
:class="{
'bg-blue-100 text-blue-800': termin.kategorie === 'Training',
'bg-green-100 text-green-800': termin.kategorie === 'Punktspiel',
'bg-purple-100 text-purple-800': termin.kategorie === 'Turnier',
'bg-yellow-100 text-yellow-800': termin.kategorie === 'Veranstaltung',
'bg-gray-100 text-gray-800': termin.kategorie === 'Sonstiges'
}"
class="px-2 py-1 text-xs font-medium rounded-full"
>
{{ termin.kategorie }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button
class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(termin)"
>
<Pencil :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(termin)"
>
<Trash2 :size="18" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="termine.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Termine vorhanden.
</div>
</div>
<!-- Add Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
</h2>
<form
class="space-y-4"
@submit.prevent="saveTermin"
>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
<input
v-model="formData.datum"
type="date"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
<input
v-model="formData.uhrzeit"
type="time"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
<select
v-model="formData.kategorie"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
<option value="Training">
Training
</option>
<option value="Punktspiel">
Punktspiel
</option>
<option value="Turnier">
Turnier
</option>
<option value="Veranstaltung">
Veranstaltung
</option>
<option value="Sonstiges">
Sonstiges
</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
<input
v-model="formData.titel"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea
v-model="formData.beschreibung"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
/>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/>
{{ errorMessage }}
</div>
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-vue-next'
const isLoading = ref(true)
const isSaving = ref(false)
const termine = ref([])
const showModal = ref(false)
const errorMessage = ref('')
const isEditing = ref(false)
const originalTermin = ref(null)
const formData = ref({
datum: '',
titel: '',
beschreibung: '',
kategorie: 'Sonstiges'
})
const loadTermine = async () => {
isLoading.value = true
try {
const response = await $fetch('/api/termine-manage')
termine.value = response.termine
} catch (error) {
console.error('Fehler beim Laden der Termine:', error)
} finally {
isLoading.value = false
}
}
const openAddModal = () => {
formData.value = {
datum: '',
titel: '',
beschreibung: '',
kategorie: 'Sonstiges',
uhrzeit: ''
}
showModal.value = true
errorMessage.value = ''
isEditing.value = false
originalTermin.value = null
}
const closeModal = () => {
showModal.value = false
errorMessage.value = ''
isEditing.value = false
originalTermin.value = null
}
const saveTermin = async () => {
isSaving.value = true
errorMessage.value = ''
try {
if (isEditing.value && originalTermin.value) {
const params = new URLSearchParams({
datum: originalTermin.value.datum,
uhrzeit: originalTermin.value.uhrzeit || '',
titel: originalTermin.value.titel,
beschreibung: originalTermin.value.beschreibung || '',
kategorie: originalTermin.value.kategorie || 'Sonstiges'
})
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
}
await $fetch('/api/termine-manage', {
method: 'POST',
body: formData.value
})
closeModal()
await loadTermine()
} catch (error) {
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Termins.'
} finally {
isSaving.value = false
}
}
const openEditModal = (termin) => {
formData.value = {
datum: termin.datum || '',
uhrzeit: termin.uhrzeit || '',
titel: termin.titel || '',
beschreibung: termin.beschreibung || '',
kategorie: termin.kategorie || 'Sonstiges'
}
originalTermin.value = { ...termin }
isEditing.value = true
showModal.value = true
errorMessage.value = ''
}
const confirmDelete = async (termin) => {
window.showConfirmModal('Termin löschen', `Möchten Sie den Termin "${termin.titel}" wirklich löschen?`, async () => {
try {
const params = new URLSearchParams({
datum: termin.datum,
uhrzeit: termin.uhrzeit || '',
titel: termin.titel,
beschreibung: termin.beschreibung || '',
kategorie: termin.kategorie || 'Sonstiges'
})
await $fetch(`/api/termine-manage?${params.toString()}`, {
method: 'DELETE'
})
await loadTermine()
window.showSuccessModal('Erfolg', 'Termin wurde erfolgreich gelöscht')
} catch (error) {
console.error('Fehler beim Löschen:', error)
window.showErrorModal('Fehler', 'Fehler beim Löschen des Termins')
}
})
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
onMounted(() => {
loadTermine()
})
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'Termine verwalten - Harheimer TC',
})
</script>

View File

@@ -1,392 +0,0 @@
<template>
<div class="min-h-full bg-gray-50">
<!-- Fixed Header below navigation -->
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
TT-Regeln bearbeiten
</h1>
<div class="space-x-3">
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div>
</div>
</div>
</div>
<!-- Fixed Toolbar below header -->
<div
class="fixed left-0 right-0 z-30 bg-white border-b border-gray-200 shadow-sm"
style="top: 9.5rem;"
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2">
<!-- Formatierung -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('bold')"
>
<strong>B</strong>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('italic')"
>
<em>I</em>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(1)"
>
H1
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(2)"
>
H2
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(3)"
>
H3
</button>
</div>
<!-- Listen -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertUnorderedList')"
>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertOrderedList')"
>
1.
</button>
</div>
<!-- Schnellzugriff für Regeln -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
@click="insertRuleTemplate('generic')"
>
Neue Regel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
@click="insertRuleTemplate('basic')"
>
Grundregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
@click="insertRuleTemplate('penalty')"
>
Strafregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
@click="insertRuleTemplate('service')"
>
Aufschlag
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
@click="deleteCurrentRule()"
>
Regel löschen
</button>
</div>
<!-- Weitere Tools -->
<div class="flex items-center gap-1">
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="createLink()"
>
Link
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="removeFormat()"
>
Clear
</button>
</div>
</div>
</div>
</div>
<!-- Content with top padding -->
<div
class="pb-16"
style="padding-top: 12rem;"
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Hilfe-Sektion -->
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-lg font-semibold text-blue-900 mb-2">
💡 So arbeiten Sie mit Regel-Kästchen:
</h3>
<div class="text-sm text-blue-800 space-y-2">
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
<ul class="ml-4 space-y-1">
<li> <span class="bg-gray-100 px-2 py-1 rounded text-xs">Neue Regel</span> - Graues Kästchen</li>
<li> <span class="bg-blue-100 px-2 py-1 rounded text-xs">Grundregel</span> - Blaues Kästchen</li>
<li> <span class="bg-green-100 px-2 py-1 rounded text-xs">Strafregel</span> - Grünes Kästchen</li>
<li> <span class="bg-yellow-100 px-2 py-1 rounded text-xs">Aufschlag</span> - Gelbes Kästchen</li>
</ul>
<p><strong>2. Kästchen löschen:</strong> Klicken Sie in ein Kästchen und dann auf <span class="bg-red-100 px-2 py-1 rounded text-xs">Regel löschen</span></p>
<p><strong>3. Kästchen bearbeiten:</strong> Klicken Sie direkt in die Texte und bearbeiten Sie sie</p>
<p><strong>4. Grid-Layout:</strong> Kästchen werden automatisch im Grid-Layout angeordnet</p>
<p class="text-xs text-blue-600 mt-2">
💡 <strong>Tipp:</strong> Neue Kästchen werden automatisch in das bestehende Grid eingefügt!
</p>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
<div
ref="editor"
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
contenteditable
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'CMS: TT-Regeln' })
const editor = ref(null)
const initialHtml = ref('')
async function load() {
const data = await $fetch('/api/config')
initialHtml.value = data?.seiten?.ttRegeln || ''
if (editor.value) editor.value.innerHTML = initialHtml.value
}
async function save() {
const html = editor.value?.innerHTML || ''
const current = await $fetch('/api/config')
const updated = { ...current, seiten: { ...(current.seiten || {}), ttRegeln: html } }
try {
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Regeln erfolgreich gespeichert!') } catch {
// Modal nicht verfügbar, ignorieren
}
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
// Modal nicht verfügbar, ignorieren
}
}
}
function format(cmd) {
document.execCommand(cmd, false, null)
}
function formatHeader(level) {
document.execCommand('formatBlock', false, 'H' + level)
}
function createLink() {
const url = prompt('URL eingeben:')
if (!url) return
document.execCommand('createLink', false, url)
}
function removeFormat() {
document.execCommand('removeFormat', false, null)
}
function insertRuleTemplate(type) {
const editorElement = editor.value
if (!editorElement) return
let template = ''
let bgColor = 'bg-gray-50'
switch (type) {
case 'generic':
template = `
<div class="text-center p-6 bg-gray-50 rounded-lg">
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Regel</h3>
<p class="text-gray-600 text-sm">[Regeltext hier eingeben]</p>
</div>
`
bgColor = 'bg-gray-50'
break
case 'basic':
template = `
<div class="text-center p-6 bg-blue-50 rounded-lg">
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Grundregel</h3>
<p class="text-gray-600 text-sm"><strong>Regel:</strong> [Regeltext hier eingeben]<br><strong>Beschreibung:</strong> [Detaillierte Beschreibung hier eingeben]<br><strong>Anwendung:</strong> [Wann gilt diese Regel?]</p>
</div>
`
bgColor = 'bg-blue-50'
break
case 'penalty':
template = `
<div class="text-center p-6 bg-green-50 rounded-lg">
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Strafregel</h3>
<p class="text-gray-600 text-sm"><strong>Verstoß:</strong> [Was ist der Verstoß?]<br><strong>Strafe:</strong> [Welche Strafe wird verhängt?]<br><strong>Häufigkeit:</strong> [Bei wiederholten Verstößen?]</p>
</div>
`
// bgColor = 'bg-green-50' // Nicht verwendet
break
case 'service':
template = `
<div class="text-center p-6 bg-yellow-50 rounded-lg">
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Aufschlagregel</h3>
<p class="text-gray-600 text-sm"><strong>Regel:</strong> [Aufschlagregel hier eingeben]<br><strong>Technik:</strong> [Wie muss der Aufschlag ausgeführt werden?]<br><strong>Fehler:</strong> [Was gilt als Fehler?]</p>
</div>
`
// bgColor = 'bg-yellow-50'
break
}
// Editor fokussieren
editorElement.focus()
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// Prüfen ob der Cursor im Editor ist
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
// Aktuelles Element finden
let currentElement = range.commonAncestorContainer
// Falls es ein Text-Node ist, zum Parent-Element gehen
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement
}
// Zum spezifischen Container navigieren mit den Klassen "grid md:grid-cols-2 lg:grid-cols-3 gap-6"
let targetContainer = currentElement
while (targetContainer && !targetContainer.classList.contains('grid')) {
targetContainer = targetContainer.parentElement
}
// Prüfen ob es der richtige Container ist
if (targetContainer &&
targetContainer.classList.contains('md:grid-cols-2') &&
targetContainer.classList.contains('lg:grid-cols-3') &&
targetContainer.classList.contains('gap-6')) {
// Wir sind im richtigen Container - neues Kästchen hinzufügen
const tempDiv = document.createElement('div')
tempDiv.innerHTML = template
// Neues Kästchen in den Container einfügen
// Suche nach dem ersten Element-Node (nicht Text-Node)
let newCard = null
for (let i = 0; i < tempDiv.childNodes.length; i++) {
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
newCard = tempDiv.childNodes[i]
break
}
}
if (newCard) {
targetContainer.appendChild(newCard)
// Cursor in das neue Kästchen setzen
const newRange = document.createRange()
const titleElement = newCard.querySelector('h3')
if (titleElement) {
newRange.setStart(titleElement, 0)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
}
} else {
console.error('No valid element found in template');
}
} else {
// Spezifischer Container nicht gefunden - am Ende einfügen
editorElement.innerHTML += template
}
} else {
// Cursor ist nicht im Editor - Template am Ende einfügen
editorElement.innerHTML += template
}
} else {
// Keine Auswahl - Template am Ende einfügen
editorElement.innerHTML += template
}
}
function deleteCurrentRule() {
const editorElement = editor.value
if (!editorElement) return
// Editor fokussieren
editorElement.focus()
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// Prüfen ob der Cursor im Editor ist
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
// Aktuelles Element finden
let currentElement = range.commonAncestorContainer
// Falls es ein Text-Node ist, zum Parent-Element gehen
if (currentElement.nodeType === Node.TEXT_NODE) {
currentElement = currentElement.parentElement
}
// Zum Grid-Kästchen navigieren
let cardElement = currentElement
while (cardElement && !cardElement.classList.contains('text-center')) {
cardElement = cardElement.parentElement
}
if (cardElement && cardElement.classList.contains('text-center')) {
// Grid-Kästchen gefunden - löschen
cardElement.remove()
// Cursor in das nächste Kästchen oder Grid setzen
const gridContainer = editorElement.querySelector('.grid')
if (gridContainer && gridContainer.children.length > 0) {
const firstCard = gridContainer.firstElementChild
const titleElement = firstCard.querySelector('h3')
if (titleElement) {
const newRange = document.createRange()
newRange.setStart(titleElement, 0)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
}
}
}
}
}
}
onMounted(load)
</script>

View File

@@ -1,154 +0,0 @@
<template>
<div class="min-h-full bg-gray-50">
<!-- Fixed Header below navigation -->
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
Über uns bearbeiten
</h1>
<div class="space-x-3">
<button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div>
</div>
</div>
</div>
<!-- Fixed Toolbar below header -->
<div class="fixed top-[9.5rem] left-0 right-0 z-30 bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2">
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('bold')"
>
<strong>B</strong>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('italic')"
>
<em>I</em>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(1)"
>
H1
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(2)"
>
H2
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(3)"
>
H3
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertUnorderedList')"
>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertOrderedList')"
>
1.
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="createLink()"
>
Link
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="removeFormat()"
>
Clear
</button>
</div>
</div>
</div>
<!-- Content with top padding -->
<div class="pt-36 sm:pt-44 pb-16">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
<div
ref="editor"
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
contenteditable
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'CMS: Über uns' })
const editor = ref(null)
const initialHtml = ref('')
async function load() {
const data = await $fetch('/api/config')
initialHtml.value = data?.seiten?.ueberUns || ''
if (editor.value) editor.value.innerHTML = initialHtml.value
}
async function save() {
const html = editor.value?.innerHTML || ''
const current = await $fetch('/api/config')
const updated = { ...current, seiten: { ...(current.seiten || {}), ueberUns: html } }
try {
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
// Modal nicht verfügbar, ignorieren
}
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
// Modal nicht verfügbar, ignorieren
}
}
}
function format(cmd) {
document.execCommand(cmd, false, null)
}
function formatHeader(level) {
document.execCommand('formatBlock', false, 'H' + level)
}
function createLink() {
const url = prompt('URL eingeben:')
if (!url) return
document.execCommand('createLink', false, url)
}
function removeFormat() {
document.execCommand('removeFormat', false, null)
}
onMounted(load)
</script>

162
pages/links.vue Normal file
View File

@@ -0,0 +1,162 @@
<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">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
Links
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<p class="text-lg text-gray-600 mb-10">
Nützliche Verweise rund um Tischtennis, Verbände, Ergebnisse und Partner.
</p>
<div class="grid md:grid-cols-2 gap-6">
<section
v-for="section in sections"
:key="section.title"
class="bg-white rounded-xl shadow-lg p-6"
>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
{{ section.title }}
</h2>
<ul class="space-y-3">
<li
v-for="(item, idx) in section.items"
:key="`${section.title}-${idx}`"
>
<a
:href="item.href"
target="_blank"
rel="noopener noreferrer"
class="text-primary-700 hover:text-primary-900 font-medium"
>
{{ item.label }}
</a>
<span
v-if="item.description"
class="text-gray-600"
> {{ item.description }}</span>
</li>
</ul>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const rawContent = ref('')
const defaultLinksHtml = `
<h2>Ergebnisse &amp; Portale</h2>
<ul>
<li><a href="http://www.mytischtennis.de/public/home" target="_blank" rel="noopener noreferrer">MyTischtennis.de</a> (offizielle QTTR-Werte)</li>
<li><a href="http://httv.click-tt.de/" target="_blank" rel="noopener noreferrer">Click-tt Ergebnisse</a> (offizieller Ergebnisdienst HTTV)</li>
<li><a href="https://www.tischtennis-pur.de/" target="_blank" rel="noopener noreferrer">Tischtennis Pur - das Tischtennis Portal</a> (Informationen, Blogs, Fachbeiträge, Tipps)</li>
<li><a href="https://ticker.tt-news.com/" target="_blank" rel="noopener noreferrer">Liveticker 2. und 3. TT-Bundesliga</a></li>
</ul>
<h2>Verbände</h2>
<ul>
<li><a href="http://www.httv.de/" target="_blank" rel="noopener noreferrer">Hessischer Tischtennisverband (HTTV)</a></li>
<li><a href="http://www.tischtennis.de/aktuelles/" target="_blank" rel="noopener noreferrer">Deutscher Tischtennisbund (DTTB)</a></li>
<li><a href="http://www.ettu.org/" target="_blank" rel="noopener noreferrer">European Table Tennis Union (ETTU)</a></li>
<li><a href="https://www.ittf.com/" target="_blank" rel="noopener noreferrer">International Table Tennis Federation (ITTF)</a></li>
</ul>
<h2>Regionale Links</h2>
<ul>
<li><a href="http://www.frankfurt.de/" target="_blank" rel="noopener noreferrer">Stadt Frankfurt</a></li>
<li><a href="http://www.harheim.com/" target="_blank" rel="noopener noreferrer">Vereinsring Harheim</a></li>
</ul>
<h2>Partner &amp; Vereine</h2>
<ul>
<li><a href="http://www.ttcoe.de/" target="_blank" rel="noopener noreferrer">TTC OE Bad Homburg</a></li>
<li><a href="https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis" target="_blank" rel="noopener noreferrer">SpVgg Steinkirchen e.V.</a></li>
<li><a href="https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/" target="_blank" rel="noopener noreferrer">Ergebnisse SpVgg Steinkirchen</a></li>
</ul>
`
const sections = computed(() => parseLinksHtml(rawContent.value))
function stripTags(html) {
return String(html || '')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function parseLinksHtml(html) {
const source = String(html || '')
const sectionRegex = /<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[^>]*>|$)/gi
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi
const anchorRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i
const parsed = []
let sectionMatch
while ((sectionMatch = sectionRegex.exec(source)) !== null) {
const title = stripTags(sectionMatch[1])
const body = sectionMatch[2]
const items = []
let liMatch
while ((liMatch = liRegex.exec(body)) !== null) {
const liContent = liMatch[1]
const anchorMatch = anchorRegex.exec(liContent)
if (!anchorMatch) continue
const href = anchorMatch[1].trim()
const label = stripTags(anchorMatch[2])
const remainder = liContent.replace(anchorMatch[0], '')
const desc = stripTags(remainder)
items.push({
href,
label,
description: desc || ''
})
}
if (title && items.length > 0) {
parsed.push({ title, items })
}
}
return parsed
}
async function loadConfig() {
try {
const data = await $fetch('/api/config')
const structured = data?.seiten?.linksStructured
if (Array.isArray(structured) && structured.length > 0) {
const htmlFromStructured = structured
.filter((section) => section?.title && Array.isArray(section?.items) && section.items.length > 0)
.map((section) => {
const itemsHtml = section.items
.filter((item) => item?.label && item?.href)
.map((item) => `<li><a href="${item.href}" target="_blank" rel="noopener noreferrer">${item.label}</a>${item.description ? ` ${item.description}` : ''}</li>`)
.join('')
return `<h2>${section.title}</h2><ul>${itemsHtml}</ul>`
})
.join('\n')
rawContent.value = htmlFromStructured || defaultLinksHtml
return
}
const links = data?.seiten?.links
rawContent.value = typeof links === 'string' && links.trim() ? links : defaultLinksHtml
} catch {
rawContent.value = defaultLinksHtml
}
}
onMounted(loadConfig)
useHead({
title: 'Links - Harheimer TC',
})
</script>

View File

@@ -154,7 +154,9 @@ const handleLogin = async () => {
// Redirect based on role
setTimeout(() => {
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
if (roles.includes('trainer')) {
router.push('/cms/kontaktanfragen')
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
router.push('/cms')
} else {
router.push('/mitgliederbereich')

View File

@@ -71,54 +71,56 @@
</NuxtLink>
</div>
<!-- Welcome Message -->
<div class="bg-white p-8 rounded-xl shadow-lg border border-gray-100">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Willkommen, {{ authStore.user?.name || 'Mitglied' }}!
</h2>
<p class="text-gray-600 mb-6">
Hier finden Sie alle wichtigen Informationen und Funktionen für Mitglieder des Harheimer TC.
</p>
<div class="grid sm:grid-cols-2 gap-4">
<div class="flex items-start">
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Zugriff auf Mitgliederliste mit Kontaktdaten</span>
<!-- Geburtstage Widget (statt Willkommens-Kachel) -->
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center">
<Calendar :size="20" class="text-pink-600" />
</div>
<div class="flex items-start">
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Vereinsnews und Ankündigungen</span>
</div>
<div class="flex items-start">
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Profilverwaltung und Passwort ändern</span>
</div>
<div class="flex items-start">
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Weitere Funktionen folgen in Kürze</span>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Geburtstage (nächste 4 Wochen)</h2>
</div>
<div v-if="loadingBirthdays" class="text-sm text-gray-500">Lade...</div>
<ul v-else class="space-y-2">
<li v-for="b in birthdays" :key="b.name + b.dayMonth" class="flex items-center justify-between p-3 border border-gray-100 rounded-lg">
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ b.name }}</div>
<div class="text-xs text-gray-600">{{ b.dayMonth }}</div>
</div>
<div class="text-sm text-gray-500">{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}</div>
</li>
<li v-if="birthdays.length === 0" class="text-sm text-gray-600">Keine Geburtstage in den nächsten 4 Wochen.</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { User, Users, Newspaper, Check } from 'lucide-vue-next'
import { User, Users, Newspaper, Check, Calendar } from 'lucide-vue-next'
import { ref, onMounted } from 'vue'
const authStore = useAuthStore()
const birthdays = ref([])
const loadingBirthdays = ref(true)
const loadBirthdays = async () => {
loadingBirthdays.value = true
try {
const res = await $fetch('/api/birthdays')
birthdays.value = res.birthdays || []
} catch (e) {
console.error('Fehler beim Laden der Geburtstage', e)
birthdays.value = []
} finally {
loadingBirthdays.value = false
}
}
onMounted(() => {
loadBirthdays()
})
definePageMeta({
middleware: 'auth',
layout: 'default'

View File

@@ -54,6 +54,16 @@
</div>
</div>
<!-- Sortieroptionen -->
<div class="mb-4 flex items-center space-x-2">
<label for="sortMode" class="text-sm text-gray-700">Sortieren nach:</label>
<select id="sortMode" v-model="sortMode" class="px-2 py-1 border rounded">
<option value="name">Name (Vorname Nachname)</option>
<option value="lastname">Nachname (Nachname Vorname)</option>
<option value="birthday">Geburtstag</option>
</select>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
@@ -99,13 +109,21 @@
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="member in members"
v-for="member in sortedMembers"
:key="member.id"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<template v-if="member.lastName || member.firstName">
{{ member.firstName }} {{ member.lastName }}
</template>
<template v-else>
{{ member.name }}
</template>
</div>
<div v-if="member.birthday" class="text-xs text-gray-500">
🎂 {{ formatBirthday(member.birthday) }}
</div>
<div
v-if="member.notes"
@@ -115,42 +133,30 @@
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData">
<template v-if="member.showEmail && member.email">
<a
v-if="member.email"
:href="`mailto:${member.email}`"
class="text-sm text-primary-600 hover:text-primary-800"
>
{{ member.email }}
</a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template>
<span
v-else
class="text-sm text-gray-400"
>Nur für Vorstand</span>
<template v-else>
<span class="text-sm text-gray-400">Kontaktdaten nur für Vorstand sichtbar</span>
</template>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData">
<template v-if="member.showPhone && member.phone">
<a
v-if="member.phone"
:href="`tel:${member.phone}`"
class="text-sm text-primary-600 hover:text-primary-800"
>
{{ member.phone }}
</a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template>
<span
v-else
class="text-sm text-gray-400"
>Nur für Vorstand</span>
<template v-else>
<span class="text-sm text-gray-400">Kontaktdaten nur für Vorstand sichtbar</span>
</template>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<button
@@ -241,7 +247,7 @@
class="space-y-4"
>
<div
v-for="member in members"
v-for="member in sortedMembers"
:key="member.id"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
>
@@ -249,7 +255,15 @@
<div class="flex-1">
<div class="flex items-center mb-2">
<h3 class="text-xl font-semibold text-gray-900">
<template v-if="member.lastName || member.firstName">
{{ member.firstName }} {{ member.lastName }}
</template>
<template v-else>
{{ member.name }}
</template>
<span v-if="member.birthday" class="text-xs text-gray-500 ml-2">
🎂 {{ formatBirthday(member.birthday) }}
</span>
</h3>
<span
v-if="member.hasLogin"
@@ -296,43 +310,32 @@
</div>
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
<template v-if="canViewContactData">
<div
v-if="member.email"
class="flex items-center"
>
<Mail
:size="16"
class="mr-2 text-primary-600"
/>
<a
:href="`mailto:${member.email}`"
class="hover:text-primary-600"
>{{ member.email }}</a>
</div>
<div
v-if="member.phone"
class="flex items-center"
>
<Phone
:size="16"
class="mr-2 text-primary-600"
/>
<a
:href="`tel:${member.phone}`"
class="hover:text-primary-600"
>{{ member.phone }}</a>
<template v-if="!(member.showEmail && member.email) && !(member.showPhone && member.phone)">
<div class="col-span-2 flex items-center text-gray-500 text-sm italic">
<Mail :size="16" class="mr-2" />
Kontaktdaten nur für Vorstand sichtbar
</div>
</template>
<div
v-else
class="col-span-2 flex items-center text-gray-500 text-sm italic"
>
<Mail
:size="16"
class="mr-2"
/>
Kontaktdaten nur für Vorstand sichtbar
<template v-else>
<div v-if="member.showEmail && member.email" class="flex items-center">
<Mail :size="16" class="mr-2 text-primary-600" />
<a :href="`mailto:${member.email}`" class="hover:text-primary-600">{{ member.email }}</a>
</div>
<div v-if="member.showPhone && member.phone" class="flex items-center">
<Phone :size="16" class="mr-2 text-primary-600" />
<a :href="`tel:${member.phone}`" class="hover:text-primary-600">{{ member.phone }}</a>
</div>
</template>
<!-- Sichtbarkeits-Flags anzeigen -->
<div class="col-span-2 flex items-center gap-2 mt-2 text-xs text-gray-500">
<span v-if="member.showEmail" title="E-Mail sichtbar">📧</span>
<span v-else title="E-Mail verborgen" class="opacity-40">📧</span>
<span v-if="member.showPhone" title="Telefon sichtbar">📞</span>
<span v-else title="Telefon verborgen" class="opacity-40">📞</span>
<span v-if="member.showAddress" title="Adresse sichtbar">🏠</span>
<span v-else title="Adresse verborgen" class="opacity-40">🏠</span>
<span v-if="member.showBirthday" title="Geburtstag sichtbar">🎂</span>
<span v-else title="Geburtstag verborgen" class="opacity-40">🎂</span>
</div>
<div
v-if="member.address"
@@ -770,6 +773,79 @@
</template>
<script setup>
// ...existing code...
const sortMode = ref('name')
const sortedMembers = computed(() => {
if (!Array.isArray(members.value)) return []
const arr = [...members.value]
if (sortMode.value === 'name') {
arr.sort((a, b) => {
// Sortiere nach Vorname Nachname (firstName lastName)
const af = (a.firstName || '').toLocaleLowerCase()
const bf = (b.firstName || '').toLocaleLowerCase()
const al = (a.lastName || '').toLocaleLowerCase()
const bl = (b.lastName || '').toLocaleLowerCase()
if (af === bf) return al.localeCompare(bl)
return af.localeCompare(bf)
})
} else if (sortMode.value === 'lastname') {
arr.sort((a, b) => {
// Sortiere nach Nachname, dann Vorname
const al = (a.lastName || '').toLocaleLowerCase()
const bl = (b.lastName || '').toLocaleLowerCase()
if (al === bl) {
const af = (a.firstName || '').toLocaleLowerCase()
const bf = (b.firstName || '').toLocaleLowerCase()
return af.localeCompare(bf)
}
return al.localeCompare(bl)
})
} else if (sortMode.value === 'birthday') {
arr.sort((a, b) => {
// Robust: akzeptiere YYYY-MM-DD, DD.MM.YYYY, ggf. nur MM-TT
function parseBirthday(val) {
if (!val) return null
if (val.includes('-')) {
const parts = val.split('-')
if (parts.length === 3) return { m: parts[1].padStart(2, '0'), d: parts[2].padStart(2, '0') }
} else if (val.includes('.')) {
const parts = val.split('.')
if (parts.length >= 2) return { d: parts[0].padStart(2, '0'), m: parts[1].padStart(2, '0') }
}
return null
}
const ad = parseBirthday(a.birthday)
const bd = parseBirthday(b.birthday)
if (!ad && !bd) return 0
if (!ad) return 1
if (!bd) return -1
// Monat zuerst, dann Tag
if (ad.m === bd.m) return ad.d.localeCompare(bd.d)
return ad.m.localeCompare(bd.m)
})
}
return arr
})
function formatBirthday(dateStr) {
// Erwartet YYYY-MM-DD oder DD.MM.YYYY
if (!dateStr) return ''
if (dateStr.includes('-')) {
const [, m, d] = dateStr.split('-')
return `${d}.${m}.`
} else if (dateStr.includes('.')) {
const parts = dateStr.split('.')
if (parts.length >= 2) return `${parts[0]}.${parts[1]}.`
}
return dateStr
}
// members muss showBirthday und birthday enthalten:
// showBirthday: true, wenn das Mitglied die Anzeige erlaubt
// birthday: im Format YYYY-MM-DD oder DD.MM.YYYY
// Falls die Datenstruktur anders ist, bitte anpassen!
import { ref, computed, onMounted } from 'vue'
import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next'

View File

@@ -77,6 +77,29 @@
>
</div>
<!-- Sichtbarkeits-Einstellungen -->
<div class="mt-4 border-t border-gray-100 pt-4">
<h3 class="text-sm font-medium text-gray-900 mb-2">Sichtbarkeit für andere Mitglieder</h3>
<div class="flex flex-col gap-2 text-sm text-gray-700">
<label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showEmail" :disabled="isSaving" />
E-Mail für alle eingeloggten Mitglieder sichtbar
</label>
<label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showPhone" :disabled="isSaving" />
Telefonnummer für alle eingeloggten Mitglieder sichtbar
</label>
<label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showAddress" :disabled="isSaving" />
Adresse für alle eingeloggten Mitglieder sichtbar
</label>
<label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showBirthday" :disabled="isSaving" />
Geburtstag für alle eingeloggten Mitglieder sichtbar
</label>
</div>
</div>
<!-- Passwort ändern -->
<div class="border-t border-gray-200 pt-6 mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
@@ -279,6 +302,13 @@ const formData = ref({
phone: ''
})
// Visibility preferences for other logged-in members
const visibility = ref({
showEmail: true,
showPhone: true,
showAddress: false
})
const passwordData = ref({
current: '',
new: '',
@@ -297,6 +327,7 @@ const loadProfile = async () => {
email: response.user.email,
phone: response.user.phone || ''
}
visibility.value = response.user.visibility || visibility.value
} catch {
errorMessage.value = 'Fehler beim Laden des Profils.'
} finally {
@@ -398,6 +429,7 @@ const handleSave = async () => {
name: formData.value.name,
email: formData.value.email,
phone: formData.value.phone,
visibility: visibility.value,
currentPassword: passwordData.value.current || undefined,
newPassword: passwordData.value.new || undefined
}

View File

@@ -62,7 +62,7 @@
</a>
<span class="text-sm text-gray-500">oder</span>
<a
href="/satzung"
href="/verein/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
>
<svg

View File

@@ -32,22 +32,40 @@
</div>
-->
<!-- Name -->
<!-- Vorname -->
<div>
<label
for="name"
for="firstName"
class="block text-sm font-medium text-gray-700 mb-2"
>
Vollständiger Name
Vorname
</label>
<input
id="name"
v-model="formData.name"
id="firstName"
v-model="formData.firstName"
type="text"
required
autocomplete="name"
autocomplete="given-name"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
placeholder="Max Mustermann"
placeholder="Max"
>
</div>
<!-- Nachname -->
<div>
<label
for="lastName"
class="block text-sm font-medium text-gray-700 mb-2"
>
Nachname
</label>
<input
id="lastName"
v-model="formData.lastName"
type="text"
required
autocomplete="family-name"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
placeholder="Mustermann"
>
</div>
@@ -318,7 +336,8 @@ import { AlertCircle, Check, Loader2, Info } from 'lucide-vue-next'
// console.log('[DEBUG] Component setup started')
const formData = ref({
name: '',
firstName: '',
lastName: '',
email: '',
phone: '',
password: '',
@@ -400,7 +419,9 @@ const handleRegister = async () => {
const response = await $fetch('/api/auth/register', {
method: 'POST',
body: {
name: formData.value.name,
firstName: formData.value.firstName,
lastName: formData.value.lastName,
name: `${formData.value.firstName} ${formData.value.lastName}`.trim(),
email: formData.value.email,
phone: formData.value.phone,
password: formData.value.password
@@ -412,7 +433,8 @@ const handleRegister = async () => {
// Reset form
formData.value = {
name: '',
firstName: '',
lastName: '',
email: '',
phone: '',
password: '',

View File

@@ -0,0 +1,25 @@
Split-Name Scripts
Diese Scripts helfen, das Feld `name` in `firstName` und `lastName` zu splitten, für verschiedene Datenquellen im Projekt.
Available scripts:
- `scripts/split-names-in-users.js` (CommonJS)
- Splittet `server/data/users.json` und ergänzt fehlende `firstName`/`lastName`.
- Erstellt ein Backup `users.json.bak.<timestamp>` falls Änderungen gemacht werden.
- Ausführen: `node scripts/split-names-in-users.js`
- `scripts/split-names-in-members.js` (ESM)
- Liest `members.json` über `server/utils/members.js` (beachtet Verschlüsselung), führt Dry-Run by default.
- Mit `--apply` werden Änderungen geschrieben und ein Backup erstellt.
- Ausführen (dry-run): `node scripts/split-names-in-members.js`
- Ausführen (apply): `node scripts/split-names-in-members.js --apply`
- `scripts/split-names-in-membership-apps.js` (CommonJS)
- Bearbeitet alle JSON-Dateien in `server/data/membership-applications/` und erstellt `.bak` Backups pro Datei.
- Ausführen: `node scripts/split-names-in-membership-apps.js`
Hinweis:
- Die Scripts sind vorsichtig: sie erstellen Backups bevor sie schreiben (außer beim Dry-Run für members.js).
- `split-names-in-members.js` nutzt die vorhandenen `readMembers`/`writeMembers` Utilities, um Verschlüsselung zu respektieren.
- Teste zuerst mit DRY-RUN oder in einer Kopie des Datenverzeichnisses.

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
(async () => {
try {
const { readMembers } = await import('../server/utils/members.js')
const auth = await import('../server/utils/auth.js')
const { readUsers } = auth
const manual = await readMembers()
const users = await readUsers()
// Build simple merged list similar to members.get
const merged = []
// Add manual members
for (const m of manual) {
const fullName = `${m.firstName || ''} ${m.lastName || ''}`.trim()
const vis = m.visibility || {}
const visibility = {
showEmail: vis.showEmail === undefined ? false : Boolean(vis.showEmail),
showPhone: vis.showPhone === undefined ? false : Boolean(vis.showPhone),
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress)
}
merged.push({
id: m.id || null,
name: fullName || m.name || '(kein name)',
email: m.email || '',
phone: m.phone || '',
address: m.address || '',
source: 'manual',
visibility,
raw: m
})
}
// Add registered users (default visibility: false unless stored)
for (const u of users) {
if (!u.active) continue
const visibility = u.visibility || { showEmail: false, showPhone: false, showAddress: false }
merged.push({
id: u.id,
name: u.name,
email: u.email || '',
phone: u.phone || '',
address: u.address || '',
source: 'login',
visibility,
raw: u
})
}
merged.sort((a, b) => a.name.localeCompare(b.name))
const viewers = [
{ label: 'unauthenticated', isPrivileged: false },
{ label: 'admin', isPrivileged: false },
{ label: 'vorstand', isPrivileged: true }
]
for (const v of viewers) {
console.log('\n=== Viewer:', v.label, ' (vorstand override:', v.isPrivileged, ') ===')
for (const m of merged) {
const hadEmail = !!m.email
const hadPhone = !!m.phone
const showEmail = v.isPrivileged || Boolean(m.visibility.showEmail)
const showPhone = v.isPrivileged || Boolean(m.visibility.showPhone)
const contactHidden = (!showEmail && hadEmail) || (!showPhone && hadPhone)
console.log(`- ${m.name}`)
console.log(` source: ${m.source} roles?: ${m.raw.roles || m.raw.role || ''}`)
console.log(` email: ${hadEmail ? (showEmail ? m.email : '<HIDDEN>') : '-'}`)
console.log(` phone: ${hadPhone ? (showPhone ? m.phone : '<HIDDEN>') : '-'}`)
if (contactHidden) console.log(' -> contactHidden = true')
}
}
process.exit(0)
} catch (e) {
console.error('ERROR', e)
process.exit(2)
}
})()

View File

@@ -0,0 +1,22 @@
// Diagnose-Skript: Zeigt alle Mitglieder aus members.json mit Status und Sichtbarkeit
import { readMembers } from '../server/utils/members.js'
async function main() {
const members = await readMembers()
if (!members || members.length === 0) {
console.log('Keine Mitglieder geladen (members.json leer oder nicht entschlüsselbar)')
return
}
for (const m of members) {
const status = m.active === true ? 'active' : (m.status ? m.status : 'inactive')
const vis = m.visibility || {}
console.log(`ID: ${m.id || '-'} | Name: ${m.firstName || ''} ${m.lastName || ''}`)
console.log(` Status: ${status}`)
console.log(` Email: ${m.email || '-'} | Phone: ${m.phone || '-'}`)
console.log(` Sichtbarkeit:`, vis)
console.log('---')
}
console.log(`Insgesamt: ${members.length} Mitglieder geladen.`)
}
main()

View File

@@ -0,0 +1,107 @@
import fs from 'fs/promises'
import path from 'path'
import sharp from 'sharp'
const getDataPath = (filename) => {
const cwd = process.cwd()
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
return path.join(cwd, 'server/data', filename)
}
const GALERIE_DIR = getDataPath('galerie')
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
async function readJsonArray(file) {
try {
const data = await fs.readFile(file, 'utf-8')
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : []
} catch (e) {
if (e.code === 'ENOENT') return []
throw e
}
}
async function writeJson(file, obj) {
await fs.writeFile(file, JSON.stringify(obj, null, 2), 'utf-8')
}
async function ensureDirs() {
await fs.mkdir(path.join(GALERIE_DIR, 'originals'), { recursive: true })
await fs.mkdir(path.join(GALERIE_DIR, 'previews'), { recursive: true })
}
async function fileExists(p) {
try {
await fs.access(p)
return true
} catch {
return false
}
}
async function generatePreviewForEntry(entry, size) {
const original = path.join(GALERIE_DIR, 'originals', entry.filename)
if (!(await fileExists(original))) return { ok: false, reason: 'missing original' }
const previewFilename = entry.previewFilename && String(entry.previewFilename).trim() !== ''
? entry.previewFilename
: `preview_${entry.filename}`
const preview = path.join(GALERIE_DIR, 'previews', previewFilename)
await sharp(original)
.rotate()
.resize(size, size, {
fit: 'cover',
withoutEnlargement: true
})
.jpeg({ quality: 82, mozjpeg: true })
.toFile(preview)
return { ok: true, previewFilename }
}
async function main() {
const size = Number(process.env.GALERIE_PREVIEW_SIZE || 300)
await ensureDirs()
const entries = await readJsonArray(GALERIE_METADATA)
if (entries.length === 0) {
console.log('Keine Galerie-Metadaten gefunden.')
return
}
let changed = 0
let generated = 0
let skipped = 0
for (const entry of entries) {
if (!entry || !entry.filename) {
skipped++
continue
}
const res = await generatePreviewForEntry(entry, size)
if (!res.ok) {
skipped++
continue
}
generated++
if (entry.previewFilename !== res.previewFilename) {
entry.previewFilename = res.previewFilename
changed++
}
}
if (changed > 0) await writeJson(GALERIE_METADATA, entries)
console.log(`Previews erzeugt: ${generated}, übersprungen: ${skipped}, metadata-updates: ${changed}`)
}
main().catch(e => {
console.error(e)
process.exit(1)
})

View File

@@ -60,25 +60,34 @@ async function inspect(pdfPath) {
async function main() {
const repoRoot = process.cwd()
const template = path.join(repoRoot, 'server', 'templates', 'mitgliedschaft-fillable.pdf')
// pick latest generated PDF in public/uploads that is not the sample
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const uploads = path.join(repoRoot, 'public', 'uploads')
// Prefer internal upload directory used by the API (server/data/uploads).
// If legacy files exist in public/uploads, warn and inspect them as well.
const internalUploads = path.join(repoRoot, 'server', 'data', 'uploads')
const publicUploads = path.join(repoRoot, 'public', 'uploads')
let pdfFiles = []
if (fs.existsSync(uploads)) {
pdfFiles = fs.readdirSync(uploads).filter(f => f.toLowerCase().endsWith('.pdf'))
if (fs.existsSync(internalUploads)) {
pdfFiles = fs.readdirSync(internalUploads).filter(f => f.toLowerCase().endsWith('.pdf'))
.map(f => {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const filePath = path.join(uploads, f)
return { f, mtime: fs.statSync(filePath).mtimeMs }
const filePath = path.join(internalUploads, f)
return { f, mtime: fs.statSync(filePath).mtimeMs, dir: internalUploads }
})
.sort((a,b) => b.mtime - a.mtime)
.map(x => x.f)
}
const apiPdf = pdfFiles.find(n => !n.includes('sample')) || pdfFiles[0]
// Do NOT fall back to public/uploads to avoid encouraging public exposure.
if (pdfFiles.length === 0) {
if (fs.existsSync(publicUploads)) {
console.warn('WARN: PDFs exist in public/uploads. Please migrate them to server/data/uploads using scripts/migrate-public-galerie-to-metadata.js')
}
}
pdfFiles = pdfFiles.sort((a, b) => b.mtime - a.mtime)
const apiPdfEntry = pdfFiles.find(e => !e.f.includes('sample')) || pdfFiles[0]
await inspect(template)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
if (apiPdf) await inspect(path.join(uploads, apiPdf))
else console.log('No API-generated PDF found in public/uploads')
if (apiPdfEntry) await inspect(path.join(apiPdfEntry.dir, apiPdfEntry.f))
else console.log('No API-generated PDF found in server/data/uploads or public/uploads')
}
main().catch(e => { console.error(e); process.exit(1) })

View File

@@ -0,0 +1,96 @@
import fs from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
const allowed = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'])
const getDataPath = (filename) => {
const cwd = process.cwd()
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
return path.join(cwd, 'server/data', filename)
}
const GALERIE_DIR = getDataPath('galerie')
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
const PUBLIC_GALERIE_DIR = path.join(process.cwd(), 'public', 'galerie')
function titleFromFilename(filename) {
const nameWithoutExt = path.parse(filename).name
return nameWithoutExt.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
async function readJsonArray(file) {
try {
const data = await fs.readFile(file, 'utf-8')
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : []
} catch (e) {
if (e.code === 'ENOENT') return []
throw e
}
}
async function ensureDirs() {
await fs.mkdir(path.join(GALERIE_DIR, 'originals'), { recursive: true })
await fs.mkdir(path.join(GALERIE_DIR, 'previews'), { recursive: true })
}
async function main() {
await ensureDirs()
const entries = await readJsonArray(GALERIE_METADATA)
const existingByName = new Map(entries.map(e => [e.filename, e]))
let files = []
try {
files = await fs.readdir(PUBLIC_GALERIE_DIR)
} catch (e) {
console.error('public/galerie nicht gefunden:', PUBLIC_GALERIE_DIR)
process.exit(1)
}
const candidates = files.filter(f => allowed.has(path.extname(f).toLowerCase()))
let migrated = 0
let skipped = 0
for (const filename of candidates) {
if (existingByName.has(filename)) {
skipped++
continue
}
const id = randomUUID()
const title = titleFromFilename(filename)
const src = path.join(PUBLIC_GALERIE_DIR, filename)
const dest = path.join(GALERIE_DIR, 'originals', filename)
await fs.rename(src, dest)
const meta = {
id,
filename,
previewFilename: `preview_${filename}`,
title,
description: '',
isPublic: true,
uploadedBy: 'migration',
uploadedAt: new Date().toISOString(),
originalName: filename
}
entries.push(meta)
existingByName.set(filename, meta)
migrated++
}
await fs.writeFile(GALERIE_METADATA, JSON.stringify(entries, null, 2), 'utf-8')
console.log(`Fertig. Migriert: ${migrated}, übersprungen: ${skipped}`)
console.log('Hinweis: previews werden nicht automatisch erzeugt. Für echte previews bitte neu hochladen oder ein separates preview-generator script verwenden.')
}
main().catch(e => {
console.error(e)
process.exit(1)
})

View File

@@ -0,0 +1,54 @@
// Re-Encrypt Klartext-Mitgliedsanträge mit aktuellem ENCRYPTION_KEY
// Backup wird als .bak angelegt
import fs from 'fs/promises'
import path from 'path'
import { encryptObject } from '../server/utils/encryption.js'
const DIR = path.join(process.cwd(), 'server/data/membership-applications')
const KEY = process.env.ENCRYPTION_KEY
if (!KEY) {
console.error('ENCRYPTION_KEY fehlt! Bitte als Environment-Variable setzen.')
process.exit(1)
}
async function reencryptFile(file) {
const filePath = path.join(DIR, file)
try {
const content = await fs.readFile(filePath, 'utf8')
// Prüfe, ob bereits verschlüsselt (v2: Prefix)
if (content.startsWith('v2:')) {
console.log('Überspringe (bereits verschlüsselt):', file)
return false
}
// Prüfe, ob Klartext-JSON
if (!content.trim().startsWith('{')) {
console.warn('Überspringe (kein Klartext-JSON):', file)
return false
}
// Backup anlegen
await fs.copyFile(filePath, filePath + '.bak')
// Verschlüsseln
const obj = JSON.parse(content)
const encrypted = encryptObject(obj, KEY)
await fs.writeFile(filePath, encrypted, 'utf8')
console.log('Re-Encrypted:', file)
return true
} catch (e) {
console.error('Fehler bei', file, ':', e.message)
return false
}
}
async function main() {
const files = await fs.readdir(DIR)
let changed = 0
for (const file of files) {
if (!file.endsWith('.json')) continue
const ok = await reencryptFile(file)
if (ok) changed++
}
console.log('Fertig. Re-encrypted:', changed, 'Dateien.')
}
main()

View File

@@ -0,0 +1,22 @@
// Setzt für alle Mitglieder in members.json das Feld active: true und verschlüsselt neu
import 'dotenv/config'
import { readMembers, writeMembers } from '../server/utils/members.js'
async function main() {
const members = await readMembers()
if (!members || members.length === 0) {
console.log('Keine Mitglieder geladen (members.json leer oder nicht entschlüsselbar)')
return
}
let changed = 0
for (const m of members) {
if (m.active !== true) {
m.active = true
changed++
}
}
await writeMembers(members)
console.log(`Fertig. ${changed} Mitglieder auf active: true gesetzt und gespeichert.`)
}
main()

69
scripts/set-visibility.js Normal file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
import arg from 'arg'
async function main() {
const args = arg({
'--email': String,
'--showEmail': Boolean,
'--showPhone': Boolean,
'--showAddress': Boolean,
'--target': String // 'members'|'users'|'both'
})
const email = args['--email']
if (!email) {
console.error('Usage: node scripts/set-visibility.js --email <email> [--showEmail] [--showPhone] [--showAddress] [--target both|members|users]')
process.exit(2)
}
const showEmail = '--showEmail' in args ? Boolean(args['--showEmail']) : undefined
const showPhone = '--showPhone' in args ? Boolean(args['--showPhone']) : undefined
const showAddress = '--showAddress' in args ? Boolean(args['--showAddress']) : undefined
const target = args['--target'] || 'both'
const membersUtils = await import('../server/utils/members.js')
const authUtils = await import('../server/utils/auth.js')
if (target === 'both' || target === 'members') {
const members = await membersUtils.readMembers()
let changed = false
for (const m of members) {
if ((m.email || '').toLowerCase() === email.toLowerCase()) {
m.visibility = m.visibility || {}
if (showEmail !== undefined) m.visibility.showEmail = showEmail
if (showPhone !== undefined) m.visibility.showPhone = showPhone
if (showAddress !== undefined) m.visibility.showAddress = showAddress
changed = true
console.log('Updated manual member visibility for', email)
}
}
if (changed) {
await membersUtils.writeMembers(members)
console.log('Wrote members.json')
}
}
if (target === 'both' || target === 'users') {
const users = await authUtils.readUsers()
let changed = false
for (const u of users) {
if ((u.email || '').toLowerCase() === email.toLowerCase()) {
u.visibility = u.visibility || {}
if (showEmail !== undefined) u.visibility.showEmail = showEmail
if (showPhone !== undefined) u.visibility.showPhone = showPhone
if (showAddress !== undefined) u.visibility.showAddress = showAddress
changed = true
console.log('Updated user visibility for', email)
}
}
if (changed) {
await authUtils.writeUsers(users)
console.log('Wrote users.json')
}
}
}
main().catch(e => {
console.error(e)
process.exit(1)
})

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
import fs from 'fs'
import { promises as fsp } from 'fs'
import path from 'path'
import { readMembers, writeMembers } from '../server/utils/members.js'
// Script to split `name` into firstName/lastName for members.json.
// Usage:
// node scripts/split-names-in-members.js # dry-run, no writes
// node scripts/split-names-in-members.js --apply # apply changes and create backup
const MEMBERS_FILE_PATH = path.join(process.cwd(), 'server/data/members.json')
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
async function main() {
const apply = process.argv.includes('--apply')
console.log('Reading members via server utils (handles encryption)...')
const members = await readMembers()
if (!Array.isArray(members)) {
console.error('Unerwartetes Format von members:', typeof members)
process.exit(2)
}
let changed = false
for (const m of members) {
if ((!m.firstName || !m.lastName) && m.name) {
const { firstName, lastName } = extractNames(m.name)
if (!m.firstName) m.firstName = firstName
if (!m.lastName) m.lastName = lastName
changed = true
}
}
if (!changed) {
console.log('Keine Änderungen erforderlich. Alle Mitglieder haben firstName/lastName.')
return
}
console.log(`Gefundene Änderungen: Mitglieder mit ergänztenn Namen werden ${apply ? 'angewendet' : 'nur angezeigt (dry-run)'}.`)
if (!apply) {
console.log('Vorschau der Änderungen (erstes 10 geänderte Mitglieder):')
let count = 0
for (const m of members) {
if (m.firstName || m.lastName) {
console.log('-', m.id || '(keine id)', m.firstName, m.lastName, '-', m.name)
count++
if (count >= 10) break
}
}
console.log('\nFühre das Skript mit --apply aus, um die Änderungen dauerhaft zu schreiben (Backup wird erstellt).')
return
}
// Create backup of raw file (may be encrypted)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = MEMBERS_FILE_PATH + `.bak.${timestamp}`
try {
await fsp.copyFile(MEMBERS_FILE_PATH, backupPath)
console.log('Backup erstellt:', backupPath)
} catch (err) {
console.warn('Konnte kein Backup anlegen (Datei evtl. nicht vorhanden):', err.message)
}
// Write members using writeMembers (will handle encryption)
await writeMembers(members)
console.log('Mitglieder erfolgreich aktualisiert und verschlüsselt gespeichert.')
}
main().catch(err => {
console.error('Fehler:', err)
process.exit(1)
})

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Script to split name field in membership application JSON files under server/data/membership-applications/ (ESM)
// It will create backups for each modified file.
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const APPS_DIR = path.join(__dirname, '../server/data/membership-applications')
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
function main() {
if (!fs.existsSync(APPS_DIR)) {
console.error('membership-applications Verzeichnis nicht gefunden:', APPS_DIR)
process.exit(1)
}
const files = fs.readdirSync(APPS_DIR).filter(f => f.endsWith('.json'))
if (files.length === 0) {
console.log('Keine Bewerbungsdateien gefunden.')
return
}
let modified = 0
for (const file of files) {
const p = path.join(APPS_DIR, file)
let data
try {
data = JSON.parse(fs.readFileSync(p, 'utf8'))
} catch (err) {
console.error('Fehler beim Lesen von', p, err.message)
continue
}
if ((!data.firstName || !data.lastName) && data.name) {
const { firstName, lastName } = extractNames(data.name)
data.firstName = data.firstName || firstName
data.lastName = data.lastName || lastName
// Backup
const backup = p + '.bak'
fs.copyFileSync(p, backup)
fs.writeFileSync(p, JSON.stringify(data, null, 2))
modified++
console.log('Updated', p, '-> backup at', backup)
}
}
console.log('Done. Modified files:', modified)
}
main()

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Script: split-names-in-users.js (ESM)
// Splittet das Feld "name" in firstName und lastName für alle User in users.json, falls noch nicht vorhanden.
// Backup wird automatisch angelegt.
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const usersPath = path.join(__dirname, '../server/data/users.json')
const backupPath = usersPath + '.bak.' + new Date().toISOString().replace(/[:.]/g, '-')
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
function main() {
if (!fs.existsSync(usersPath)) {
console.error('users.json nicht gefunden:', usersPath)
process.exit(1)
}
const users = JSON.parse(fs.readFileSync(usersPath, 'utf8'))
let changed = false
for (const user of users) {
if ((!user.firstName || !user.lastName) && user.name) {
const { firstName, lastName } = extractNames(user.name)
if (!user.firstName) user.firstName = firstName
if (!user.lastName) user.lastName = lastName
changed = true
}
}
if (changed) {
fs.copyFileSync(usersPath, backupPath)
fs.writeFileSync(usersPath, JSON.stringify(users, null, 2))
console.log('Felder firstName/lastName ergänzt. Backup:', backupPath)
} else {
console.log('Keine Änderungen nötig. Alle Namen bereits gesplittet.')
}
}
main()

View File

@@ -1,5 +1,5 @@
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
import nodemailer from 'nodemailer'
import { sendRegistrationNotification } from '../../utils/email-service.js'
import { assertPasswordNotPwned } from '../../utils/hibp.js'
export default defineEventHandler(async (event) => {
@@ -55,61 +55,11 @@ export default defineEventHandler(async (event) => {
users.push(newUser)
await writeUsers(users)
// Send notification email to admin
// Send notification to Vorstand/admin via central email service
try {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (!smtpUser || !smtpPass) {
console.warn('SMTP-Credentials fehlen! E-Mail-Versand wird übersprungen.')
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
// Continue without sending email
} else {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: {
user: smtpUser,
pass: smtpPass
}
})
// Email to admin
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de',
subject: 'Neue Registrierung - Harheimer TC',
html: `
<h2>Neue Registrierung</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${name}</li>
<li><strong>E-Mail:</strong> ${email}</li>
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
})
// Email to user
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: email,
subject: 'Registrierung erhalten - Harheimer TC',
html: `
<h2>Registrierung erhalten</h2>
<p>Hallo ${name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
})
}
await sendRegistrationNotification({ name, email, phone })
} catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
// Continue anyway - user is registered
console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
}
return {

View File

@@ -0,0 +1,90 @@
import { readMembers, normalizeDate } from '../utils/members.js'
import { readUsers, migrateUserRoles, getUserFromToken, verifyToken } from '../utils/auth.js'
// Helper: returns array of upcoming birthdays within daysAhead (inclusive)
function getUpcomingBirthdays(entries, daysAhead = 28) {
const now = new Date()
const results = []
// iterate entries with geburtsdatum and name
for (const e of entries) {
const raw = e.geburtsdatum
if (!raw) continue
const parsed = new Date(raw)
if (isNaN(parsed.getTime())) continue
// Build next occurrence for this year
const thisYear = now.getFullYear()
const occ = new Date(thisYear, parsed.getMonth(), parsed.getDate())
// If already passed this year, consider next year
if (occ < now) {
occ.setFullYear(thisYear + 1)
}
const diffDays = Math.ceil((occ - now) / (1000 * 60 * 60 * 24))
if (diffDays >= 0 && diffDays <= daysAhead) {
results.push({
name: e.name || `${e.firstName || ''} ${e.lastName || ''}`.trim(),
dayMonth: `${String(occ.getDate()).padStart(2, '0')}.${String(occ.getMonth()+1).padStart(2, '0')}`,
date: occ,
diffDays
})
}
}
// Sort by upcoming date
results.sort((a, b) => a.date - b.date)
return results
}
export default defineEventHandler(async (event) => {
try {
// Determine viewer for visibility rules; token optional
const token = getCookie(event, 'auth_token')
let currentUser = null
if (token) {
const decoded = verifyToken(token)
if (decoded) {
currentUser = await getUserFromToken(token)
}
}
const manualMembers = await readMembers()
const registeredUsers = await readUsers()
// Build unified list of candidates with geburtsdatum and visibility
const candidates = []
for (const m of manualMembers) {
const isAccepted = m.active === true || (m.status && String(m.status).toLowerCase() === 'accepted') || m.accepted === true
if (!isAccepted) continue
const vis = m.visibility || {}
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' })
}
for (const u of registeredUsers) {
if (!u.active) continue
const vis = u.visibility || {}
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
}
// Respect visibility: if viewer is vorstand they see all birthdays
const isPrivilegedViewer = currentUser ? (Array.isArray(currentUser.roles) ? currentUser.roles.includes('vorstand') : currentUser.role === 'vorstand') : false
const filtered = candidates.filter(c => c.geburtsdatum && (isPrivilegedViewer || c.visibility.showBirthday === true))
const upcoming = getUpcomingBirthdays(filtered, 28)
// Return only next 4 weeks entries with name and dayMonth
return {
success: true,
birthdays: upcoming.map(b => ({ name: b.name, dayMonth: b.dayMonth, inDays: b.diffDays }))
}
} catch (error) {
console.error('Fehler beim Abrufen der Geburtstage:', error)
throw error
}
})

View File

@@ -0,0 +1,17 @@
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
import { readContactRequests } from '../../utils/contact-requests.js'
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
throw createError({
statusCode: 403,
statusMessage: 'Zugriff verweigert'
})
}
const requests = await readContactRequests()
return requests
})

View File

@@ -0,0 +1,75 @@
import nodemailer from 'nodemailer'
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
function createTransporter() {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: { user: smtpUser, pass: smtpPass }
})
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
throw createError({
statusCode: 403,
statusMessage: 'Zugriff verweigert'
})
}
const body = await readBody(event)
const replyMessage = String(body?.message || '').trim()
if (!replyMessage) {
throw createError({ statusCode: 400, statusMessage: 'Antworttext fehlt' })
}
const requestId = getRouterParam(event, 'id')
if (!requestId) {
throw createError({ statusCode: 400, statusMessage: 'Anfrage-ID fehlt' })
}
const all = await readContactRequests()
const target = all.find((r) => r.id === requestId)
if (!target) {
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
}
const transporter = createTransporter()
if (!transporter) {
throw createError({
statusCode: 500,
statusMessage: 'SMTP ist nicht konfiguriert'
})
}
const originalSubject = target.subject || 'Kontaktanfrage'
const responseSubject = `Aw: ${originalSubject}`
await transporter.sendMail({
from: `"Harheimer TC" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
to: target.email,
subject: responseSubject,
text: replyMessage
})
const responderEmail = currentUser.email || ''
const updated = await addContactReply({
requestId,
replyText: replyMessage,
responderEmail
})
return {
success: true,
request: updated
}
})

View File

@@ -0,0 +1,33 @@
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
import { readContactRequests, updateContactRequestStatus } from '../../../../utils/contact-requests.js'
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
throw createError({
statusCode: 403,
statusMessage: 'Zugriff verweigert'
})
}
const requestId = getRouterParam(event, 'id')
if (!requestId) {
throw createError({ statusCode: 400, statusMessage: 'Anfrage-ID fehlt' })
}
const all = await readContactRequests()
const target = all.find((r) => r.id === requestId)
if (!target) {
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
}
const newStatus = target.status === 'beantwortet' ? 'offen' : 'beantwortet'
const updated = await updateContactRequestStatus(requestId, newStatus)
return {
success: true,
request: updated
}
})

View File

@@ -26,9 +26,12 @@ const getDataPath = (filename) => {
}
// Multer-Konfiguration für PDF-Uploads
// Store uploads in internal data directory instead of public/
const DOCUMENTS_DIR = getDataPath('documents')
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'public/documents/')
cb(null, DOCUMENTS_DIR)
},
filename: (req, file, cb) => {
cb(null, 'satzung.pdf')
@@ -75,7 +78,8 @@ export default defineEventHandler(async (event) => {
}
try {
await fs.mkdir(path.join(process.cwd(), 'public', 'documents'), { recursive: true })
// Ensure internal documents dir exists
await fs.mkdir(DOCUMENTS_DIR, { recursive: true })
// Multer-Middleware für File-Upload
await new Promise((resolve, reject) => {
@@ -96,112 +100,50 @@ export default defineEventHandler(async (event) => {
// Zusätzliche Validierung: Magic-Bytes prüfen (mimetype kann gespooft sein)
await assertPdfMagicHeader(file.path)
// PDF-Text extrahieren mit pdftotext (falls verfügbar) oder Fallback
// 1. Versuche, den Text mit pdftotext zu extrahieren
let extractedText = ''
try {
// Versuche pdftotext zu verwenden (falls auf dem System installiert)
const { stdout } = await execAsync(`pdftotext "${file.path}" -`)
extractedText = stdout
} catch (_error) {
console.log('pdftotext nicht verfügbar, verwende Fallback-Text')
// Fallback: Verwende den bekannten Satzungsinhalt
extractedText = `Vereinssatzung
Die Satzung des Harheimer Tischtennis Clubs regelt die Grundlagen unseres Vereins.
§ 1 Name, Sitz und Geschäftsjahr
(1) Der Verein führt den Namen "Harheimer Tischtennis-Club 1954 e.V." (HTC).
(2) Der Verein hat seinen Sitz in Frankfurt am Main.
(3) Das Geschäftsjahr ist das Kalenderjahr.
§ 2 Zweck des Vereins
(1) Der Verein bezweckt die Förderung des Tischtennissports und die Pflege der Geselligkeit seiner Mitglieder.
(2) Der Verein ist selbstlos tätig; er verfolgt nicht in erster Linie eigenwirtschaftliche Zwecke.
§ 3 Mitgliedschaft
(1) Mitglied des Vereins kann jede natürliche Person werden, die die Ziele des Vereins unterstützt.
(2) Der Antrag auf Mitgliedschaft ist schriftlich an den Vorstand zu richten.
(3) Über die Aufnahme entscheidet der Vorstand.
§ 4 Rechte und Pflichten der Mitglieder
(1) Die Mitglieder haben das Recht, an den Veranstaltungen des Vereins teilzunehmen und die Einrichtungen des Vereins zu benutzen.
(2) Die Mitglieder sind verpflichtet, die Satzung und die Beschlüsse der Vereinsorgane zu beachten und den Mitgliedsbeitrag zu entrichten.
§ 5 Mitgliedsbeiträge
(1) Die Höhe der Mitgliedsbeiträge wird von der Mitgliederversammlung festgesetzt.
(2) Die Mitgliedsbeiträge sind im Voraus zu entrichten.
§ 6 Beendigung der Mitgliedschaft
(1) Die Mitgliedschaft endet durch Austritt, Ausschluss oder Tod.
(2) Der Austritt erfolgt durch schriftliche Erklärung gegenüber dem Vorstand.
(3) Ein Mitglied kann aus wichtigem Grund ausgeschlossen werden.
§ 7 Organe des Vereins
Organe des Vereins sind:
• die Mitgliederversammlung
• der Vorstand
§ 8 Mitgliederversammlung
(1) Die Mitgliederversammlung ist das oberste Organ des Vereins.
(2) Sie wird vom Vorsitzenden mindestens einmal im Jahr einberufen.
(3) Die Mitgliederversammlung beschließt über alle wichtigen Angelegenheiten des Vereins.
§ 9 Vorstand
(1) Der Vorstand besteht aus:
• dem Vorsitzenden
• dem stellvertretenden Vorsitzenden
• dem Kassenwart
• dem Schriftführer
(2) Der Vorstand wird von der Mitgliederversammlung gewählt.
(3) Der Vorstand führt die Geschäfte des Vereins.
§ 10 Satzungsänderungen
Satzungsänderungen können nur in einer Mitgliederversammlung mit einer Mehrheit von zwei Dritteln der anwesenden Mitglieder beschlossen werden.
§ 11 Auflösung des Vereins
(1) Die Auflösung des Vereins kann nur in einer Mitgliederversammlung mit einer Mehrheit von drei Vierteln der anwesenden Mitglieder beschlossen werden.
(2) Bei Auflösung des Vereins fällt das Vereinsvermögen an eine gemeinnützige Organisation.`
// UTF-8 erzwingen, Ausgabe nach stdout
const { stdout } = await execAsync(`pdftotext -enc UTF-8 "${file.path}" -`)
extractedText = stdout || ''
} catch (err) {
console.error('pdftotext Fehler beim Verarbeiten der Satzung:', err)
throw createError({
statusCode: 500,
statusMessage: 'Die Satzung konnte nicht aus dem PDF gelesen werden (pdftotext-Fehler). Bitte den Server-Administrator kontaktieren.'
})
}
// Text in HTML-Format konvertieren
const htmlContent = convertTextToHtml(extractedText)
// Minimale Plausibilitätsprüfung: genug Text & typische Satzungs-Merkmale
const cleaned = extractedText.trim()
if (!cleaned || cleaned.length < 500 || !cleaned.includes('§')) {
console.error('Satzung: extrahierter Text wirkt unplausibel oder zu kurz:', {
length: cleaned.length
})
throw createError({
statusCode: 500,
statusMessage: 'Die Satzung konnte nicht zuverlässig aus dem PDF gelesen werden. Bitte die PDF-Datei prüfen.'
})
}
// Config aktualisieren
// 2. In HTML-Format konvertieren
const htmlContent = convertTextToHtml(cleaned)
// 3. Config aktualisieren (PDF + geparster Inhalt)
const configPath = getDataPath('config.json')
const configData = JSON.parse(await fs.readFile(configPath, 'utf-8'))
if (!configData.seiten) {
configData.seiten = {}
}
// Serve the uploaded statute via internal media proxy
configData.seiten.satzung = {
pdfUrl: '/documents/satzung.pdf',
pdfUrl: '/api/media/documents/satzung.pdf',
content: htmlContent
}
await fs.writeFile(configPath, JSON.stringify(configData, null, 2))
await fs.writeFile(configPath, JSON.stringify(configData, null, 2), 'utf-8')
return {
success: true,
@@ -224,44 +166,154 @@ Satzungsänderungen können nur in einer Mitgliederversammlung mit einer Mehrhei
// PDF-Text zu HTML konvertieren
function convertTextToHtml(text) {
// Text bereinigen und strukturieren
let html = text
let cleaned = text
.replace(/\r\n/g, '\n') // Windows-Zeilenumbrüche normalisieren
.replace(/\r/g, '\n') // Mac-Zeilenumbrüche normalisieren
.replace(/\n\s*\n/g, '\n\n') // Mehrfache Zeilenumbrüche reduzieren
.trim()
// Überschriften erkennen und formatieren
html = html.replace(/^(Vereinssatzung|Satzung)$/gm, '<h1>$1</h1>')
html = html.replace(/^\s*\d+[^§\n]*)$/gm, '<h2>$1</h2>')
// Seitenzahlen und Seitenfuß entfernen
cleaned = cleaned
.replace(/^Seite\s+\d+\s+von\s+\d+.*$/gm, '')
.replace(/^-+\d+-+\s*$/gm, '')
.replace(/\n\s*-+\d+-+\s*\n/g, '\n')
.replace(/\s*-+\d+-+\s*/g, '')
.replace(/zuletzt geändert am \d{2}\.\d{2}\.\d{4}.*$/gm, '')
// Absätze erstellen
html = html.split('\n\n').map(paragraph => {
paragraph = paragraph.trim()
if (!paragraph) return ''
// Zeilenweise aufteilen und leere Zeilen filtern
let rawLines = cleaned.split('\n').map(l => l.trim()).filter(l => {
if (!l || l.length === 0) return false
if (/^-+\d+-+$/.test(l)) return false
if (/^Seite\s+\d+\s+von\s+\d+/.test(l)) return false
return true
})
// Überschriften nicht als Paragraphen behandeln
if (paragraph.match(/^<h[1-6]>/) || paragraph.match(/^§\s*\d+/)) {
return paragraph
// ============================================================
// SCHRITT 1: Zusammengehörige Zeilen zusammenführen
// pdftotext trennt oft Nummer/Prefix und Inhalt auf zwei Zeilen
// ============================================================
const merged = []
for (let j = 0; j < rawLines.length; j++) {
const line = rawLines[j]
const next = j + 1 < rawLines.length ? rawLines[j + 1] : null
// Fall 1: "§ 1" (nur Paragraphennummer) + nächste Zeile ist der Titel
// z.B. "§ 1" + "Name, Sitz und Zweck" → "§ 1 Name, Sitz und Zweck"
if (/^§\s*\d+\s*$/.test(line) && next && !next.match(/^§/) && !next.match(/^\d+\.\s/)) {
merged.push(line + ' ' + next)
j++ // nächste Zeile überspringen
continue
}
// Listen erkennen
if (paragraph.includes('•') || paragraph.includes('-') || paragraph.match(/^\d+\./)) {
const listItems = paragraph.split(/\n/).map(item => {
item = item.trim()
if (item.match(/^[•-]\s/) || item.match(/^\d+\.\s/)) {
return `<li>${item.replace(/^[•-]\s/, '').replace(/^\d+\.\s/, '')}</li>`
// Fall 2: "1." (nur Nummer mit Punkt) + nächste Zeile ist der Text
// z.B. "1." + "Der Harheimer TC..." → "1. Der Harheimer TC..."
if (/^\d+\.\s*$/.test(line) && next) {
merged.push(line + ' ' + next)
j++
continue
}
return `<li>${item}</li>`
}).join('')
return `<ul>${listItems}</ul>`
// Fall 3: "a)" (nur Buchstabe mit Klammer) + nächste Zeile ist der Text
// z.B. "a)" + "Die Bestimmungen..." → "a) Die Bestimmungen..."
if (/^[a-z]\)\s*$/i.test(line) && next) {
merged.push(line + ' ' + next)
j++
continue
}
// Keine Zusammenführung nötig
merged.push(line)
}
// ============================================================
// SCHRITT 2: HTML-Elemente erzeugen
// ============================================================
const result = []
let i = 0
while (i < merged.length) {
const line = merged[i]
// Überschriften erkennen (§1, § 2, etc.)
if (line.match(/^§\s*\d+/)) {
result.push(`<h2>${line}</h2>`)
i++
continue
}
// Prüfe ob wir eine Liste mit a), b), c) haben
// Suche nach einem Muster wie "2. Text:" gefolgt von "a) ...", "b) ...", etc.
if (line.match(/^\d+\.\s+.*:$/) && i + 1 < merged.length && merged[i + 1].match(/^[a-z]\)\s+/i)) {
// Einleitender Text für die Liste (ohne Nummer)
const introText = line.replace(/^\d+\.\s+/, '')
const listItems = []
i++
// Sammle alle Listenpunkte a), b), c) ...
while (i < merged.length && merged[i].match(/^[a-z]\)\s+/i)) {
const itemText = merged[i].replace(/^[a-z]\)\s+/i, '').trim()
if (itemText) {
listItems.push(itemText)
}
i++
}
if (listItems.length > 0) {
const listHtml = listItems.map(item => `<li>${item}</li>`).join('')
result.push(`<p><strong>${introText}</strong></p><ul>${listHtml}</ul>`)
} else {
result.push(`<p>${line}</p>`)
}
continue
}
// Einzelne Listenpunkte a), b), c) erkennen
if (line.match(/^[a-z]\)\s+/i)) {
const items = []
while (i < merged.length && merged[i].match(/^[a-z]\)\s+/i)) {
const itemText = merged[i].replace(/^[a-z]\)\s+/i, '').trim()
if (itemText) {
items.push(itemText)
}
i++
}
if (items.length > 0) {
const listHtml = items.map(item => `<li>${item}</li>`).join('')
result.push(`<ul>${listHtml}</ul>`)
}
continue
}
// Nummerierte Listen (1., 2., 3.) - aber nur wenn mehrere aufeinander folgen
if (line.match(/^\d+\.\s+/) && i + 1 < merged.length && merged[i + 1].match(/^\d+\.\s+/)) {
const items = []
while (i < merged.length && merged[i].match(/^\d+\.\s+/)) {
const itemText = merged[i].replace(/^\d+\.\s+/, '').trim()
// Prüfe ob es eine Einleitung für eine Unterliste ist (endet mit ":")
if (itemText.endsWith(':') && i + 1 < merged.length && merged[i + 1].match(/^[a-z]\)\s+/i)) {
break // Wird oben als Einleitung + Unterliste behandelt
}
if (itemText) {
items.push(itemText)
}
i++
}
if (items.length > 0) {
const listHtml = items.map(item => `<li>${item}</li>`).join('')
result.push(`<ol>${listHtml}</ol>`)
}
continue
}
// Normale Absätze
return `<p>${paragraph.replace(/\n/g, '<br>')}</p>`
}).join('\n')
// Mehrfache Zeilenumbrüche entfernen
html = html.replace(/\n{3,}/g, '\n\n')
return html
result.push(`<p>${line}</p>`)
i++
}
let html = result.join('\n')
// Leere Absätze entfernen
html = html.replace(/<p>\s*<\/p>/g, '')
html = html.replace(/<p><\/p>/g, '')
return html.trim()
}

View File

@@ -4,7 +4,15 @@ import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
let token = getCookie(event, 'auth_token')
if (!token) {
const authHeader = getHeader(event, 'authorization')
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7).trim()
}
}
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser) {
@@ -45,25 +53,12 @@ export default defineEventHandler(async (event) => {
})
}
// Wichtig: In Production werden statische Dateien aus `.output/public` ausgeliefert.
// Wenn PM2 `cwd` auf das Repo-Root setzt, ist `process.cwd()` NICHT `.output`
// daher schreiben wir robust in alle sinnvollen Zielorte:
// - `.output/public/data/<file>` (damit die laufende Instanz sofort die neuen Daten liefert)
// - `public/data/<file>` (damit der nächste Build die Daten wieder übernimmt)
//
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is validated against allowlist above, path traversal prevented
// Neuer Ablauf (Option B): Schreibe CSVs ausschließlich in internes Datenverzeichnis,
// damit keine direkten Schreibzugriffe auf `public/` stattfinden.
// Später kann ein kontrollierter Deploy-/Sync-Prozess die Daten aus `server/data/public-data`
// in die öffentlich ausgelieferte `public/`-Location übernehmen.
const cwd = process.cwd()
const pathExists = async (p) => {
try {
await fs.access(p)
return true
} catch {
return false
}
}
const writeFileAtomicAndVerify = async (targetPath, data) => {
const dataDir = path.dirname(targetPath)
await fs.mkdir(dataDir, { recursive: true })
@@ -97,47 +92,25 @@ export default defineEventHandler(async (event) => {
}
}
// Preferred: das tatsächlich ausgelieferte Verzeichnis in Production
// (Nuxt/Nitro serve static aus `.output/public`)
const preferredPaths = []
if (await pathExists(path.join(cwd, '.output/public'))) {
preferredPaths.push(path.join(cwd, '.output/public/data', filename))
}
if (await pathExists(path.join(cwd, '../.output/public'))) {
preferredPaths.push(path.join(cwd, '../.output/public/data', filename))
}
// Fallbacks: Source-Public (für Persistenz bei nächstem Build) und diverse cwd-Layouts
const fallbackPaths = [
path.join(cwd, 'public/data', filename),
path.join(cwd, '../public/data', filename)
// Ziel: internes Datenverzeichnis unter `server/data/public-data` (persistente, interne Quelle)
const internalPaths = [
path.join(cwd, 'server/data/public-data', filename),
path.join(cwd, '../server/data/public-data', filename)
]
const uniquePaths = [...new Set([...preferredPaths, ...fallbackPaths])]
const uniquePaths = [...new Set([...internalPaths])]
const writeResults = []
const writeErrors = []
let wrotePreferred = false
for (const targetPath of uniquePaths) {
try {
await writeFileAtomicAndVerify(targetPath, content)
writeResults.push(targetPath)
if (preferredPaths.includes(targetPath)) wrotePreferred = true
} catch (e) {
writeErrors.push({ targetPath, error: e?.message || String(e) })
}
}
// Wenn wir ein `.output/public` gefunden haben, MUSS auch dorthin geschrieben worden sein.
// Sonst melden wir nicht "Erfolg", weil die laufende Instanz dann weiterhin alte/defekte Daten ausliefert.
if (preferredPaths.length > 0 && !wrotePreferred) {
console.error('CSV wurde NICHT in .output/public geschrieben. Errors:', writeErrors)
throw createError({
statusCode: 500,
statusMessage: 'CSV konnte nicht in das ausgelieferte Verzeichnis geschrieben werden'
})
}
if (writeResults.length === 0) {
console.error('Konnte CSV-Datei in keinen Zielpfad schreiben:', writeErrors)
throw createError({

View File

@@ -17,25 +17,32 @@ export default defineEventHandler(async (event) => {
const isVorstand = hasRole(currentUser, 'vorstand')
// Return users without Passwörter; Kontaktdaten nur für Vorstand
// Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
const safeUsers = users.map(u => {
const migrated = migrateUserRoles({ ...u })
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
const email = isVorstand ? u.email : undefined
const phone = isVorstand ? (u.phone || '') : undefined
return {
return canSeePrivate
? {
id: u.id,
email,
email: u.email,
name: u.name,
roles: roles,
role: roles[0] || 'mitglied', // Rückwärtskompatibilität
phone,
role: roles[0] || 'mitglied',
phone: u.phone || '',
active: u.active,
created: u.created,
lastLogin: u.lastLogin
}
: {
id: u.id,
name: u.name,
role: roles[0] || 'mitglied',
active: u.active,
lastLogin: u.lastLogin
}
})
return {

View File

@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { userId, roles } = body
const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter']
const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter', 'trainer']
const rolesArray = Array.isArray(roles) ? roles : (roles ? [roles] : ['mitglied'])
if (!rolesArray.every(r => validRoles.includes(r))) {

View File

@@ -1,10 +1,93 @@
import nodemailer from 'nodemailer'
import { promises as fs } from 'fs'
import path from 'path'
import { createContactRequest } from '../utils/contact-requests.js'
import { readUsers, migrateUserRoles } from '../utils/auth.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant ('config.json'), never user input
const getDataPath = (filename) => {
const cwd = process.cwd()
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
return path.join(cwd, 'server/data', filename)
}
async function loadConfig() {
try {
const configFile = getDataPath('config.json')
const raw = await fs.readFile(configFile, 'utf-8')
return JSON.parse(raw)
} catch (error) {
console.error('Fehler beim Laden der Konfiguration für Kontaktanfragen:', error)
return {}
}
}
async function collectRecipients(config) {
const recipients = []
// Vorstand
if (config?.vorstand && typeof config.vorstand === 'object') {
for (const member of Object.values(config.vorstand)) {
if (member?.email && typeof member.email === 'string' && member.email.trim()) {
recipients.push(member.email.trim())
}
}
}
// Trainer
if (Array.isArray(config?.trainer)) {
for (const trainer of config.trainer) {
if (trainer?.email && typeof trainer.email === 'string' && trainer.email.trim()) {
recipients.push(trainer.email.trim())
}
}
}
// Zusätzlich: Benutzer mit Trainer-Rolle aus dem Login-System
try {
const users = await readUsers()
for (const rawUser of users) {
const user = migrateUserRoles({ ...rawUser })
const roles = Array.isArray(user.roles) ? user.roles : []
if (roles.includes('trainer') && user.email && String(user.email).trim()) {
recipients.push(String(user.email).trim())
}
}
} catch (error) {
console.error('Fehler beim Laden der Trainer-Empfänger aus Benutzerdaten:', error)
}
const unique = [...new Set(recipients)]
if (unique.length > 0) return unique
// Fallback
if (config?.website?.verantwortlicher?.email) {
return [config.website.verantwortlicher.email]
}
if (process.env.SMTP_USER) {
return [process.env.SMTP_USER]
}
return ['j.dichmann@gmx.de']
}
function createTransporter() {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: { user: smtpUser, pass: smtpPass }
})
}
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
// Validierung der Eingabedaten
if (!body.name || !body.email || !body.subject || !body.message) {
throw createError({
statusCode: 400,
@@ -12,7 +95,6 @@ export default defineEventHandler(async (event) => {
})
}
// E-Mail-Validierung
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
throw createError({
@@ -21,34 +103,32 @@ export default defineEventHandler(async (event) => {
})
}
// SMTP-Konfiguration (hier können Sie Ihre SMTP-Daten eintragen)
const smtpUser = process.env.SMTP_USER || 'j.dichmann@gmx.de'
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) {
throw createError({
statusCode: 500,
statusMessage: 'SMTP-Credentials fehlen! Bitte setzen Sie SMTP_USER und SMTP_PASS in der .env Datei.'
})
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false, // true für 465, false für andere Ports
auth: {
user: smtpUser,
pass: smtpPass
}
// Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
await createContactRequest({
name: String(body.name).trim(),
email: String(body.email).trim(),
phone: body.phone ? String(body.phone).trim() : '',
subject: String(body.subject).trim(),
message: String(body.message).trim()
})
// E-Mail-Template
const config = await loadConfig()
const recipients = await collectRecipients(config)
const transporter = createTransporter()
if (!transporter) {
return {
success: true,
message: 'Anfrage wurde gespeichert. E-Mail-Versand ist aktuell nicht konfiguriert.'
}
}
const nowLabel = new Date().toLocaleString('de-DE')
const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #dc2626; border-bottom: 2px solid #dc2626; padding-bottom: 10px;">
Neue Kontaktanfrage - Harheimer TC
</h2>
<div style="background-color: #f9fafb; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="color: #374151; margin-top: 0;">Kontaktdaten:</h3>
<p><strong>Name:</strong> ${body.name}</p>
@@ -56,21 +136,18 @@ export default defineEventHandler(async (event) => {
<p><strong>Telefon:</strong> ${body.phone || 'Nicht angegeben'}</p>
<p><strong>Betreff:</strong> ${body.subject}</p>
</div>
<div style="background-color: #ffffff; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h3 style="color: #374151; margin-top: 0;">Nachricht:</h3>
<p style="white-space: pre-wrap; line-height: 1.6;">${body.message}</p>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
<p>Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.</p>
<p>Zeitstempel: ${new Date().toLocaleString('de-DE')}</p>
<p>Zeitstempel: ${nowLabel}</p>
</div>
</div>
`
const emailText = `
Neue Kontaktanfrage - Harheimer TC
const emailText = `Neue Kontaktanfrage - Harheimer TC
Kontaktdaten:
Name: ${body.name}
@@ -83,36 +160,29 @@ ${body.message}
---
Diese Nachricht wurde über das Kontaktformular der Harheimer TC Website gesendet.
Zeitstempel: ${new Date().toLocaleString('de-DE')}
`
Zeitstempel: ${nowLabel}`
// E-Mail senden
const mailOptions = {
from: `"Harheimer TC Website" <${process.env.SMTP_USER || 'j.dichmann@gmx.de'}>`,
to: 'j.dichmann@gmx.de',
await transporter.sendMail({
from: `"Harheimer TC Website" <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
to: recipients.join(', '),
replyTo: body.email,
subject: `Kontaktanfrage: ${body.subject}`,
text: emailText,
html: emailHtml
}
await transporter.sendMail(mailOptions)
})
return {
success: true,
message: 'E-Mail wurde erfolgreich gesendet!'
message: 'Anfrage wurde erfolgreich gesendet.'
}
} catch (error) {
console.error('Fehler beim Senden der E-Mail:', error)
console.error('Fehler bei Kontaktanfrage:', error)
if (error.statusCode) {
throw error
}
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Fehler beim Senden der E-Mail. Bitte versuchen Sie es später erneut.'
statusMessage: 'Fehler beim Senden der Anfrage. Bitte versuchen Sie es später erneut.'
})
}
})

View File

@@ -45,35 +45,49 @@ export default defineEventHandler(async (event) => {
}
}
const metadata = await readGalerieMetadata()
let metadata = []
try {
metadata = await readGalerieMetadata()
if (!Array.isArray(metadata)) {
console.warn('Galerie-Metadaten haben unerwartetes Format, verwende leere Liste')
metadata = []
}
} catch (e) {
console.error('Fehler beim Lesen der Galerie-Metadaten, liefere leeres Ergebnis:', e.message)
metadata = []
}
// Filtere Bilder basierend auf Sichtbarkeit
const visibleImages = metadata.filter(image => {
// Öffentliche Bilder sind für alle sichtbar
// Defensive checks
if (!image || typeof image !== 'object') return false
if (image.isPublic) return true
// Private Bilder nur für eingeloggte Mitglieder
return isLoggedIn
})
// Sortiere nach Upload-Datum (neueste zuerst)
visibleImages.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt))
// Sortiere nach Upload-Datum (neueste zuerst) - defensive
visibleImages.sort((a, b) => {
const ta = new Date(a.uploadedAt || 0).getTime()
const tb = new Date(b.uploadedAt || 0).getTime()
return tb - ta
})
// Pagination
const page = parseInt(getQuery(event).page) || 1
const perPage = 10
// Pagination (defensive defaults)
const page = Math.max(1, parseInt(getQuery(event).page) || 1)
const perPage = Math.max(1, parseInt(getQuery(event).perPage) || 10)
const start = (page - 1) * perPage
const end = start + perPage
const paginatedImages = visibleImages.slice(start, end)
const paginatedImages = visibleImages.slice(start, start + perPage)
// Konsistente Rückgabeform
return {
success: true,
images: paginatedImages.map(img => ({
id: img.id,
title: img.title,
description: img.description,
isPublic: img.isPublic,
uploadedAt: img.uploadedAt,
previewFilename: img.previewFilename
id: img.id || img.filename || null,
title: img.title || '',
description: img.description || '',
isPublic: !!img.isPublic,
uploadedAt: img.uploadedAt || null,
previewFilename: img.previewFilename || null
})),
pagination: {
page,

View File

@@ -15,7 +15,10 @@ export default defineEventHandler(async (event) => {
const cwd = process.cwd()
const filename = 'mannschaften.csv'
// Prefer server/data, then .output/public/data, then public/data
const candidates = [
path.join(cwd, '.output/server/data', filename),
path.join(cwd, 'server/data', filename),
path.join(cwd, '.output/public/data', filename),
path.join(cwd, 'public/data', filename),
path.join(cwd, '../.output/public/data', filename),

View File

@@ -0,0 +1,83 @@
import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken, verifyToken } from '../../../utils/auth.js'
const getDataPath = (filename) => {
const cwd = process.cwd()
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
return path.join(cwd, 'server/data', filename)
}
const GALERIE_DIR = getDataPath('galerie')
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
async function readGalerieMetadata() {
try {
const data = await fs.readFile(GALERIE_METADATA, 'utf-8')
return JSON.parse(data)
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
}
async function isLoggedIn(event) {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) return false
const decoded = verifyToken(token)
if (!decoded) return false
const user = await getUserFromToken(token)
return !!(user && user.active)
}
export default defineEventHandler(async (event) => {
const imageId = getRouterParam(event, 'id')
const query = getQuery(event)
const preview = query.preview === 'true'
if (!imageId) {
throw createError({ statusCode: 400, statusMessage: 'Bild-ID erforderlich' })
}
const metadata = await readGalerieMetadata()
const image = metadata.find(img => img.id === imageId)
if (!image) {
throw createError({ statusCode: 404, statusMessage: 'Bild nicht gefunden' })
}
if (!image.isPublic) {
const ok = await isLoggedIn(event)
if (!ok) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
}
const filename = preview ? image.previewFilename : image.filename
const sanitized = path.basename(path.normalize(String(filename || '')))
if (!sanitized || sanitized.includes('..')) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Dateiname' })
}
const subdir = preview ? 'previews' : 'originals'
const filePath = path.join(GALERIE_DIR, subdir, sanitized)
try {
await fs.access(filePath)
} catch (e) {
throw createError({ statusCode: 404, statusMessage: 'Bilddatei nicht gefunden' })
}
const ext = path.extname(sanitized).toLowerCase()
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml'
}
const contentType = mimeTypes[ext] || 'application/octet-stream'
const buf = await fs.readFile(filePath)
setHeader(event, 'Content-Type', contentType)
setHeader(event, 'Cache-Control', image.isPublic ? 'public, max-age=31536000, immutable' : 'private, max-age=0, no-store')
return buf
})

View File

@@ -0,0 +1,66 @@
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
// Minimal auth check using existing auth cookie/session
function isAuthenticated(event) {
try {
const token = getCookie(event, 'auth_token') || getCookie(event, 'session_token')
return token && String(token).trim() !== ''
} catch (e) {
return false
}
}
// Resolve file path within a non-public internal media directory
function resolveInternalPath(reqPath) {
const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal')
// prevent path traversal
const safe = path.normalize(reqPath).replace(/^\/+/, '')
return path.join(baseDir, safe)
}
export default defineEventHandler(async (event) => {
// auth gate
if (!isAuthenticated(event)) {
throw createError({ statusCode: 401, statusMessage: 'Nicht autorisiert' })
}
const param = event.context.params?.path
const reqPath = Array.isArray(param) ? param.join('/') : String(param || '')
if (!reqPath) {
throw createError({ statusCode: 400, statusMessage: 'Bildpfad fehlt' })
}
const filePath = resolveInternalPath(reqPath)
// check existence and ensure it stays within baseDir
const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal')
const resolved = path.resolve(filePath)
if (!resolved.startsWith(path.resolve(baseDir))) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Pfad' })
}
try {
const stat = await fsp.stat(resolved)
if (!stat.isFile()) throw new Error('not a file')
} catch (e) {
throw createError({ statusCode: 404, statusMessage: 'Datei nicht gefunden' })
}
// determine content type by extension
const ext = path.extname(resolved).toLowerCase()
const contentType = (
ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' :
ext === '.png' ? 'image/png' :
ext === '.gif' ? 'image/gif' :
ext === '.webp' ? 'image/webp' :
ext === '.svg' ? 'image/svg+xml' :
'application/octet-stream'
)
// stream the file
const stream = fs.createReadStream(resolved)
setHeader(event, 'Content-Type', contentType)
setHeader(event, 'Cache-Control', 'private, max-age=0, no-store')
return sendStream(event, stream)
})

View File

@@ -28,6 +28,17 @@ export default defineEventHandler(async (event) => {
const manualMembers = await readMembers()
const registeredUsers = await readUsers()
// Debug: Log alle geladenen Mitglieder (decryptet)
console.log('--- DEBUG: Decrypted manualMembers ---')
if (Array.isArray(manualMembers)) {
for (const m of manualMembers) {
console.log(JSON.stringify(m, null, 2))
}
console.log('--- Gesamt:', manualMembers.length, 'Mitglieder ---')
} else {
console.log('manualMembers ist kein Array:', manualMembers)
}
// Merge members: combine manual + registered, detect duplicates
const mergedMembers = []
@@ -35,14 +46,30 @@ export default defineEventHandler(async (event) => {
const emailToIndexMap = new Map() // email -> index in mergedMembers
const nameToIndexMap = new Map() // name -> index in mergedMembers
// First, add all manual members and build lookup maps
// First, add manual members that are active/accepted (filter out pending applications)
for (let i = 0; i < manualMembers.length; i++) {
const member = manualMembers[i]
// Normalize acceptance flags: accept if member.active===true or member.status==='accepted' or member.accepted===true
const isAccepted = member.active === true || (member.status && String(member.status).toLowerCase() === 'accepted') || member.accepted === true
if (!isAccepted) {
// Skip applications that are not yet accepted
continue
}
const normalizedEmail = member.email?.toLowerCase().trim() || ''
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim()
const normalizedName = fullName.toLowerCase()
const memberIndex = mergedMembers.length
// Ensure visibility flags are booleans for manual entries
const vis = member.visibility || {}
member.visibility = {
// Default: visible to all logged-in members unless explicitly hidden
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
// Address remains private by default
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress)
}
mergedMembers.push({
...member,
name: fullName, // Computed for display
@@ -73,6 +100,14 @@ export default defineEventHandler(async (event) => {
const normalizedEmail = user.email?.toLowerCase().trim() || ''
const normalizedName = user.name?.toLowerCase().trim() || ''
// Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name
function extractNames(name) {
if (!name || typeof name !== 'string') return { firstName: '', lastName: '' }
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: '' }
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
}
// Check if this user matches an existing manual member using O(1) lookup
let matchedManualIndex = -1
@@ -108,6 +143,8 @@ export default defineEventHandler(async (event) => {
// Merge with existing manual member
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
// Extrahiere Namen nur, wenn Felder leer sind
const { firstName, lastName } = extractNames(user.name)
mergedMembers[matchedManualIndex] = {
...mergedMembers[matchedManualIndex],
hasLogin: true,
@@ -115,21 +152,44 @@ export default defineEventHandler(async (event) => {
loginRoles: roles,
loginRole: roles[0] || 'mitglied', // Rückwärtskompatibilität
lastLogin: user.lastLogin,
isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true
isMannschaftsspieler: user.isMannschaftsspieler === true || mergedMembers[matchedManualIndex].isMannschaftsspieler === true,
firstName: mergedMembers[matchedManualIndex].firstName || firstName,
lastName: mergedMembers[matchedManualIndex].lastName || lastName,
editable: true
}
// If the registered user has visibility preferences, apply them (coerce to booleans)
if (user.visibility && typeof user.visibility === 'object') {
const vis = mergedMembers[matchedManualIndex].visibility || {}
mergedMembers[matchedManualIndex].visibility = {
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone),
showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress)
}
}
} else {
// Add as new member (from login system)
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
// Registered-only user: default to privacy-preserving visibility (hidden) unless user explicitly set visibility elsewhere
// Use stored visibility from user if present, otherwise default to false
const userVis = user.visibility || {}
const { firstName, lastName } = extractNames(user.name)
mergedMembers.push({
id: user.id,
name: user.name,
firstName,
lastName,
email: user.email,
phone: user.phone || '',
address: '',
visibility: {
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone),
showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress)
},
notes: `Rolle(n): ${roles.join(', ')}`,
source: 'login',
editable: false,
editable: true,
hasLogin: true,
loginEmail: user.email,
loginRoles: roles,
@@ -142,21 +202,75 @@ export default defineEventHandler(async (event) => {
// Sort by name
mergedMembers.sort((a, b) => a.name.localeCompare(b.name))
// Die Mitgliederliste ist nur für authentifizierte Nutzer sichtbar (siehe oben).
// Respektiere individuelle Sichtbarkeitspräferenzen (user.visibility)
const currentUserToken = token
const isViewerAuthenticated = !!currentUser
// Only 'vorstand' may override member visibility
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
// Serverseitiger Datenschutz: Kontaktdaten nur für Vorstand
const isVorstand = hasRole(currentUser, 'vorstand')
const safeMembers = isVorstand
? mergedMembers
: mergedMembers.map(m => ({
...m,
email: undefined,
phone: undefined,
address: undefined
}))
// Filtere den Admin-Account heraus
const filteredMembers = mergedMembers.filter(m => m.email?.toLowerCase() !== 'admin@harheimertc.de')
const sanitizedMembers = filteredMembers.map(member => {
// Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them
const visibility = member.visibility || {}
const showEmail = visibility.showEmail === undefined ? true : Boolean(visibility.showEmail)
const showPhone = visibility.showPhone === undefined ? true : Boolean(visibility.showPhone)
const showAddress = visibility.showAddress === undefined ? false : Boolean(visibility.showAddress)
// Determine if contact info existed but was hidden to the viewer
const hadEmail = !!member.email
const hadPhone = !!member.phone
const hadAddress = !!member.address
const hadBirthday = !!member.geburtsdatum
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true)))
const contactHidden = (!emailVisible && hadEmail) || (!phoneVisible && hadPhone) || (!addressVisible && hadAddress)
return {
id: member.id,
name: member.name,
firstName: member.firstName || '',
lastName: member.lastName || '',
source: member.source,
editable: member.editable,
hasLogin: member.hasLogin,
loginRoles: member.loginRoles,
loginRole: member.loginRole,
lastLogin: member.lastLogin,
isMannschaftsspieler: member.isMannschaftsspieler,
notes: member.notes || '',
// Sichtbarkeits-Flags explizit mitgeben
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress),
showBirthday: visibility.showBirthday === undefined ? true : Boolean(visibility.showBirthday),
// Privileged viewers (vorstand) always see contact fields
email: emailVisible ? member.email : undefined,
phone: phoneVisible ? member.phone : undefined,
address: addressVisible ? member.address : undefined,
// Birthday: expose only day + month and only if allowed; do not expose year or age
birthday: (birthdayVisible && hadBirthday) ? (function(){
try {
const d = new Date(member.geburtsdatum)
if (isNaN(d.getTime())) return undefined
const day = `${d.getDate()}`.padStart(2, '0')
const month = `${d.getMonth()+1}`.padStart(2, '0')
return `${day}.${month}`
} catch (_e) {
return undefined
}
})() : undefined,
geburtsdatum: member.geburtsdatum || undefined // Originalfeld für das Edit-Formular
}
})
return {
success: true,
members: safeMembers
members: sanitizedMembers
}
} catch (error) {
console.error('Fehler beim Abrufen der Mitgliederliste:', error)

View File

@@ -48,7 +48,7 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler } = body
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, active } = body
if (!firstName || !lastName) {
throw createError({
@@ -74,7 +74,8 @@ export default defineEventHandler(async (event) => {
phone: phone || '',
address: address || '',
notes: notes || '',
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true'
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
active: typeof active === 'boolean' ? active : true
})
return {

View File

@@ -4,6 +4,13 @@ import { decryptObject } from '../../utils/encryption.js'
export default defineEventHandler(async (event) => {
try {
// Nur Vorstand oder Admin darf Mitgliedschaftsantraege lesen
const token = getCookie(event, 'auth_token')
const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({ statusCode: 403, statusMessage: 'Zugriff verweigert' })
}
const config = useRuntimeConfig()
const encryptionKey = config.encryptionKey || 'local_development_encryption_key_change_in_production'

View File

@@ -13,8 +13,8 @@ export default defineEventHandler(async (event) => {
})
}
// Upload-Verzeichnis finden
const uploadDir = path.join(process.cwd(), 'public', 'uploads')
// Upload-Verzeichnis finden (intern)
const uploadDir = path.join(process.cwd(), '..', 'server', 'data', 'uploads')
console.log('Upload-Verzeichnis:', uploadDir)
// Alle Dateien im Upload-Verzeichnis durchsuchen

View File

@@ -5,6 +5,7 @@ import fs from 'fs/promises'
import path from 'path'
import { StandardFonts } from 'pdf-lib'
import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js'
import { sendMembershipEmail as sendMembershipEmailUtil } from '../../utils/email-service.js'
// const require = createRequire(import.meta.url) // Nicht verwendet
const execAsync = promisify(exec)
@@ -299,7 +300,7 @@ Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
Das ausgefüllte Formular ist als Anhang verfügbar.`
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const textPath = path.join(process.cwd(), 'public', 'uploads', `${filename}.txt`)
const textPath = path.join(getDataPath('uploads'), `${filename}.txt`)
await fs.writeFile(textPath, textContent, 'utf8')
return `${filename}.txt`
@@ -317,59 +318,7 @@ function getDataPath(filename) {
return path.join(projectRoot, 'server', 'data', filename)
}
async function sendMembershipEmail(data, _filename, _event) {
try {
const configPath = getDataPath('config.json')
const configData = await fs.readFile(configPath, 'utf8')
const config = JSON.parse(configData)
let recipients = []
let subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
// Sammle alle verfügbaren E-Mail-Adressen
const availableEmails = []
// Vorsitzender E-Mail hinzufügen (falls vorhanden)
if (config.vorstand.vorsitzender.email && config.vorstand.vorsitzender.email.trim() !== '') {
availableEmails.push(config.vorstand.vorsitzender.email)
}
// Schriftführer E-Mail hinzufügen (falls vorhanden)
if (config.vorstand.schriftfuehrer.email && config.vorstand.schriftfuehrer.email.trim() !== '') {
availableEmails.push(config.vorstand.schriftfuehrer.email)
}
// Fallback: Wenn keine E-Mails verfügbar sind, verwende tsschulz@tsschulz.de
if (availableEmails.length === 0) {
recipients = ['tsschulz@tsschulz.de']
} else {
recipients = availableEmails
}
// In nicht-Produktionsumgebung: Alle E-Mails an tsschulz@tsschulz.de
if (process.env.NODE_ENV !== 'production') {
recipients = ['tsschulz@tsschulz.de']
}
const message = `Ein neuer Mitgliedschaftsantrag wurde eingereicht.
Antragsteller: ${data.vorname} ${data.nachname}
Mitgliedschaftsart: ${data.mitgliedschaftsart}
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
Das ausgefüllte Formular ist als Anhang verfügbar.`
// E-Mail-Versand implementieren (hier würde normalerweise nodemailer verwendet)
console.log('E-Mail würde gesendet werden an:', recipients)
console.log('Betreff:', subject)
console.log('Nachricht:', message)
return { success: true, recipients, subject, message }
} catch (error) {
console.error('Fehler beim Senden der E-Mail:', error)
return { success: false, error: error.message }
}
}
// Use central email service
export default defineEventHandler(async (event) => {
try {
@@ -660,7 +609,7 @@ export default defineEventHandler(async (event) => {
}
let usedTemplate = false
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
const uploadsDir = getDataPath('uploads')
await fs.mkdir(uploadsDir, { recursive: true })
try {
const filled = await fillPdfTemplate(data)
@@ -669,18 +618,7 @@ export default defineEventHandler(async (event) => {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.writeFile(finalPdfPath, filled)
// Zusätzlich: Kopie ins repo-root public/uploads legen, falls Nitro cwd anders ist
try {
const repoRoot = path.resolve(process.cwd(), '..')
const repoUploads = path.join(repoRoot, 'public', 'uploads')
await fs.mkdir(repoUploads, { recursive: true })
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
} catch (_e) {
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
}
// Do NOT copy filled PDFs into public repo uploads to avoid accidental exposure.
usedTemplate = true
} catch (templateError) {
// Template konnte nicht verwendet werden -> weiter zum LaTeX-Fallback
@@ -689,8 +627,8 @@ export default defineEventHandler(async (event) => {
let emailResult
if (usedTemplate) {
// E-Mail senden
emailResult = await sendMembershipEmail(data, filename, event)
// E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
// Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
const encryptedData = encrypt(JSON.stringify(data), encryptionKey)
@@ -739,27 +677,11 @@ export default defineEventHandler(async (event) => {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const pdfPath = path.join(tempDir, `${filename}.pdf`)
await fs.mkdir(uploadsDir, { recursive: true })
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
await fs.copyFile(pdfPath, finalPdfPath)
// Kopie ins repo-root public/uploads für bessere Auffindbarkeit
try {
const repoRoot = path.resolve(process.cwd(), '..')
const repoUploads = path.join(repoRoot, 'public', 'uploads')
await fs.mkdir(repoUploads, { recursive: true })
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is generated from timestamp, not user input, path traversal prevented
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
} catch (e) {
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
}
// E-Mail senden
emailResult = await sendMembershipEmail(data, filename, event)
// E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
// Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
@@ -791,16 +713,16 @@ export default defineEventHandler(async (event) => {
const fallbackFilename = await generateSimplePDF(data, filename, event)
// E-Mail senden (Fallback)
const emailResult = await sendMembershipEmail(data, filename, event)
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`))
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
console.log('Betreff:', emailResult.subject || '')
console.log('Nachricht:', emailResult.message || '')
console.log('Upload-Verzeichnis:', path.join(process.cwd(), 'public', 'uploads'))
console.log('Upload-Verzeichnis:', getDataPath('uploads'))
// Verfügbare Dateien auflisten
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
const uploadsDir = getDataPath('uploads')
try {
const files = await fs.readdir(uploadsDir)
console.log('Verfügbare Dateien:', files)

View File

@@ -1,51 +1,36 @@
import { verifyToken, getUserById, migrateUserRoles } from '../utils/auth.js'
import { verifyToken, getUserFromToken } from '../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
if (!token) {
throw createError({
statusCode: 401,
message: 'Nicht authentifiziert.'
})
throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
}
const decoded = verifyToken(token)
if (!decoded) {
throw createError({
statusCode: 401,
message: 'Ungültiges Token.'
})
throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
}
const user = await getUserById(decoded.id)
if (!user || user.active === false) {
throw createError({
statusCode: 403,
message: 'Benutzer nicht gefunden oder inaktiv.'
})
const user = await getUserFromToken(token)
if (!user) {
throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
}
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
// Return user data (without password)
// Rückgabe des eigenen Profils inkl. Sichtbarkeitspräferenzen
return {
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
email: user.email,
phone: user.phone || '',
roles: roles,
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
visibility: Object.assign({ showBirthday: true }, (user.visibility || {}))
}
}
} catch (error) {
console.error('Profil-Abruf-Fehler:', error)
console.error('Fehler beim Laden des Profil:', error)
throw error
}
})

View File

@@ -59,6 +59,18 @@ export default defineEventHandler(async (event) => {
user.email = email
user.phone = phone || ''
// Optional visibility preferences (what to show to other logged-in members)
// Expected shape: { showEmail: boolean, showPhone: boolean, showAddress: boolean, showBirthday: boolean }
const visibility = body.visibility || body.visibilityPreferences || null
if (visibility && typeof visibility === 'object') {
user.visibility = user.visibility || {}
// Coerce values to booleans to be robust against string values from clients
if (visibility.showEmail !== undefined) user.visibility.showEmail = Boolean(visibility.showEmail)
if (visibility.showPhone !== undefined) user.visibility.showPhone = Boolean(visibility.showPhone)
if (visibility.showAddress !== undefined) user.visibility.showAddress = Boolean(visibility.showAddress)
if (visibility.showBirthday !== undefined) user.visibility.showBirthday = Boolean(visibility.showBirthday)
}
// Handle password change
if (currentPassword && newPassword) {
const isValid = await verifyPassword(currentPassword, user.password)
@@ -93,6 +105,7 @@ export default defineEventHandler(async (event) => {
email: user.email,
name: user.name,
phone: user.phone,
visibility: user.visibility || {},
roles: roles,
role: roles[0] || 'mitglied' // Rückwärtskompatibilität
}

View File

@@ -13,10 +13,15 @@ export default defineEventHandler(async (event) => {
})
}
// Lade Spielplandaten
const csvPath = path.join(process.cwd(), 'public/data/spielplan.csv')
let csvContent
// Lade Spielplandaten - bevorzugt aus server/data
let csvPath = path.join(process.cwd(), 'server/data/spielplan.csv')
try {
await fs.access(csvPath)
} catch {
csvPath = path.join(process.cwd(), 'public/data/spielplan.csv')
}
let csvContent
try {
csvContent = await fs.readFile(csvPath, 'utf-8')
} catch (_error) {

View File

@@ -5,13 +5,20 @@ export default defineEventHandler(async (event) => {
try {
const cwd = process.cwd()
// In production (.output/server), working dir is .output
// Prefer internal server/data, fallback to public/data
let csvPath
if (cwd.endsWith('.output')) {
csvPath = path.join(cwd, '../server/data/termine.csv')
// fallback
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, '../public/data/termine.csv')
}
} else {
csvPath = path.join(cwd, 'server/data/termine.csv')
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, 'public/data/termine.csv')
}
}
const csv = await fs.readFile(csvPath, 'utf-8')
const lines = csv.split('\n').filter(line => line.trim() !== '')

View File

@@ -5,13 +5,19 @@ export default defineEventHandler(async (event) => {
try {
const cwd = process.cwd()
// In production (.output/server), working dir is .output
// Prefer internal server/data, fallback to public/data
let csvPath
if (cwd.endsWith('.output')) {
csvPath = path.join(cwd, '../server/data/vereinsmeisterschaften.csv')
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, '../public/data/vereinsmeisterschaften.csv')
}
} else {
csvPath = path.join(cwd, 'server/data/vereinsmeisterschaften.csv')
if (!(await fs.access(csvPath).then(()=>true).catch(()=>false))) {
csvPath = path.join(cwd, 'public/data/vereinsmeisterschaften.csv')
}
}
// CSV-Datei direkt als Text zurückgeben (keine Caching-Probleme)
const csv = await fs.readFile(csvPath, 'utf-8')

View File

@@ -0,0 +1,44 @@
// Script: set-all-birthday-visible.cjs
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
const fs = require('fs')
const path = require('path')
const membersPath = path.join(__dirname, 'data', 'members.json')
let raw
try {
raw = fs.readFileSync(membersPath, 'utf8')
} catch (e) {
console.error('Fehler beim Lesen von members.json:', e)
process.exit(1)
}
let members
try {
members = JSON.parse(raw)
} catch (e) {
console.error('Fehler beim Parsen von members.json:', e)
process.exit(1)
}
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0
for (const m of members) {
if (!m.visibility) m.visibility = {}
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true
changed++
}
}
if (changed > 0) {
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
}

View File

@@ -0,0 +1,44 @@
// Script: set-all-birthday-visible.js
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
const fs = require('fs')
const path = require('path')
const membersPath = path.join(__dirname, 'data', 'members.json')
let raw
try {
raw = fs.readFileSync(membersPath, 'utf8')
} catch (e) {
console.error('Fehler beim Lesen von members.json:', e)
process.exit(1)
}
let members
try {
members = JSON.parse(raw)
} catch (e) {
console.error('Fehler beim Parsen von members.json:', e)
process.exit(1)
}
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0
for (const m of members) {
if (!m.visibility) m.visibility = {}
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true
changed++
}
}
if (changed > 0) {
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
}

View File

@@ -0,0 +1,33 @@
// Script: set-all-birthday-visible.mjs
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true (mit Entschlüsselung)
import { readMembers, writeMembers } from './utils/members.js';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
async function main() {
let members = await readMembers();
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0;
for (const m of members) {
if (!m.visibility) m.visibility = {};
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true;
changed++;
}
}
if (changed > 0) {
await writeMembers(members);
console.log(`Flag für ${changed} Mitglieder gesetzt.`);
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.');
}
}
main();

View File

@@ -0,0 +1,72 @@
// Script: set-all-visibility-flags.mjs
// Setzt für alle Mitglieder in allen relevanten Dateien alle visibility-Flags auf true (inkl. Entschlüsselung)
import { readMembers, writeMembers } from './utils/members.js';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const usersPath = path.resolve(process.cwd(), 'server/data/users.json');
async function updateVisibility(obj) {
let changed = 0;
if (Array.isArray(obj)) {
for (const m of obj) {
if (!m.visibility) m.visibility = {};
if (m.visibility.showEmail !== true) { m.visibility.showEmail = true; changed++; }
if (m.visibility.showPhone !== true) { m.visibility.showPhone = true; changed++; }
if (m.visibility.showAddress !== true) { m.visibility.showAddress = true; changed++; }
if (m.visibility.showBirthday !== true) { m.visibility.showBirthday = true; changed++; }
}
}
return changed;
}
async function updateUsersFile() {
let changed = 0;
try {
let raw = await fs.readFile(usersPath, 'utf8');
let users;
if (raw.trim().startsWith('v2:')) {
// encrypted, try to use decryptObject from encryption.js
const { decryptObject } = await import('./utils/encryption.js');
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
users = decryptObject(raw, key);
} else {
users = JSON.parse(raw);
}
changed = await updateVisibility(users);
// write back (encrypted if vorher encrypted)
if (raw.trim().startsWith('v2:')) {
const { encryptObject } = await import('./utils/encryption.js');
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
const encrypted = encryptObject(users, key);
await fs.writeFile(usersPath, encrypted, 'utf8');
} else {
await fs.writeFile(usersPath, JSON.stringify(users, null, 2), 'utf8');
}
return changed;
} catch (e) {
console.error('Fehler beim Bearbeiten von users.json:', e);
return 0;
}
}
async function main() {
let changedMembers = 0;
let changedUsers = 0;
// members.json (manuelle Mitglieder)
let members = await readMembers();
changedMembers = await updateVisibility(members);
if (changedMembers > 0) {
await writeMembers(members);
}
// users.json (Login-System)
changedUsers = await updateUsersFile();
console.log(`members.json: ${changedMembers} Änderungen, users.json: ${changedUsers} Änderungen`);
}
main();

View File

@@ -0,0 +1,98 @@
import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant, never user input
const getDataPath = (filename) => {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, '../server/data', filename)
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'server/data', filename)
}
const CONTACT_REQUESTS_FILE = getDataPath('contact-requests.json')
export async function readContactRequests() {
try {
const raw = await fs.readFile(CONTACT_REQUESTS_FILE, 'utf-8')
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch (error) {
if (error.code === 'ENOENT') return []
console.error('Fehler beim Lesen der Kontaktanfragen:', error)
return []
}
}
export async function writeContactRequests(items) {
await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8')
}
export async function createContactRequest(data) {
const current = await readContactRequests()
const now = new Date().toISOString()
const item = {
id: randomUUID(),
createdAt: now,
updatedAt: now,
status: 'offen',
name: data.name,
email: data.email,
phone: data.phone || '',
subject: data.subject,
message: data.message,
replies: []
}
current.unshift(item)
await writeContactRequests(current)
return item
}
export async function addContactReply({ requestId, replyText, responderEmail }) {
const current = await readContactRequests()
const index = current.findIndex((r) => r.id === requestId)
if (index === -1) return null
const now = new Date().toISOString()
const request = current[index]
const replies = Array.isArray(request.replies) ? request.replies : []
replies.push({
id: randomUUID(),
createdAt: now,
responderEmail: responderEmail || '',
message: replyText
})
current[index] = {
...request,
status: 'beantwortet',
replies,
updatedAt: now
}
await writeContactRequests(current)
return current[index]
}
export async function updateContactRequestStatus(requestId, newStatus) {
const validStatuses = ['offen', 'beantwortet']
if (!validStatuses.includes(newStatus)) return null
const current = await readContactRequests()
const index = current.findIndex((r) => r.id === requestId)
if (index === -1) return null
const now = new Date().toISOString()
current[index] = {
...current[index],
status: newStatus,
updatedAt: now
}
await writeContactRequests(current)
return current[index]
}

View File

@@ -12,16 +12,13 @@ export function getCookieSecureDefault() {
export function getSameSiteDefault() {
// Cookie SameSite-Konfiguration
// - 'none': Erlaubt Cookies in Cross-Site-iframes (erfordert Secure: true)
// - 'lax': Erlaubt Cookies bei Navigation (Standard für Cross-Site)
// - 'strict': Blockiert alle Cross-Site-Cookies (sicherste Option, blockiert iframes)
// - 'lax': Erlaubt Cookies bei Navigation (Standard)
// - 'strict': Blockiert alle Cross-Site-Cookies (sicherste Option)
// - 'none': Erlaubt Cookies in Cross-Site-iframes (erfordert Secure: true / HTTPS)
const v = (process.env.COOKIE_SAMESITE || '').toLowerCase().trim()
if (v === 'strict' || v === 'lax' || v === 'none') return v
// Default: 'none' für Cross-Site-iframes (wenn in iframe eingebettet)
// WICHTIG: SameSite: none erfordert Secure: true (HTTPS)
// Falls iframe-Einbettung nicht benötigt wird, kann auf 'strict' oder 'lax' geändert werden
return 'none' // Ermöglicht Einbettung in iframes (z.B. von harheimertc.de)
return 'lax'
}
export function getAuthCookieOptions() {

View File

@@ -56,25 +56,29 @@ function getEmailRecipients(data, config) {
const recipients = []
// Add 1. Vorsitzender
if (config.vorsitzender && config.vorsitzender.email) {
recipients.push(config.vorsitzender.email)
// Config uses a 'vorstand' object with nested roles; collect all emails
if (config.vorstand && typeof config.vorstand === 'object') {
Object.values(config.vorstand).forEach((member) => {
if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') {
recipients.push(member.email.trim())
}
})
}
// Add Schriftführer
if (config.schriftfuehrer && config.schriftfuehrer.email) {
recipients.push(config.schriftfuehrer.email)
}
// For minors, also add 1. Trainer
if (!data.isVolljaehrig && config.trainer && config.trainer.email) {
recipients.push(config.trainer.email)
// For minors, also add first trainer email if configured (trainer is an array)
if (!data.isVolljaehrig && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) {
recipients.push(config.trainer[0].email)
}
// Fallback if no recipients found
if (recipients.length === 0) {
// Prefer website verantwortlicher if set
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
recipients.push(config.website.verantwortlicher.email)
} else {
recipients.push('tsschulz@tsschulz.de')
}
}
return recipients
}
@@ -94,7 +98,7 @@ function createTransporter() {
)
}
return nodemailer.createTransporter({
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
@@ -162,3 +166,60 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
}
}
}
/**
* Sends a simple registration notification to Vorstand/admin and a confirmation to user.
* @param {Object} data - { name, email, phone }
*/
export async function sendRegistrationNotification(data) {
try {
const config = await loadConfig()
const recipients = getEmailRecipients(data, config)
// Create transporter
const transporter = createTransporter()
// Notify Vorstand/admin
const adminSubject = 'Neue Registrierung - Harheimer TC'
const adminHtml = `
<h2>Neue Registrierung</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${data.name}</li>
<li><strong>E-Mail:</strong> ${data.email}</li>
<li><strong>Telefon:</strong> ${data.phone || 'Nicht angegeben'}</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: recipients.join(', '),
subject: adminSubject,
html: adminHtml
})
// Confirmation to user
const userSubject = 'Registrierung erhalten - Harheimer TC'
const userHtml = `
<h2>Registrierung erhalten</h2>
<p>Hallo ${data.name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: data.email,
subject: userSubject,
html: userHtml
})
return { success: true, recipients }
} catch (error) {
console.error('sendRegistrationNotification failed:', error.message || error)
throw error
}
}

View File

@@ -2,20 +2,16 @@ import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
// Handle both dev and production paths
// filename is always a hardcoded constant (e.g., 'termine.csv'), never user input
// Use internal server/data directory for Termine CSV to avoid writing to public/
const getDataPath = (filename) => {
const cwd = process.cwd()
// In production (.output/server), working dir is .output
// Prefer server/data in both production and development
// e.g. project-root/server/data/termine.csv or .output/server/data/termine.csv
if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, '../public/data', filename)
return path.join(cwd, '../server/data', filename)
}
// In development, working dir is project root
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'public/data', filename)
return path.join(cwd, 'server/data', filename)
}
const TERMINE_FILE = getDataPath('termine.csv')

View File

@@ -37,7 +37,38 @@ vi.mock('child_process', () => ({
}))
vi.mock('util', () => ({
promisify: () => () => Promise.resolve({ stdout: 'PDF Inhalt', stderr: '' })
promisify: () => () => Promise.resolve({
stdout: `§ 1 Name und Sitz
Der Verein führt den Namen Harheimer TC.
§ 2 Zweck
Der Verein verfolgt ausschließlich und unmittelbar gemeinnützige Zwecke.
§ 3 Mitgliedschaft
(1) Mitglied kann jede natürliche Person werden.
(2) Über die Aufnahme entscheidet der Vorstand.
§ 4 Beiträge
Die Mitglieder zahlen Beiträge nach Maßgabe der Beitragsordnung.
§ 5 Vorstand
Der Vorstand besteht aus dem Vorsitzenden, dem Schriftführer und dem Kassenwart.
§ 6 Schlussbestimmungen
Diese Satzung tritt mit Beschluss der Mitgliederversammlung in Kraft.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
Zusätzlicher Satzungstext zur Plausibilitätsprüfung.
`,
stderr: ''
})
}))
vi.mock('../server/utils/upload-validation.js', () => ({
assertPdfMagicHeader: vi.fn().mockResolvedValue(undefined)
}))
import saveCsvHandler from '../server/api/cms/save-csv.post.js'
@@ -67,11 +98,26 @@ describe('CMS File Endpoints', () => {
mockSuccessReadBody({ filename: 'mannschaften.csv', content: 'data' })
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
vi.spyOn(fs, 'rename').mockResolvedValue(undefined)
vi.spyOn(fs, 'stat').mockResolvedValue({ size: Buffer.byteLength('data', 'utf8') } as any)
const response = await saveCsvHandler(event)
expect(response.success).toBe(true)
expect(fs.writeFile).toHaveBeenCalled()
})
it('erlaubt vorstand beim CSV-Speichern', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({ filename: 'spielplan.csv', content: 'kopf;wert' })
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
vi.spyOn(fs, 'rename').mockResolvedValue(undefined)
vi.spyOn(fs, 'stat').mockResolvedValue({ size: Buffer.byteLength('kopf;wert', 'utf8') } as any)
getUserFromToken.mockResolvedValue({ id: 'vorstand', role: 'vorstand' })
const response = await saveCsvHandler(event)
expect(response.success).toBe(true)
})
})
describe('POST /api/cms/upload-spielplan-pdf', () => {