Compare commits

...

24 Commits

Author SHA1 Message Date
Torsten Schulz (local)
acfa842131 Add SMTP credentials for tests and enhance user role handling in CMS and Galerie endpoints
Some checks are pending
Code Analysis (JS/Vue) / analyze (push) Waiting to run
2025-12-20 10:32:06 +01:00
Torsten Schulz (local)
4f453f77bc Update package dependencies to vitest v4.0.16 and enhance role management in auth utilities with new role-checking functions in tests
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 57s
2025-12-20 10:25:16 +01:00
Torsten Schulz (local)
42b9a10437 Refactor error handling in various components to ignore modal display failures and improve code clarity
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 51s
2025-12-20 10:19:29 +01:00
Torsten Schulz (local)
b20b89d333 Update package-lock.json and package.json to include 'globals' dependency and improve code formatting in various components for better readability.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 54s
2025-12-20 10:17:16 +01:00
Torsten Schulz (local)
861802b716 Update package dependencies to include eslint-plugin-vue and vue-eslint-parser for enhanced linting support
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 33s
2025-12-19 17:32:56 +01:00
Torsten Schulz (local)
164c5d9297 Update code analysis workflow to run on ubuntu-latest for improved compatibility
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s
2025-12-19 17:28:47 +01:00
Torsten Schulz (local)
a323684f13 Update code analysis workflow to improve clarity and efficiency by renaming steps and simplifying Semgrep command execution.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2025-12-19 16:53:09 +01:00
Torsten Schulz (local)
131edc0cb1 Add linting script to package.json for improved code quality
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Has been cancelled
2025-12-19 16:38:29 +01:00
6243db3020 Merge pull request 'Update dependency @babel/parser to v7.28.5' (#2) from renovate/babel-monorepo into main
Reviewed-on: #2
2025-12-19 16:13:11 +01:00
97742b24bb Merge pull request 'Update dependency @pinia/nuxt to v0.11.3' (#3) from renovate/pinia-nuxt-0.x-lockfile into main
Reviewed-on: #3
2025-12-19 16:12:58 +01:00
40c2139aa8 Update dependency @pinia/nuxt to v0.11.3 2025-12-19 16:11:56 +01:00
e05eb46bc5 Update dependency @babel/parser to v7.28.5 2025-12-19 16:11:48 +01:00
edb8d1c521 Merge pull request 'Configure Renovate' (#1) from renovate/configure into main
Reviewed-on: #1
2025-12-19 16:09:18 +01:00
c50aa1b5c1 Add renovate.json 2025-12-19 15:58:03 +01:00
Torsten Schulz (local)
7e8d693832 Refactor training time input fields in Einstellungen component to improve layout and usability. Add optional information field for training times and ensure proper handling of group data. Update rendering logic in Training component to display additional information if provided. 2025-12-19 10:23:58 +01:00
Torsten Schulz (local)
6b24ac0071 Implement toggle functionality for Mannschaftsspieler status in Mitgliederbereich. Add button for editing status and update local state upon toggling. Enhance API response handling to include isMannschaftsspieler attribute for user data retrieval. 2025-12-19 10:14:41 +01:00
Torsten Schulz (local)
5a85c3d31a Remove debug console logs from MannschaftenUebersicht, TermineVorschau, spielplaene, and filterData components to clean up code and improve performance. 2025-12-19 10:06:01 +01:00
Torsten Schulz (local)
cbe02a6caf Enhance newsletter management by adding role-based access control for group creation. Introduce computed property to determine if the user can create groups based on their roles, improving functionality and user experience. 2025-12-19 10:03:21 +01:00
Torsten Schulz (local)
b33773e214 Update DATENSCHUTZ_UEBERSICHT.md to enhance data protection overview with a comprehensive table of contents, detailed descriptions of encrypted and non-encrypted data, and security policies. Include sections on encryption technology, authentication, authorization, API access control, roles and permissions, session management, environment variables, file system structure, external dependencies, and security guidelines. 2025-12-19 09:57:56 +01:00
Torsten Schulz (local)
435e28fd55 Update dependencies to include TinyMCE and Quill, enhance Navigation component with a new Newsletter submenu, and implement role-based access control for CMS features. Refactor user role handling to support multiple roles and improve user management functionality across various API endpoints. 2025-12-19 09:51:28 +01:00
Torsten Schulz (local)
baf6c59c0d Enhance Vereinsmeisterschaften and Vorstand pages with image support for players and board members. Implement lightbox functionality for player images in Vereinsmeisterschaften. Update CSV handling to include image filenames for better data management. Refactor components to utilize PersonCard for board members, improving code readability and maintainability. 2025-12-18 13:37:03 +01:00
Torsten Schulz (local)
a004ffba9b Implement checks for existing encryption with the new key in re-encryption scripts. Add functionality to skip re-encryption if data is already encrypted, enhancing efficiency and preventing redundant operations. 2025-12-18 13:12:02 +01:00
Torsten Schulz (local)
10499e0249 Enhance set-admin-password script to handle decryption errors by prompting for recreation of users.json file. Implement backup creation before overwriting and update README with usage instructions for the new functionality. 2025-12-18 12:45:53 +01:00
Torsten Schulz (local)
4b017453b2 Update AUTH_README.md to clarify admin password setup process and provide usage instructions for the set-admin-password script. Change file permissions for deploy.sh, production-setup.sh, and fetch-template.sh to make them executable. 2025-12-18 12:39:22 +01:00
148 changed files with 13459 additions and 3032 deletions

View File

@@ -0,0 +1,33 @@
name: Code Analysis (JS/Vue)
on:
pull_request:
push:
branches: [ main ]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Node versions
run: |
node -v
npm -v
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Unit tests
run: npm test
- name: Build
run: npm run build --if-present
- name: Semgrep (SAST)
run: semgrep --config p/default --error .

View File

@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"dependencies": {
"@babel/parser": "7.28.4",
"@babel/parser": "7.28.5",
"@pdf-lib/standard-fonts": "1.0.0",
"@pdf-lib/upng": "1.0.1",
"@vue/compiler-core": "3.5.22",

View File

@@ -7,9 +7,23 @@
⚠️ **WICHTIG:** Ändern Sie dieses Passwort sofort nach der ersten Anmeldung!
## Passwort-Hash generieren
## Admin-Passwort setzen
Um einen neuen Benutzer oder ein neues Passwort zu erstellen, können Sie folgenden Node.js-Code verwenden:
Das einfachste Verfahren ist das Script `scripts/set-admin-password.js`:
```bash
# Mit Passwort als Argument
node scripts/set-admin-password.js "mein-neues-passwort"
# Oder interaktiv (Passwort wird abgefragt)
node scripts/set-admin-password.js
```
Siehe auch: `scripts/README-set-admin-password.md`
## Passwort-Hash generieren (manuell)
Falls Sie einen Passwort-Hash manuell generieren möchten, können Sie folgenden Node.js-Code verwenden:
```javascript
import bcrypt from 'bcryptjs'

510
DATENSCHUTZ_UEBERSICHT.md Normal file
View File

@@ -0,0 +1,510 @@
# Übersicht: Verschlüsselung personenbezogener Daten & Security-Informationen
## 📋 Inhaltsverzeichnis
1. [Verschlüsselte Daten](#verschlüsselte-daten)
2. [Verschlüsselungstechnologie](#verschlüsselungstechnologie)
3. [Authentifizierung & Autorisierung](#authentifizierung--autorisierung)
4. [API-Endpunkte & Zugriffsschutz](#api-endpunkte--zugriffsschutz)
5. [Rollen & Berechtigungen](#rollen--berechtigungen)
6. [Session-Management](#session-management)
7. [Umgebungsvariablen](#umgebungsvariablen)
8. [Dateisystem-Struktur](#dateisystem-struktur)
9. [Externe Dependencies](#externe-dependencies)
10. [Sicherheitsrichtlinien](#sicherheitsrichtlinien)
---
## ✅ Verschlüsselte Daten
### Kritische Dateien mit personenbezogenen Daten:
1. **users.json** - Benutzerdaten
- E-Mail-Adressen
- Namen
- Passwort-Hashes (bcrypt)
- Rollen (Array)
- Telefonnummern
- Aktivierungsstatus
2. **members.json** - Mitgliederdaten
- Namen
- E-Mail-Adressen
- Telefonnummern
- Adressen
- Geburtsdaten
- Notizen
3. **newsletter-posts.json** - Newsletter-Posts
- Empfängerlisten (E-Mail-Adressen)
- Post-Inhalte
- Metadaten
4. **newsletter-subscribers.json** - Newsletter-Abonnenten
- E-Mail-Adressen
- Namen
- Bestätigungsstatus
- Abonnement-Gruppen
- Tokens (Bestätigung, Abmeldung)
5. **sessions.json** - Session-Tokens
- User-IDs
- JWT-Tokens
- Erstellungs- und Ablaufzeiten
6. **membership-applications/*.json** - Mitgliedschaftsanträge
- Vollständige Antragsdaten (verschlüsselt als `encryptedData`)
- Persönliche Informationen
- Kontaktdaten
### ⚠️ Enthält personenbezogene Daten, aber weniger kritisch:
1. **config.json** - Konfigurationsdatei
- E-Mail-Adressen von Vorstand und Website-Verantwortlichem
- Diese sind öffentliche Kontaktdaten, die auf der Website angezeigt werden
- Könnte optional verschlüsselt werden, ist aber nicht kritisch
### ✅ Keine personenbezogenen Daten:
- **news.json** - Nur Autor-Name, keine E-Mail
- **newsletter-groups.json** - Nur Metadaten (Gruppenname, Typ, etc.)
- **galerie-metadata.json** - Keine personenbezogenen Daten
- **termine.json** - Nur öffentliche Termine
- **mannschaften.csv** - Öffentliche Mannschaftsinformationen
---
## 🔐 Verschlüsselungstechnologie
### Algorithmus & Konfiguration:
- **Algorithmus**: AES-256-CBC
- **Schlüsselableitung**: PBKDF2 mit SHA-512
- **Iterationen**: 100.000 Runden
- **Salt-Länge**: 32 Bytes (zufällig pro Verschlüsselung)
- **IV-Länge**: 16 Bytes (zufällig pro Verschlüsselung)
- **Format**: Base64-kodiert (Salt + IV + verschlüsselter Text)
### Verschlüsselungsschlüssel:
- **Umgebungsvariable**: `ENCRYPTION_KEY`
- **Standardwert (Development)**: `local_development_encryption_key_change_in_production`
- **⚠️ WICHTIG**: Muss in Produktion geändert werden!
- **Speicherort**: `.env` Datei (nicht in Git)
### Verschlüsselungsfunktionen:
- `encrypt(text, password)` - Verschlüsselt einen Text
- `decrypt(encryptedData, password)` - Entschlüsselt einen Text
- `encryptObject(obj, password)` - Verschlüsselt ein JSON-Objekt
- `decryptObject(encryptedData, password)` - Entschlüsselt ein JSON-Objekt
**Datei**: `server/utils/encryption.js`
---
## 🔑 Authentifizierung & Autorisierung
### Passwort-Hashing:
- **Algorithmus**: bcrypt
- **Rounds**: 10
- **Bibliothek**: `bcryptjs` v2.4.3
### JWT-Tokens:
- **Bibliothek**: `jsonwebtoken` v9.0.2
- **Algorithmus**: HS256 (HMAC SHA-256)
- **Secret**: `JWT_SECRET` aus Umgebungsvariablen
- **Standardwert (Development)**: `harheimertc-secret-key-change-in-production`
- **Gültigkeitsdauer**: 7 Tage
- **Payload**: `{ id, email, roles }`
### Cookie-Konfiguration:
- **Name**: `auth_token`
- **HttpOnly**: `true` (verhindert JavaScript-Zugriff)
- **Secure**: `false` (in Production sollte dies `true` sein, wenn HTTPS verwendet wird)
- **SameSite**: `lax`
- **MaxAge**: 7 Tage (604.800 Sekunden)
### Authentifizierungs-Endpunkte:
- `POST /api/auth/login` - Login mit E-Mail/Passwort
- `POST /api/auth/logout` - Logout (löscht Session)
- `POST /api/auth/register` - Registrierung (erfordert Admin-Freischaltung)
- `POST /api/auth/reset-password` - Passwort-Reset
- `GET /api/auth/status` - Prüft aktuellen Auth-Status
---
## 🛡️ API-Endpunkte & Zugriffsschutz
### Geschützte Routen:
- `/mitgliederbereich/*` - Erfordert Authentifizierung
- `/cms/*` - Erfordert Authentifizierung + spezifische Rollen
### Middleware:
**Datei**: `middleware/auth.js`
- Prüft Authentifizierung für geschützte Routen
- Rollenbasierte Zugriffskontrolle für CMS-Bereich
- Redirect zu `/login` bei fehlender Authentifizierung
### API-Endpunkt-Kategorien:
#### Öffentliche Endpunkte (keine Authentifizierung):
- `GET /api/news-public` - Öffentliche News
- `GET /api/termine` - Öffentliche Termine
- `GET /api/spielplaene` - Spielpläne
- `GET /api/galerie/list` - Galerie-Liste (mit Zugriffskontrolle)
- `GET /api/galerie/[id]` - Einzelbild (mit Zugriffskontrolle)
- `POST /api/newsletter/subscribe` - Newsletter-Anmeldung
- `GET /api/newsletter/confirm` - Newsletter-Bestätigung
- `GET /api/newsletter/unsubscribe` - Newsletter-Abmeldung
- `POST /api/newsletter/unsubscribe-by-email` - Abmeldung per E-Mail
- `GET /api/newsletter/groups/public-list` - Öffentliche Newsletter-Gruppen
- `GET /api/newsletter/check-subscription` - Prüft Abonnement-Status
- `POST /api/contact` - Kontaktformular
#### Authentifizierte Endpunkte (erfordern Login):
**Mitgliederbereich:**
- `GET /api/members` - Mitgliederliste (mit Rollenprüfung)
- `GET /api/profile` - Eigenes Profil
- `PUT /api/profile` - Profil bearbeiten
- `GET /api/news` - Interne News
- `POST /api/news` - News erstellen (admin/vorstand)
- `DELETE /api/news` - News löschen (admin/vorstand)
**CMS-Endpunkte (admin/vorstand):**
- `GET /api/cms/users/list` - Benutzerliste
- `POST /api/cms/users/approve` - Benutzer freischalten
- `POST /api/cms/users/update-role` - Rolle ändern
- `POST /api/cms/users/deactivate` - Benutzer deaktivieren
- `POST /api/cms/users/reject` - Registrierung ablehnen
- `PUT /api/config` - Konfiguration bearbeiten
- `POST /api/cms/save-csv` - CSV speichern
- `POST /api/cms/upload-spielplan-pdf` - Spielplan hochladen
- `POST /api/cms/satzung-upload` - Satzung hochladen
- `POST /api/members` - Mitglied hinzufügen/bearbeiten
- `DELETE /api/members` - Mitglied löschen
- `POST /api/members/bulk` - Bulk-Import
- `POST /api/personen/upload` - Personenbild hochladen
- `POST /api/galerie/upload` - Galeriebild hochladen
- `DELETE /api/galerie/[id]` - Galeriebild löschen
- `POST /api/termine-manage` - Termin erstellen/bearbeiten
- `DELETE /api/termine-manage` - Termin löschen
- `GET /api/termine-manage` - Termine verwalten
- `GET /api/membership/applications` - Mitgliedschaftsanträge
- `POST /api/membership/generate-pdf` - PDF generieren
- `GET /api/membership/download/[id]` - PDF herunterladen
- `PUT /api/membership/update-status` - Status aktualisieren
**Newsletter-Endpunkte (admin/vorstand/newsletter):**
- `GET /api/newsletter/groups/list` - Newsletter-Gruppen auflisten
- `POST /api/newsletter/groups/create` - Gruppe erstellen
- `GET /api/newsletter/groups/[id]/posts/list` - Posts auflisten
- `POST /api/newsletter/groups/[id]/posts/create` - Post erstellen und versenden
- `GET /api/newsletter/groups/[id]/subscribers/list` - Abonnenten auflisten
- `POST /api/newsletter/groups/[id]/subscribers/add` - Abonnent hinzufügen
- `POST /api/newsletter/groups/[id]/subscribers/remove` - Abonnent entfernen
### Zugriffskontrolle:
- **Helper-Funktionen**: `hasRole()`, `hasAnyRole()`, `hasAllRoles()` in `server/utils/auth.js`
- **Token-Validierung**: `getUserFromToken()` prüft JWT und lädt Benutzer
- **Rollenprüfung**: Alle geschützten Endpunkte prüfen Rollen vor Ausführung
---
## 👥 Rollen & Berechtigungen
### Verfügbare Rollen:
1. **mitglied** (Standard)
- Zugriff auf Mitgliederbereich
- Eigene Profilansicht und -bearbeitung
- Mitgliederliste (ohne Kontaktdaten)
- Interne News lesen
2. **vorstand**
- Alle Mitglieder-Berechtigungen
- Zugriff auf Kontaktdaten in Mitgliederliste
- CMS-Zugriff (außer Benutzerverwaltung)
- News erstellen/bearbeiten/löschen
- Mitglieder verwalten
- Termine verwalten
- Galerie verwalten
- Newsletter verwalten
3. **admin**
- Alle Vorstand-Berechtigungen
- Benutzerverwaltung
- Rollenverwaltung
- Konfiguration ändern
- Mitgliedschaftsanträge verwalten
4. **newsletter**
- Newsletter-Verwaltung (Gruppen, Posts, Abonnenten)
- Kein Zugriff auf andere CMS-Funktionen
- Kein Zugriff auf Mitgliederbereich (außer über Newsletter-Seite)
### Rollensystem:
- **Multi-Rollen-Support**: Benutzer können mehrere Rollen haben
- **Datenstruktur**: `roles: string[]` (Array von Rollen)
- **Migration**: Alte `role: string` wird automatisch zu `roles: [role]` migriert
- **Rollenprüfung**: `hasAnyRole(user, 'admin', 'vorstand')` prüft, ob Benutzer eine der Rollen hat
---
## 🔄 Session-Management
### Session-Speicherung:
- **Datei**: `server/data/sessions.json` (verschlüsselt)
- **Struktur**:
```json
{
"id": "session-id",
"userId": "user-id",
"token": "jwt-token",
"createdAt": "ISO-timestamp",
"expiresAt": "ISO-timestamp"
}
```
### Session-Lebensdauer:
- **Gültigkeit**: 7 Tage
- **Ablauf**: Automatische Bereinigung abgelaufener Sessions
- **Logout**: Löscht Session aus Datei
### Session-Funktionen:
- `createSession(userId, token)` - Erstellt neue Session
- `deleteSession(token)` - Löscht Session
- `cleanExpiredSessions()` - Bereinigt abgelaufene Sessions
---
## 🌍 Umgebungsvariablen
### Erforderliche Variablen:
**Authentifizierung:**
- `JWT_SECRET` - Secret für JWT-Token-Signierung
- Standard: `harheimertc-secret-key-change-in-production`
- ⚠️ Muss in Produktion geändert werden!
**Verschlüsselung:**
- `ENCRYPTION_KEY` - Schlüssel für Datenverschlüsselung
- Standard: `local_development_encryption_key_change_in_production`
- ⚠️ Muss in Produktion geändert werden!
**E-Mail-Versand:**
- `SMTP_HOST` - SMTP-Server (Standard: `smtp.gmail.com`)
- `SMTP_PORT` - SMTP-Port (Standard: `587`)
- `SMTP_USER` - SMTP-Benutzername
- `SMTP_PASS` - SMTP-Passwort
- `SMTP_FROM` - Absender-E-Mail (Standard: `noreply@harheimertc.de`)
**Server-Konfiguration:**
- `NODE_ENV` - Umgebung (development/production)
- `PORT` - Server-Port (Standard: `3100`)
- `NUXT_PUBLIC_BASE_URL` - Basis-URL der Anwendung
### Konfigurationsdatei:
- **Datei**: `.env` (nicht in Git)
- **Beispiel**: `env.example`
- **⚠️ WICHTIG**: `.env` muss in `.gitignore` sein!
---
## 📁 Dateisystem-Struktur
### Datenverzeichnis:
```
server/data/
├── users.json # Verschlüsselt: Benutzerdaten
├── members.json # Verschlüsselt: Mitgliederdaten
├── sessions.json # Verschlüsselt: Session-Tokens
├── newsletter-subscribers.json # Verschlüsselt: Newsletter-Abonnenten
├── newsletter-posts.json # Verschlüsselt: Newsletter-Posts mit Empfängerlisten
├── newsletter-groups.json # Unverschlüsselt: Newsletter-Gruppen-Metadaten
├── news.json # Unverschlüsselt: News (nur Autor-Name)
├── config.json # Unverschlüsselt: Konfiguration (öffentliche Kontaktdaten)
├── termine.json # Unverschlüsselt: Öffentliche Termine
├── galerie-metadata.json # Unverschlüsselt: Galerie-Metadaten
├── membership-applications/ # Verschlüsselt: Mitgliedschaftsanträge
│ └── *.json
├── galerie/ # Nicht öffentlich zugänglich
│ ├── originals/ # Originalbilder
│ └── previews/ # Vorschaubilder (150x150px)
└── personen/ # Nicht öffentlich zugänglich
└── *.jpg/png # Personenbilder
```
### Upload-Verzeichnisse:
- **Galerie**: `server/data/galerie/originals/` und `server/data/galerie/previews/`
- **Personen**: `server/data/personen/`
- **PDFs**: `server/data/spielplaene/`, `server/data/satzung/`
### Zugriffsschutz:
- Upload-Verzeichnisse sind **nicht** direkt über Web-Server zugänglich
- Zugriff nur über API-Endpunkte mit Authentifizierung
---
## 📦 Externe Dependencies
### Sicherheitsrelevante Pakete:
**Authentifizierung & Verschlüsselung:**
- `bcryptjs` v2.4.3 - Passwort-Hashing
- `jsonwebtoken` v9.0.2 - JWT-Token-Generierung
- `crypto` (Node.js built-in) - Verschlüsselung
**E-Mail:**
- `nodemailer` v7.0.9 - E-Mail-Versand
**Datei-Upload:**
- `multer` v2.0.2 - Multipart-Form-Daten-Verarbeitung
- `sharp` v0.34.5 - Bildverarbeitung (Resize, EXIF-Korrektur)
**PDF:**
- `pdf-lib` v1.17.1 - PDF-Generierung
- `pdf-parse` v2.4.5 - PDF-Parsing
**Framework:**
- `nuxt` v4.1.3 - Vue.js Framework
- `vue` v3.5.22 - Frontend-Framework
- `pinia` v3.0.3 - State Management
### Sicherheits-Updates:
- Regelmäßige Updates empfohlen
- Prüfung auf bekannte Schwachstellen (npm audit)
---
## 🔒 Sicherheitsrichtlinien
### Passwort-Richtlinien:
- **Minimale Länge**: 6 Zeichen (empfohlen: mindestens 12)
- **Hashing**: bcrypt mit 10 Runden
- **Speicherung**: Nur Hash, niemals Klartext
### Token-Sicherheit:
- **HttpOnly Cookies**: Verhindert XSS-Angriffe
- **SameSite**: `lax` verhindert CSRF-Angriffe
- **Ablaufzeit**: 7 Tage (kann angepasst werden)
### Datenverschlüsselung:
- **At-Rest-Verschlüsselung**: Alle kritischen Daten verschlüsselt
- **Schlüsselverwaltung**: Schlüssel nur in `.env`, niemals in Git
- **Schlüsselrotation**: Regelmäßige Rotation empfohlen
### API-Sicherheit:
- **Authentifizierung**: Erforderlich für alle geschützten Endpunkte
- **Autorisierung**: Rollenbasierte Zugriffskontrolle
- **Input-Validierung**: Prüfung aller Eingaben
- **Rate Limiting**: Nicht implementiert (empfohlen für Produktion)
### Best Practices:
- ✅ Passwörter werden niemals im Klartext gespeichert
- ✅ Sensitive Daten sind verschlüsselt
- ✅ JWT-Tokens haben Ablaufzeit
- ✅ HttpOnly Cookies verhindern XSS
- ✅ Rollenbasierte Zugriffskontrolle
- ⚠️ Rate Limiting fehlt (empfohlen)
- ⚠️ HTTPS sollte in Produktion verwendet werden
- ⚠️ Secure-Flag für Cookies sollte in Production `true` sein
---
## 🧪 Test-Zugangsdaten
### Standard-Admin-Account:
**⚠️ NUR FÜR ENTWICKLUNG - MUSS IN PRODUKTION GEÄNDERT WERDEN!**
- **E-Mail**: `admin@harheimertc.de`
- **Passwort**: `admin123` (Standard, sollte geändert werden)
- **Rolle**: `admin`
### Passwort ändern:
```bash
node scripts/set-admin-password.js "neues-passwort"
```
### Test-Umgebung:
- **Development-Port**: `3100`
- **Base-URL**: `http://localhost:3100`
- **Standard-Secrets**: Siehe `env.example`
---
## 📝 Hinweise für Security-Tests
### Zu testende Bereiche:
1. **Authentifizierung**
- Login/Logout-Funktionalität
- Token-Validierung
- Session-Management
- Passwort-Reset-Mechanismus
2. **Autorisierung**
- Rollenbasierte Zugriffskontrolle
- API-Endpunkt-Zugriff
- Frontend-Route-Zugriff
- Multi-Rollen-Support
3. **Verschlüsselung**
- Datenverschlüsselung/Entschlüsselung
- Schlüsselverwaltung
- Verschlüsselte Dateien
4. **Input-Validierung**
- SQL-Injection (nicht relevant, keine DB)
- XSS (Cross-Site-Scripting)
- CSRF (Cross-Site-Request-Forgery)
- File-Upload-Sicherheit
5. **Sensitive Daten**
- Passwort-Hashing
- Token-Sicherheit
- Cookie-Konfiguration
- Logging von sensiblen Daten
6. **API-Sicherheit**
- Endpunkt-Zugriffskontrolle
- Rate Limiting (fehlt)
- Input-Sanitization
- Error-Handling (keine sensiblen Informationen in Fehlermeldungen)
### Bekannte Limitierungen:
- ⚠️ Rate Limiting nicht implementiert
- ⚠️ HTTPS sollte in Produktion verwendet werden
- ⚠️ Secure-Flag für Cookies sollte in Production `true` sein
- ⚠️ Standard-Secrets müssen in Produktion geändert werden
---
**Letzte Aktualisierung**: 2024
**Verantwortlich**: Entwicklungsteam Harheimer TC

View File

@@ -0,0 +1 @@
vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ==

View File

@@ -1,5 +1,8 @@
<template>
<section id="about" class="py-16 sm:py-20 bg-gradient-to-b from-white to-gray-50">
<section
id="about"
class="py-16 sm:py-20 bg-gradient-to-b from-white to-gray-50"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -36,7 +39,9 @@
Jährlich finden außerdem unsere Vereinsmeisterschaften statt.
</p>
<div class="bg-primary-50 border-l-4 border-primary-600 p-6 rounded-lg">
<h4 class="text-xl font-semibold text-primary-800 mb-3">Wir suchen Verstärkung!</h4>
<h4 class="text-xl font-semibold text-primary-800 mb-3">
Wir suchen Verstärkung!
</h4>
<p class="text-primary-700 mb-4">
Wir suchen ständig Verstärkungen für unsere Mannschaften!
</p>
@@ -63,7 +68,11 @@
class="bg-white p-6 rounded-xl shadow-lg hover:shadow-xl transition-shadow border border-gray-100"
>
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mb-4">
<component :is="value.icon" :size="24" class="text-primary-600" />
<component
:is="value.icon"
:size="24"
class="text-primary-600"
/>
</div>
<h4 class="text-xl font-display font-bold text-gray-900 mb-2">
{{ value.title }}

View File

@@ -1,5 +1,8 @@
<template>
<section id="calendar" class="py-16 sm:py-20 bg-white">
<section
id="calendar"
class="py-16 sm:py-20 bg-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -28,7 +31,11 @@
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-shadow p-6 border border-gray-100">
<div class="flex items-start space-x-4">
<div :class="['flex-shrink-0 w-14 h-14 bg-gradient-to-br rounded-xl flex items-center justify-center', event.color]">
<component :is="event.icon" :size="28" class="text-white" />
<component
:is="event.icon"
:size="28"
class="text-white"
/>
</div>
<div class="flex-1">
<div class="text-sm font-semibold text-primary-600 mb-1">
@@ -57,7 +64,10 @@
<div class="mt-16 text-center">
<div class="bg-gray-50 rounded-2xl p-8 max-w-2xl mx-auto">
<CalendarIcon :size="48" class="text-primary-600 mx-auto mb-4" />
<CalendarIcon
:size="48"
class="text-primary-600 mx-auto mb-4"
/>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-3">
Regelmäßige Angebote
</h3>

View File

@@ -1,5 +1,8 @@
<template>
<section id="contact" class="py-16 sm:py-20 bg-white">
<section
id="contact"
class="py-16 sm:py-20 bg-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -20,13 +23,21 @@
class="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl hover:shadow-lg transition-shadow"
>
<div :class="['flex-shrink-0 w-12 h-12 bg-gradient-to-br rounded-lg flex items-center justify-center', info.color]">
<component :is="info.icon" :size="24" class="text-white" />
<component
:is="info.icon"
:size="24"
class="text-white"
/>
</div>
<div>
<h3 class="font-display font-bold text-gray-900 mb-2">
{{ info.title }}
</h3>
<p v-for="(line, i) in info.content" :key="i" class="text-gray-600">
<p
v-for="(line, i) in info.content"
:key="i"
class="text-gray-600"
>
{{ line }}
</p>
</div>
@@ -61,60 +72,78 @@
<h3 class="text-2xl font-display font-bold text-gray-900 mb-6">
Senden Sie uns eine Nachricht
</h3>
<form class="space-y-4" @submit.prevent="sendEmail">
<form
class="space-y-4"
@submit.prevent="sendEmail"
>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
<label
for="name"
class="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
type="text"
id="name"
v-model="formData.name"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="Ihr Name"
/>
>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-1"
>
E-Mail *
</label>
<input
type="email"
id="email"
v-model="formData.email"
type="email"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="ihre@email.de"
/>
>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">
<label
for="phone"
class="block text-sm font-medium text-gray-700 mb-1"
>
Telefon
</label>
<input
type="tel"
id="phone"
v-model="formData.phone"
type="tel"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="+49 123 456789"
/>
>
</div>
<div>
<label for="subject" class="block text-sm font-medium text-gray-700 mb-1">
<label
for="subject"
class="block text-sm font-medium text-gray-700 mb-1"
>
Betreff *
</label>
<input
type="text"
id="subject"
v-model="formData.subject"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="Worum geht es?"
/>
>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 mb-1">
<label
for="message"
class="block text-sm font-medium text-gray-700 mb-1"
>
Nachricht *
</label>
<textarea
@@ -127,11 +156,26 @@
/>
</div>
<!-- Status Message -->
<div v-if="submitStatus" class="p-4 rounded-lg" :class="submitStatus === 'success' ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
<div
v-if="submitStatus"
class="p-4 rounded-lg"
:class="submitStatus === 'success' ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'"
>
<div class="flex items-center">
<CheckCircle v-if="submitStatus === 'success'" :size="20" class="text-green-600 mr-2" />
<AlertCircle v-else :size="20" class="text-red-600 mr-2" />
<p :class="submitStatus === 'success' ? 'text-green-800' : 'text-red-800'" class="text-sm font-medium">
<CheckCircle
v-if="submitStatus === 'success'"
:size="20"
class="text-green-600 mr-2"
/>
<AlertCircle
v-else
:size="20"
class="text-red-600 mr-2"
/>
<p
:class="submitStatus === 'success' ? 'text-green-800' : 'text-red-800'"
class="text-sm font-medium"
>
{{ submitMessage }}
</p>
</div>
@@ -142,8 +186,15 @@
:disabled="isSubmitting"
class="w-full px-6 py-4 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center"
>
<Send v-if="!isSubmitting" :size="20" class="mr-2" />
<div v-else class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<Send
v-if="!isSubmitting"
:size="20"
class="mr-2"
/>
<div
v-else
class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"
/>
{{ isSubmitting ? 'Wird gesendet...' : 'E-Mail senden' }}
</button>
<p class="text-sm text-gray-600 text-center">

View File

@@ -1,5 +1,8 @@
<template>
<section id="facilities" class="py-16 sm:py-20 bg-white">
<section
id="facilities"
class="py-16 sm:py-20 bg-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -20,7 +23,11 @@
<div :class="['absolute top-0 left-0 right-0 h-1 bg-gradient-to-r opacity-0 group-hover:opacity-100 transition-opacity', facility.color]" />
<div class="p-8">
<div :class="['w-16 h-16 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform', facility.color]">
<component :is="facility.icon" :size="32" class="text-white" />
<component
:is="facility.icon"
:size="32"
class="text-white"
/>
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-3">
{{ facility.title }}
@@ -40,7 +47,9 @@
style="background-image: url('https://images.unsplash.com/photo-1534438097545-77fef53fe2e8?q=80&w=2070')"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<p class="text-white font-semibold text-xl p-6">Hochwertige Wettkampftische</p>
<p class="text-white font-semibold text-xl p-6">
Hochwertige Wettkampftische
</p>
</div>
</div>
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
@@ -49,7 +58,9 @@
style="background-image: url('https://images.unsplash.com/photo-1611004275469-8583ed5d7b8d?q=80&w=2070')"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<p class="text-white font-semibold text-xl p-6">Moderne Tischtennishalle</p>
<p class="text-white font-semibold text-xl p-6">
Moderne Tischtennishalle
</p>
</div>
</div>
</div>

View File

@@ -6,31 +6,43 @@
© {{ currentYear }} Harheimer TC 1954 e.V.
</p>
<div class="flex items-center space-x-6 text-sm relative">
<NuxtLink to="/impressum" class="text-gray-400 hover:text-primary-400 transition-colors">
<NuxtLink
to="/impressum"
class="text-gray-400 hover:text-primary-400 transition-colors"
>
Impressum
</NuxtLink>
<NuxtLink to="/kontakt" class="text-gray-400 hover:text-primary-400 transition-colors">
<NuxtLink
to="/kontakt"
class="text-gray-400 hover:text-primary-400 transition-colors"
>
Kontakt
</NuxtLink>
<!-- Login/Logout -->
<template v-if="isLoggedIn">
<button
@click="handleLogout"
class="flex items-center space-x-1 text-gray-400 hover:text-primary-400 transition-colors"
@click="handleLogout"
>
<User :size="16" />
<span>Abmelden</span>
</button>
</template>
<div v-else class="relative">
<div
v-else
class="relative"
>
<button
@click="toggleMemberMenu"
class="flex items-center space-x-1 text-gray-400 hover:text-primary-400 transition-colors"
@click="toggleMemberMenu"
>
<User :size="16" />
<span>Mitglieder</span>
<ChevronUp :size="14" :class="['transition-transform', isMemberMenuOpen ? 'rotate-0' : 'rotate-180']" />
<ChevronUp
:size="14"
:class="['transition-transform', isMemberMenuOpen ? 'rotate-0' : 'rotate-180']"
/>
</button>
<!-- Dropdown Menu (appears above) - Only when NOT logged in -->
@@ -48,22 +60,22 @@
>
<NuxtLink
to="/login"
@click="isMemberMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false"
>
Anmelden
</NuxtLink>
<NuxtLink
to="/registrieren"
@click="isMemberMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false"
>
Registrieren
</NuxtLink>
<NuxtLink
to="/passwort-vergessen"
@click="isMemberMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="isMemberMenuOpen = false"
>
Passwort vergessen
</NuxtLink>

View File

@@ -1,5 +1,9 @@
<template>
<section v-if="images.length > 0" id="gallery" class="py-16 sm:py-20 bg-gradient-to-b from-white to-gray-50">
<section
v-if="images.length > 0"
id="gallery"
class="py-16 sm:py-20 bg-gradient-to-b from-white to-gray-50"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -22,9 +26,11 @@
:src="`/galerie/${image.filename}`"
:alt="image.title"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
>
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end">
<p class="text-white font-semibold text-xs p-1 truncate">{{ image.title }}</p>
<p class="text-white font-semibold text-xs p-1 truncate">
{{ image.title }}
</p>
</div>
</div>
</div>
@@ -37,8 +43,8 @@
>
<div class="relative w-full h-full flex items-center justify-center">
<button
@click.stop="closeLightbox"
class="absolute top-4 right-4 z-10 w-10 h-10 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center text-white transition-colors"
@click.stop="closeLightbox"
>
<X :size="24" />
</button>
@@ -47,7 +53,7 @@
:alt="lightboxImage.title"
class="max-w-[80vw] max-h-[80vh] object-contain rounded-lg"
@click.stop
/>
>
<div class="absolute bottom-4 left-4 right-4 text-center">
<p class="text-white font-semibold text-lg bg-black/50 rounded-lg px-4 py-2">
{{ lightboxImage.title }}

View File

@@ -1,5 +1,8 @@
<template>
<section id="home" class="relative min-h-full flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100">
<section
id="home"
class="relative min-h-full flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100"
>
<!-- Decorative Elements -->
<div class="absolute inset-0 z-0">
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-200/30 rounded-full blur-3xl" />
@@ -15,14 +18,13 @@
<div class="relative z-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-8">
<div class="text-center">
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-display font-bold text-gray-900 mb-6 leading-tight animate-fade-in">
Willkommen beim<br />
Willkommen beim<br>
<span class="text-primary-600">Harheimer TC</span>
</h1>
<p class="text-xl sm:text-2xl text-gray-700 mb-8 max-w-3xl mx-auto animate-fade-in-delay-1">
Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit {{ yearsSinceFounding }} Jahren
</p>
</div>
</div>
</section>

View File

@@ -9,7 +9,10 @@
>
<div class="flex items-center mb-4">
<div class="w-16 h-16 bg-primary-100 rounded-xl flex items-center justify-center group-hover:bg-primary-600 transition-colors">
<UserPlus :size="32" class="text-primary-600 group-hover:text-white transition-colors" />
<UserPlus
:size="32"
class="text-primary-600 group-hover:text-white transition-colors"
/>
</div>
<h3 class="ml-4 text-2xl font-display font-bold text-gray-900">
Mitglied werden
@@ -21,7 +24,10 @@
</p>
<div class="flex items-center text-primary-600 font-semibold group-hover:translate-x-2 transition-transform">
Mehr erfahren
<ArrowRight :size="20" class="ml-2" />
<ArrowRight
:size="20"
class="ml-2"
/>
</div>
</NuxtLink>
@@ -32,7 +38,10 @@
>
<div class="flex items-center mb-4">
<div class="w-16 h-16 bg-primary-100 rounded-xl flex items-center justify-center group-hover:bg-primary-600 transition-colors">
<Mail :size="32" class="text-primary-600 group-hover:text-white transition-colors" />
<Mail
:size="32"
class="text-primary-600 group-hover:text-white transition-colors"
/>
</div>
<h3 class="ml-4 text-2xl font-display font-bold text-gray-900">
Kontakt aufnehmen
@@ -44,7 +53,10 @@
</p>
<div class="flex items-center text-primary-600 font-semibold group-hover:translate-x-2 transition-transform">
Jetzt kontaktieren
<ArrowRight :size="20" class="ml-2" />
<ArrowRight
:size="20"
class="ml-2"
/>
</div>
</NuxtLink>
</div>

View File

@@ -18,7 +18,10 @@
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
Alle Termine anzeigen
<ArrowRight :size="20" class="ml-2" />
<ArrowRight
:size="20"
class="ml-2"
/>
</NuxtLink>
</div>
</div>

163
components/ImageUpload.vue Normal file
View File

@@ -0,0 +1,163 @@
<template>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{{ label }}
<span
v-if="!required"
class="text-gray-500 text-xs"
>(optional)</span>
</label>
<div
v-if="imageFilename"
class="mb-2"
>
<div class="relative inline-block">
<img
:src="`/api/personen/${imageFilename}?width=100&height=100`"
:alt="label"
class="w-24 h-24 object-cover rounded-lg border-2 border-gray-300"
>
<button
v-if="!uploading"
class="absolute -top-2 -right-2 bg-red-600 text-white rounded-full p-1 hover:bg-red-700 transition-colors"
type="button"
title="Bild entfernen"
@click="removeImage"
>
<X :size="14" />
</button>
</div>
</div>
<div class="flex items-center gap-2">
<label
:class="[
'flex-1 px-4 py-2 border-2 border-dashed rounded-lg cursor-pointer transition-colors',
uploading ? 'border-gray-300 bg-gray-50 cursor-not-allowed' :
dragOver ? 'border-primary-500 bg-primary-50' :
'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
]"
>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
class="hidden"
:disabled="uploading"
@change="handleFileSelect"
>
<div class="text-center">
<div
v-if="uploading"
class="flex items-center justify-center gap-2 text-gray-600"
>
<Loader2
:size="16"
class="animate-spin"
/>
<span>Wird hochgeladen...</span>
</div>
<div
v-else
class="text-sm text-gray-600"
>
<span v-if="!imageFilename">📷 Bild auswählen oder hier ablegen</span>
<span v-else>🔄 Bild ändern</span>
</div>
</div>
</label>
</div>
<p
v-if="error"
class="mt-1 text-sm text-red-600"
>
{{ error }}
</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Loader2, X } from 'lucide-vue-next'
const props = defineProps({
modelValue: {
type: String,
default: null
},
label: {
type: String,
default: 'Bild'
},
required: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const fileInput = ref(null)
const uploading = ref(false)
const dragOver = ref(false)
const error = ref('')
const imageFilename = computed(() => props.modelValue)
async function handleFileSelect(event) {
const file = event.target.files?.[0]
if (!file) return
await uploadImage(file)
}
async function uploadImage(file) {
if (!file.type.match(/^image\/(jpeg|jpg|png|gif|webp)$/)) {
error.value = 'Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)'
return
}
if (file.size > 10 * 1024 * 1024) {
error.value = 'Bild darf maximal 10MB groß sein'
return
}
uploading.value = true
error.value = ''
try {
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/api/personen/upload', {
method: 'POST',
body: formData,
credentials: 'include'
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ statusMessage: 'Fehler beim Hochladen' }))
throw new Error(errorData.statusMessage || 'Fehler beim Hochladen')
}
const data = await response.json()
emit('update:modelValue', data.filename)
} catch (err) {
error.value = err.message || 'Fehler beim Hochladen des Bildes'
console.error('Upload error:', err)
} finally {
uploading.value = false
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
function removeImage() {
emit('update:modelValue', null)
error.value = ''
}
</script>

View File

@@ -1,6 +1,9 @@
<template>
<div>
<div v-if="mannschaften.length > 0" class="space-y-8">
<div
v-if="mannschaften.length > 0"
class="space-y-8"
>
<div
v-for="(mannschaft, index) in mannschaften"
:key="index"
@@ -11,7 +14,9 @@
<h2 class="text-2xl font-display font-bold text-white mb-2">
{{ mannschaft.mannschaft }}
</h2>
<p class="text-primary-100 text-lg">{{ mannschaft.liga }}</p>
<p class="text-primary-100 text-lg">
{{ mannschaft.liga }}
</p>
</div>
<!-- Content -->
@@ -20,24 +25,24 @@
<div class="grid md:grid-cols-2 gap-6 mb-6">
<div class="space-y-3">
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Staffelleiter:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.staffelleiter }}</span>
</div>
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Telefon:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.telefon }}</span>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Heimspieltag:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.heimspieltag }}</span>
</div>
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Spielsystem:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.spielsystem }}</span>
</div>
@@ -56,8 +61,13 @@
class="bg-gray-50 rounded-lg p-4 text-center"
:class="spieler === mannschaft.mannschaftsfuehrer ? 'ring-2 ring-primary-500 bg-primary-50' : ''"
>
<div class="font-semibold text-gray-900">{{ spieler }}</div>
<div v-if="spieler === mannschaft.mannschaftsfuehrer" class="text-xs text-primary-600 font-medium mt-1">
<div class="font-semibold text-gray-900">
{{ spieler }}
</div>
<div
v-if="spieler === mannschaft.mannschaftsfuehrer"
class="text-xs text-primary-600 font-medium mt-1"
>
Mannschaftsführer
</div>
</div>
@@ -76,9 +86,17 @@
</div>
</div>
<div v-else class="text-center py-12 bg-gray-50 rounded-xl">
<Users :size="48" class="text-gray-400 mx-auto mb-4" />
<p class="text-gray-600">Keine Mannschaftsdaten geladen</p>
<div
v-else
class="text-center py-12 bg-gray-50 rounded-xl"
>
<Users
:size="48"
class="text-gray-400 mx-auto mb-4"
/>
<p class="text-gray-600">
Keine Mannschaftsdaten geladen
</p>
</div>
</div>
</template>
@@ -91,23 +109,18 @@ const mannschaften = ref([])
const loadMannschaften = async () => {
try {
console.log('Lade Mannschaften...')
const response = await fetch('/data/mannschaften.csv')
console.log('Response:', response)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const csv = await response.text()
console.log('CSV Text:', csv)
// Vereinfachter CSV-Parser
const lines = csv.split('\n').filter(line => line.trim() !== '')
console.log('CSV Lines:', lines)
if (lines.length < 2) {
console.log('Keine Datenzeilen gefunden')
return
}
@@ -132,7 +145,6 @@ const loadMannschaften = async () => {
values.push(current.trim())
if (values.length < 10) {
console.log(`Zeile ${index + 2} hat zu wenige Werte:`, values)
return null
}
@@ -149,14 +161,8 @@ const loadMannschaften = async () => {
letzte_aktualisierung: values[9] ? values[9].trim() : ''
}
console.log(`Mannschaft ${index + 1}:`, mannschaft)
console.log(`Parsed values count: ${values.length}`)
console.log(`Letzte Aktualisierung raw: "${values[9]}"`)
console.log(`Letzte Aktualisierung trimmed: "${values[9] ? values[9].trim() : 'undefined'}")`)
return mannschaft
}).filter(mannschaft => mannschaft !== null)
console.log('Alle geparsten Mannschaften:', mannschaften.value)
} catch (error) {
console.error('Fehler beim Laden der Mannschaften:', error)
}

View File

@@ -1,5 +1,8 @@
<template>
<section id="membership" class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white">
<section
id="membership"
class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -20,7 +23,10 @@
plan.popular ? 'ring-4 ring-primary-500 scale-105' : ''
]"
>
<div v-if="plan.popular" class="absolute top-0 right-0 bg-primary-600 text-white px-4 py-1 text-sm font-semibold rounded-bl-lg">
<div
v-if="plan.popular"
class="absolute top-0 right-0 bg-primary-600 text-white px-4 py-1 text-sm font-semibold rounded-bl-lg"
>
Beliebt
</div>
@@ -28,7 +34,11 @@
<div class="p-8">
<div :class="['w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4', plan.gradient]">
<component :is="plan.icon" :size="24" class="text-white" />
<component
:is="plan.icon"
:size="24"
class="text-white"
/>
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">
@@ -46,8 +56,15 @@
</div>
<ul class="space-y-3 mb-8">
<li v-for="feature in plan.features" :key="feature" class="flex items-start">
<Check :size="20" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<li
v-for="feature in plan.features"
:key="feature"
class="flex items-start"
>
<Check
:size="20"
class="text-primary-600 mr-3 flex-shrink-0 mt-0.5"
/>
<span class="text-gray-700">{{ feature }}</span>
</li>
</ul>
@@ -84,7 +101,10 @@
target="_blank"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<FileText :size="20" class="mr-2" />
<FileText
:size="20"
class="mr-2"
/>
Satzung herunterladen (PDF)
</a>
<span class="text-sm text-gray-500">oder</span>
@@ -92,7 +112,10 @@
to="/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 :size="20" class="mr-2" />
<Eye
:size="20"
class="mr-2"
/>
Online ansehen
</NuxtLink>
</div>

View File

@@ -1,5 +1,8 @@
<template>
<section id="membership" class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white">
<section
id="membership"
class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -20,7 +23,10 @@
plan.popular ? 'ring-4 ring-primary-500 scale-105' : ''
]"
>
<div v-if="plan.popular" class="absolute top-0 right-0 bg-primary-600 text-white px-4 py-1 text-sm font-semibold rounded-bl-lg">
<div
v-if="plan.popular"
class="absolute top-0 right-0 bg-primary-600 text-white px-4 py-1 text-sm font-semibold rounded-bl-lg"
>
Beliebt
</div>
@@ -28,7 +34,11 @@
<div class="p-8">
<div :class="['w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4', plan.gradient]">
<component :is="plan.icon" :size="24" class="text-white" />
<component
:is="plan.icon"
:size="24"
class="text-white"
/>
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">
@@ -46,8 +56,15 @@
</div>
<ul class="space-y-3 mb-8">
<li v-for="feature in plan.features" :key="feature" class="flex items-start">
<Check :size="20" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<li
v-for="feature in plan.features"
:key="feature"
class="flex items-start"
>
<Check
:size="20"
class="text-primary-600 mr-3 flex-shrink-0 mt-0.5"
/>
<span class="text-gray-700">{{ feature }}</span>
</li>
</ul>
@@ -84,7 +101,10 @@
target="_blank"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<FileText :size="20" class="mr-2" />
<FileText
:size="20"
class="mr-2"
/>
Satzung herunterladen (PDF)
</a>
<span class="text-sm text-gray-500">oder</span>
@@ -92,7 +112,10 @@
to="/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 :size="20" class="mr-2" />
<Eye
:size="20"
class="mr-2"
/>
Online ansehen
</NuxtLink>
</div>

View File

@@ -8,18 +8,32 @@
<div class="bg-white rounded-lg max-w-md w-full p-6">
<div class="flex items-center mb-4">
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
<svg
class="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<div class="text-center">
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ successTitle }}</h3>
<p class="text-sm text-gray-600 mb-6">{{ successMessage }}</p>
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ successTitle }}
</h3>
<p class="text-sm text-gray-600 mb-6">
{{ successMessage }}
</p>
<div class="flex justify-center">
<button
@click="closeSuccess"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
@click="closeSuccess"
>
OK
</button>
@@ -37,18 +51,32 @@
<div class="bg-white rounded-lg max-w-md w-full p-6">
<div class="flex items-center mb-4">
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-red-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-red-600" 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"></path>
<svg
class="w-6 h-6 text-red-600"
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>
</div>
</div>
<div class="text-center">
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ errorTitle }}</h3>
<p class="text-sm text-gray-600 mb-6">{{ errorMessage }}</p>
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ errorTitle }}
</h3>
<p class="text-sm text-gray-600 mb-6">
{{ errorMessage }}
</p>
<div class="flex justify-center">
<button
@click="closeError"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
@click="closeError"
>
OK
</button>
@@ -66,24 +94,38 @@
<div class="bg-white rounded-lg max-w-md w-full p-6">
<div class="flex items-center mb-4">
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-yellow-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
<svg
class="w-6 h-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
</div>
<div class="text-center">
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ confirmTitle }}</h3>
<p class="text-sm text-gray-600 mb-6">{{ confirmMessage }}</p>
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ confirmTitle }}
</h3>
<p class="text-sm text-gray-600 mb-6">
{{ confirmMessage }}
</p>
<div class="flex space-x-3 justify-center">
<button
@click="closeConfirm"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeConfirm"
>
Abbrechen
</button>
<button
@click="executeConfirm"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
@click="executeConfirm"
>
Bestätigen
</button>

File diff suppressed because it is too large Load Diff

45
components/PersonCard.vue Normal file
View File

@@ -0,0 +1,45 @@
<template>
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div
v-if="imageFilename"
class="mb-4 flex justify-center"
>
<img
:src="`/api/personen/${imageFilename}?width=200&height=200`"
:alt="`${title}: ${name}`"
class="w-32 h-32 object-cover rounded-full border-4 border-primary-100 shadow-md"
loading="lazy"
>
</div>
<h3
v-if="title"
class="text-xl font-display font-bold text-gray-900 mb-2"
>
{{ title }}
</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ name }}
</h4>
<div class="space-y-1 text-gray-600">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: ''
},
name: {
type: String,
required: true
},
imageFilename: {
type: String,
default: null
}
})
</script>

View File

@@ -1,5 +1,8 @@
<template>
<section v-if="news.length > 0" class="py-16 sm:py-20 bg-white">
<section
v-if="news.length > 0"
class="py-16 sm:py-20 bg-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
@@ -12,15 +15,21 @@
</div>
<div class="flex justify-center">
<div class="grid gap-8" :class="getGridClass()">
<div
class="grid gap-8"
:class="getGridClass()"
>
<article
v-for="item in news"
:key="item.id"
@click="openNewsModal(item)"
class="bg-gray-50 rounded-xl p-6 border border-gray-200 hover:shadow-lg transition-shadow w-full max-w-sm flex flex-col cursor-pointer"
@click="openNewsModal(item)"
>
<div class="flex items-center text-sm text-gray-500 mb-3">
<Calendar :size="16" class="mr-2" />
<Calendar
:size="16"
class="mr-2"
/>
{{ formatDate(item.created) }}
</div>
@@ -47,7 +56,10 @@
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div class="flex-1">
<div class="flex items-center text-sm text-gray-500 mb-2">
<Calendar :size="16" class="mr-2" />
<Calendar
:size="16"
class="mr-2"
/>
{{ formatDate(selectedNews.created) }}
</div>
<h2 class="text-2xl font-display font-bold text-gray-900">
@@ -55,8 +67,8 @@
</h2>
</div>
<button
@click="closeNewsModal"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
@click="closeNewsModal"
>
<X :size="24" />
</button>

View File

@@ -0,0 +1,159 @@
<template>
<div>
<label
v-if="label"
class="block text-sm font-medium text-gray-700 mb-2"
>
{{ label }}
<span
v-if="required"
class="text-red-500"
>*</span>
</label>
<div
ref="editorContainer"
class="border border-gray-300 rounded-lg bg-white"
/>
<input
type="hidden"
:value="modelValue"
>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
label: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const editorContainer = ref(null)
let quill = null
onMounted(async () => {
if (process.client && editorContainer.value) {
// Dynamisch Quill nur im Client laden
const Quill = (await import('quill')).default
await import('quill/dist/quill.snow.css')
quill = new Quill(editorContainer.value, {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'align': [] }],
['link', 'image'],
['blockquote', 'code-block'],
['clean']
]
},
placeholder: 'Newsletter-Inhalt eingeben...'
})
// Setze initialen Inhalt
if (props.modelValue) {
quill.root.innerHTML = props.modelValue
}
// Emitiere Änderungen
quill.on('text-change', () => {
const html = quill.root.innerHTML
// Prüfe ob Inhalt wirklich geändert wurde (nicht nur leere Tags)
const textContent = quill.getText().trim()
if (textContent || html !== '<p><br></p>') {
emit('update:modelValue', html)
} else {
emit('update:modelValue', '')
}
})
}
})
watch(() => props.modelValue, (newValue) => {
if (quill && quill.root.innerHTML !== newValue) {
// Temporär Event-Listener entfernen um Endlosschleife zu vermeiden
const currentContent = quill.root.innerHTML
if (currentContent !== newValue) {
quill.root.innerHTML = newValue || ''
}
}
})
onBeforeUnmount(() => {
if (quill) {
quill = null
}
})
</script>
<style>
/* Quill Editor Styles */
.ql-container {
font-family: Arial, sans-serif;
font-size: 14px;
min-height: 300px;
}
.ql-editor {
min-height: 300px;
}
.ql-editor.ql-blank::before {
color: #9ca3af;
font-style: normal;
}
.ql-toolbar {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.ql-container {
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.ql-snow .ql-stroke {
stroke: #374151;
}
.ql-snow .ql-fill {
fill: #374151;
}
.ql-snow .ql-picker-label {
color: #374151;
}
.ql-snow .ql-tooltip {
background-color: #ffffff;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.ql-snow .ql-tooltip input[type=text] {
border: 1px solid #d1d5db;
}
.ql-snow .ql-tooltip a.ql-action::after {
border-right: 1px solid #d1d5db;
}
</style>

View File

@@ -2,56 +2,137 @@
<section class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-900 mb-4">Nächste Spiele</h2>
<h2 class="text-3xl font-bold text-gray-900 mb-4">
Nächste Spiele
</h2>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-8">
<svg class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<div
v-if="isLoading"
class="text-center py-8"
>
<svg
class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<p class="text-gray-600">Spielplan wird geladen...</p>
<p class="text-gray-600">
Spielplan wird geladen...
</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-8">
<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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
<div
v-else-if="error"
class="text-center py-8"
>
<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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<p class="text-gray-600 mb-4">{{ error }}</p>
<NuxtLink to="/mannschaften/spielplaene" class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors">
<p class="text-gray-600 mb-4">
{{ error }}
</p>
<NuxtLink
to="/mannschaften/spielplaene"
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Zum Spielplan
</NuxtLink>
</div>
<!-- Empty State -->
<div v-else-if="!upcomingGames || upcomingGames.length === 0" class="text-center py-8">
<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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
<div
v-else-if="!upcomingGames || upcomingGames.length === 0"
class="text-center py-8"
>
<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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine kommenden Spiele</h3>
<p class="text-gray-600 mb-4">Derzeit sind keine Spiele geplant.</p>
<NuxtLink to="/mannschaften/spielplaene" class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors">
<h3 class="text-lg font-medium text-gray-900 mb-2">
Keine kommenden Spiele
</h3>
<p class="text-gray-600 mb-4">
Derzeit sind keine Spiele geplant.
</p>
<NuxtLink
to="/mannschaften/spielplaene"
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Zum Spielplan
</NuxtLink>
</div>
<!-- Games Grid -->
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div v-for="game in upcomingGames" :key="`${game.Termin}-${game.HeimMannschaft}`"
class="bg-white rounded-xl shadow-lg border border-gray-200 hover:shadow-xl transition-shadow">
<div
v-else
class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="game in upcomingGames"
:key="`${game.Termin}-${game.HeimMannschaft}`"
class="bg-white rounded-xl shadow-lg border border-gray-200 hover:shadow-xl transition-shadow"
>
<div class="p-6">
<!-- Date and Time -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<svg class="w-5 h-5 text-primary-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<svg
class="w-5 h-5 text-primary-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="text-sm font-medium text-gray-900">{{ formatDate(game.Termin) }}</span>
</div>
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 text-gray-400 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
class="w-4 h-4 text-gray-400 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ formatTime(game.Termin) }}
</div>
@@ -61,8 +142,12 @@
<div class="mb-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm text-gray-600 mb-1">Heim</p>
<p class="font-semibold text-gray-900">{{ formatTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse) }}</p>
<p class="text-sm text-gray-600 mb-1">
Heim
</p>
<p class="font-semibold text-gray-900">
{{ formatTeamName(game.HeimMannschaft, game.HeimMannschaftAltersklasse) }}
</p>
</div>
<div class="text-center mx-4">
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
@@ -70,16 +155,33 @@
</div>
</div>
<div class="flex-1 text-right">
<p class="text-sm text-gray-600 mb-1">Gast</p>
<p class="font-semibold text-gray-900">{{ formatTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse) }}</p>
<p class="text-sm text-gray-600 mb-1">
Gast
</p>
<p class="font-semibold text-gray-900">
{{ formatTeamName(game.GastMannschaft, game.GastMannschaftAltersklasse) }}
</p>
</div>
</div>
</div>
<!-- Competition Info -->
<div v-if="game.Runde" class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 text-gray-400 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" />
<div
v-if="game.Runde"
class="flex items-center text-sm text-gray-600"
>
<svg
class="w-4 h-4 text-gray-400 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>
{{ formatRunde(game.Runde) }}
</div>
@@ -88,12 +190,27 @@
</div>
<!-- View All Button -->
<div v-if="upcomingGames && upcomingGames.length > 0" class="text-center mt-8">
<NuxtLink to="/mannschaften/spielplaene"
class="inline-flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors">
<div
v-if="upcomingGames && upcomingGames.length > 0"
class="text-center mt-8"
>
<NuxtLink
to="/mannschaften/spielplaene"
class="inline-flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Alle Spiele anzeigen
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<svg
class="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</NuxtLink>
</div>

View File

@@ -1,6 +1,9 @@
<template>
<div>
<div v-if="naechsteTermine.length > 0" class="space-y-2 mb-6">
<div
v-if="naechsteTermine.length > 0"
class="space-y-2 mb-6"
>
<div
v-for="(termin, index) in naechsteTermine"
:key="index"
@@ -20,23 +23,37 @@
</span>
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ termin.titel }}</h3>
<p class="text-sm text-gray-600">{{ termin.beschreibung }}</p>
<h3 class="font-semibold text-gray-900">
{{ termin.titel }}
</h3>
<p class="text-sm text-gray-600">
{{ termin.beschreibung }}
</p>
</div>
</div>
<span :class="[
<span
:class="[
'px-2 py-1 text-xs font-medium rounded-full',
termin.kategorie === 'Turnier' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'
]">
]"
>
{{ termin.kategorie }}
</span>
</div>
</div>
</div>
<div v-else class="text-center py-8 bg-gray-50 rounded-lg">
<Calendar :size="32" class="text-gray-400 mx-auto mb-2" />
<p class="text-gray-600 text-sm">Keine kommenden Termine</p>
<div
v-else
class="text-center py-8 bg-gray-50 rounded-lg"
>
<Calendar
:size="32"
class="text-gray-400 mx-auto mb-2"
/>
<p class="text-gray-600 text-sm">
Keine kommenden Termine
</p>
</div>
</div>
</template>
@@ -49,7 +66,6 @@ const termine = ref([])
const naechsteTermine = computed(() => {
const heute = new Date()
console.log('Heute ist:', heute.toISOString().split('T')[0])
const kommende = termine.value
.filter(t => {
@@ -61,9 +77,7 @@ const naechsteTermine = computed(() => {
} else {
terminDatum = new Date(t.datum)
}
const istKommend = terminDatum >= heute
console.log(`Termin ${t.titel} (${t.datum}): ${istKommend ? 'KOMMEND' : 'VERSTRICHEN'}`)
return istKommend
return terminDatum >= heute
})
.sort((a, b) => {
const da = new Date(`${a.datum}${a.uhrzeit ? 'T' + a.uhrzeit + ':00' : ''}`)
@@ -71,7 +85,6 @@ const naechsteTermine = computed(() => {
return da - db
})
console.log('Kommende Termine:', kommende)
return kommende
})

0
deploy.sh Normal file → Executable file
View File

95
eslint.config.js Normal file
View File

@@ -0,0 +1,95 @@
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import parser from 'vue-eslint-parser'
import globals from 'globals'
export default [
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.vue', '**/*.js'],
languageOptions: {
parser: parser,
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
...globals.es2021,
// Nuxt/Vue specific globals
'$fetch': 'readonly',
'useAuthStore': 'readonly',
'useRoute': 'readonly',
'useRouter': 'readonly',
'navigateTo': 'readonly',
'useHead': 'readonly',
'useFetch': 'readonly',
'definePageMeta': 'readonly',
'defineNuxtRouteMiddleware': 'readonly',
'defineEventHandler': 'readonly',
'readBody': 'readonly',
'getCookie': 'readonly',
'setCookie': 'readonly',
'deleteCookie': 'readonly',
'getHeader': 'readonly',
'getRouterParam': 'readonly',
'getQuery': 'readonly',
'sendStream': 'readonly',
'sendRedirect': 'readonly',
'createError': 'readonly',
'useRuntimeConfig': 'readonly',
'process': 'readonly',
// Vue Composition API
'onUnmounted': 'readonly',
'provide': 'readonly',
'inject': 'readonly',
'ref': 'readonly',
'reactive': 'readonly',
'computed': 'readonly',
'watch': 'readonly',
'onMounted': 'readonly',
'defineComponent': 'readonly',
'defineProps': 'readonly',
'defineEmits': 'readonly',
'defineExpose': 'readonly'
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn',
'no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'vue/no-unused-vars': ['warn', {
ignorePattern: '^_'
}],
'no-undef': 'warn',
'no-console': 'off'
}
},
{
ignores: [
'node_modules/**',
'.output/**',
'.nuxt/**',
'.next/**',
'dist/**',
'build/**',
'*.config.js',
'*.config.ts',
'*.config.cjs',
'*.cjs',
'temp/**',
'backups/**',
'public/**',
'tests/**',
'scripts/**'
]
}
]

View File

@@ -17,11 +17,22 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
// Check role for CMS
if (to.path.startsWith('/cms')) {
const isAdmin = auth.value.role === 'admin' || auth.value.role === 'vorstand'
const roles = auth.value.roles || (auth.value.role ? [auth.value.role] : [])
const hasAccess = roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')
// Newsletter-Seite nur für Newsletter-Rolle, Admin oder Vorstand
if (to.path.startsWith('/cms/newsletter')) {
if (!hasAccess) {
return navigateTo('/mitgliederbereich')
}
} else {
// Andere CMS-Seiten nur für Admin oder Vorstand
const isAdmin = roles.includes('admin') || roles.includes('vorstand')
if (!isAdmin) {
return navigateTo('/mitgliederbereich')
}
}
}
} catch (error) {
return navigateTo('/login?redirect=' + to.path)
}

2117
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,12 @@
"start": "nuxt start --port 3100",
"postinstall": "nuxt prepare",
"test": "vitest run",
"test:watch": "vitest watch"
"test:watch": "vitest watch",
"lint": "eslint . --fix"
},
"dependencies": {
"@pinia/nuxt": "^0.11.2",
"@tinymce/tinymce-vue": "^6.3.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -24,17 +26,22 @@
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"pinia": "^3.0.3",
"quill": "^2.0.3",
"sharp": "^0.34.5",
"tinymce": "^8.3.1",
"vue": "^3.5.22"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.11.0",
"autoprefixer": "^10.4.0",
"dotenv": "^17.2.3",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"lucide-vue-next": "^0.344.0",
"postcss": "^8.4.0",
"supertest": "^7.1.0",
"tailwindcss": "^3.4.0",
"vitest": "^2.1.4"
"vitest": "^4.0.16",
"vue-eslint-parser": "^10.2.0"
}
}

View File

@@ -17,9 +17,15 @@
</div>
<!-- Pending Users -->
<div v-if="pendingUsers.length > 0" class="mb-8">
<div
v-if="pendingUsers.length > 0"
class="mb-8"
>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
<AlertCircle :size="24" class="inline text-yellow-600 mr-2" />
<AlertCircle
:size="24"
class="inline text-yellow-600 mr-2"
/>
Wartende Registrierungen ({{ pendingUsers.length }})
</h2>
<div class="space-y-4">
@@ -30,9 +36,18 @@
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900">{{ user.name }}</h3>
<p class="text-sm text-gray-600 mt-1">{{ user.email }}</p>
<p v-if="user.phone" class="text-sm text-gray-600">{{ user.phone }}</p>
<h3 class="text-lg font-semibold text-gray-900">
{{ user.name }}
</h3>
<p class="text-sm text-gray-600 mt-1">
{{ user.email }}
</p>
<p
v-if="user.phone"
class="text-sm text-gray-600"
>
{{ user.phone }}
</p>
<p class="text-xs text-gray-500 mt-2">
Registriert am: {{ formatDate(user.created) }}
</p>
@@ -43,26 +58,41 @@
v-model="user.selectedRole"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-600"
>
<option value="mitglied">Mitglied</option>
<option value="vorstand">Vorstand</option>
<option value="admin">Administrator</option>
<option value="mitglied">
Mitglied
</option>
<option value="vorstand">
Vorstand
</option>
<option value="admin">
Administrator
</option>
<option value="newsletter">
Newsletter
</option>
</select>
<!-- Approve Button -->
<button
@click="approveUser(user)"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors flex items-center justify-center"
@click="approveUser(user)"
>
<Check :size="16" class="mr-1" />
<Check
:size="16"
class="mr-1"
/>
Freischalten
</button>
<!-- Reject Button -->
<button
@click="rejectUser(user)"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors flex items-center justify-center"
@click="rejectUser(user)"
>
<X :size="16" class="mr-1" />
<X
:size="16"
class="mr-1"
/>
Ablehnen
</button>
</div>
@@ -101,31 +131,48 @@
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="user in activeUsers" :key="user.id" class="hover:bg-gray-50">
<tr
v-for="user in activeUsers"
:key="user.id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ user.name }}</div>
<div class="text-sm font-medium text-gray-900">
{{ user.name }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">{{ user.email }}</div>
<div class="text-sm text-gray-600">
{{ user.email }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">{{ user.phone || '-' }}</div>
<div class="text-sm text-gray-600">
{{ user.phone || '-' }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<select
v-model="user.role"
@change="updateUserRole(user)"
class="px-3 py-1 border border-gray-300 rounded text-sm"
<div class="flex flex-wrap gap-1">
<span
v-for="role in (user.roles || (user.role ? [user.role] : ['mitglied']))"
:key="role"
class="px-2 py-1 text-xs font-medium rounded"
:class="{
'bg-red-50 border-red-300': user.role === 'admin',
'bg-blue-50 border-blue-300': user.role === 'vorstand',
'bg-gray-50 border-gray-300': user.role === 'mitglied'
'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-gray-100 text-gray-800': role === 'mitglied'
}"
>
<option value="mitglied">Mitglied</option>
<option value="vorstand">Vorstand</option>
<option value="admin">Administrator</option>
</select>
{{ role === 'admin' ? 'Admin' : role === 'vorstand' ? 'Vorstand' : role === 'newsletter' ? 'Newsletter' : 'Mitglied' }}
</span>
</div>
<button
class="mt-1 text-xs text-primary-600 hover:text-primary-800"
@click="openRoleModal(user)"
>
Bearbeiten
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">
@@ -135,12 +182,15 @@
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button
v-if="user.id !== currentUserId"
@click="deactivateUser(user)"
class="text-red-600 hover:text-red-800 font-medium"
@click="deactivateUser(user)"
>
Deaktivieren
</button>
<span v-else class="text-gray-400">Eigenes Konto</span>
<span
v-else
class="text-gray-400"
>Eigenes Konto</span>
</td>
</tr>
</tbody>
@@ -149,19 +199,107 @@
</div>
<!-- Success/Error Messages -->
<div v-if="successMessage" class="fixed bottom-20 right-4 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg">
<div
v-if="successMessage"
class="fixed bottom-20 right-4 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg"
>
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
<Check
:size="18"
class="mr-2"
/>
{{ successMessage }}
</p>
</div>
<div v-if="errorMessage" class="fixed bottom-20 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg">
<div
v-if="errorMessage"
class="fixed bottom-20 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg"
>
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
<AlertCircle
:size="18"
class="mr-2"
/>
{{ errorMessage }}
</p>
</div>
</div>
<!-- Role Edit Modal -->
<div
v-if="showRoleModal && editingUser"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeRoleModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Rollen bearbeiten: {{ editingUser.name }}
</h2>
<div class="space-y-3 mb-6">
<label class="flex items-center">
<input
v-model="selectedRoles"
type="checkbox"
value="mitglied"
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">Mitglied</span>
</label>
<label class="flex items-center">
<input
v-model="selectedRoles"
type="checkbox"
value="vorstand"
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">Vorstand</span>
</label>
<label class="flex items-center">
<input
v-model="selectedRoles"
type="checkbox"
value="newsletter"
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">Newsletter</span>
</label>
<label class="flex items-center">
<input
v-model="selectedRoles"
type="checkbox"
value="admin"
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">Administrator</span>
</label>
</div>
<div
v-if="selectedRoles.length === 0"
class="mb-4 text-sm text-red-600"
>
Mindestens eine Rolle muss ausgewählt werden.
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeRoleModal"
>
Abbrechen
</button>
<button
:disabled="selectedRoles.length === 0"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="saveUserRoles"
>
Speichern
</button>
</div>
</div>
</div>
</div>
</template>
@@ -173,11 +311,17 @@ const allUsers = ref([])
const currentUserId = ref(null)
const successMessage = ref('')
const errorMessage = ref('')
const showRoleModal = ref(false)
const editingUser = ref(null)
const selectedRoles = ref([])
const pendingUsers = computed(() => {
return allUsers.value
.filter(u => u.active === false)
.map(u => ({ ...u, selectedRole: u.role || 'mitglied' }))
.map(u => ({
...u,
selectedRole: (u.roles && u.roles.length > 0) ? u.roles[0] : (u.role || 'mitglied')
}))
})
const activeUsers = computed(() => {
@@ -210,7 +354,7 @@ const approveUser = async (user) => {
method: 'POST',
body: {
userId: user.id,
role: user.selectedRole
roles: [user.selectedRole || 'mitglied']
}
})
@@ -224,6 +368,41 @@ const approveUser = async (user) => {
}
}
function openRoleModal(user) {
editingUser.value = user
selectedRoles.value = user.roles || (user.role ? [user.role] : ['mitglied'])
showRoleModal.value = true
}
function closeRoleModal() {
showRoleModal.value = false
editingUser.value = null
selectedRoles.value = []
}
async function saveUserRoles() {
if (!editingUser.value || selectedRoles.value.length === 0) return
try {
await $fetch('/api/cms/users/update-role', {
method: 'POST',
body: {
userId: editingUser.value.id,
roles: selectedRoles.value
}
})
successMessage.value = `Rollen von ${editingUser.value.name} wurden aktualisiert`
setTimeout(() => successMessage.value = '', 3000)
closeRoleModal()
await loadUsers()
} catch (error) {
errorMessage.value = 'Fehler beim Aktualisieren der Rollen'
setTimeout(() => errorMessage.value = '', 3000)
}
}
const rejectUser = async (user) => {
window.showConfirmModal('Registrierung ablehnen', `Möchten Sie die Registrierung von ${user.name} wirklich ablehnen?`, async () => {
try {
@@ -241,24 +420,6 @@ const rejectUser = async (user) => {
})
}
const updateUserRole = async (user) => {
try {
await $fetch('/api/cms/users/update-role', {
method: 'POST',
body: {
userId: user.id,
role: user.role
}
})
successMessage.value = `Rolle von ${user.name} wurde aktualisiert`
setTimeout(() => successMessage.value = '', 3000)
} catch (error) {
errorMessage.value = 'Fehler beim Aktualisieren der Rolle'
setTimeout(() => errorMessage.value = '', 3000)
await loadUsers() // Reload to revert changes
}
}
const deactivateUser = async (user) => {
window.showConfirmModal('Benutzer deaktivieren', `Möchten Sie ${user.name} wirklich deaktivieren?`, async () => {

View File

@@ -12,11 +12,15 @@
</div>
<div class="space-x-3">
<button
@click="saveConfig"
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 disabled:opacity-50 disabled:cursor-not-allowed text-sm sm:text-base"
:disabled="isSaving"
@click="saveConfig"
>
<Loader2 v-if="isSaving" :size="16" class="animate-spin mr-2" />
<Loader2
v-if="isSaving"
:size="16"
class="animate-spin mr-2"
/>
{{ isSaving ? 'Speichern...' : 'Speichern' }}
</button>
</div>
@@ -27,28 +31,40 @@
<!-- Content with top padding -->
<div class="pt-28 sm:pt-32 pb-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<Loader2 :size="40" class="animate-spin text-primary-600" />
<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="space-y-6">
<div
v-else
class="space-y-6"
>
<!-- Tabs -->
<div class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<div class="flex border-b border-gray-200">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="[
'flex-1 px-6 py-4 text-sm font-medium transition-colors',
activeTab === tab.id
? 'bg-primary-600 text-white'
: 'bg-gray-50 text-gray-700 hover:bg-gray-100'
]"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="18" class="inline mr-2" />
<component
:is="tab.icon"
:size="18"
class="inline mr-2"
/>
{{ tab.label }}
</button>
</div>
@@ -57,7 +73,9 @@
<div class="p-8">
<!-- Vereinsdaten -->
<div v-if="activeTab === 'verein'">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Vereinsdaten</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Vereinsdaten
</h2>
<div class="space-y-6">
<div>
@@ -66,7 +84,7 @@
v-model="config.verein.name"
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"
/>
>
</div>
<div class="p-4 bg-blue-50 rounded-lg border border-blue-200">
@@ -75,7 +93,7 @@
v-model="config.verein.useVorsitzenderAddress"
type="checkbox"
class="w-5 h-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
/>
>
<span class="ml-3 text-sm font-medium text-gray-900">
Adresse des 1. Vorsitzenden als Vereinsadresse verwenden
</span>
@@ -85,8 +103,13 @@
</p>
</div>
<div v-if="!config.verein.useVorsitzenderAddress" class="p-6 bg-gray-50 rounded-lg border border-gray-200">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Separate Vereinsadresse</h3>
<div
v-if="!config.verein.useVorsitzenderAddress"
class="p-6 bg-gray-50 rounded-lg border border-gray-200"
>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Separate Vereinsadresse
</h3>
<div class="grid sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">Straße & Hausnummer</label>
@@ -94,7 +117,7 @@
v-model="config.verein.strasse"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">PLZ</label>
@@ -102,7 +125,7 @@
v-model="config.verein.plz"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ort</label>
@@ -110,7 +133,7 @@
v-model="config.verein.ort"
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"
/>
>
</div>
</div>
</div>
@@ -119,11 +142,15 @@
<!-- Trainingszeiten -->
<div v-if="activeTab === 'training'">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Trainingszeiten & Trainingsort</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Trainingszeiten & Trainingsort
</h2>
<!-- Trainingsort -->
<div class="mb-8 p-6 bg-gray-50 rounded-lg">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Trainingsort</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Trainingsort
</h3>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
@@ -131,7 +158,7 @@
v-model="config.training.ort.name"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Straße</label>
@@ -139,7 +166,7 @@
v-model="config.training.ort.strasse"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">PLZ</label>
@@ -147,7 +174,7 @@
v-model="config.training.ort.plz"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ort</label>
@@ -155,7 +182,7 @@
v-model="config.training.ort.ort"
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"
/>
>
</div>
</div>
</div>
@@ -163,12 +190,17 @@
<!-- Trainingszeiten -->
<div>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">Trainingszeiten</h3>
<h3 class="text-lg font-semibold text-gray-900">
Trainingszeiten
</h3>
<button
@click="addTrainingTime"
class="flex items-center px-3 py-1 bg-primary-600 hover:bg-primary-700 text-white text-sm rounded-lg transition-colors"
@click="addTrainingTime"
>
<Plus :size="16" class="mr-1" />
<Plus
:size="16"
class="mr-1"
/>
Zeit hinzufügen
</button>
</div>
@@ -201,7 +233,7 @@
v-model="zeit.von"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Bis</label>
@@ -209,20 +241,30 @@
v-model="zeit.bis"
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"
/>
>
</div>
<div class="sm:col-span-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Gruppe</label>
<div class="flex space-x-2">
<input
v-model="zeit.gruppe"
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"
>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Zusätzliche Information (optional)</label>
<div class="flex space-x-2">
<input
v-model="zeit.info"
type="text"
placeholder="z.B. 'Nur in der Schulzeit', 'Ab 10 Jahren', etc."
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
>
<button
@click="removeTrainingTime(index)"
class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Löschen"
@click="removeTrainingTime(index)"
>
<Trash2 :size="18" />
</button>
@@ -232,17 +274,21 @@
</div>
</div>
</div>
</div>
<!-- Trainer -->
<div v-if="activeTab === 'trainer'">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-display font-bold text-gray-900">Trainer</h2>
<h2 class="text-2xl font-display font-bold text-gray-900">
Trainer
</h2>
<button
@click="addTrainer"
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="addTrainer"
>
<Plus :size="20" class="mr-2" />
<Plus
:size="20"
class="mr-2"
/>
Trainer hinzufügen
</button>
</div>
@@ -260,7 +306,7 @@
v-model="trainer.name"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Lizenz</label>
@@ -268,7 +314,7 @@
v-model="trainer.lizenz"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Schwerpunkt</label>
@@ -276,7 +322,7 @@
v-model="trainer.schwerpunkt"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Zusatzinfo</label>
@@ -285,16 +331,22 @@
v-model="trainer.zusatz"
type="text"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
>
<button
@click="removeTrainer(index)"
class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Löschen"
@click="removeTrainer(index)"
>
<Trash2 :size="18" />
</button>
</div>
</div>
<div class="sm:col-span-2">
<ImageUpload
v-model="trainer.imageFilename"
label="Foto"
/>
</div>
</div>
</div>
</div>
@@ -303,12 +355,17 @@
<!-- Mitgliedschaft -->
<div v-if="activeTab === 'mitgliedschaft'">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-display font-bold text-gray-900">Mitgliedschaftsoptionen</h2>
<h2 class="text-2xl font-display font-bold text-gray-900">
Mitgliedschaftsoptionen
</h2>
<button
@click="addMembership"
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="addMembership"
>
<Plus :size="20" class="mr-2" />
<Plus
:size="20"
class="mr-2"
/>
Option hinzufügen
</button>
</div>
@@ -326,7 +383,7 @@
v-model="membership.typ"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
@@ -334,7 +391,7 @@
v-model="membership.beschreibung"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Preis (/Jahr)</label>
@@ -343,11 +400,11 @@
v-model.number="membership.preis"
type="number"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
>
<button
@click="removeMembership(index)"
class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Löschen"
@click="removeMembership(index)"
>
<Trash2 :size="18" />
</button>
@@ -368,19 +425,22 @@
v-model="membership.features[fIndex]"
type="text"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
>
<button
@click="membership.features.splice(fIndex, 1)"
class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
@click="membership.features.splice(fIndex, 1)"
>
<X :size="18" />
</button>
</div>
<button
@click="membership.features.push('')"
class="flex items-center px-3 py-1 text-primary-600 hover:bg-primary-50 rounded-lg transition-colors text-sm"
@click="membership.features.push('')"
>
<Plus :size="16" class="mr-1" />
<Plus
:size="16"
class="mr-1"
/>
Leistung hinzufügen
</button>
</div>
@@ -391,7 +451,9 @@
<!-- Vorstand -->
<div v-if="activeTab === 'vorstand'">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Vorstandsdaten</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Vorstandsdaten
</h2>
<div class="space-y-8">
<div
@@ -409,7 +471,7 @@
v-model="position.vorname"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nachname</label>
@@ -417,7 +479,7 @@
v-model="position.nachname"
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"
/>
>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">Straße & Hausnummer</label>
@@ -425,7 +487,7 @@
v-model="position.strasse"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">PLZ</label>
@@ -433,7 +495,7 @@
v-model="position.plz"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ort</label>
@@ -441,7 +503,7 @@
v-model="position.ort"
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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
@@ -449,7 +511,7 @@
v-model="position.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"
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
@@ -457,6 +519,12 @@
v-model="position.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 class="sm:col-span-2">
<ImageUpload
v-model="position.imageFilename"
label="Foto"
/>
</div>
</div>
@@ -467,16 +535,27 @@
</div>
<!-- Error/Success Messages -->
<div v-if="errorMessage" class="flex items-center p-4 rounded-lg bg-red-50 text-red-700">
<AlertCircle :size="20" class="mr-2" />
<div
v-if="errorMessage"
class="flex items-center p-4 rounded-lg bg-red-50 text-red-700"
>
<AlertCircle
:size="20"
class="mr-2"
/>
{{ errorMessage }}
</div>
<div v-if="successMessage" class="flex items-center p-4 rounded-lg bg-green-50 text-green-700">
<Check :size="20" class="mr-2" />
<div
v-if="successMessage"
class="flex items-center p-4 rounded-lg bg-green-50 text-green-700"
>
<Check
:size="20"
class="mr-2"
/>
{{ successMessage }}
</div>
</div>
</div>
</div>
@@ -536,13 +615,17 @@ const saveConfig = async () => {
})
successMessage.value = 'Konfiguration erfolgreich gespeichert!'
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Konfiguration erfolgreich gespeichert!') } catch (e) {}
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Konfiguration erfolgreich gespeichert!') } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
setTimeout(() => {
successMessage.value = ''
}, 3000)
} catch (error) {
errorMessage.value = error.data?.message || 'Fehler beim Speichern der Konfiguration.'
try { window.showErrorModal && window.showErrorModal('Fehler', errorMessage.value) } catch (e) {}
try { window.showErrorModal && window.showErrorModal('Fehler', errorMessage.value) } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
} finally {
isSaving.value = false
}
@@ -572,7 +655,8 @@ const addTrainingTime = () => {
tag: 'Montag',
von: '19:00',
bis: '22:00',
gruppe: `Gruppe ${naechsteGruppeNummer}`
gruppe: `Gruppe ${naechsteGruppeNummer}`,
info: ''
})
}

View File

@@ -4,46 +4,126 @@
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">Geschichte bearbeiten</h1>
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
Geschichte bearbeiten
</h1>
<div class="space-x-3">
<button @click="save" class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base">Speichern</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>
<!-- 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="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>
<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>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertUnorderedList')"
>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertOrderedList')"
>
1.
</button>
</div>
<!-- Schnellzugriff für Geschichts-Abschnitte -->
<div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertHistoryTemplate('generic')">Neuer Abschnitt</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertHistoryTemplate('founding')">Gründung</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertHistoryTemplate('milestone')">Meilenstein</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertHistoryTemplate('achievement')">Erfolg</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentSection()">Abschnitt löschen</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
@click="insertHistoryTemplate('generic')"
>
Neuer Abschnitt
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
@click="insertHistoryTemplate('founding')"
>
Gründung
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
@click="insertHistoryTemplate('milestone')"
>
Meilenstein
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
@click="insertHistoryTemplate('achievement')"
>
Erfolg
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
@click="deleteCurrentSection()"
>
Abschnitt löschen
</button>
</div>
<!-- Weitere Tools -->
<div class="flex items-center gap-1">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button>
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button>
<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>
@@ -52,7 +132,6 @@
<!-- 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"
@@ -89,9 +168,13 @@ async function save() {
const updated = { ...current, seiten: { ...(current.seiten || {}), geschichte: html } }
try {
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch (e) {}
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch (e) {}
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
}
}

View File

@@ -14,9 +14,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center group-hover:bg-indigo-600 transition-colors">
<Newspaper :size="24" class="text-indigo-600 group-hover:text-white" />
<Newspaper
:size="24"
class="text-indigo-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Über uns</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Über uns
</h2>
</div>
<p class="text-gray-600">
Seite Über uns" bearbeiten (WYSIWYG)
@@ -30,9 +35,14 @@
>
<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" />
<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>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Geschichte
</h2>
</div>
<p class="text-gray-600">
Vereinsgeschichte bearbeiten (WYSIWYG)
@@ -46,9 +56,14 @@
>
<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" />
<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>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
TT-Regeln
</h2>
</div>
<p class="text-gray-600">
Tischtennis-Regeln bearbeiten (WYSIWYG)
@@ -62,11 +77,23 @@
>
<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
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>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Satzung
</h2>
</div>
<p class="text-gray-600">
Satzung als PDF hochladen
@@ -79,9 +106,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-600 transition-colors">
<Newspaper :size="24" class="text-blue-600 group-hover:text-white" />
<Newspaper
:size="24"
class="text-blue-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">News</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
News
</h2>
</div>
<p class="text-gray-600">
News erstellen und verwalten (intern und öffentlich)
@@ -95,9 +127,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-600 transition-colors">
<Calendar :size="24" class="text-green-600 group-hover:text-white" />
<Calendar
:size="24"
class="text-green-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Termine</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Termine
</h2>
</div>
<p class="text-gray-600">
Vereinstermine erstellen und verwalten
@@ -111,9 +148,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-600 transition-colors">
<Users :size="24" class="text-purple-600 group-hover:text-white" />
<Users
:size="24"
class="text-purple-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Mitglieder</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Mitglieder
</h2>
</div>
<p class="text-gray-600">
Mitgliederliste bearbeiten
@@ -127,9 +169,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center group-hover:bg-orange-600 transition-colors">
<Settings :size="24" class="text-orange-600 group-hover:text-white" />
<Settings
:size="24"
class="text-orange-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Einstellungen</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Einstellungen
</h2>
</div>
<p class="text-gray-600">
Training, Trainer, Mitgliedschaft & Vorstand
@@ -138,15 +185,20 @@
<!-- Benutzerverwaltung (nur für Admin) -->
<NuxtLink
v-if="authStore.role === 'admin'"
v-if="authStore.hasRole('admin')"
to="/cms/benutzer"
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-yellow-100 rounded-lg flex items-center justify-center group-hover:bg-yellow-600 transition-colors">
<UserCog :size="24" class="text-yellow-600 group-hover:text-white" />
<UserCog
:size="24"
class="text-yellow-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Benutzerverwaltung</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Benutzerverwaltung
</h2>
</div>
<p class="text-gray-600">
Benutzer freischalten und verwalten

View File

@@ -8,9 +8,9 @@
Mitgliedschaftsanträge
</h1>
<button
@click="refreshApplications"
: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>
@@ -22,20 +22,37 @@
<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"></div>
<p class="mt-4 text-gray-600">Lade Anträge...</p>
<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
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-else
class="space-y-6"
>
<div
v-for="application in applications"
:key="application.id"
@@ -66,32 +83,42 @@
<!-- Actions -->
<div class="flex space-x-2">
<button
@click="viewApplication(application)"
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"
@click="downloadPDF(application.id)"
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"></path>
<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'"
@click="approveApplication(application.id)"
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'"
@click="rejectApplication(application.id)"
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>
@@ -104,7 +131,9 @@
<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>
<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">
@@ -117,7 +146,9 @@
</div>
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
<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>
@@ -144,11 +175,21 @@
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
</h2>
<button
@click="closeModal"
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"></path>
<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>
@@ -158,7 +199,9 @@
<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>
<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>
@@ -173,7 +216,9 @@
<!-- Application Details -->
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
<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>
@@ -187,32 +232,42 @@
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<button
@click="closeModal"
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"
@click="downloadPDF(selectedApplication.id)"
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"></path>
<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'"
@click="approveApplication(selectedApplication.id)"
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'"
@click="rejectApplication(selectedApplication.id)"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
@click="rejectApplication(selectedApplication.id)"
>
Ablehnen
</button>

1049
pages/cms/newsletter.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,25 +2,36 @@
<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>
<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>
<h2 class="text-xl font-semibold mb-4">
PDF-Upload
</h2>
<form @submit.prevent="uploadPdf" enctype="multipart/form-data" class="space-y-4">
<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">
<label
for="pdf-file"
class="block text-sm font-medium text-gray-700 mb-2"
>
Neue Satzung hochladen (PDF)
</label>
<input
ref="fileInput"
id="pdf-file"
ref="fileInput"
type="file"
accept=".pdf"
@change="handleFileSelect"
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>
@@ -31,20 +42,44 @@
: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"></circle>
<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"></path>
<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
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>
<p class="text-gray-600">
PDF-Datei verfügbar
</p>
<a
:href="currentPdfUrl"
target="_blank"
@@ -59,7 +94,11 @@
</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'">
<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>

View File

@@ -4,15 +4,35 @@
<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>
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
Spielpläne bearbeiten
</h1>
<div class="space-x-3">
<button @click="showUploadModal = true" 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">
<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"></path>
<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 @click="save" class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base">Speichern</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>
@@ -21,24 +41,45 @@
<!-- 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>
<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
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"></path>
<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>
<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 @click="removeFile" class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors">
<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>
@@ -46,46 +87,75 @@
<!-- 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"
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 }"
>
<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"></path>
<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>
<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"
@change="handleFileSelect"
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
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
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">
>
<label
:for="`column-${index}`"
class="ml-3 text-sm font-medium text-gray-900"
>
{{ header }}
</label>
</div>
@@ -100,18 +170,29 @@
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
</div>
<div class="space-x-3">
<button @click="selectAllColumns" class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors">
<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 @click="deselectAllColumns" class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors">
<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 @click="suggestHalleColumns" class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors">
<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 @click="confirmColumnSelection"
<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">
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>
@@ -119,14 +200,25 @@
</div>
<!-- Data Preview -->
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6">
<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>
<h2 class="text-xl font-semibold text-gray-900">
Datenvorschau
</h2>
<div class="flex space-x-2">
<button @click="exportCSV" class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors">
<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 @click="clearData" class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors">
<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>
@@ -137,17 +229,26 @@
<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">
<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">
<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>
@@ -155,7 +256,10 @@
</table>
</div>
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">
<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>
@@ -166,12 +270,29 @@
</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"></path>
<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>
<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>
@@ -183,7 +304,9 @@
@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>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
CSV-Datei hochladen
</h3>
<div class="space-y-4">
<div>
@@ -192,18 +315,27 @@
ref="modalFileInput"
type="file"
accept=".csv"
@change="handleModalFileSelect"
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
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>
<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>
@@ -215,15 +347,15 @@
<div class="flex justify-end space-x-3 pt-4">
<button
@click="closeUploadModal"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeUploadModal"
>
Abbrechen
</button>
<button
@click="processSelectedFile"
: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>
@@ -237,9 +369,13 @@
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"></div>
<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 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>
@@ -325,16 +461,6 @@ const processFile = async (file) => {
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
columnsSelected.value = false
// Debug: Zeige verfügbare Spalten
console.log('Verfügbare Spalten:', csvHeaders.value)
const halleSpalten = csvHeaders.value.filter(header =>
header.toLowerCase().includes('halle') ||
header.toLowerCase().includes('strasse') ||
header.toLowerCase().includes('plz') ||
header.toLowerCase().includes('ort')
)
console.log('Halle-Spalten gefunden:', halleSpalten)
// Datei-Info speichern
currentFile.value = {
name: file.name,
@@ -549,8 +675,8 @@ const loadExistingData = async () => {
}
}
}
} catch (error) {
console.log('Keine bestehende Spielplan-Datei gefunden')
} catch (_error) {
// Fehler beim Laden der Datei, ignorieren
}
}
</script>

View File

@@ -9,35 +9,63 @@
<div class="w-24 h-1 bg-primary-600 mb-4" />
</div>
<button
@click="openAddModal"
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" />
<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
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
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>
<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">
<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>
@@ -66,16 +94,16 @@
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button
@click="openEditModal(termin)"
class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(termin)"
>
<Pencil :size="18" />
</button>
<button
@click="confirmDelete(termin)"
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(termin)"
>
<Trash2 :size="18" />
</button>
@@ -85,7 +113,10 @@
</table>
</div>
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">
<div
v-if="termine.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Termine vorhanden.
</div>
</div>
@@ -101,7 +132,10 @@
{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
</h2>
<form @submit.prevent="saveTermin" class="space-y-4">
<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>
@@ -111,7 +145,7 @@
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>
@@ -120,7 +154,7 @@
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>
@@ -131,11 +165,21 @@
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>
<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>
@@ -148,7 +192,7 @@
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>
@@ -161,17 +205,23 @@
/>
</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" />
<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"
@click="closeModal"
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>
@@ -180,7 +230,11 @@
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" />
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>

View File

@@ -4,58 +4,142 @@
<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>
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
TT-Regeln bearbeiten
</h1>
<div class="space-x-3">
<button @click="save" class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base">Speichern</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>
<!-- 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="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>
<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>
<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>
<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>
<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="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>
<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">
@@ -67,7 +151,9 @@
<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>
<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>
@@ -107,9 +193,13 @@ async function save() {
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 (e) {}
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Regeln erfolgreich gespeichert!') } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch (e) {}
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
}
}

View File

@@ -4,9 +4,16 @@
<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>
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
Über uns bearbeiten
</h1>
<div class="space-x-3">
<button @click="save" class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base">Speichern</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>
@@ -16,15 +23,60 @@
<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>
<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>
@@ -32,7 +84,6 @@
<!-- 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"
@@ -69,9 +120,13 @@ async function save() {
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 (e) {}
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
} catch (error) {
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch (e) {}
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch (_e) {
// Modal nicht verfügbar, ignorieren
}
}
}

View File

@@ -4,15 +4,35 @@
<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">Vereinsmeisterschaften bearbeiten</h1>
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
Vereinsmeisterschaften bearbeiten
</h1>
<div class="space-x-3">
<button @click="addNewResult" 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">
<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 6v6m0 0v6m0-6h6m-6 0H6"></path>
<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="addNewResult"
>
<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 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Neues Ergebnis
</button>
<button @click="save" class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base">Speichern</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>
@@ -21,54 +41,58 @@
<!-- 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">
<!-- Filter -->
<div class="mb-8 flex flex-wrap gap-4">
<button
v-for="jahr in verfuegbareJahre"
:key="jahr"
@click="selectedYear = jahr"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
selectedYear === jahr
? 'bg-primary-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
]"
@click="selectedYear = jahr"
>
{{ jahr }}
</button>
<button
@click="selectedYear = 'alle'"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
selectedYear === 'alle'
? 'bg-primary-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
]"
@click="selectedYear = 'alle'"
>
Alle Jahre
</button>
</div>
<!-- Ergebnisse -->
<div v-if="filteredResults.length > 0" class="space-y-8">
<div
v-if="filteredResults.length > 0"
class="space-y-8"
>
<div
v-for="entry in sortedGroupedResults"
:key="entry.jahr"
class="bg-white rounded-xl shadow-lg p-6"
>
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-display font-bold text-gray-900">{{ entry.jahr }}</h2>
<h2 class="text-2xl font-display font-bold text-gray-900">
{{ entry.jahr }}
</h2>
<div class="flex space-x-2">
<button
@click="addResultForYear(entry.jahr)"
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
@click="addResultForYear(entry.jahr)"
>
Ergebnis hinzufügen
</button>
<button
@click="deleteYear(entry.jahr)"
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="deleteYear(entry.jahr)"
>
Jahr löschen
</button>
@@ -76,12 +100,17 @@
</div>
<!-- Besondere Bemerkungen -->
<div v-if="entry.data.bemerkungen" class="mb-6 p-4 bg-yellow-50 border-l-4 border-yellow-400 rounded-r-lg">
<div
v-if="entry.data.bemerkungen"
class="mb-6 p-4 bg-yellow-50 border-l-4 border-yellow-400 rounded-r-lg"
>
<div class="flex items-center justify-between">
<p class="text-gray-700 font-medium">{{ entry.data.bemerkungen }}</p>
<p class="text-gray-700 font-medium">
{{ entry.data.bemerkungen }}
</p>
<button
@click="editBemerkung(entry.jahr)"
class="px-2 py-1 text-xs bg-yellow-100 hover:bg-yellow-200 text-yellow-700 rounded transition-colors"
@click="editBemerkung(entry.jahr)"
>
Bearbeiten
</button>
@@ -89,24 +118,29 @@
</div>
<!-- Kategorien -->
<div v-if="Object.keys(entry.data.kategorien).length > 0" class="space-y-6">
<div
v-if="Object.keys(entry.data.kategorien).length > 0"
class="space-y-6"
>
<div
v-for="(kategorieResults, kategorie) in entry.data.kategorien"
:key="kategorie"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">{{ kategorie }}</h3>
<h3 class="text-lg font-semibold text-gray-900">
{{ kategorie }}
</h3>
<div class="flex space-x-2">
<button
@click="addResultForKategorie(entry.jahr, kategorie)"
class="px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 rounded transition-colors"
@click="addResultForKategorie(entry.jahr, kategorie)"
>
Ergebnis hinzufügen
</button>
<button
@click="deleteKategorie(entry.jahr, kategorie)"
class="px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 rounded transition-colors"
@click="deleteKategorie(entry.jahr, kategorie)"
>
Kategorie löschen
</button>
@@ -123,21 +157,49 @@
<span class="w-8 h-8 bg-primary-600 text-white rounded-full flex items-center justify-center text-sm font-bold">
{{ result.platz }}
</span>
<div>
<div class="flex items-center gap-2">
<div
v-if="result.imageFilename1"
class="flex-shrink-0"
>
<img
:src="`/api/personen/${result.imageFilename1}?width=32&height=32`"
:alt="result.spieler1"
class="w-8 h-8 rounded-full object-cover border border-gray-300"
loading="lazy"
>
</div>
<span class="font-medium text-gray-900">{{ result.spieler1 }}</span>
<span v-if="result.spieler2" class="text-gray-600"> & {{ result.spieler2 }}</span>
<span
v-if="result.spieler2"
class="text-gray-600 flex items-center gap-2"
>
&
<div
v-if="result.imageFilename2"
class="flex-shrink-0"
>
<img
:src="`/api/personen/${result.imageFilename2}?width=32&height=32`"
:alt="result.spieler2"
class="w-8 h-8 rounded-full object-cover border border-gray-300"
loading="lazy"
>
</div>
{{ result.spieler2 }}
</span>
</div>
</div>
<div class="flex space-x-2">
<button
@click="editResult(result, entry.jahr, kategorie, index)"
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 rounded transition-colors"
@click="editResult(result, entry.jahr, kategorie, index)"
>
Bearbeiten
</button>
<button
@click="deleteResult(entry.jahr, kategorie, index)"
class="px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 rounded transition-colors"
@click="deleteResult(entry.jahr, kategorie, index)"
>
Löschen
</button>
@@ -149,14 +211,29 @@
</div>
</div>
<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 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
<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 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
<p class="text-gray-600">Keine Ergebnisse vorhanden.</p>
<p class="text-gray-600">
Keine Ergebnisse vorhanden.
</p>
<button
@click="addNewResult"
class="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
@click="addNewResult"
>
Erstes Ergebnis hinzufügen
</button>
@@ -170,12 +247,19 @@
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-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
<div class="bg-white rounded-lg max-w-md w-full max-h-[90vh] flex flex-col">
<div class="p-6 border-b border-gray-200 flex-shrink-0">
<h3 class="text-lg font-semibold text-gray-900">
{{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }}
</h3>
</div>
<form @submit.prevent="saveResult" class="space-y-4">
<div class="overflow-y-auto flex-1 p-6">
<form
id="result-form"
class="space-y-4"
@submit.prevent="saveResult"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Jahr</label>
<input
@@ -183,7 +267,7 @@
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
@@ -193,12 +277,24 @@
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Kategorie wählen</option>
<option value="Einzel">Einzel</option>
<option value="Doppel">Doppel</option>
<option value="Mixed">Mixed</option>
<option value="Jugend">Jugend</option>
<option value="Senioren">Senioren</option>
<option value="">
Kategorie wählen
</option>
<option value="Einzel">
Einzel
</option>
<option value="Doppel">
Doppel
</option>
<option value="Mixed">
Mixed
</option>
<option value="Jugend">
Jugend
</option>
<option value="Senioren">
Senioren
</option>
</select>
</div>
@@ -209,11 +305,21 @@
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Platz wählen</option>
<option value="1">1. Platz</option>
<option value="2">2. Platz</option>
<option value="3">3. Platz</option>
<option value="4">4. Platz</option>
<option value="">
Platz wählen
</option>
<option value="1">
1. Platz
</option>
<option value="2">
2. Platz
</option>
<option value="3">
3. Platz
</option>
<option value="4">
4. Platz
</option>
</select>
</div>
@@ -224,7 +330,7 @@
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div v-if="formData.kategorie === 'Doppel' || formData.kategorie === 'Mixed'">
@@ -233,6 +339,20 @@
v-model="formData.spieler2"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
</div>
<div>
<ImageUpload
v-model="formData.imageFilename1"
label="Foto Spieler 1"
/>
</div>
<div v-if="formData.kategorie === 'Doppel' || formData.kategorie === 'Mixed'">
<ImageUpload
v-model="formData.imageFilename2"
label="Foto Spieler 2"
/>
</div>
@@ -242,25 +362,27 @@
v-model="formData.bemerkung"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
></textarea>
/>
</div>
</form>
</div>
<div class="flex justify-end space-x-3 pt-4">
<div class="p-6 border-t border-gray-200 flex-shrink-0 flex justify-end space-x-3">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
form="result-form"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
</button>
</div>
</form>
</div>
</div>
@@ -271,23 +393,28 @@
@click.self="closeBemerkungModal"
>
<div class="bg-white rounded-lg max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Bemerkung bearbeiten</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Bemerkung bearbeiten
</h3>
<form @submit.prevent="saveBemerkung" class="space-y-4">
<form
class="space-y-4"
@submit.prevent="saveBemerkung"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Bemerkung</label>
<textarea
v-model="bemerkungText"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
></textarea>
/>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@click="closeBemerkungModal"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeBemerkungModal"
>
Abbrechen
</button>
@@ -330,7 +457,9 @@ const formData = ref({
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
})
const loadResults = async () => {
@@ -370,6 +499,7 @@ const loadResults = async () => {
}
values.push(current.trim())
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
if (values.length < 6) return null
return {
@@ -378,7 +508,9 @@ const loadResults = async () => {
platz: values[2].trim(),
spieler1: values[3].trim(),
spieler2: values[4].trim(),
bemerkung: values[5].trim()
bemerkung: values[5].trim(),
imageFilename1: values[6]?.trim() || '',
imageFilename2: values[7]?.trim() || ''
}
}).filter(result => result !== null)
} catch (error) {
@@ -445,7 +577,9 @@ const addNewResult = () => {
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
}
showModal.value = true
}
@@ -461,7 +595,9 @@ const addResultForYear = (jahr) => {
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
}
showModal.value = true
}
@@ -477,7 +613,9 @@ const addResultForKategorie = (jahr, kategorie) => {
platz: '',
spieler1: '',
spieler2: '',
bemerkung: ''
bemerkung: '',
imageFilename1: '',
imageFilename2: ''
}
showModal.value = true
}
@@ -493,7 +631,9 @@ const editResult = (result, jahr, kategorie, index) => {
platz: result.platz,
spieler1: result.spieler1,
spieler2: result.spieler2,
bemerkung: result.bemerkung
bemerkung: result.bemerkung,
imageFilename1: result.imageFilename1 || '',
imageFilename2: result.imageFilename2 || ''
}
showModal.value = true
}
@@ -649,7 +789,7 @@ const closeBemerkungModal = () => {
const save = async () => {
try {
// CSV generieren
const csvHeader = 'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung'
const csvHeader = 'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2'
const csvRows = results.value.map(result => {
return [
result.jahr,
@@ -657,7 +797,9 @@ const save = async () => {
result.platz,
result.spieler1,
result.spieler2,
result.bemerkung
result.bemerkung,
result.imageFilename1 || '',
result.imageFilename2 || ''
].map(field => `"${field}"`).join(',')
})

View File

@@ -28,7 +28,9 @@
<div class="space-y-6">
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">10.06.1954 - Gründung des HTC</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
10.06.1954 - Gründung des HTC
</h3>
<p class="text-gray-600 mb-3">
Bei der am 20.05.1954 stattgefundenen Sitzung der SGH wurde die Trennung der einzelnen Abteilungen beschlossen.
Somit sah sich die TT-Abteilung veranlasst, ihren Sportbetrieb in eigener Regie weiterzuführen.
@@ -40,7 +42,9 @@
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">1964 - Neue Trainingsstätte</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
1964 - Neue Trainingsstätte
</h3>
<p class="text-gray-600">
Mit der Erbauung der Schulturnhalle im Jahre 1964 stand eine für die damaligen Verhältnisse recht moderne
Übungsstätte zur Verfügung, die dem HTC für einen Tag in der Woche überlassen wurde. Damit waren viele
@@ -49,7 +53,9 @@
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">1974 - Bürgerhaus</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
1974 - Bürgerhaus
</h3>
<p class="text-gray-600">
Mit der Erstellung des Bürgerhauses wurde wiederum neuer Trainingsraum geschaffen, der besonders für den
Tischtennissport geeignet ist. Der HTC nahm die Gelegenheit war und hielt ab Mai 1974 seine Übungsabende
@@ -58,7 +64,9 @@
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">1976 - Eintragung ins Vereinsregister</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
1976 - Eintragung ins Vereinsregister
</h3>
<p class="text-gray-600">
Die Eintragung in das Vereinsregister (e. V.) erfolgte im Jahre 1976 und gleichzeitig wurde dem Verein
die Gemeinnützigkeit zuerkannt.
@@ -66,7 +74,9 @@
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">1978/79 - Sportlicher Höhepunkt</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
1978/79 - Sportlicher Höhepunkt
</h3>
<p class="text-gray-600">
Ein besonderes Geschenk machten die Spieler des HTC im Jubiläumsjahr ihrem Verein: Die 1. Herrenmannschaft
wurde Meister der Bezirksklasse Ffm.-Ost und die 2. Herrenmannschaft Meister der Kreisklasse-A Ffm.-Nord.
@@ -76,7 +86,9 @@
</div>
<div class="bg-white p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">Heute</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
Heute
</h3>
<p class="text-gray-600">
Der HTC hat sich auch in Zukunft zur Aufgabe gemacht, allen interessierten Bürgern und Jugendlichen im
Rahmen seiner Möglichkeiten das Tischtennisspielen als Leistungssport oder zur Freizeitgestaltung zu ermöglichen.

View File

@@ -8,52 +8,65 @@
<div class="bg-white p-8 rounded-xl shadow-lg space-y-6">
<div v-if="config">
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Angaben gemäß § 5 TMG</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Angaben gemäß § 5 TMG
</h2>
<p class="text-gray-700">
{{ config.verein.name }}<br />
{{ config.verein.name }}<br>
<template v-if="config.verein.useVorsitzenderAddress">
{{ config.vorstand.vorsitzender.strasse }}<br />
{{ config.vorstand.vorsitzender.strasse }}<br>
{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}
</template>
<template v-else>
{{ config.verein.strasse }}<br />
{{ config.verein.strasse }}<br>
{{ config.verein.plz }} {{ config.verein.ort }}
</template>
</p>
</div>
<div v-if="config">
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Kontakt</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Kontakt
</h2>
<p class="text-gray-700">
Telefon: {{ config.vorstand.vorsitzender.telefon }}<br />
E-Mail: <a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">{{ config.vorstand.vorsitzender.email }}</a><br />
Telefon: {{ config.vorstand.vorsitzender.telefon }}<br>
E-Mail: <a
:href="`mailto:${config.vorstand.vorsitzender.email}`"
class="text-primary-600 hover:underline"
>{{ config.vorstand.vorsitzender.email }}</a><br>
Internet: www.harheimertc.de
</p>
</div>
<div v-if="config">
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Vertretungsberechtigter Vorstand</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Vertretungsberechtigter Vorstand
</h2>
<p class="text-gray-700">
<span v-if="config.vorstand.vorsitzender.vorname">{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}, Vorsitzender<br /></span>
<span v-if="config.vorstand.stellvertreter.vorname">{{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }}, Stellvertreter<br /></span>
<span v-if="config.vorstand.kassenwart.vorname">{{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }}, Kassenwart<br /></span>
<span v-if="config.vorstand.schriftfuehrer.vorname">{{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }}, Schriftführer<br /></span>
<span v-if="config.vorstand.sportwart.vorname">{{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }}, Sportwart<br /></span>
<span v-if="config.vorstand.vorsitzender.vorname">{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}, Vorsitzender<br></span>
<span v-if="config.vorstand.stellvertreter.vorname">{{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }}, Stellvertreter<br></span>
<span v-if="config.vorstand.kassenwart.vorname">{{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }}, Kassenwart<br></span>
<span v-if="config.vorstand.schriftfuehrer.vorname">{{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }}, Schriftführer<br></span>
<span v-if="config.vorstand.sportwart.vorname">{{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }}, Sportwart<br></span>
<span v-if="config.vorstand.jugendwart.vorname">{{ config.vorstand.jugendwart.vorname }} {{ config.vorstand.jugendwart.nachname }}, Jugendwart</span>
</p>
</div>
<div>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Registereintrag</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Registereintrag
</h2>
<p class="text-gray-700">
lsb h-Vereinsnummer: 24091<br />
Registereintrag: Amtsgericht Frankfurt am Main, Registergericht<br />
lsb h-Vereinsnummer: 24091<br>
Registereintrag: Amtsgericht Frankfurt am Main, Registergericht<br>
Registernummer: VR 6835
</p>
</div>
<div>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Vereinsatzung</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Vereinsatzung
</h2>
<p class="text-gray-700 mb-4">
Unsere aktuelle Vereinsatzung können Sie hier herunterladen oder online einsehen:
</p>
@@ -63,64 +76,91 @@
target="_blank"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<FileText :size="16" class="mr-2" />
<FileText
:size="16"
class="mr-2"
/>
Satzung herunterladen (PDF)
</a>
<NuxtLink
to="/satzung"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-900 font-medium rounded-lg transition-colors"
>
<Eye :size="16" class="mr-2" />
<Eye
:size="16"
class="mr-2"
/>
Online ansehen
</NuxtLink>
</div>
</div>
<div v-if="config">
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p class="text-gray-700">
{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}<br />
<span v-if="config.vorstand.vorsitzender.strasse">{{ config.vorstand.vorsitzender.strasse }}<br /></span>
{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}<br>
<span v-if="config.vorstand.vorsitzender.strasse">{{ config.vorstand.vorsitzender.strasse }}<br></span>
<span v-if="config.vorstand.vorsitzender.plz">{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}</span>
</p>
</div>
<div v-if="config">
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Verantwortlich für die Website</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Verantwortlich für die Website
</h2>
<p class="text-gray-700">
{{ config.website.verantwortlicher.vorname }} {{ config.website.verantwortlicher.nachname }}<br />
E-Mail: <a :href="`mailto:${config.website.verantwortlicher.email}`" class="text-primary-600 hover:underline">{{ config.website.verantwortlicher.email }}</a>
{{ config.website.verantwortlicher.vorname }} {{ config.website.verantwortlicher.nachname }}<br>
E-Mail: <a
:href="`mailto:${config.website.verantwortlicher.email}`"
class="text-primary-600 hover:underline"
>{{ config.website.verantwortlicher.email }}</a>
</p>
</div>
<div>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Haftungsausschluss</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Haftungsausschluss
</h2>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">Haftung für Inhalte</h3>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">
Haftung für Inhalte
</h3>
<p class="text-gray-700 mb-4">
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. Grundsätzlich sind alle unsere Informationen ohne Gewähr. Auch für den Fall das unzutreffende oder falsche Informationen enthalten sind, wird vom HTC jegliche Haftung ausgeschlossen.
</p>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">Haftung für Links</h3>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">
Haftung für Links
</h3>
<p class="text-gray-700 mb-4">
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Eine Haftung für Schäden, die ggf. durch das Aufrufen dieser Seiten, bzw. deren Inhalte entstehen, wird vom HTC nicht übernommen. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.
</p>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">Urheberrecht</h3>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">
Urheberrecht
</h3>
<p class="text-gray-700 mb-4">
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.
</p>
</div>
<div>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">Datenschutzerklärung</h2>
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
Datenschutzerklärung
</h2>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">Datenschutz</h3>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">
Datenschutz
</h3>
<p class="text-gray-700 mb-4">
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung. Die Nutzung unserer Website ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben. Wir weisen darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.
</p>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">Widerspruch Werbe-Mails</h3>
<h3 class="font-semibold text-gray-900 mt-4 mb-2">
Widerspruch Werbe-Mails
</h3>
<p class="text-gray-700">
Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-E-Mails, vor.
</p>

View File

@@ -11,10 +11,16 @@
</div>
<div class="bg-white rounded-xl shadow-lg p-8">
<form @submit.prevent="handleLogin" class="space-y-6">
<form
class="space-y-6"
@submit.prevent="handleLogin"
>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail-Adresse
</label>
<input
@@ -26,12 +32,15 @@
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"
:class="{ 'border-red-500': errorMessage }"
placeholder="ihre-email@example.com"
/>
>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="password"
class="block text-sm font-medium text-gray-700 mb-2"
>
Passwort
</label>
<input
@@ -43,21 +52,33 @@
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"
:class="{ 'border-red-500': errorMessage }"
placeholder="••••••••"
/>
>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
<AlertCircle
:size="18"
class="mr-2"
/>
{{ errorMessage }}
</p>
</div>
<!-- Success Message -->
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
<div
v-if="successMessage"
class="bg-green-50 border border-green-200 rounded-lg p-4"
>
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
<Check
:size="18"
class="mr-2"
/>
{{ successMessage }}
</p>
</div>
@@ -68,7 +89,11 @@
:disabled="isLoading"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<Loader2
v-if="isLoading"
:size="20"
class="mr-2 animate-spin"
/>
<span>{{ isLoading ? 'Anmeldung läuft...' : 'Anmelden' }}</span>
</button>
@@ -87,7 +112,10 @@
<!-- Info Box -->
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
<p class="text-sm text-primary-800 text-center">
<Lock :size="16" class="inline mr-1" />
<Lock
:size="16"
class="inline mr-1"
/>
Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.
</p>
</div>
@@ -125,7 +153,8 @@ const handleLogin = async () => {
// Redirect based on role
setTimeout(() => {
if (response.user.role === 'admin' || response.user.role === 'vorstand') {
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
router.push('/cms')
} else {
router.push('/mitgliederbereich')

View File

@@ -1,39 +1,46 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div v-if="mannschaft" class="space-y-8">
<div
v-if="mannschaft"
class="space-y-8"
>
<!-- Header -->
<div class="bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-8 text-white">
<h1 class="text-4xl font-display font-bold mb-2">
{{ mannschaft.mannschaft }}
</h1>
<p class="text-primary-100 text-xl">{{ mannschaft.liga }}</p>
<p class="text-primary-100 text-xl">
{{ mannschaft.liga }}
</p>
</div>
<!-- Liga-Info -->
<div class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Liga-Informationen</h2>
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
Liga-Informationen
</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Staffelleiter:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.staffelleiter }}</span>
</div>
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Telefon:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.telefon }}</span>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Heimspieltag:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.heimspieltag }}</span>
</div>
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<div class="w-2 h-2 bg-primary-600 rounded-full" />
<span class="text-gray-600">Spielsystem:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.spielsystem }}</span>
</div>
@@ -53,8 +60,13 @@
class="bg-gray-50 rounded-lg p-4 text-center"
:class="spieler === mannschaft.mannschaftsfuehrer ? 'ring-2 ring-primary-500 bg-primary-50' : ''"
>
<div class="font-semibold text-gray-900">{{ spieler }}</div>
<div v-if="spieler === mannschaft.mannschaftsfuehrer" class="text-xs text-primary-600 font-medium mt-1">
<div class="font-semibold text-gray-900">
{{ spieler }}
</div>
<div
v-if="spieler === mannschaft.mannschaftsfuehrer"
class="text-xs text-primary-600 font-medium mt-1"
>
Mannschaftsführer
</div>
</div>
@@ -63,7 +75,9 @@
<!-- Links -->
<div class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-semibold text-gray-900 mb-6">Weitere Informationen</h2>
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
Weitere Informationen
</h2>
<div class="text-center">
<a
v-if="mannschaft.weitere_informationen_link && mannschaft.weitere_informationen_link !== ''"
@@ -71,7 +85,10 @@
target="_blank"
class="inline-flex items-center px-8 py-4 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<BarChart :size="24" class="mr-3" />
<BarChart
:size="24"
class="mr-3"
/>
Weitere Informationen
</a>
</div>
@@ -95,9 +112,16 @@
</div>
</div>
<div v-else class="text-center py-16">
<h1 class="text-4xl font-display font-bold text-gray-900 mb-4">Mannschaft nicht gefunden</h1>
<p class="text-gray-600 mb-8">Die angeforderte Mannschaft konnte nicht gefunden werden.</p>
<div
v-else
class="text-center py-16"
>
<h1 class="text-4xl font-display font-bold text-gray-900 mb-4">
Mannschaft nicht gefunden
</h1>
<p class="text-gray-600 mb-8">
Die angeforderte Mannschaft konnte nicht gefunden werden.
</p>
<NuxtLink
to="/mannschaften"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"

View File

@@ -7,12 +7,20 @@
<div class="w-24 h-1 bg-primary-600 mb-8" />
<div class="bg-white p-8 rounded-xl shadow-lg">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">1. Damen</h3>
<p class="text-gray-600 mb-4">Liga: Bezirksliga</p>
<p class="text-gray-600 mb-6">Mannschaftsführerin: Name folgt</p>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">
1. Damen
</h3>
<p class="text-gray-600 mb-4">
Liga: Bezirksliga
</p>
<p class="text-gray-600 mb-6">
Mannschaftsführerin: Name folgt
</p>
<div class="mt-8">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Wir suchen Verstärkung!</h4>
<h4 class="text-lg font-semibold text-gray-900 mb-4">
Wir suchen Verstärkung!
</h4>
<p class="text-gray-600 mb-4">
Unsere Damenmannschaft freut sich über neue Spielerinnen. Interessiert? Dann melde dich bei uns!
</p>

View File

@@ -8,21 +8,39 @@
<div class="space-y-8">
<div class="bg-white p-8 rounded-xl shadow-lg">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">1. Herren</h3>
<p class="text-gray-600 mb-4">Liga: Bezirksoberliga</p>
<p class="text-gray-600">Mannschaftsführer: Name folgt</p>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">
1. Herren
</h3>
<p class="text-gray-600 mb-4">
Liga: Bezirksoberliga
</p>
<p class="text-gray-600">
Mannschaftsführer: Name folgt
</p>
</div>
<div class="bg-white p-8 rounded-xl shadow-lg">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">2. Herren</h3>
<p class="text-gray-600 mb-4">Liga: Bezirksliga</p>
<p class="text-gray-600">Mannschaftsführer: Name folgt</p>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">
2. Herren
</h3>
<p class="text-gray-600 mb-4">
Liga: Bezirksliga
</p>
<p class="text-gray-600">
Mannschaftsführer: Name folgt
</p>
</div>
<div class="bg-white p-8 rounded-xl shadow-lg">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">3. Herren</h3>
<p class="text-gray-600 mb-4">Liga: Kreisliga</p>
<p class="text-gray-600">Mannschaftsführer: Name folgt</p>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">
3. Herren
</h3>
<p class="text-gray-600 mb-4">
Liga: Kreisliga
</p>
<p class="text-gray-600">
Mannschaftsführer: Name folgt
</p>
</div>
</div>
</div>

View File

@@ -8,15 +8,27 @@
<div class="space-y-8">
<div class="bg-white p-8 rounded-xl shadow-lg">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">Jugend 1 (U18)</h3>
<p class="text-gray-600 mb-4">Liga: Bezirksliga</p>
<p class="text-gray-600">Betreuer: Name folgt</p>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">
Jugend 1 (U18)
</h3>
<p class="text-gray-600 mb-4">
Liga: Bezirksliga
</p>
<p class="text-gray-600">
Betreuer: Name folgt
</p>
</div>
<div class="bg-white p-8 rounded-xl shadow-lg">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">Jugend 2 (U15)</h3>
<p class="text-gray-600 mb-4">Liga: Kreisliga</p>
<p class="text-gray-600">Betreuer: Name folgt</p>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">
Jugend 2 (U15)
</h3>
<p class="text-gray-600 mb-4">
Liga: Kreisliga
</p>
<p class="text-gray-600">
Betreuer: Name folgt
</p>
</div>
<div class="bg-primary-50 p-8 rounded-xl border border-primary-100">
@@ -24,7 +36,7 @@
Jugendtraining
</h3>
<p class="text-gray-600 mb-6">
<strong>Dienstag & Donnerstag:</strong> 17:00 - 19:00 Uhr<br />
<strong>Dienstag & Donnerstag:</strong> 17:00 - 19:00 Uhr<br>
Für Kinder und Jugendliche von 8-18 Jahren
</p>
<NuxtLink

View File

@@ -5,8 +5,12 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Spielpläne</h1>
<p class="mt-2 text-gray-600">Alle Spielpläne der Mannschaften</p>
<h1 class="text-3xl font-bold text-gray-900">
Spielpläne
</h1>
<p class="mt-2 text-gray-600">
Alle Spielpläne der Mannschaften
</p>
</div>
</div>
</div>
@@ -21,36 +25,58 @@
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Wettbewerbs-Filter -->
<div class="flex items-center space-x-2">
<label for="wettbewerb-select" class="text-sm font-medium text-gray-700">
<label
for="wettbewerb-select"
class="text-sm font-medium text-gray-700"
>
Wettbewerb:
</label>
<select
id="wettbewerb-select"
v-model="selectedWettbewerb"
@change="filterData"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white text-sm"
@change="filterData"
>
<option value="punktrunde">Punktrunde</option>
<option value="pokal">Pokal</option>
<option value="alle">Alle</option>
<option value="punktrunde">
Punktrunde
</option>
<option value="pokal">
Pokal
</option>
<option value="alle">
Alle
</option>
</select>
</div>
<!-- Mannschafts-Filter -->
<div class="flex items-center space-x-2">
<label for="filter-select" class="text-sm font-medium text-gray-700">
<label
for="filter-select"
class="text-sm font-medium text-gray-700"
>
Mannschaft:
</label>
<select
id="filter-select"
v-model="selectedFilter"
@change="filterData"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white text-sm"
@change="filterData"
>
<option value="all">
Gesamt
</option>
<option value="erwachsene">
Erwachsene
</option>
<option value="nachwuchs">
Nachwuchs
</option>
<option
v-for="mannschaft in mannschaften"
:key="mannschaft"
:value="mannschaft"
>
<option value="all">Gesamt</option>
<option value="erwachsene">Erwachsene</option>
<option value="nachwuchs">Nachwuchs</option>
<option v-for="mannschaft in mannschaften" :key="mannschaft" :value="mannschaft">
{{ mannschaft }}
</option>
</select>
@@ -59,12 +85,22 @@
<!-- Download Button -->
<button
@click="downloadPDF"
:disabled="isLoading || !filteredData.length"
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="downloadPDF"
>
<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
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 Download
</button>
@@ -88,38 +124,95 @@
</div>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-12">
<svg class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<div
v-if="isLoading"
class="text-center py-12"
>
<svg
class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<p class="text-gray-600">Spielpläne werden geladen...</p>
<p class="text-gray-600">
Spielpläne werden geladen...
</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<svg class="w-12 h-12 text-red-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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
<div
v-else-if="error"
class="bg-red-50 border border-red-200 rounded-lg p-6 text-center"
>
<svg
class="w-12 h-12 text-red-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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h3 class="text-lg font-medium text-red-800 mb-2">Fehler beim Laden</h3>
<p class="text-red-600 mb-4">{{ error }}</p>
<button @click="loadData" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
<h3 class="text-lg font-medium text-red-800 mb-2">
Fehler beim Laden
</h3>
<p class="text-red-600 mb-4">
{{ error }}
</p>
<button
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
@click="loadData"
>
Erneut versuchen
</button>
</div>
<!-- Empty State -->
<div v-else-if="!spielplanData || spielplanData.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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
<div
v-else-if="!spielplanData || spielplanData.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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Spielpläne verfügbar</h3>
<p class="text-gray-600">Es wurden noch keine Spielplandaten hochgeladen.</p>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Keine Spielpläne verfügbar
</h3>
<p class="text-gray-600">
Es wurden noch keine Spielplandaten hochgeladen.
</p>
</div>
<!-- Spielplan Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">Spielplan</h2>
<h2 class="text-xl font-semibold text-gray-900">
Spielplan
</h2>
<p class="text-sm text-gray-600 mt-1">
{{ getWettbewerbText() }} - {{ filteredData.length }} von {{ spielplanData.length }} Einträgen
</p>
@@ -129,24 +222,42 @@
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th v-for="header in headers" :key="header"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
v-for="header in headers"
:key="header"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{{ formatHeader(header) }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, index) in filteredData" :key="index"
:class="getRowClass(row)">
<td v-for="header in headers" :key="header"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span v-if="header.toLowerCase().includes('datum')" class="font-mono">
<tr
v-for="(row, index) in filteredData"
:key="index"
:class="getRowClass(row)"
>
<td
v-for="header in headers"
:key="header"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
<span
v-if="header.toLowerCase().includes('datum')"
class="font-mono"
>
{{ formatDate(row[getOriginalHeader(header)]) }}
</span>
<span v-else-if="header.toLowerCase().includes('uhrzeit')" class="font-mono">
<span
v-else-if="header.toLowerCase().includes('uhrzeit')"
class="font-mono"
>
{{ formatTime(row[getOriginalHeader(header)]) }}
</span>
<span v-else-if="header.toLowerCase().includes('mannschaft')" class="font-medium">
<span
v-else-if="header.toLowerCase().includes('mannschaft')"
class="font-medium"
>
{{ row[getOriginalHeader(header)] || '-' }}
</span>
<span v-else-if="header.toLowerCase().includes('runde')">
@@ -337,14 +448,6 @@ const filterData = () => {
const runde = (row.Runde || '').toLowerCase()
const isMatch = runde === 'vr' || runde === 'rr' || runde.includes('vorrunde') || runde.includes('rückrunde')
// Debug: Zeige gefilterte Spiele
if (!isMatch && Math.random() < 0.1) { // 10% der gefilterten Spiele loggen
console.log('Gefiltert heraus:', row.Termin, 'Runde:', row.Runde)
}
if (isMatch && Math.random() < 0.05) { // 5% der akzeptierten Spiele loggen
console.log('Akzeptiert als Punktrunde:', row.Termin, 'Runde:', row.Runde)
}
return isMatch
})
} else if (selectedWettbewerb.value === 'pokal') {
@@ -356,9 +459,6 @@ const filterData = () => {
}
// "alle" zeigt alle Spiele ohne weitere Filterung
console.log('selectedWettbewerb.value:', selectedWettbewerb.value)
console.log('Nach Wettbewerb-Filter:', wettbewerbFiltered.length, 'von', saisonFiltered.length)
// Dann nach Mannschaft filtern
if (selectedFilter.value === 'all') {
filteredData.value = wettbewerbFiltered
@@ -495,7 +595,6 @@ const filterData = () => {
})
}
console.log('Finale gefilterte Daten:', filteredData.value.length, 'von', spielplanData.value.length)
}
const downloadPDF = () => {

View File

@@ -13,19 +13,27 @@
<!-- Authentication Info -->
<div class="bg-blue-50 border-l-4 border-blue-500 p-6 rounded-lg mb-8">
<h2 class="text-xl font-semibold text-blue-900 mb-2">Authentifizierung</h2>
<h2 class="text-xl font-semibold text-blue-900 mb-2">
Authentifizierung
</h2>
<p class="text-blue-800 mb-4">
Alle API-Endpoints erfordern Authentifizierung (außer Login). Es werden zwei Methoden unterstützt:
</p>
<div class="space-y-3">
<div>
<strong class="text-blue-900">1. Cookie-basiert:</strong>
<p class="text-blue-700 text-sm mt-1">Nach dem Login über <code>/api/auth/login</code> wird automatisch ein Cookie gesetzt.</p>
<p class="text-blue-700 text-sm mt-1">
Nach dem Login über <code>/api/auth/login</code> wird automatisch ein Cookie gesetzt.
</p>
</div>
<div>
<strong class="text-blue-900">2. Authorization Header:</strong>
<p class="text-blue-700 text-sm mt-1">Header: <code>Authorization: Bearer &lt;token&gt;</code></p>
<p class="text-blue-700 text-sm">Der Token wird im Login-Response im Feld <code>token</code> zurückgegeben.</p>
<p class="text-blue-700 text-sm mt-1">
Header: <code>Authorization: Bearer &lt;token&gt;</code>
</p>
<p class="text-blue-700 text-sm">
Der Token wird im Login-Response im Feld <code>token</code> zurückgegeben.
</p>
</div>
</div>
</div>
@@ -34,19 +42,27 @@
<div class="space-y-8">
<!-- Authentication Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Authentifizierung</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Authentifizierung
</h2>
<div class="space-y-6">
<!-- Login -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/auth/login</h3>
<h3 class="text-lg font-semibold text-gray-900">
POST /api/auth/login
</h3>
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">Öffentlich</span>
</div>
<p class="text-gray-600 mb-3">Benutzer einloggen und Token erhalten</p>
<p class="text-gray-600 mb-3">
Benutzer einloggen und Token erhalten
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"email": "benutzer@example.com",
"password": "passwort"
@@ -54,7 +70,9 @@
</div>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Response:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
@@ -71,13 +89,19 @@
<!-- Logout -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/auth/logout</h3>
<h3 class="text-lg font-semibold text-gray-900">
POST /api/auth/logout
</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Benutzer ausloggen</p>
<p class="text-gray-600 mb-3">
Benutzer ausloggen
</p>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Response:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"message": "Erfolgreich ausgeloggt"
@@ -88,13 +112,19 @@
<!-- Auth Status -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/auth/status</h3>
<h3 class="text-lg font-semibold text-gray-900">
GET /api/auth/status
</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Aktuellen Authentifizierungsstatus abrufen</p>
<p class="text-gray-600 mb-3">
Aktuellen Authentifizierungsstatus abrufen
</p>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Response:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"isLoggedIn": true,
"user": {
@@ -112,19 +142,27 @@
<!-- Members Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Mitglieder</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Mitglieder
</h2>
<div class="space-y-6">
<!-- Get Members -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/members</h3>
<h3 class="text-lg font-semibold text-gray-900">
GET /api/members
</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Alle Mitglieder abrufen (mit Merge aus registrierten Benutzern)</p>
<p class="text-gray-600 mb-3">
Alle Mitglieder abrufen (mit Merge aus registrierten Benutzern)
</p>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Response:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"members": [
@@ -148,13 +186,19 @@
<!-- Post Members -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/members</h3>
<h3 class="text-lg font-semibold text-gray-900">
POST /api/members
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Neues Mitglied hinzufügen oder bestehendes bearbeiten</p>
<p class="text-gray-600 mb-3">
Neues Mitglied hinzufügen oder bestehendes bearbeiten
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "optional-für-update",
"firstName": "Max",
@@ -168,7 +212,9 @@
</div>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Response:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"message": "Mitglied erfolgreich gespeichert."
@@ -185,13 +231,19 @@
<!-- Bulk Import Members -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/members/bulk</h3>
<h3 class="text-lg font-semibold text-gray-900">
POST /api/members/bulk
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Mehrere Mitglieder auf einmal importieren (Bulk-Import)</p>
<p class="text-gray-600 mb-3">
Mehrere Mitglieder auf einmal importieren (Bulk-Import)
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"members": [
{
@@ -214,7 +266,9 @@
</div>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Response:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"summary": {
@@ -253,20 +307,28 @@
<!-- Delete Members -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">DELETE /api/members</h3>
<h3 class="text-lg font-semibold text-gray-900">
DELETE /api/members
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Mitglied löschen</p>
<p class="text-gray-600 mb-3">
Mitglied löschen
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "member-id"
}</code></pre>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Response:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"message": "Mitglied erfolgreich gelöscht."
@@ -278,28 +340,40 @@
<!-- News Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">News</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
News
</h2>
<div class="space-y-6">
<!-- Get News -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/news</h3>
<h3 class="text-lg font-semibold text-gray-900">
GET /api/news
</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Alle News abrufen (inkl. interner News)</p>
<p class="text-gray-600 mb-3">
Alle News abrufen (inkl. interner News)
</p>
</div>
<!-- Post News -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/news</h3>
<h3 class="text-lg font-semibold text-gray-900">
POST /api/news
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Neue News erstellen oder bestehende bearbeiten</p>
<p class="text-gray-600 mb-3">
Neue News erstellen oder bestehende bearbeiten
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "optional-für-update",
"title": "Titel der News",
@@ -314,13 +388,19 @@
<!-- Delete News -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">DELETE /api/news</h3>
<h3 class="text-lg font-semibold text-gray-900">
DELETE /api/news
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">News löschen</p>
<p class="text-gray-600 mb-3">
News löschen
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "news-id"
}</code></pre>
@@ -331,28 +411,40 @@
<!-- Termine Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Termine</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Termine
</h2>
<div class="space-y-6">
<!-- Get Termine -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/termine-manage</h3>
<h3 class="text-lg font-semibold text-gray-900">
GET /api/termine-manage
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Alle Termine abrufen (für Verwaltung)</p>
<p class="text-gray-600 mb-3">
Alle Termine abrufen (für Verwaltung)
</p>
</div>
<!-- Post Termine -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/termine-manage</h3>
<h3 class="text-lg font-semibold text-gray-900">
POST /api/termine-manage
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Neuen Termin erstellen</p>
<p class="text-gray-600 mb-3">
Neuen Termin erstellen
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"datum": "2025-12-25",
"uhrzeit": "19:00",
@@ -366,13 +458,19 @@
<!-- Delete Termine -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">DELETE /api/termine-manage</h3>
<h3 class="text-lg font-semibold text-gray-900">
DELETE /api/termine-manage
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Termin löschen</p>
<p class="text-gray-600 mb-3">
Termin löschen
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Query Parameters:</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Query Parameters:
</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>?datum=2025-12-25&uhrzeit=19:00&titel=Weihnachtsfeier&beschreibung=...&kategorie=...</code></pre>
</div>
</div>
@@ -381,29 +479,43 @@
<!-- Config Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Konfiguration</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Konfiguration
</h2>
<div class="space-y-6">
<!-- Get Config -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/config</h3>
<h3 class="text-lg font-semibold text-gray-900">
GET /api/config
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Vereinskonfiguration abrufen</p>
<p class="text-gray-600 mb-3">
Vereinskonfiguration abrufen
</p>
</div>
<!-- Put Config -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">PUT /api/config</h3>
<h3 class="text-lg font-semibold text-gray-900">
PUT /api/config
</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Vereinskonfiguration aktualisieren</p>
<p class="text-gray-600 mb-3">
Vereinskonfiguration aktualisieren
</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-xs text-gray-600">Komplettes Config-Objekt mit allen Einstellungen</p>
<p class="text-sm font-medium text-gray-700 mb-2">
Request Body:
</p>
<p class="text-xs text-gray-600">
Komplettes Config-Objekt mit allen Einstellungen
</p>
</div>
</div>
</div>
@@ -412,11 +524,15 @@
<!-- Example Usage -->
<div class="mt-12 bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Beispiel-Usage</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Beispiel-Usage
</h2>
<div class="space-y-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">cURL Beispiel:</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
cURL Beispiel:
</h3>
<pre class="text-xs bg-gray-900 text-gray-100 p-4 rounded overflow-x-auto"><code># Login und Token erhalten
curl -X POST http://localhost:3100/api/auth/login \
-H "Content-Type: application/json" \
@@ -457,7 +573,9 @@ curl -X POST http://localhost:3100/api/members/bulk \
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">JavaScript/Fetch Beispiel:</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
JavaScript/Fetch Beispiel:
</h3>
<pre class="text-xs bg-gray-900 text-gray-100 p-4 rounded overflow-x-auto"><code>// Login
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
@@ -509,14 +627,16 @@ const bulkResponse = await fetch('/api/members/bulk', {
})
})
const result = await bulkResponse.json()
console.log(`Importiert: ${result.summary.imported}, Duplikate: ${result.summary.duplicates}`)</code></pre>
// Ergebnis: Importiert: ${result.summary.imported}, Duplikate: ${result.summary.duplicates}</code></pre>
</div>
</div>
</div>
<!-- Role Legend -->
<div class="mt-8 bg-gray-50 rounded-xl p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Legende</h2>
<h2 class="text-xl font-semibold text-gray-900 mb-4">
Legende
</h2>
<div class="grid md:grid-cols-3 gap-4">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">Öffentlich</span>

View File

@@ -14,9 +14,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center group-hover:bg-primary-600 transition-colors">
<User :size="24" class="text-primary-600 group-hover:text-white" />
<User
:size="24"
class="text-primary-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Mein Profil</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Mein Profil
</h2>
</div>
<p class="text-gray-600">
Persönliche Daten und Passwort verwalten
@@ -30,9 +35,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center group-hover:bg-primary-600 transition-colors">
<Users :size="24" class="text-primary-600 group-hover:text-white" />
<Users
:size="24"
class="text-primary-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Mitglieder</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Mitglieder
</h2>
</div>
<p class="text-gray-600">
Kontaktdaten der Vereinsmitglieder
@@ -46,9 +56,14 @@
>
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center group-hover:bg-primary-600 transition-colors">
<Newspaper :size="24" class="text-primary-600 group-hover:text-white" />
<Newspaper
:size="24"
class="text-primary-600 group-hover:text-white"
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">News</h2>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
News
</h2>
</div>
<p class="text-gray-600">
Neuigkeiten und Ankündigungen
@@ -66,19 +81,31 @@
</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" />
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Zugriff auf Mitgliederliste mit Kontaktdaten</span>
</div>
<div class="flex items-start">
<Check :size="20" class="text-primary-600 mr-2 mt-0.5" />
<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" />
<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" />
<Check
:size="20"
class="text-primary-600 mr-2 mt-0.5"
/>
<span class="text-gray-700">Weitere Funktionen folgen in Kürze</span>
</div>
</div>

View File

@@ -10,74 +10,173 @@
</div>
<div class="flex items-center space-x-3">
<button
@click="viewMode = viewMode === 'cards' ? 'table' : 'cards'"
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" />
<component
:is="viewMode === 'cards' ? Table2 : Grid3x3"
:size="20"
class="mr-2"
/>
{{ viewMode === 'cards' ? 'Tabelle' : 'Karten' }}
</button>
<button
v-if="canEdit"
@click="showBulkImportModal = true"
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"></path>
<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"
@click="openAddModal"
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" />
<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
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
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">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>
<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">
<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>
<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">
<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>
<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>
<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">
<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>
<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>
<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">
@@ -95,37 +194,52 @@
</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">
<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
@click="openEditModal(member)"
class="text-blue-600 hover:text-blue-900"
title="Bearbeiten"
@click="openEditModal(member)"
>
<Edit :size="18" />
</button>
<button
@click="confirmDelete(member)"
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>
<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">
<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-else
class="space-y-4"
>
<div
v-for="member in members"
:key="member.id"
@@ -134,7 +248,9 @@
<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>
<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"
@@ -153,50 +269,119 @@
>
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
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
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" />
<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" />
<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" />
<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" />
<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">
<div
v-if="canEdit && member.editable"
class="flex space-x-2 ml-4"
>
<button
@click="openEditModal(member)"
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Bearbeiten"
@click="openEditModal(member)"
>
<Edit :size="20" />
</button>
<button
@click="confirmDelete(member)"
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Löschen"
@click="confirmDelete(member)"
>
<Trash2 :size="20" />
</button>
@@ -204,7 +389,10 @@
</div>
</div>
<div v-if="members.length === 0" class="text-center py-12 text-gray-500">
<div
v-if="members.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Mitglieder gefunden.
</div>
</div>
@@ -220,7 +408,10 @@
{{ editingMember ? 'Mitglied bearbeiten' : 'Mitglied hinzufügen' }}
</h2>
<form @submit.prevent="saveMember" class="space-y-4">
<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>
@@ -230,7 +421,7 @@
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>
@@ -240,7 +431,7 @@
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>
@@ -252,8 +443,10 @@
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>
>
<p class="text-xs text-gray-500 mt-1">
Wird zur eindeutigen Identifizierung benötigt
</p>
</div>
<div>
@@ -263,7 +456,7 @@
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>
@@ -273,7 +466,7 @@
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>
@@ -283,7 +476,7 @@
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>
@@ -296,17 +489,39 @@
/>
</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" />
<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"
@click="closeModal"
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>
@@ -315,7 +530,11 @@
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" />
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
@@ -338,33 +557,54 @@
<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"
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 }"
>
<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"></path>
<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>
<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"
@change="handleBulkFileSelect"
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>
<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>
@@ -374,65 +614,126 @@
</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
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>
<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
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">
<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
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>
<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 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 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 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
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">
<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
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">
<div
v-for="err in bulkImportResults.results.errors"
:key="err.index"
>
Zeile {{ err.index }}: {{ err.error }}
</div>
</div>
@@ -443,18 +744,22 @@
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
@click="closeBulkImportModal"
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
@click="processBulkImport"
: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" />
<Loader2
v-if="isBulkImporting"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isBulkImporting ? 'Importiert...' : 'Importieren' }}</span>
</button>
</div>
@@ -494,18 +799,17 @@ const formData = ref({
email: '',
phone: '',
address: '',
notes: ''
notes: '',
isMannschaftsspieler: false
})
const canEdit = computed(() => {
return authStore.role === 'admin' || authStore.role === 'vorstand'
return authStore.hasAnyRole('admin', 'vorstand')
})
const canViewContactData = computed(() => {
// Explicitly check for 'vorstand' role only
const role = authStore.role
console.log('Current role:', role, 'Can view contact:', role === 'vorstand')
return role === 'vorstand'
return authStore.hasRole('vorstand')
})
const loadMembers = async () => {
@@ -529,7 +833,8 @@ const openAddModal = () => {
email: '',
phone: '',
address: '',
notes: ''
notes: '',
isMannschaftsspieler: false
}
showModal.value = true
errorMessage.value = ''
@@ -544,7 +849,8 @@ const openEditModal = (member) => {
email: member.email || '',
phone: member.phone || '',
address: member.address || '',
notes: member.notes || ''
notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true
}
showModal.value = true
errorMessage.value = ''
@@ -588,6 +894,27 @@ const saveMember = async () => {
}
}
const toggleMannschaftsspieler = async (member) => {
try {
const response = await $fetch('/api/members/toggle-mannschaftsspieler', {
method: 'POST',
body: { memberId: member.id }
})
// Update local state immediately
member.isMannschaftsspieler = response.isMannschaftsspieler
// Reload to ensure consistency
await loadMembers()
} catch (error) {
console.error('Fehler beim Umschalten des Mannschaftsspieler-Status:', error)
const errorMsg = error.data?.message || error.message || 'Fehler beim Umschalten des Status.'
if (window.showErrorModal) {
window.showErrorModal('Fehler', errorMsg)
}
}
}
const confirmDelete = async (member) => {
window.showConfirmModal('Mitglied löschen', `Möchten Sie "${member.name}" wirklich löschen?`, async () => {
try {

View File

@@ -10,21 +10,33 @@
</div>
<button
v-if="canWrite"
@click="openAddModal"
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" />
<Plus
:size="20"
class="mr-2"
/>
News erstellen
</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
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- News List -->
<div v-else class="space-y-6">
<div
v-else
class="space-y-6"
>
<article
v-for="item in news"
:key="item.id"
@@ -40,52 +52,76 @@
v-if="item.isPublic"
class="px-3 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full flex items-center"
>
<Globe :size="14" class="mr-1" />
<Globe
:size="14"
class="mr-1"
/>
Öffentlich
</span>
<span
v-if="item.isHidden"
class="px-3 py-1 bg-yellow-100 text-yellow-800 text-xs font-semibold rounded-full flex items-center"
>
<EyeOff :size="14" class="mr-1" />
<EyeOff
:size="14"
class="mr-1"
/>
Ausgeblendet
</span>
<span
v-if="item.expiresAt && isExpired(item.expiresAt)"
class="px-3 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full flex items-center"
>
<Calendar :size="14" class="mr-1" />
<Calendar
:size="14"
class="mr-1"
/>
Abgelaufen
</span>
</div>
<div class="flex items-center text-sm text-gray-500 space-x-4">
<div class="flex items-center">
<User :size="16" class="mr-1" />
<User
:size="16"
class="mr-1"
/>
{{ item.author }}
</div>
<div class="flex items-center">
<Calendar :size="16" class="mr-1" />
<Calendar
:size="16"
class="mr-1"
/>
{{ formatDate(item.created) }}
</div>
<div v-if="item.updated !== item.created" class="flex items-center">
<Edit :size="16" class="mr-1" />
<div
v-if="item.updated !== item.created"
class="flex items-center"
>
<Edit
:size="16"
class="mr-1"
/>
Aktualisiert: {{ formatDate(item.updated) }}
</div>
</div>
</div>
<div v-if="canWrite" class="flex space-x-2 ml-4">
<div
v-if="canWrite"
class="flex space-x-2 ml-4"
>
<button
@click="openEditModal(item)"
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Bearbeiten"
@click="openEditModal(item)"
>
<Edit :size="20" />
</button>
<button
@click="confirmDelete(item)"
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Löschen"
@click="confirmDelete(item)"
>
<Trash2 :size="20" />
</button>
@@ -97,10 +133,21 @@
</div>
</article>
<div v-if="news.length === 0" class="text-center py-12">
<Newspaper :size="48" class="mx-auto text-gray-400 mb-4" />
<p class="text-gray-500 text-lg">Noch keine News vorhanden.</p>
<p v-if="canWrite" class="text-gray-400 text-sm mt-2">
<div
v-if="news.length === 0"
class="text-center py-12"
>
<Newspaper
:size="48"
class="mx-auto text-gray-400 mb-4"
/>
<p class="text-gray-500 text-lg">
Noch keine News vorhanden.
</p>
<p
v-if="canWrite"
class="text-gray-400 text-sm mt-2"
>
Klicken Sie auf "News erstellen", um die erste News zu veröffentlichen.
</p>
</div>
@@ -117,7 +164,10 @@
{{ editingNews ? 'News bearbeiten' : 'News erstellen' }}
</h2>
<form @submit.prevent="saveNews" class="space-y-4">
<form
class="space-y-4"
@submit.prevent="saveNews"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
<input
@@ -126,7 +176,7 @@
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>
@@ -147,10 +197,16 @@
type="checkbox"
class="w-5 h-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
:disabled="isSaving"
/>
<label for="isPublic" class="text-sm font-medium text-gray-900 cursor-pointer flex-1">
>
<label
for="isPublic"
class="text-sm font-medium text-gray-900 cursor-pointer flex-1"
>
<div class="flex items-center">
<Globe :size="18" class="mr-2 text-blue-600" />
<Globe
:size="18"
class="mr-2 text-blue-600"
/>
<span>Öffentliche News (auf Startseite anzeigen)</span>
</div>
<p class="text-xs text-gray-600 mt-1 ml-6">
@@ -159,7 +215,10 @@
</label>
</div>
<div v-if="formData.isPublic" class="space-y-4">
<div
v-if="formData.isPublic"
class="space-y-4"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ablaufdatum (optional)</label>
<input
@@ -167,7 +226,7 @@
type="datetime-local"
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-600 mt-1">
Nach diesem Datum wird die News automatisch nicht mehr auf der Startseite angezeigt.
</p>
@@ -180,10 +239,16 @@
type="checkbox"
class="w-5 h-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
:disabled="isSaving"
/>
<label for="isHidden" class="text-sm font-medium text-gray-900 cursor-pointer flex-1">
>
<label
for="isHidden"
class="text-sm font-medium text-gray-900 cursor-pointer flex-1"
>
<div class="flex items-center">
<EyeOff :size="18" class="mr-2 text-yellow-600" />
<EyeOff
:size="18"
class="mr-2 text-yellow-600"
/>
<span>News ausblenden</span>
</div>
<p class="text-xs text-gray-600 mt-1 ml-6">
@@ -193,17 +258,23 @@
</div>
</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" />
<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"
@click="closeModal"
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>
@@ -212,7 +283,11 @@
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" />
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
@@ -245,7 +320,7 @@ const formData = ref({
})
const canWrite = computed(() => {
return authStore.role === 'admin' || authStore.role === 'vorstand'
return authStore.hasAnyRole('admin', 'vorstand')
})
const loadNews = async () => {

View File

@@ -8,15 +8,28 @@
<div class="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<Loader2 :size="40" class="animate-spin text-primary-600" />
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- Profile Form -->
<form v-else @submit.prevent="handleSave" class="space-y-6">
<form
v-else
class="space-y-6"
@submit.prevent="handleSave"
>
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="name"
class="block text-sm font-medium text-gray-700 mb-2"
>
Name
</label>
<input
@@ -26,12 +39,15 @@
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
/>
>
</div>
<!-- E-Mail -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail-Adresse
</label>
<input
@@ -41,12 +57,15 @@
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
/>
>
</div>
<!-- Telefon -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="phone"
class="block text-sm font-medium text-gray-700 mb-2"
>
Telefonnummer
</label>
<input
@@ -55,16 +74,21 @@
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 focus:border-primary-500"
:disabled="isSaving"
/>
>
</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">Passwort ändern</h3>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Passwort ändern
</h3>
<div class="space-y-4">
<div>
<label for="currentPassword" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="currentPassword"
class="block text-sm font-medium text-gray-700 mb-2"
>
Aktuelles Passwort
</label>
<input
@@ -73,11 +97,14 @@
type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
/>
>
</div>
<div>
<label for="newPassword" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="newPassword"
class="block text-sm font-medium text-gray-700 mb-2"
>
Neues Passwort
</label>
<input
@@ -86,11 +113,14 @@
type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
/>
>
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-2"
>
Passwort bestätigen
</label>
<input
@@ -99,19 +129,31 @@
type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
/>
>
</div>
</div>
</div>
<!-- Error/Success Messages -->
<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" />
<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 v-if="successMessage" class="flex items-center p-3 rounded-md bg-green-50 text-green-700 text-sm">
<Check :size="20" class="mr-2" />
<div
v-if="successMessage"
class="flex items-center p-3 rounded-md bg-green-50 text-green-700 text-sm"
>
<Check
:size="20"
class="mr-2"
/>
{{ successMessage }}
</div>
@@ -119,9 +161,9 @@
<div class="flex justify-end space-x-4">
<button
type="button"
@click="loadProfile"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="loadProfile"
>
Zurücksetzen
</button>
@@ -130,7 +172,11 @@
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" />
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>

View File

@@ -4,11 +4,14 @@
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
Mitgliedschaft
</h1>
<div class="w-24 h-1 bg-primary-600 mb-4"></div>
<div class="w-24 h-1 bg-primary-600 mb-4" />
<!-- Mitgliedschaftspläne (ohne "Noch Fragen" Box) -->
<div class="mb-4">
<section id="membership" class="py-8 sm:py-12 bg-gradient-to-b from-gray-50 to-white">
<section
id="membership"
class="py-8 sm:py-12 bg-gradient-to-b from-gray-50 to-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-8">
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
@@ -36,12 +39,24 @@
target="_blank"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="20" class="lucide lucide-file-text-icon mr-2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<path d="M10 9H8"></path>
<path d="M16 13H8"></path>
<path d="M16 17H8"></path>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
size="20"
class="lucide lucide-file-text-icon mr-2"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
Satzung herunterladen (PDF)
</a>
@@ -50,9 +65,25 @@
href="/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 xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" size="20" class="lucide lucide-eye-icon mr-2">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path>
<circle cx="12" cy="12" r="3"></circle>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
size="20"
class="lucide lucide-eye-icon mr-2"
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle
cx="12"
cy="12"
r="3"
/>
</svg>
Online ansehen
</a>
@@ -73,16 +104,35 @@
target="_blank"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7,10 12,15 17,10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line
x1="12"
y1="15"
x2="12"
y2="3"
/>
</svg>
Mitgliedsantrag herunterladen
</a>
</div>
<form id="membershipForm" class="space-y-8">
<form
id="membershipForm"
class="space-y-8"
>
<!-- Persönliche Daten -->
<div class="space-y-6">
<h3 class="text-xl font-semibold text-gray-900 border-b border-gray-200 pb-2">
@@ -91,53 +141,73 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="nachname" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="nachname"
class="block text-sm font-medium text-gray-700 mb-2"
>
Nachname
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="nachname"
name="nachname"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
<label for="vorname" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="vorname"
class="block text-sm font-medium text-gray-700 mb-2"
>
Vorname
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="vorname"
name="vorname"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
</div>
<div>
<label for="strasse" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="strasse"
class="block text-sm font-medium text-gray-700 mb-2"
>
Straße und Hausnummer
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="strasse"
name="strasse"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="plz" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="plz"
class="block text-sm font-medium text-gray-700 mb-2"
>
PLZ
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="plz"
name="plz"
@@ -145,39 +215,52 @@
required
pattern="[0-9]{5}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
<label for="ort" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="ort"
class="block text-sm font-medium text-gray-700 mb-2"
>
Wohnort
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="ort"
name="ort"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="geburtsdatum" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="geburtsdatum"
class="block text-sm font-medium text-gray-700 mb-2"
>
Geburtsdatum
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="geburtsdatum"
name="geburtsdatum"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
<label for="telefon_privat" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="telefon_privat"
class="block text-sm font-medium text-gray-700 mb-2"
>
Telefon (privat)
</label>
<input
@@ -185,26 +268,34 @@
name="telefon_privat"
type="tel"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="email"
name="email"
type="email"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
<label for="telefon_mobil" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="telefon_mobil"
class="block text-sm font-medium text-gray-700 mb-2"
>
Telefon (Mobil)
</label>
<input
@@ -212,7 +303,7 @@
name="telefon_mobil"
type="tel"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
</div>
</div>
@@ -230,7 +321,7 @@
value="aktiv"
checked
class="mr-3 text-primary-600 focus:ring-primary-500"
/>
>
<span class="text-gray-700">Aktives Mitglied</span>
</label>
<label class="flex items-center">
@@ -239,7 +330,7 @@
type="radio"
value="passiv"
class="mr-3 text-primary-600 focus:ring-primary-500"
/>
>
<span class="text-gray-700">Passives Mitglied</span>
</label>
</div>
@@ -269,7 +360,7 @@
type="checkbox"
required
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
/>
>
<div>
<span class="text-gray-700">
Hierzu erteile ich das beigefügte SEPA-Lastschriftmandat.
@@ -286,24 +377,34 @@
</h3>
<div>
<label for="kontoinhaber" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="kontoinhaber"
class="block text-sm font-medium text-gray-700 mb-2"
>
Kontoinhaber (Vorname und Name)
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="kontoinhaber"
name="kontoinhaber"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
<label for="iban" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="iban"
class="block text-sm font-medium text-gray-700 mb-2"
>
IBAN
</label>
<p class="text-xs text-gray-500 italic mb-2">Pflichtfeld</p>
<p class="text-xs text-gray-500 italic mb-2">
Pflichtfeld
</p>
<input
id="iban"
name="iban"
@@ -311,11 +412,14 @@
required
placeholder="DE89 3704 0044 0532 0130 00"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
<label for="bic" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="bic"
class="block text-sm font-medium text-gray-700 mb-2"
>
BIC
</label>
<input
@@ -324,11 +428,14 @@
type="text"
placeholder="COBADEFFXXX"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
<div>
<label for="bank" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="bank"
class="block text-sm font-medium text-gray-700 mb-2"
>
Kreditinstitut
</label>
<input
@@ -336,7 +443,7 @@
name="bank"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
>
</div>
</div>
@@ -356,7 +463,7 @@
type="checkbox"
required
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
/>
>
<div>
<span class="text-sm text-gray-700">
Ich bestätige das Vorstehende zur Kenntnis genommen zu haben und willige ein, dass der Harheimer Tischtennis-Club 1954 e.V. allgemeine Daten zu meiner Person (Name, Fotografien, Mannschaft, Leistungsergebnisse, Turnierteilnahmen, Lizenzen u.ä.) auf der Homepage des Vereins veröffentlichen darf.
@@ -378,7 +485,7 @@
type="checkbox"
required
class="mr-3 mt-1 text-primary-600 focus:ring-primary-500"
/>
>
<div>
<span class="text-gray-700">
Ich erkenne die Vereinssatzung (erhältlich beim Vorstand bzw. auf der Vereinshomepage) an.
@@ -390,7 +497,9 @@
<!-- Hinweise -->
<div class="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<h4 class="font-semibold text-gray-900 mb-2">Wichtige Hinweise:</h4>
<h4 class="font-semibold text-gray-900 mb-2">
Wichtige Hinweise:
</h4>
<ul class="text-sm text-gray-700 space-y-1">
<li> Die Mitgliedschaft im Harheimer Tischtennis-Club erlangt erst nach Bestätigung durch den Vorstand Wirksamkeit.</li>
<li> Die Beitragspflicht beginnt mit dem darauf folgenden Monat.</li>
@@ -401,13 +510,30 @@
<!-- Submit Button -->
<div class="flex justify-center pt-6">
<button
type="submit"
id="submitBtn"
type="submit"
class="px-8 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<svg v-if="isGenerating" 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"></circle>
<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"></path>
<svg
v-if="isGenerating"
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>
Beitrittsformular erstellen
</button>

View File

@@ -0,0 +1,103 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-lg p-8 text-center">
<div
v-if="loading"
class="py-12"
>
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg
class="w-8 h-8 text-blue-600 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
<p class="text-lg text-gray-600">
Newsletter-Anmeldung wird bestätigt...
</p>
</div>
<div
v-else-if="error"
class="py-12"
>
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg
class="w-8 h-8 text-red-600"
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>
</div>
<h1 class="text-3xl font-display font-bold text-gray-900 mb-4">
Fehler
</h1>
<p class="text-lg text-gray-600 mb-8">
{{ error }}
</p>
<NuxtLink
to="/newsletter/subscribe"
class="inline-block px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 transition-colors"
>
Zurück zur Anmeldung
</NuxtLink>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
useHead({
title: 'Newsletter bestätigen - Harheimer TC',
})
const route = useRoute()
const loading = ref(true)
const error = ref('')
onMounted(async () => {
const token = route.query.token
if (!token) {
error.value = 'Bestätigungstoken fehlt'
loading.value = false
return
}
try {
// Rufe den API-Endpoint auf, der die Bestätigung durchführt
const response = await $fetch(`/api/newsletter/confirm?token=${token}`)
// Wenn erfolgreich, weiterleiten zur Bestätigungsseite
if (response.alreadyConfirmed) {
await navigateTo('/newsletter/confirmed?already=true')
} else {
await navigateTo('/newsletter/confirmed')
}
} catch (err) {
console.error('Fehler bei Newsletter-Bestätigung:', err)
error.value = err.data?.statusMessage || err.message || 'Fehler bei der Newsletter-Bestätigung'
loading.value = false
}
})
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-lg p-8 text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg
class="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 class="text-3xl font-display font-bold text-gray-900 mb-4">
{{ alreadyConfirmed ? 'Bereits bestätigt' : 'Anmeldung bestätigt!' }}
</h1>
<p class="text-lg text-gray-600 mb-8">
{{ alreadyConfirmed
? 'Ihre Newsletter-Anmeldung wurde bereits bestätigt.'
: 'Vielen Dank! Ihre Newsletter-Anmeldung wurde erfolgreich bestätigt. Sie erhalten ab sofort unseren Newsletter.' }}
</p>
<NuxtLink
to="/"
class="inline-block px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 transition-colors"
>
Zur Startseite
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
const route = useRoute()
const alreadyConfirmed = route.query.already === 'true'
useHead({
title: 'Newsletter bestätigt - Harheimer TC',
})
</script>

View File

@@ -0,0 +1,215 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-lg p-8">
<h1 class="text-3xl font-display font-bold text-gray-900 mb-6">
Newsletter abonnieren
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<div
v-if="loadingGroups"
class="text-center py-8"
>
<p class="text-gray-600">
Lade verfügbare Newsletter...
</p>
</div>
<form
v-else
class="space-y-6"
@submit.prevent="subscribe"
>
<div>
<label
for="groupId"
class="block text-sm font-medium text-gray-700 mb-2"
>
Newsletter auswählen *
</label>
<select
id="groupId"
v-model="form.groupId"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
@change="checkSubscription"
>
<option value="">
Bitte wählen Sie einen Newsletter
</option>
<option
v-for="group in groups"
:key="group.id"
:value="group.id"
>
{{ group.name }}
</option>
</select>
<p
v-if="selectedGroup?.description"
class="mt-2 text-sm text-gray-600"
>
{{ selectedGroup.description }}
</p>
<div
v-if="alreadySubscribed"
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
>
<p class="text-sm text-blue-700">
Sie sind bereits für diesen Newsletter angemeldet.
</p>
</div>
</div>
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail-Adresse *
</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="ihre.email@example.com"
@blur="checkSubscription"
>
</div>
<div>
<label
for="name"
class="block text-sm font-medium text-gray-700 mb-2"
>
Name (optional)
</label>
<input
id="name"
v-model="form.name"
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"
placeholder="Ihr Name"
>
</div>
<div
v-if="error"
class="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700"
>
{{ error }}
</div>
<div
v-if="success"
class="p-4 bg-green-50 border border-green-200 rounded-lg text-green-700"
>
{{ success }}
</div>
<button
type="submit"
:disabled="loading || alreadySubscribed || !form.groupId"
class="w-full px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ loading ? 'Wird verarbeitet...' : alreadySubscribed ? 'Bereits abonniert' : 'Newsletter abonnieren' }}
</button>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
useHead({
title: 'Newsletter abonnieren - Harheimer TC',
})
const groups = ref([])
const loadingGroups = ref(true)
const form = ref({
groupId: '',
email: '',
name: ''
})
const loading = ref(false)
const checking = ref(false)
const error = ref('')
const success = ref('')
const alreadySubscribed = ref(false)
const selectedGroup = computed(() => {
return groups.value.find(g => g.id === form.value.groupId)
})
async function loadGroups() {
try {
const response = await $fetch('/api/newsletter/groups/public-list')
groups.value = response.groups || []
} catch (err) {
console.error('Fehler beim Laden der Newsletter-Gruppen:', err)
error.value = 'Fehler beim Laden der verfügbaren Newsletter. Bitte versuchen Sie es später erneut.'
} finally {
loadingGroups.value = false
}
}
async function checkSubscription() {
if (!form.value.groupId || !form.value.email || !form.value.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
alreadySubscribed.value = false
return
}
checking.value = true
try {
const response = await $fetch('/api/newsletter/check-subscription', {
query: {
email: form.value.email,
groupId: form.value.groupId
}
})
alreadySubscribed.value = response.subscribed || false
} catch (err) {
// Fehler ignorieren - könnte bedeuten, dass nicht abonniert ist
alreadySubscribed.value = false
} finally {
checking.value = false
}
}
async function subscribe() {
if (alreadySubscribed.value) {
return
}
loading.value = true
error.value = ''
success.value = ''
try {
const response = await $fetch('/api/newsletter/subscribe', {
method: 'POST',
body: form.value
})
success.value = response.message || 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet.'
form.value = { groupId: form.value.groupId, email: '', name: '' }
alreadySubscribed.value = false
} catch (err) {
error.value = err.data?.statusMessage || err.message || 'Fehler bei der Anmeldung. Bitte versuchen Sie es später erneut.'
} finally {
loading.value = false
}
}
onMounted(() => {
loadGroups()
})
</script>

View File

@@ -0,0 +1,158 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-lg p-8">
<h1 class="text-3xl font-display font-bold text-gray-900 mb-6">
Newsletter abmelden
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<div
v-if="loadingGroups"
class="text-center py-8"
>
<p class="text-gray-600">
Lade verfügbare Newsletter...
</p>
</div>
<form
v-else
class="space-y-6"
@submit.prevent="unsubscribe"
>
<div>
<label
for="groupId"
class="block text-sm font-medium text-gray-700 mb-2"
>
Newsletter auswählen *
</label>
<select
id="groupId"
v-model="form.groupId"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">
Bitte wählen Sie einen Newsletter
</option>
<option
v-for="group in groups"
:key="group.id"
:value="group.id"
>
{{ group.name }}
</option>
</select>
<p
v-if="selectedGroup?.description"
class="mt-2 text-sm text-gray-600"
>
{{ selectedGroup.description }}
</p>
</div>
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail-Adresse *
</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="ihre.email@example.com"
>
</div>
<div
v-if="error"
class="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700"
>
{{ error }}
</div>
<div
v-if="success"
class="p-4 bg-green-50 border border-green-200 rounded-lg text-green-700"
>
{{ success }}
</div>
<button
type="submit"
:disabled="loading || !form.groupId"
class="w-full px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ loading ? 'Wird verarbeitet...' : 'Newsletter abmelden' }}
</button>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
useHead({
title: 'Newsletter abmelden - Harheimer TC',
})
const groups = ref([])
const loadingGroups = ref(true)
const form = ref({
groupId: '',
email: ''
})
const loading = ref(false)
const error = ref('')
const success = ref('')
const selectedGroup = computed(() => {
return groups.value.find(g => g.id === form.value.groupId)
})
async function loadGroups() {
try {
const response = await $fetch('/api/newsletter/groups/public-list')
groups.value = response.groups || []
} catch (err) {
console.error('Fehler beim Laden der Newsletter-Gruppen:', err)
error.value = 'Fehler beim Laden der verfügbaren Newsletter. Bitte versuchen Sie es später erneut.'
} finally {
loadingGroups.value = false
}
}
async function unsubscribe() {
loading.value = true
error.value = ''
success.value = ''
try {
const response = await $fetch('/api/newsletter/unsubscribe-by-email', {
method: 'POST',
body: form.value
})
success.value = response.message || 'Sie wurden erfolgreich vom Newsletter abgemeldet.'
form.value = { groupId: '', email: '' }
} catch (err) {
error.value = err.data?.statusMessage || err.message || 'Fehler bei der Abmeldung. Bitte versuchen Sie es später erneut.'
} finally {
loading.value = false
}
}
onMounted(() => {
loadGroups()
})
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-xl shadow-lg p-8 text-center">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg
class="w-8 h-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<h1 class="text-3xl font-display font-bold text-gray-900 mb-4">
{{ alreadyUnsubscribed ? 'Bereits abgemeldet' : 'Erfolgreich abgemeldet' }}
</h1>
<p class="text-lg text-gray-600 mb-8">
{{ alreadyUnsubscribed
? 'Sie sind bereits vom Newsletter abgemeldet.'
: 'Sie wurden erfolgreich vom Newsletter abgemeldet. Sie erhalten keine weiteren Newsletter mehr.' }}
</p>
<NuxtLink
to="/"
class="inline-block px-6 py-3 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 transition-colors"
>
Zur Startseite
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
const route = useRoute()
const alreadyUnsubscribed = route.query.already === 'true'
useHead({
title: 'Newsletter abgemeldet - Harheimer TC',
})
</script>

View File

@@ -11,10 +11,16 @@
</div>
<div class="bg-white rounded-xl shadow-lg p-8">
<form @submit.prevent="handleReset" class="space-y-6">
<form
class="space-y-6"
@submit.prevent="handleReset"
>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail-Adresse
</label>
<input
@@ -26,21 +32,33 @@
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"
:class="{ 'border-red-500': errorMessage }"
placeholder="ihre-email@example.com"
/>
>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
<AlertCircle
:size="18"
class="mr-2"
/>
{{ errorMessage }}
</p>
</div>
<!-- Success Message -->
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
<div
v-if="successMessage"
class="bg-green-50 border border-green-200 rounded-lg p-4"
>
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
<Check
:size="18"
class="mr-2"
/>
{{ successMessage }}
</p>
</div>
@@ -51,7 +69,11 @@
:disabled="isLoading"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<Loader2
v-if="isLoading"
:size="20"
class="mr-2 animate-spin"
/>
<span>{{ isLoading ? 'Wird gesendet...' : 'Passwort zurücksetzen' }}</span>
</button>

View File

@@ -11,10 +11,16 @@
</div>
<div class="bg-white rounded-xl shadow-lg p-8">
<form @submit.prevent="handleRegister" class="space-y-6">
<form
class="space-y-6"
@submit.prevent="handleRegister"
>
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="name"
class="block text-sm font-medium text-gray-700 mb-2"
>
Vollständiger Name
</label>
<input
@@ -25,12 +31,15 @@
autocomplete="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"
/>
>
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail-Adresse
</label>
<input
@@ -41,12 +50,15 @@
autocomplete="email"
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="ihre-email@example.com"
/>
>
</div>
<!-- Phone -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="phone"
class="block text-sm font-medium text-gray-700 mb-2"
>
Telefonnummer (optional)
</label>
<input
@@ -56,12 +68,15 @@
autocomplete="tel"
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="069-12345678"
/>
>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="password"
class="block text-sm font-medium text-gray-700 mb-2"
>
Passwort
</label>
<input
@@ -72,7 +87,7 @@
autocomplete="new-password"
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="••••••••"
/>
>
<p class="mt-1 text-xs text-gray-500">
Mindestens 8 Zeichen
</p>
@@ -80,7 +95,10 @@
<!-- Confirm Password -->
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-2">
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-2"
>
Passwort bestätigen
</label>
<input
@@ -91,21 +109,33 @@
autocomplete="new-password"
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="••••••••"
/>
>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
<AlertCircle
:size="18"
class="mr-2"
/>
{{ errorMessage }}
</p>
</div>
<!-- Success Message -->
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
<div
v-if="successMessage"
class="bg-green-50 border border-green-200 rounded-lg p-4"
>
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
<Check
:size="18"
class="mr-2"
/>
{{ successMessage }}
</p>
</div>
@@ -116,7 +146,11 @@
:disabled="isLoading"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<Loader2
v-if="isLoading"
:size="20"
class="mr-2 animate-spin"
/>
<span>{{ isLoading ? 'Wird gesendet...' : 'Registrierung beantragen' }}</span>
</button>
@@ -135,7 +169,10 @@
<!-- Info Box -->
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p class="text-sm text-yellow-800">
<Info :size="16" class="inline mr-1" />
<Info
:size="16"
class="inline mr-1"
/>
<strong>Hinweis:</strong> Ihre Registrierung muss vom Vorstand freigegeben werden.
Sie erhalten eine E-Mail, sobald Ihr Zugang aktiviert wurde.
</p>

View File

@@ -14,7 +14,9 @@
<div class="prose prose-lg max-w-none">
<div class="space-y-8">
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 1 Name, Sitz und Geschäftsjahr</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 1 Name, Sitz und Geschäftsjahr
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Der Verein führt den Namen "Harheimer Tischtennis-Club 1954 e.V." (HTC).</p>
<p><strong>(2)</strong> Der Verein hat seinen Sitz in Frankfurt am Main.</p>
@@ -23,7 +25,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 2 Zweck des Vereins</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 2 Zweck des Vereins
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Der Verein bezweckt die Förderung des Tischtennissports und die Pflege der Geselligkeit seiner Mitglieder.</p>
<p><strong>(2)</strong> Der Verein ist selbstlos tätig; er verfolgt nicht in erster Linie eigenwirtschaftliche Zwecke.</p>
@@ -31,7 +35,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 3 Mitgliedschaft</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 3 Mitgliedschaft
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Mitglied des Vereins kann jede natürliche Person werden, die die Ziele des Vereins unterstützt.</p>
<p><strong>(2)</strong> Der Antrag auf Mitgliedschaft ist schriftlich an den Vorstand zu richten.</p>
@@ -40,7 +46,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 4 Rechte und Pflichten der Mitglieder</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 4 Rechte und Pflichten der Mitglieder
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Die Mitglieder haben das Recht, an den Veranstaltungen des Vereins teilzunehmen und die Einrichtungen des Vereins zu benutzen.</p>
<p><strong>(2)</strong> Die Mitglieder sind verpflichtet, die Satzung und die Beschlüsse der Vereinsorgane zu beachten und den Mitgliedsbeitrag zu entrichten.</p>
@@ -48,7 +56,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 5 Mitgliedsbeiträge</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 5 Mitgliedsbeiträge
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Die Höhe der Mitgliedsbeiträge wird von der Mitgliederversammlung festgesetzt.</p>
<p><strong>(2)</strong> Die Mitgliedsbeiträge sind im Voraus zu entrichten.</p>
@@ -56,7 +66,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 6 Beendigung der Mitgliedschaft</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 6 Beendigung der Mitgliedschaft
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Die Mitgliedschaft endet durch Austritt, Ausschluss oder Tod.</p>
<p><strong>(2)</strong> Der Austritt erfolgt durch schriftliche Erklärung gegenüber dem Vorstand.</p>
@@ -65,7 +77,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 7 Organe des Vereins</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 7 Organe des Vereins
</h3>
<div class="space-y-2 text-gray-700">
<p>Organe des Vereins sind:</p>
<ul class="list-disc list-inside ml-4 space-y-1">
@@ -76,7 +90,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 8 Mitgliederversammlung</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 8 Mitgliederversammlung
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Die Mitgliederversammlung ist das oberste Organ des Vereins.</p>
<p><strong>(2)</strong> Sie wird vom Vorsitzenden mindestens einmal im Jahr einberufen.</p>
@@ -85,7 +101,9 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 9 Vorstand</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 9 Vorstand
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Der Vorstand besteht aus:</p>
<ul class="list-disc list-inside ml-4 space-y-1">
@@ -100,14 +118,18 @@
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 10 Satzungsänderungen</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 10 Satzungsänderungen
</h3>
<div class="space-y-2 text-gray-700">
<p>Satzungsänderungen können nur in einer Mitgliederversammlung mit einer Mehrheit von zwei Dritteln der anwesenden Mitglieder beschlossen werden.</p>
</div>
</div>
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">§ 11 Auflösung des Vereins</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">
§ 11 Auflösung des Vereins
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>(1)</strong> Die Auflösung des Vereins kann nur in einer Mitgliederversammlung mit einer Mehrheit von drei Vierteln der anwesenden Mitglieder beschlossen werden.</p>
<p><strong>(2)</strong> Bei Auflösung des Vereins fällt das Vereinsvermögen an eine gemeinnützige Organisation.</p>
@@ -118,7 +140,9 @@
<div class="mt-12 p-6 bg-primary-50 rounded-lg border border-primary-200">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div>
<h4 class="text-lg font-semibold text-primary-800 mb-2">Satzung als PDF herunterladen</h4>
<h4 class="text-lg font-semibold text-primary-800 mb-2">
Satzung als PDF herunterladen
</h4>
<p class="text-primary-700 text-sm">
Laden Sie die vollständige Satzung als PDF-Dokument herunter.
</p>
@@ -128,7 +152,10 @@
target="_blank"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<FileText :size="20" class="mr-2" />
<FileText
:size="20"
class="mr-2"
/>
PDF herunterladen
</a>
</div>

View File

@@ -5,18 +5,46 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Spielplan</h1>
<p class="mt-2 text-gray-600">Aktuelle Termine und Spiele des Vereins</p>
<h1 class="text-3xl font-bold text-gray-900">
Spielplan
</h1>
<p class="mt-2 text-gray-600">
Aktuelle Termine und Spiele des Vereins
</p>
</div>
<div class="flex items-center space-x-4">
<button @click="refreshData"
<button
:disabled="isLoading"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400">
<svg v-if="isLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="refreshData"
>
<svg
v-if="isLoading"
class="w-4 h-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<svg
v-else
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Aktualisieren
</button>
@@ -28,60 +56,134 @@
<!-- Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-12">
<svg class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<div
v-if="isLoading"
class="text-center py-12"
>
<svg
class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<p class="text-gray-600">Spielplan wird geladen...</p>
<p class="text-gray-600">
Spielplan wird geladen...
</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<svg class="w-12 h-12 text-red-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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
<div
v-else-if="error"
class="bg-red-50 border border-red-200 rounded-lg p-6 text-center"
>
<svg
class="w-12 h-12 text-red-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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h3 class="text-lg font-medium text-red-800 mb-2">Fehler beim Laden</h3>
<p class="text-red-600 mb-4">{{ error }}</p>
<button @click="loadData" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
<h3 class="text-lg font-medium text-red-800 mb-2">
Fehler beim Laden
</h3>
<p class="text-red-600 mb-4">
{{ error }}
</p>
<button
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
@click="loadData"
>
Erneut versuchen
</button>
</div>
<!-- Empty State -->
<div v-else-if="!spielplanData || spielplanData.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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
<div
v-else-if="!spielplanData || spielplanData.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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Kein Spielplan verfügbar</h3>
<p class="text-gray-600">Es wurden noch keine Spielplandaten hochgeladen.</p>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Kein Spielplan verfügbar
</h3>
<p class="text-gray-600">
Es wurden noch keine Spielplandaten hochgeladen.
</p>
</div>
<!-- Spielplan Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">Aktuelle Spiele</h2>
<p class="text-sm text-gray-600 mt-1">{{ spielplanData.length }} Einträge</p>
<h2 class="text-xl font-semibold text-gray-900">
Aktuelle Spiele
</h2>
<p class="text-sm text-gray-600 mt-1">
{{ spielplanData.length }} Einträge
</p>
</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 in headers" :key="header"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
v-for="header in headers"
:key="header"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{{ formatHeader(header) }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, index) in spielplanData" :key="index"
:class="index % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
<td v-for="header in headers" :key="header"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span v-if="header.toLowerCase().includes('datum')" class="font-mono">
<tr
v-for="(row, index) in spielplanData"
:key="index"
:class="index % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
>
<td
v-for="header in headers"
:key="header"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
<span
v-if="header.toLowerCase().includes('datum')"
class="font-mono"
>
{{ formatDate(row[header]) }}
</span>
<span v-else-if="header.toLowerCase().includes('uhrzeit')" class="font-mono">
<span
v-else-if="header.toLowerCase().includes('uhrzeit')"
class="font-mono"
>
{{ formatTime(row[header]) }}
</span>
<span v-else>

View File

@@ -15,31 +15,34 @@
<button
v-for="kategorie in verfuegbareKategorien"
:key="kategorie"
@click="selectedCategory = kategorie"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
selectedCategory === kategorie
? 'bg-primary-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
]"
@click="selectedCategory = kategorie"
>
{{ kategorie }}
</button>
<button
@click="selectedCategory = 'alle'"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
selectedCategory === 'alle'
? 'bg-primary-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
]"
@click="selectedCategory = 'alle'"
>
Alle Kategorien
</button>
</div>
<!-- Spielsysteme -->
<div v-if="filteredSystems.length > 0" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-if="filteredSystems.length > 0"
class="grid md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<div
v-for="system in filteredSystems"
:key="system.name"
@@ -51,7 +54,10 @@
{{ system.name }}
</h3>
<div class="flex items-center mb-3">
<Users :size="16" class="text-primary-600 mr-2" />
<Users
:size="16"
class="text-primary-600 mr-2"
/>
<span class="text-sm font-medium text-gray-600">{{ system.mannschaftsgroesse }}</span>
</div>
</div>
@@ -70,31 +76,60 @@
</p>
<div class="space-y-2 text-sm">
<div v-if="system.spielabfolge" class="flex items-center">
<Calendar :size="14" class="text-primary-600 mr-2 flex-shrink-0" />
<div
v-if="system.spielabfolge"
class="flex items-center"
>
<Calendar
:size="14"
class="text-primary-600 mr-2 flex-shrink-0"
/>
<span class="text-gray-600"><strong>Spielabfolge:</strong> {{ system.spielabfolge }}</span>
</div>
<div v-if="system.anzahl_spiele" class="flex items-center">
<Hash :size="14" class="text-primary-600 mr-2 flex-shrink-0" />
<div
v-if="system.anzahl_spiele"
class="flex items-center"
>
<Hash
:size="14"
class="text-primary-600 mr-2 flex-shrink-0"
/>
<span class="text-gray-600"><strong>Anzahl Spiele:</strong> {{ system.anzahl_spiele }}</span>
</div>
<div v-if="system.besonderheiten" class="flex items-center">
<Star :size="14" class="text-primary-600 mr-2 flex-shrink-0" />
<div
v-if="system.besonderheiten"
class="flex items-center"
>
<Star
:size="14"
class="text-primary-600 mr-2 flex-shrink-0"
/>
<span class="text-gray-600"><strong>Besonderheiten:</strong> {{ system.besonderheiten }}</span>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12 bg-white rounded-xl shadow-lg">
<Settings :size="48" class="text-gray-400 mx-auto mb-4" />
<p class="text-gray-600">Keine Spielsysteme für die ausgewählte Kategorie gefunden.</p>
<div
v-else
class="text-center py-12 bg-white rounded-xl shadow-lg"
>
<Settings
:size="48"
class="text-gray-400 mx-auto mb-4"
/>
<p class="text-gray-600">
Keine Spielsysteme für die ausgewählte Kategorie gefunden.
</p>
</div>
<!-- Zusätzliche Informationen -->
<div class="mt-12 bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-8 text-white">
<h3 class="text-2xl font-display font-bold mb-6 flex items-center">
<BookOpen :size="28" class="mr-3" />
<BookOpen
:size="28"
class="mr-3"
/>
Weitere Informationen
</h3>
<div class="space-y-4">
@@ -112,13 +147,15 @@
target="_blank"
class="inline-flex items-center px-6 py-3 bg-white text-primary-600 font-semibold rounded-lg hover:bg-gray-100 transition-colors"
>
<ExternalLink :size="20" class="mr-2" />
<ExternalLink
:size="20"
class="mr-2"
/>
Detaillierte Erklärungen auf Wikiwand
</a>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -11,7 +11,10 @@
</p>
</div>
<div v-if="naechsteTermine.length > 0" class="space-y-4">
<div
v-if="naechsteTermine.length > 0"
class="space-y-4"
>
<div
v-for="(termin, index) in naechsteTermine"
:key="index"
@@ -25,14 +28,22 @@
<div class="flex-1">
<div class="flex items-start justify-between">
<div>
<h3 class="text-xl font-semibold text-gray-900 mb-1">{{ termin.titel }}</h3>
<p class="text-gray-600 mb-2">{{ termin.beschreibung }}</p>
<p class="text-sm text-gray-500">{{ formatFullDateTime(termin.datum, termin.uhrzeit) }}</p>
<h3 class="text-xl font-semibold text-gray-900 mb-1">
{{ termin.titel }}
</h3>
<p class="text-gray-600 mb-2">
{{ termin.beschreibung }}
</p>
<p class="text-sm text-gray-500">
{{ formatFullDateTime(termin.datum, termin.uhrzeit) }}
</p>
</div>
<span :class="[
<span
:class="[
'px-3 py-1 text-sm font-medium rounded-full',
termin.kategorie === 'Turnier' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'
]">
]"
>
{{ termin.kategorie }}
</span>
</div>
@@ -41,9 +52,17 @@
</div>
</div>
<div v-else class="text-center py-16 bg-white rounded-xl shadow-lg">
<Calendar :size="64" class="text-gray-400 mx-auto mb-4" />
<h3 class="text-2xl font-semibold text-gray-900 mb-2">Keine kommenden Termine</h3>
<div
v-else
class="text-center py-16 bg-white rounded-xl shadow-lg"
>
<Calendar
:size="64"
class="text-gray-400 mx-auto mb-4"
/>
<h3 class="text-2xl font-semibold text-gray-900 mb-2">
Keine kommenden Termine
</h3>
<p class="text-gray-600">
Aktuell sind keine Termine geplant. Schauen Sie bald wieder vorbei!
</p>

View File

@@ -17,37 +17,63 @@
</h3>
<ul class="space-y-3">
<li class="flex items-start">
<Check :size="24" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<Check
:size="24"
class="text-primary-600 mr-3 flex-shrink-0 mt-0.5"
/>
<span class="text-gray-700">Keine Vorkenntnisse nötig</span>
</li>
<li class="flex items-start">
<Check :size="24" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<Check
:size="24"
class="text-primary-600 mr-3 flex-shrink-0 mt-0.5"
/>
<span class="text-gray-700">Schläger und Material werden gestellt</span>
</li>
<li class="flex items-start">
<Check :size="24" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<Check
:size="24"
class="text-primary-600 mr-3 flex-shrink-0 mt-0.5"
/>
<span class="text-gray-700">Sportkleidung und Hallenschuhe mitbringen</span>
</li>
<li class="flex items-start">
<Check :size="24" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<Check
:size="24"
class="text-primary-600 mr-3 flex-shrink-0 mt-0.5"
/>
<span class="text-gray-700">3x kostenlos Probetraining</span>
</li>
<li class="flex items-start">
<Check :size="24" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<Check
:size="24"
class="text-primary-600 mr-3 flex-shrink-0 mt-0.5"
/>
<span class="text-gray-700">Einstieg jederzeit möglich</span>
</li>
</ul>
</div>
<div v-if="config" class="bg-primary-50 p-8 rounded-xl border border-primary-100 not-prose">
<div
v-if="config"
class="bg-primary-50 p-8 rounded-xl border border-primary-100 not-prose"
>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4">
Anfängergruppen
</h3>
<div class="space-y-4 mb-6">
<div v-for="(zeiten, gruppe) in groupedZeiten" :key="gruppe">
<h4 class="font-semibold text-gray-900 mb-1">{{ gruppe }}</h4>
<div
v-for="(zeiten, gruppe) in groupedZeiten"
:key="gruppe"
>
<h4 class="font-semibold text-gray-900 mb-1">
{{ gruppe }}
</h4>
<div class="text-gray-600">
<p v-for="zeit in zeiten" :key="zeit.id">
<p
v-for="zeit in zeiten"
:key="zeit.id"
>
{{ zeit.tag }}, {{ zeit.von }} - {{ zeit.bis }} Uhr
</p>
</div>

View File

@@ -7,22 +7,37 @@
<div class="w-24 h-1 bg-primary-600 mb-8" />
<!-- Trainingsort -->
<div v-if="config" class="bg-white rounded-xl shadow-lg p-8 mb-12">
<div
v-if="config"
class="bg-white rounded-xl shadow-lg p-8 mb-12"
>
<div class="flex items-start space-x-4 mb-6">
<MapPin :size="32" class="text-primary-600 flex-shrink-0" />
<MapPin
:size="32"
class="text-primary-600 flex-shrink-0"
/>
<div>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">Trainingsort</h2>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-4">
Trainingsort
</h2>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{{ config.training.ort.name }}
</h3>
<p class="text-gray-700 mb-1">{{ config.training.ort.strasse }}</p>
<p class="text-gray-700 mb-4">{{ config.training.ort.plz }} {{ config.training.ort.ort }}</p>
<p class="text-gray-700 mb-1">
{{ config.training.ort.strasse }}
</p>
<p class="text-gray-700 mb-4">
{{ config.training.ort.plz }} {{ config.training.ort.ort }}
</p>
<a
:href="`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(config.training.ort.strasse + ' ' + config.training.ort.plz + ' ' + config.training.ort.ort)}`"
target="_blank"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors text-sm"
>
<MapPin :size="16" class="mr-2" />
<MapPin
:size="16"
class="mr-2"
/>
Anfahrtsplan anzeigen
</a>
</div>
@@ -34,7 +49,10 @@
Trainingszeiten
</h2>
<div v-if="config" class="grid gap-6 mb-12">
<div
v-if="config"
class="grid gap-6 mb-12"
>
<div
v-for="(zeiten, gruppe) in groupedZeiten"
:key="gruppe"
@@ -42,18 +60,30 @@
>
<div class="flex items-start justify-between">
<div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">{{ gruppe }}</h3>
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">
{{ gruppe }}
</h3>
<div class="space-y-2">
<p
<div
v-for="zeit in zeiten"
:key="zeit.id"
class="text-lg font-semibold text-primary-600"
>
<p class="text-lg font-semibold text-primary-600">
{{ zeit.tag }}: {{ zeit.von }} - {{ zeit.bis }} Uhr
</p>
<p
v-if="zeit.info"
class="text-sm text-gray-600 mt-1 italic"
>
{{ zeit.info }}
</p>
</div>
</div>
<Clock :size="32" class="text-primary-600" />
</div>
<Clock
:size="32"
class="text-primary-600"
/>
</div>
</div>
</div>

View File

@@ -10,21 +10,38 @@
Erfahrene und qualifizierte Trainer für alle Leistungsstufen
</p>
<div v-if="config" class="grid md:grid-cols-3 gap-8">
<div
v-if="config"
class="grid md:grid-cols-3 gap-8"
>
<div
v-for="trainer in config.trainer"
:key="trainer.id"
class="bg-white p-8 rounded-xl shadow-lg"
>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">{{ trainer.lizenz }}</h3>
<p class="text-gray-600 mb-4">{{ trainer.name }}</p>
<div
v-if="trainer.imageFilename"
class="mb-4 flex justify-center"
>
<img
:src="`/api/personen/${trainer.imageFilename}?width=200&height=200`"
:alt="trainer.name"
class="w-32 h-32 object-cover rounded-full border-4 border-primary-100 shadow-md"
loading="lazy"
>
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">
{{ trainer.lizenz }}
</h3>
<p class="text-gray-600 mb-4">
{{ trainer.name }}
</p>
<p class="text-sm text-gray-500">
Lizenz: {{ trainer.lizenz }}<br />
Schwerpunkt: {{ trainer.schwerpunkt }}<span v-if="trainer.zusatz"><br />{{ trainer.zusatz }}</span>
Lizenz: {{ trainer.lizenz }}<br>
Schwerpunkt: {{ trainer.schwerpunkt }}<span v-if="trainer.zusatz"><br>{{ trainer.zusatz }}</span>
</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -16,11 +16,18 @@
<div class="bg-white rounded-xl shadow-lg p-8 border border-gray-100 flex flex-col h-full">
<div class="flex items-center mb-6">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center mr-4">
<Globe :size="24" class="text-white" />
<Globe
:size="24"
class="text-white"
/>
</div>
<div>
<h2 class="text-2xl font-display font-bold text-gray-900">Offizielles ITTF-Reglement</h2>
<p class="text-gray-600">Internationale Tischtennis-Regeln</p>
<h2 class="text-2xl font-display font-bold text-gray-900">
Offizielles ITTF-Reglement
</h2>
<p class="text-gray-600">
Internationale Tischtennis-Regeln
</p>
</div>
</div>
@@ -52,11 +59,18 @@
<div class="bg-white rounded-xl shadow-lg p-8 border border-gray-100 flex flex-col h-full">
<div class="flex items-center mb-6">
<div class="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center mr-4">
<FileText :size="24" class="text-white" />
<FileText
:size="24"
class="text-white"
/>
</div>
<div>
<h2 class="text-2xl font-display font-bold text-gray-900">Tischtennis-Regeln Light</h2>
<p class="text-gray-600">Vereinfachte Übersicht</p>
<h2 class="text-2xl font-display font-bold text-gray-900">
Tischtennis-Regeln Light
</h2>
<p class="text-gray-600">
Vereinfachte Übersicht
</p>
</div>
</div>
@@ -91,9 +105,14 @@
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="text-center p-6 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Target :size="32" class="text-primary-600" />
<Target
:size="32"
class="text-primary-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Spielfeld</h3>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
Spielfeld
</h3>
<p class="text-gray-600 text-sm">
Tisch: 2,74m × 1,525m, Höhe: 76cm<br>
Netz: 15,25cm hoch
@@ -102,9 +121,14 @@
<div class="text-center p-6 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Circle :size="32" class="text-primary-600" />
<Circle
:size="32"
class="text-primary-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Ball</h3>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
Ball
</h3>
<p class="text-gray-600 text-sm">
Durchmesser: 40mm<br>
Gewicht: 2,7g
@@ -113,9 +137,14 @@
<div class="text-center p-6 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap :size="32" class="text-primary-600" />
<Zap
:size="32"
class="text-primary-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Schläger</h3>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
Schläger
</h3>
<p class="text-gray-600 text-sm">
Belag: schwarz + farbig<br>
(rot, grün, pink, blau, gelb, lila)<br>
@@ -125,9 +154,14 @@
<div class="text-center p-6 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Play :size="32" class="text-primary-600" />
<Play
:size="32"
class="text-primary-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aufschlag</h3>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
Aufschlag
</h3>
<p class="text-gray-600 text-sm">
Ball muss sichtbar hochgeworfen werden<br>
Mindestens 16cm Höhe
@@ -136,9 +170,14 @@
<div class="text-center p-6 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Trophy :size="32" class="text-primary-600" />
<Trophy
:size="32"
class="text-primary-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Satz</h3>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
Satz
</h3>
<p class="text-gray-600 text-sm">
Gewinn bei 11 Punkten<br>
Mindestens 2 Punkte Vorsprung
@@ -147,9 +186,14 @@
<div class="text-center p-6 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Users :size="32" class="text-primary-600" />
<Users
:size="32"
class="text-primary-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Spiel</h3>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
Spiel
</h3>
<p class="text-gray-600 text-sm">
Best of 5 oder 7 Sätze<br>
Wechsel alle 2 Punkte
@@ -161,7 +205,10 @@
<!-- Zusätzliche Informationen -->
<div class="bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-8 text-white">
<h3 class="text-2xl font-display font-bold mb-6 flex items-center">
<BookOpen :size="28" class="mr-3" />
<BookOpen
:size="28"
class="mr-3"
/>
Weitere Informationen
</h3>
<div class="space-y-4">
@@ -172,7 +219,11 @@
</p>
<p class="text-primary-100 leading-relaxed">
Bei Fragen zu spezifischen Regeln wenden Sie sich an den
<a href="https://www.tischtennis.de" target="_blank" class="underline hover:text-white">
<a
href="https://www.tischtennis.de"
target="_blank"
class="underline hover:text-white"
>
Deutschen Tischtennis-Bund (DTTB)
</a> oder Ihren regionalen Verband.
</p>

View File

@@ -6,12 +6,22 @@
</h1>
<!-- Galerie-Grid -->
<div v-if="loading" class="text-center py-12">
<p class="text-gray-500">Bilder werden geladen...</p>
<div
v-if="loading"
class="text-center py-12"
>
<p class="text-gray-500">
Bilder werden geladen...
</p>
</div>
<div v-else-if="images.length === 0" class="text-center py-12">
<p class="text-gray-500">Noch keine Bilder in der Galerie.</p>
<div
v-else-if="images.length === 0"
class="text-center py-12"
>
<p class="text-gray-500">
Noch keine Bilder in der Galerie.
</p>
</div>
<div v-else>
@@ -32,10 +42,15 @@
class="object-cover group-hover:scale-105 transition-transform duration-300 pointer-events-none"
style="max-width: 150px; max-height: 150px; width: auto; height: auto;"
loading="lazy"
/>
>
</div>
<div class="p-4">
<h3 class="font-semibold text-gray-900 mb-1 cursor-pointer" @click="openModal(image)">{{ image.title }}</h3>
<h3
class="font-semibold text-gray-900 mb-1 cursor-pointer"
@click="openModal(image)"
>
{{ image.title }}
</h3>
<div class="mt-2 flex items-center justify-between">
<span
v-if="!image.isPublic"
@@ -50,9 +65,9 @@
<!-- Lösch-Button für Admins -->
<button
v-if="isAdmin || isVorstand"
@click.stop="deleteImage(image.id)"
:disabled="deleting === image.id"
class="mt-2 w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@click.stop="deleteImage(image.id)"
>
{{ deleting === image.id ? 'Wird gelöscht...' : 'Löschen' }}
</button>
@@ -61,11 +76,14 @@
</div>
<!-- Pagination -->
<div v-if="pagination.totalPages > 1" class="flex justify-center items-center space-x-2 mb-8">
<div
v-if="pagination.totalPages > 1"
class="flex justify-center items-center space-x-2 mb-8"
>
<button
@click="changePage(pagination.page - 1)"
:disabled="pagination.page === 1"
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@click="changePage(pagination.page - 1)"
>
Zurück
</button>
@@ -73,9 +91,9 @@
Seite {{ pagination.page }} von {{ pagination.totalPages }} ({{ pagination.total }} Bilder)
</span>
<button
@click="changePage(pagination.page + 1)"
:disabled="pagination.page >= pagination.totalPages"
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@click="changePage(pagination.page + 1)"
>
Weiter
</button>
@@ -83,12 +101,17 @@
</div>
<!-- Upload-Bereich für Admins (Collapsible) -->
<div v-if="isAdmin || isVorstand" class="mt-12">
<button
@click="showUploadForm = !showUploadForm"
class="w-full flex items-center justify-between p-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 hover:bg-gray-100 transition-colors"
<div
v-if="isAdmin || isVorstand"
class="mt-12"
>
<h2 class="text-xl font-semibold text-gray-900">Bild hochladen</h2>
<button
class="w-full flex items-center justify-between p-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 hover:bg-gray-100 transition-colors"
@click="showUploadForm = !showUploadForm"
>
<h2 class="text-xl font-semibold text-gray-900">
Bild hochladen
</h2>
<svg
class="w-6 h-6 text-gray-600 transition-transform"
:class="{ 'rotate-180': showUploadForm }"
@@ -96,23 +119,34 @@
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<div v-if="showUploadForm" class="mt-4 p-6 bg-gray-50 rounded-lg border border-gray-300">
<form @submit.prevent="uploadImage" class="space-y-4">
<div
v-if="showUploadForm"
class="mt-4 p-6 bg-gray-50 rounded-lg border border-gray-300"
>
<form
class="space-y-4"
@submit.prevent="uploadImage"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Bilddatei
</label>
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
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-600 file:text-white hover:file:bg-primary-700"
required
/>
@change="handleFileSelect"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
@@ -124,7 +158,7 @@
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Titel des Bildes"
required
/>
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
@@ -139,12 +173,15 @@
</div>
<div class="flex items-center">
<input
id="isPublic"
v-model="uploadForm.isPublic"
type="checkbox"
id="isPublic"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="isPublic" class="ml-2 block text-sm text-gray-700">
>
<label
for="isPublic"
class="ml-2 block text-sm text-gray-700"
>
Öffentlich sichtbar (für alle Besucher)
</label>
</div>
@@ -155,8 +192,18 @@
>
{{ uploading ? 'Wird hochgeladen...' : 'Bild hochladen' }}
</button>
<p v-if="uploadError" class="text-red-600 text-sm">{{ uploadError }}</p>
<p v-if="uploadSuccess" class="text-green-600 text-sm">{{ uploadSuccess }}</p>
<p
v-if="uploadError"
class="text-red-600 text-sm"
>
{{ uploadError }}
</p>
<p
v-if="uploadSuccess"
class="text-green-600 text-sm"
>
{{ uploadSuccess }}
</p>
</form>
</div>
</div>
@@ -167,38 +214,71 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
@click="closeModal"
>
<div class="relative max-w-5xl max-h-full w-full" @click.stop>
<div
class="relative max-w-5xl max-h-full w-full"
@click.stop
>
<!-- Schließen-Button -->
<button
@click="closeModal"
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-2"
@click="closeModal"
>
<svg class="w-8 h-8" 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
class="w-8 h-8"
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>
<!-- Vorheriges Bild Button -->
<button
v-if="hasPreviousImage"
@click.stop="showPreviousImage"
class="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
aria-label="Vorheriges Bild"
@click.stop="showPreviousImage"
>
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<svg
class="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<!-- Nächstes Bild Button -->
<button
v-if="hasNextImage"
@click.stop="showNextImage"
class="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
aria-label="Nächstes Bild"
@click.stop="showNextImage"
>
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<svg
class="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
@@ -206,10 +286,15 @@
:src="`/api/galerie/${selectedImage.id}`"
:alt="selectedImage.title"
class="max-w-[90%] max-h-[90vh] object-contain mx-auto"
/>
>
<div class="mt-4 text-white text-center">
<h3 class="text-xl font-semibold">{{ selectedImage.title }}</h3>
<p v-if="selectedImage.description" class="mt-2 text-gray-300">
<h3 class="text-xl font-semibold">
{{ selectedImage.title }}
</h3>
<p
v-if="selectedImage.description"
class="mt-2 text-gray-300"
>
{{ selectedImage.description }}
</p>
<p class="mt-2 text-sm text-gray-400">
@@ -250,7 +335,10 @@ const uploadForm = ref({
})
const isAdmin = computed(() => authStore.isAdmin)
const isVorstand = computed(() => authStore.user?.role === 'vorstand')
const isVorstand = computed(() => {
const roles = authStore.user?.roles || (authStore.user?.role ? [authStore.user.role] : [])
return roles.includes('vorstand')
})
useHead({
title: 'Galerie - Harheimer TC',

View File

@@ -4,7 +4,10 @@
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
Geschichte
</h1>
<div class="prose prose-lg max-w-none" v-html="content" />
<div
class="prose prose-lg max-w-none"
v-html="content"
/>
</div>
</div>
</template>

View File

@@ -5,17 +5,35 @@
Satzung
</h1>
<div class="prose prose-lg max-w-none mb-8" v-html="content" />
<div
class="prose prose-lg max-w-none mb-8"
v-html="content"
/>
<div v-if="pdfUrl" class="mt-8 p-6 bg-gray-50 rounded-lg">
<h3 class="text-lg font-semibold mb-4">PDF-Download</h3>
<div
v-if="pdfUrl"
class="mt-8 p-6 bg-gray-50 rounded-lg"
>
<h3 class="text-lg font-semibold mb-4">
PDF-Download
</h3>
<a
:href="pdfUrl"
target="_blank"
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
<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="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
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="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>
Satzung als PDF herunterladen
</a>

View File

@@ -4,7 +4,10 @@
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
TT-Regeln
</h1>
<div class="prose prose-lg max-w-none" v-html="content" />
<div
class="prose prose-lg max-w-none"
v-html="content"
/>
</div>
</div>
</template>

View File

@@ -4,10 +4,12 @@
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
Über uns
</h1>
<div class="prose prose-lg max-w-none" v-html="content" />
<div
class="prose prose-lg max-w-none"
v-html="content"
/>
</div>
</div>
</template>
<script setup>

View File

@@ -15,54 +15,70 @@
<button
v-for="jahr in verfuegbareJahre"
:key="jahr"
@click="selectedYear = jahr"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
selectedYear === jahr
? 'bg-primary-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
]"
@click="selectedYear = jahr"
>
{{ jahr }}
</button>
<button
@click="selectedYear = 'alle'"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
selectedYear === 'alle'
? 'bg-primary-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
]"
@click="selectedYear = 'alle'"
>
Alle Jahre
</button>
</div>
<!-- Ergebnisse -->
<div v-if="filteredResults.length > 0" class="space-y-8">
<div
v-if="filteredResults.length > 0"
class="space-y-8"
>
<div
v-for="jahr in sortedJahre"
:key="jahr"
class="bg-white rounded-xl shadow-lg p-6"
>
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6 flex items-center">
<Trophy :size="28" class="text-primary-600 mr-3" />
<Trophy
:size="28"
class="text-primary-600 mr-3"
/>
{{ jahr }}
</h2>
<!-- Besondere Bemerkungen -->
<div v-if="sortedGroupedResults[jahr]?.bemerkungen" class="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-yellow-800 font-medium">{{ sortedGroupedResults[jahr].bemerkungen }}</p>
<div
v-if="sortedGroupedResults[jahr]?.bemerkungen"
class="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg"
>
<p class="text-yellow-800 font-medium">
{{ sortedGroupedResults[jahr].bemerkungen }}
</p>
</div>
<!-- Kategorien -->
<div v-if="sortedGroupedResults[jahr]?.kategorien" class="space-y-6">
<div
v-if="sortedGroupedResults[jahr]?.kategorien"
class="space-y-6"
>
<div
v-for="(kategorieData, kategorie) in sortedGroupedResults[jahr].kategorien"
:key="kategorie"
class="border-l-4 border-primary-600 pl-4"
>
<h3 class="text-xl font-semibold text-gray-900 mb-4">{{ kategorie }}</h3>
<h3 class="text-xl font-semibold text-gray-900 mb-4">
{{ kategorie }}
</h3>
<div class="grid gap-3">
<div
@@ -88,15 +104,51 @@
>
{{ ergebnis.platz }}
</div>
<div class="flex items-center gap-2">
<div
v-if="ergebnis.imageFilename1"
class="flex-shrink-0"
>
<img
:src="`/api/personen/${ergebnis.imageFilename1}?width=40&height=40`"
:alt="ergebnis.spieler1"
class="w-10 h-10 rounded-full object-cover border-2 border-gray-300 cursor-pointer hover:border-primary-500 transition-colors"
loading="lazy"
@click="openLightbox(ergebnis.imageFilename1, ergebnis.spieler1)"
>
</div>
<div>
<span class="font-semibold text-gray-900">
{{ ergebnis.spieler1 }}
<span v-if="ergebnis.spieler2" class="text-gray-600">
</span>
<span
v-if="ergebnis.spieler2"
class="text-gray-600"
>
<span
v-if="ergebnis.imageFilename2"
class="ml-2 inline-flex items-center gap-2"
>
/
<img
:src="`/api/personen/${ergebnis.imageFilename2}?width=40&height=40`"
:alt="ergebnis.spieler2"
class="w-10 h-10 rounded-full object-cover border-2 border-gray-300 cursor-pointer hover:border-primary-500 transition-colors"
loading="lazy"
@click="openLightbox(ergebnis.imageFilename2, ergebnis.spieler2)"
>
{{ ergebnis.spieler2 }}
</span>
<span
v-else
class="text-gray-600"
>
/ {{ ergebnis.spieler2 }}
</span>
</span>
</div>
</div>
</div>
<div class="text-sm text-gray-500">
{{ ergebnis.platz === '1' ? 'Vereinsmeister' : ergebnis.platz + '. Platz' }}
</div>
@@ -107,26 +159,48 @@
</div>
</div>
<div v-else class="text-center py-12 bg-white rounded-xl shadow-lg">
<Trophy :size="48" class="text-gray-400 mx-auto mb-4" />
<p class="text-gray-600">Keine Ergebnisse für das ausgewählte Jahr gefunden.</p>
<div
v-else
class="text-center py-12 bg-white rounded-xl shadow-lg"
>
<Trophy
:size="48"
class="text-gray-400 mx-auto mb-4"
/>
<p class="text-gray-600">
Keine Ergebnisse für das ausgewählte Jahr gefunden.
</p>
</div>
<!-- Statistik -->
<div class="mt-12 bg-gradient-to-r from-primary-600 to-primary-700 rounded-xl p-8 text-white">
<h3 class="text-2xl font-display font-bold mb-6">Statistik</h3>
<h3 class="text-2xl font-display font-bold mb-6">
Statistik
</h3>
<div class="grid md:grid-cols-3 gap-6">
<div class="text-center">
<div class="text-3xl font-bold mb-2">{{ verfuegbareJahre.length }}</div>
<div class="text-primary-100">Jahre mit Meisterschaften</div>
<div class="text-3xl font-bold mb-2">
{{ verfuegbareJahre.length }}
</div>
<div class="text-primary-100">
Jahre mit Meisterschaften
</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold mb-2">{{ totalWinners }}</div>
<div class="text-primary-100">Einzelgewinner</div>
<div class="text-3xl font-bold mb-2">
{{ totalWinners }}
</div>
<div class="text-primary-100">
Einzelgewinner
</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold mb-2">{{ totalDoubles }}</div>
<div class="text-primary-100">Doppelgewinner</div>
<div class="text-3xl font-bold mb-2">
{{ totalDoubles }}
</div>
<div class="text-primary-100">
Doppelgewinner
</div>
</div>
</div>
</div>
@@ -135,7 +209,10 @@
<div class="mt-8 text-center">
<div class="bg-white rounded-xl shadow-lg p-8 border-l-4 border-primary-600">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-4 flex items-center justify-center">
<Trophy :size="32" class="text-primary-600 mr-3" />
<Trophy
:size="32"
class="text-primary-600 mr-3"
/>
Herzlichen Glückwunsch!
</h3>
<p class="text-lg text-gray-700 leading-relaxed">
@@ -147,6 +224,52 @@
</div>
</div>
</div>
<!-- Lightbox für Bilder -->
<div
v-if="lightboxImage"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
tabindex="0"
@click="closeLightbox"
@keydown="handleLightboxKeydown"
>
<div
class="relative max-w-5xl max-h-full"
@click.stop
>
<!-- Close Button -->
<button
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
aria-label="Schließen"
@click="closeLightbox"
>
<svg
class="w-8 h-8"
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>
<img
:src="`/api/personen/${lightboxImage.filename}`"
:alt="lightboxImage.name"
class="max-w-[90%] max-h-[90vh] object-contain mx-auto"
>
<div class="mt-4 text-white text-center">
<h3 class="text-xl font-semibold">
{{ lightboxImage.name }}
</h3>
</div>
</div>
</div>
</div>
</template>
@@ -156,6 +279,7 @@ import { Trophy } from 'lucide-vue-next'
const results = ref([])
const selectedYear = ref('alle')
const lightboxImage = ref(null)
const loadResults = async () => {
try {
@@ -188,6 +312,7 @@ const loadResults = async () => {
}
values.push(current.trim())
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
if (values.length < 6) return null
return {
@@ -196,7 +321,9 @@ const loadResults = async () => {
platz: values[2].trim(),
spieler1: values[3].trim(),
spieler2: values[4].trim(),
bemerkung: values[5].trim()
bemerkung: values[5].trim(),
imageFilename1: values[6]?.trim() || '',
imageFilename2: values[7]?.trim() || ''
}
}).filter(result => result !== null)
} catch (error) {
@@ -268,6 +395,26 @@ const totalDoubles = computed(() => {
return results.value.filter(r => r.kategorie === 'Doppel' && r.platz === '1').length
})
function openLightbox(filename, name) {
lightboxImage.value = { filename, name }
document.body.style.overflow = 'hidden'
setTimeout(() => {
const modal = document.querySelector('[tabindex="0"]')
if (modal) modal.focus()
}, 100)
}
function closeLightbox() {
lightboxImage.value = null
document.body.style.overflow = ''
}
function handleLightboxKeydown(event) {
if (event.key === 'Escape') {
closeLightbox()
}
}
onMounted(() => {
loadResults()
})

View File

@@ -11,114 +11,165 @@
Unser engagiertes Vorstandsteam leitet den Harheimer TC mit Herz und Sachverstand.
</p>
<div v-if="config" class="grid md:grid-cols-2 gap-8 not-prose">
<div
v-if="config"
class="grid md:grid-cols-2 gap-8 not-prose"
>
<!-- Vorsitzender -->
<div v-if="config.vorstand.vorsitzender.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Vorsitzender</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}
</h4>
<div class="space-y-1 text-gray-600">
<p v-if="config.vorstand.vorsitzender.strasse">{{ config.vorstand.vorsitzender.strasse }}</p>
<p v-if="config.vorstand.vorsitzender.plz">{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}</p>
<p v-if="config.vorstand.vorsitzender.telefon">Tel. {{ config.vorstand.vorsitzender.telefon }}</p>
<PersonCard
v-if="config.vorstand.vorsitzender.vorname"
title="Vorsitzender"
:name="`${config.vorstand.vorsitzender.vorname} ${config.vorstand.vorsitzender.nachname}`"
:image-filename="config.vorstand.vorsitzender.imageFilename"
>
<p v-if="config.vorstand.vorsitzender.strasse">
{{ config.vorstand.vorsitzender.strasse }}
</p>
<p v-if="config.vorstand.vorsitzender.plz">
{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}
</p>
<p v-if="config.vorstand.vorsitzender.telefon">
Tel. {{ config.vorstand.vorsitzender.telefon }}
</p>
<p v-if="config.vorstand.vorsitzender.email">
<a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">
<a
:href="`mailto:${config.vorstand.vorsitzender.email}`"
class="text-primary-600 hover:underline"
>
{{ config.vorstand.vorsitzender.email }}
</a>
</p>
</div>
</div>
</PersonCard>
<!-- Stellvertreter -->
<div v-if="config.vorstand.stellvertreter.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Stellvertreter</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }}
</h4>
<div class="space-y-1 text-gray-600">
<p v-if="config.vorstand.stellvertreter.strasse">{{ config.vorstand.stellvertreter.strasse }}</p>
<p v-if="config.vorstand.stellvertreter.plz">{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}</p>
<p v-if="config.vorstand.stellvertreter.telefon">Tel. {{ config.vorstand.stellvertreter.telefon }}</p>
<PersonCard
v-if="config.vorstand.stellvertreter.vorname"
title="Stellvertreter"
:name="`${config.vorstand.stellvertreter.vorname} ${config.vorstand.stellvertreter.nachname}`"
:image-filename="config.vorstand.stellvertreter.imageFilename"
>
<p v-if="config.vorstand.stellvertreter.strasse">
{{ config.vorstand.stellvertreter.strasse }}
</p>
<p v-if="config.vorstand.stellvertreter.plz">
{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}
</p>
<p v-if="config.vorstand.stellvertreter.telefon">
Tel. {{ config.vorstand.stellvertreter.telefon }}
</p>
<p v-if="config.vorstand.stellvertreter.email">
<a :href="`mailto:${config.vorstand.stellvertreter.email}`" class="text-primary-600 hover:underline">
<a
:href="`mailto:${config.vorstand.stellvertreter.email}`"
class="text-primary-600 hover:underline"
>
{{ config.vorstand.stellvertreter.email }}
</a>
</p>
</div>
</div>
</PersonCard>
<!-- Kassenwart -->
<div v-if="config.vorstand.kassenwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Kassenwart</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }}
</h4>
<div class="space-y-1 text-gray-600">
<p v-if="config.vorstand.kassenwart.strasse">{{ config.vorstand.kassenwart.strasse }}</p>
<p v-if="config.vorstand.kassenwart.plz">{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}</p>
<p v-if="config.vorstand.kassenwart.telefon">Tel. {{ config.vorstand.kassenwart.telefon }}</p>
<PersonCard
v-if="config.vorstand.kassenwart.vorname"
title="Kassenwart"
:name="`${config.vorstand.kassenwart.vorname} ${config.vorstand.kassenwart.nachname}`"
:image-filename="config.vorstand.kassenwart.imageFilename"
>
<p v-if="config.vorstand.kassenwart.strasse">
{{ config.vorstand.kassenwart.strasse }}
</p>
<p v-if="config.vorstand.kassenwart.plz">
{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}
</p>
<p v-if="config.vorstand.kassenwart.telefon">
Tel. {{ config.vorstand.kassenwart.telefon }}
</p>
<p v-if="config.vorstand.kassenwart.email">
<a :href="`mailto:${config.vorstand.kassenwart.email}`" class="text-primary-600 hover:underline">
<a
:href="`mailto:${config.vorstand.kassenwart.email}`"
class="text-primary-600 hover:underline"
>
{{ config.vorstand.kassenwart.email }}
</a>
</p>
</div>
</div>
</PersonCard>
<!-- Schriftführer -->
<div v-if="config.vorstand.schriftfuehrer.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Schriftführer</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }}
</h4>
<div class="space-y-1 text-gray-600">
<p v-if="config.vorstand.schriftfuehrer.strasse">{{ config.vorstand.schriftfuehrer.strasse }}</p>
<p v-if="config.vorstand.schriftfuehrer.plz">{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}</p>
<p v-if="config.vorstand.schriftfuehrer.telefon">Tel. {{ config.vorstand.schriftfuehrer.telefon }}</p>
<PersonCard
v-if="config.vorstand.schriftfuehrer.vorname"
title="Schriftführer"
:name="`${config.vorstand.schriftfuehrer.vorname} ${config.vorstand.schriftfuehrer.nachname}`"
:image-filename="config.vorstand.schriftfuehrer.imageFilename"
>
<p v-if="config.vorstand.schriftfuehrer.strasse">
{{ config.vorstand.schriftfuehrer.strasse }}
</p>
<p v-if="config.vorstand.schriftfuehrer.plz">
{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}
</p>
<p v-if="config.vorstand.schriftfuehrer.telefon">
Tel. {{ config.vorstand.schriftfuehrer.telefon }}
</p>
<p v-if="config.vorstand.schriftfuehrer.email">
<a :href="`mailto:${config.vorstand.schriftfuehrer.email}`" class="text-primary-600 hover:underline">
<a
:href="`mailto:${config.vorstand.schriftfuehrer.email}`"
class="text-primary-600 hover:underline"
>
{{ config.vorstand.schriftfuehrer.email }}
</a>
</p>
</div>
</div>
</PersonCard>
<!-- Sportwart -->
<div v-if="config.vorstand.sportwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Sportwart</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }}
</h4>
<div class="space-y-1 text-gray-600">
<p v-if="config.vorstand.sportwart.strasse">{{ config.vorstand.sportwart.strasse }}</p>
<p v-if="config.vorstand.sportwart.plz">{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}</p>
<p v-if="config.vorstand.sportwart.telefon">Tel. {{ config.vorstand.sportwart.telefon }}</p>
<PersonCard
v-if="config.vorstand.sportwart.vorname"
title="Sportwart"
:name="`${config.vorstand.sportwart.vorname} ${config.vorstand.sportwart.nachname}`"
:image-filename="config.vorstand.sportwart.imageFilename"
>
<p v-if="config.vorstand.sportwart.strasse">
{{ config.vorstand.sportwart.strasse }}
</p>
<p v-if="config.vorstand.sportwart.plz">
{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}
</p>
<p v-if="config.vorstand.sportwart.telefon">
Tel. {{ config.vorstand.sportwart.telefon }}
</p>
<p v-if="config.vorstand.sportwart.email">
<a :href="`mailto:${config.vorstand.sportwart.email}`" class="text-primary-600 hover:underline">
<a
:href="`mailto:${config.vorstand.sportwart.email}`"
class="text-primary-600 hover:underline"
>
{{ config.vorstand.sportwart.email }}
</a>
</p>
</div>
</div>
</PersonCard>
<!-- Jugendwart -->
<div v-if="config.vorstand.jugendwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Jugendwart</h3>
<h4 class="text-lg font-semibold text-primary-600 mb-3">
{{ config.vorstand.jugendwart.vorname }} {{ config.vorstand.jugendwart.nachname }}
</h4>
<div class="space-y-1 text-gray-600">
<p v-if="config.vorstand.jugendwart.strasse">{{ config.vorstand.jugendwart.strasse }}</p>
<p v-if="config.vorstand.jugendwart.plz">{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}</p>
<p v-if="config.vorstand.jugendwart.telefon">Tel. {{ config.vorstand.jugendwart.telefon }}</p>
<PersonCard
v-if="config.vorstand.jugendwart.vorname"
title="Jugendwart"
:name="`${config.vorstand.jugendwart.vorname} ${config.vorstand.jugendwart.nachname}`"
:image-filename="config.vorstand.jugendwart.imageFilename"
>
<p v-if="config.vorstand.jugendwart.strasse">
{{ config.vorstand.jugendwart.strasse }}
</p>
<p v-if="config.vorstand.jugendwart.plz">
{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}
</p>
<p v-if="config.vorstand.jugendwart.telefon">
Tel. {{ config.vorstand.jugendwart.telefon }}
</p>
<p v-if="config.vorstand.jugendwart.email">
<a :href="`mailto:${config.vorstand.jugendwart.email}`" class="text-primary-600 hover:underline">
<a
:href="`mailto:${config.vorstand.jugendwart.email}`"
class="text-primary-600 hover:underline"
>
{{ config.vorstand.jugendwart.email }}
</a>
</p>
</div>
</div>
</PersonCard>
</div>
</div>
</div>

0
production-setup.sh Normal file → Executable file
View File

View File

@@ -1,49 +1,49 @@
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung
"2024","Einzel","1","Michael Koch","",""
"2024","Einzel","2","Olaf Nüßlein","",""
"2024","Einzel","3","Bernd Meyer","",""
"2024","Doppel","1","Sven Baublies","Johannes Binder",""
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann",""
"2024","Doppel","3","Michael Koch","Jacob Waltenberger",""
"2023","Einzel","1","André Gilzinger","",""
"2023","Einzel","2","Olaf Nüßlein","",""
"2023","Einzel","3","Michael Koch","",""
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder",""
"2023","Doppel","2","Renate Nebel","André Gilzinger",""
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz",""
"2022","Einzel","1","Sven Baublies","",""
"2022","Einzel","2","Thomas Steinbrech","",""
"2022","Einzel","3","André Gilzinger","",""
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt",""
"2022","Doppel","2","Michael Weber","Johannes Binder",""
"2022","Doppel","3","Michael Koch","Renate Nebel",""
"2021","","","","","coronabedingter Ausfall"
"2020","","","","","coronabedingter Ausfall"
"2019","Einzel","1","André Gilzinger","",""
"2019","Einzel","2","Thomas Steinbrech","",""
"2019","Einzel","3","Jürgen Kratz","",""
"2019","Doppel","1","André Gilzinger","Volker Marx",""
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau",""
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt",""
"2018","Einzel","1","André Gilzinger","",""
"2018","Einzel","2","Jürgen Kratz","",""
"2018","Einzel","3","Sven Baublies","",""
"2018","Doppel","1","André Gilzinger","Volker Marx",""
"2018","Doppel","2","Sven Baublies","Helge Stefan",""
"2018","Doppel","3","Jürgen Kratz","Renate Nebel",""
"2017","Einzel","1","André Gilzinger","",""
"2017","Einzel","2","Sven Baublies","",""
"2017","Einzel","3","Olaf Nüßlein","",""
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan",""
"2017","Doppel","2","André Gilzinger","Renate Nebel",""
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt",""
"2016","Herren-Einzel","1","André Gilzinger","",""
"2016","Herren-Einzel","2","Sven Baublies","",""
"2016","Herren-Einzel","3","Olaf Nüßlein","",""
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","",""
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","",""
"2016","Damen-Einzel","3","Renate Nebel","",""
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt",""
"2016","Doppel","2","André Gilzinger","Bernd Meyer",""
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi",""
"2025","Doppel","1","a","b",""
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2
"2024","Einzel","1","Michael Koch","","","",""
"2024","Einzel","2","Olaf Nüßlein","","","",""
"2024","Einzel","3","Bernd Meyer","","","",""
"2024","Doppel","1","Sven Baublies","Johannes Binder","","",""
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann","","",""
"2024","Doppel","3","Michael Koch","Jacob Waltenberger","","",""
"2023","Einzel","1","André Gilzinger","","","",""
"2023","Einzel","2","Olaf Nüßlein","","","",""
"2023","Einzel","3","Michael Koch","","","",""
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder","","",""
"2023","Doppel","2","Renate Nebel","André Gilzinger","","",""
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz","","",""
"2022","Einzel","1","Sven Baublies","","","",""
"2022","Einzel","2","Thomas Steinbrech","","","",""
"2022","Einzel","3","André Gilzinger","","","",""
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt","","",""
"2022","Doppel","2","Michael Weber","Johannes Binder","","",""
"2022","Doppel","3","Michael Koch","Renate Nebel","","",""
"2021","","","","","coronabedingter Ausfall","",""
"2020","","","","","coronabedingter Ausfall","",""
"2019","Einzel","1","André Gilzinger","","","",""
"2019","Einzel","2","Thomas Steinbrech","","","",""
"2019","Einzel","3","Jürgen Kratz","","","",""
"2019","Doppel","1","André Gilzinger","Volker Marx","","",""
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau","","",""
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt","","",""
"2018","Einzel","1","André Gilzinger","","","",""
"2018","Einzel","2","Jürgen Kratz","","","",""
"2018","Einzel","3","Sven Baublies","","","",""
"2018","Doppel","1","André Gilzinger","Volker Marx","","",""
"2018","Doppel","2","Sven Baublies","Helge Stefan","","",""
"2018","Doppel","3","Jürgen Kratz","Renate Nebel","","",""
"2017","Einzel","1","André Gilzinger","","","",""
"2017","Einzel","2","Sven Baublies","","","",""
"2017","Einzel","3","Olaf Nüßlein","","","",""
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan","","",""
"2017","Doppel","2","André Gilzinger","Renate Nebel","","",""
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt","","",""
"2016","Herren-Einzel","1","André Gilzinger","","","",""
"2016","Herren-Einzel","2","Sven Baublies","","","",""
"2016","Herren-Einzel","3","Olaf Nüßlein","","","",""
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","","","",""
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","","","",""
"2016","Damen-Einzel","3","Renate Nebel","","","",""
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt","","",""
"2016","Doppel","2","André Gilzinger","Bernd Meyer","","",""
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi","","",""
"2025","Doppel","1","a","b","","7f6c46f8-b93f-4807-b369-b26e0bba2da5.png","4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png"
1 Jahr Kategorie Platz Spieler1 Spieler2 Bemerkung imageFilename1 imageFilename2
2 2024 Einzel 1 Michael Koch
3 2024 Einzel 2 Olaf Nüßlein
4 2024 Einzel 3 Bernd Meyer
5 2024 Doppel 1 Sven Baublies Johannes Binder
6 2024 Doppel 2 Bernd Meyer Jürgen Dichmann
7 2024 Doppel 3 Michael Koch Jacob Waltenberger
8 2023 Einzel 1 André Gilzinger
9 2023 Einzel 2 Olaf Nüßlein
10 2023 Einzel 3 Michael Koch
11 2023 Doppel 1 Olaf Nüßlein Johannes Binder
12 2023 Doppel 2 Renate Nebel André Gilzinger
13 2023 Doppel 3 Ute Puschmann Jürgen Kratz
14 2022 Einzel 1 Sven Baublies
15 2022 Einzel 2 Thomas Steinbrech
16 2022 Einzel 3 André Gilzinger
17 2022 Doppel 1 Sven Baublies Kristin von Rauchhaupt
18 2022 Doppel 2 Michael Weber Johannes Binder
19 2022 Doppel 3 Michael Koch Renate Nebel
20 2021 coronabedingter Ausfall
21 2020 coronabedingter Ausfall
22 2019 Einzel 1 André Gilzinger
23 2019 Einzel 2 Thomas Steinbrech
24 2019 Einzel 3 Jürgen Kratz
25 2019 Doppel 1 André Gilzinger Volker Marx
26 2019 Doppel 2 Jürgen Kratz Marko Wiedau
27 2019 Doppel 3 Bernd Meyer Kristin von Rauchhaupt
28 2018 Einzel 1 André Gilzinger
29 2018 Einzel 2 Jürgen Kratz
30 2018 Einzel 3 Sven Baublies
31 2018 Doppel 1 André Gilzinger Volker Marx
32 2018 Doppel 2 Sven Baublies Helge Stefan
33 2018 Doppel 3 Jürgen Kratz Renate Nebel
34 2017 Einzel 1 André Gilzinger
35 2017 Einzel 2 Sven Baublies
36 2017 Einzel 3 Olaf Nüßlein
37 2017 Doppel 1 Olaf Nüßlein Helge Stefan
38 2017 Doppel 2 André Gilzinger Renate Nebel
39 2017 Doppel 3 Jürgen Kratz Kristin von Rauchhaupt
40 2016 Herren-Einzel 1 André Gilzinger
41 2016 Herren-Einzel 2 Sven Baublies
42 2016 Herren-Einzel 3 Olaf Nüßlein
43 2016 Damen-Einzel 1 Birgit Haas-Schrödter
44 2016 Damen-Einzel 2 Kristin von Rauchhaupt
45 2016 Damen-Einzel 3 Renate Nebel
46 2016 Doppel 1 Jürgen Kratz Matthias Schmidt
47 2016 Doppel 2 André Gilzinger Bernd Meyer
48 2016 Doppel 3 Sven Baublies Dagmar Bereksasi
49 2025 Doppel 1 a b 7f6c46f8-b93f-4807-b369-b26e0bba2da5.png 4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

6
renovate.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -0,0 +1,66 @@
# Admin-Passwort setzen
Dieses Script ermöglicht es, das Passwort für den Admin-User (`admin@harheimertc.de`) zu setzen oder zu ändern.
## Verwendung
### Mit Passwort als Argument
```bash
node scripts/set-admin-password.js "mein-neues-passwort"
```
### Interaktiv (Passwort wird abgefragt)
```bash
node scripts/set-admin-password.js
```
Das Script fragt dann nach dem neuen Passwort.
### Datei neu erstellen (bei Entschlüsselungsfehler)
Falls die `users.json` Datei nicht entschlüsselt werden kann (z.B. weil der `ENCRYPTION_KEY` nicht übereinstimmt), wird das Script automatisch fragen, ob die Datei neu erstellt werden soll. Sie können auch direkt das `--recreate` Flag verwenden:
```bash
node scripts/set-admin-password.js "mein-neues-passwort" --recreate
```
**Wichtig**: Die alte Datei wird automatisch als Backup gespeichert, bevor eine neue erstellt wird.
## Funktionen
- **Findet oder erstellt den Admin-User**: Falls der Admin-User nicht existiert, wird er automatisch erstellt
- **Passwort-Hashing**: Das Passwort wird mit bcrypt gehasht (10 Runden)
- **Verschlüsselte Speicherung**: Die Benutzerdaten werden verschlüsselt gespeichert
- **Validierung**: Prüft, dass das Passwort mindestens 8 Zeichen lang ist
## Voraussetzungen
- `ENCRYPTION_KEY` muss in der `.env` Datei gesetzt sein (für verschlüsselte Speicherung)
- Die Datei `server/data/users.json` muss existieren oder wird automatisch erstellt
## Beispiel
```bash
# Passwort direkt setzen
node scripts/set-admin-password.js "MeinSicheresPasswort123!"
# Interaktiv
node scripts/set-admin-password.js
# Eingabeaufforderung: Neues Passwort für admin@harheimertc.de:
```
## Sicherheit
- Das Passwort wird niemals im Klartext gespeichert
- Es wird mit bcrypt gehasht (10 Runden)
- Die Benutzerdaten werden verschlüsselt gespeichert
- Das Passwort wird nicht in der Kommandozeilen-Historie gespeichert (bei interaktiver Eingabe)
## Fehlerbehandlung
Falls die Entschlüsselung der Benutzerdaten fehlschlägt:
- Prüfe, ob `ENCRYPTION_KEY` in der `.env` Datei korrekt gesetzt ist
- Stelle sicher, dass der Schlüssel mit dem übereinstimmt, der zum Verschlüsseln verwendet wurde

0
scripts/fetch-template.sh Normal file → Executable file
View File

View File

@@ -82,10 +82,25 @@ function isEncrypted(data) {
}
}
// Prüft ob Daten bereits mit dem neuen Schlüssel verschlüsselt sind
async function isEncryptedWithNewKey(encryptedData) {
try {
await decryptObject(encryptedData, NEW_KEY)
return true
} catch {
return false
}
}
// Versucht mit verschiedenen Schlüsseln zu entschlüsseln
async function decryptWithFallback(encryptedData, keys) {
const errors = []
// Prüfe zuerst, ob die Daten bereits mit dem neuen Schlüssel verschlüsselt sind
if (await isEncryptedWithNewKey(encryptedData)) {
throw new Error('ALREADY_ENCRYPTED_WITH_NEW_KEY')
}
// encryptedData sollte bereits ein String sein (entweder direkt verschlüsselt oder aus einem JSON-Objekt extrahiert)
// Versuche mit jedem Schlüssel zu entschlüsseln
for (const key of keys) {
@@ -115,6 +130,12 @@ async function reencryptUsers(backupDir, oldKeys) {
return
}
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
if (await isEncryptedWithNewKey(data)) {
console.log(' users.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
return
}
console.log('🔄 Entschlüssele users.json...')
const decrypted = await decryptWithFallback(data, oldKeys)
@@ -129,6 +150,10 @@ async function reencryptUsers(backupDir, oldKeys) {
console.log(' users.json existiert nicht, überspringe...')
return
}
if (error.message === 'ALREADY_ENCRYPTED_WITH_NEW_KEY') {
console.log(' users.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
return
}
throw error
}
}
@@ -147,6 +172,12 @@ async function reencryptMembers(backupDir, oldKeys) {
return
}
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
if (await isEncryptedWithNewKey(data)) {
console.log(' members.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
return
}
console.log('🔄 Entschlüssele members.json...')
const decrypted = await decryptWithFallback(data, oldKeys)
@@ -161,6 +192,10 @@ async function reencryptMembers(backupDir, oldKeys) {
console.log(' members.json existiert nicht, überspringe...')
return
}
if (error.message === 'ALREADY_ENCRYPTED_WITH_NEW_KEY') {
console.log(' members.json ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...')
return
}
throw error
}
}
@@ -197,6 +232,11 @@ async function reencryptMembershipApplications(backupDir, oldKeys) {
// Prüfe ob encryptedData Feld vorhanden ist
if (parsed.encryptedData) {
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
if (await isEncryptedWithNewKey(parsed.encryptedData)) {
console.log(` ${file} ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...`)
skipped++
} else {
console.log(`🔄 Entschlüssele ${file}...`)
// Nur das encryptedData Feld entschlüsseln
const decrypted = await decryptWithFallback(parsed.encryptedData, oldKeys)
@@ -208,8 +248,14 @@ async function reencryptMembershipApplications(backupDir, oldKeys) {
await fs.writeFile(filePath, JSON.stringify(parsed, null, 2), 'utf-8')
console.log(`${file} erfolgreich neu verschlüsselt`)
processed++
}
} else if (file.endsWith('.data')) {
// .data Dateien sind direkt verschlüsselt
// Prüfe ob bereits mit neuem Schlüssel verschlüsselt
if (await isEncryptedWithNewKey(content)) {
console.log(` ${file} ist bereits mit dem neuen Schlüssel verschlüsselt, überspringe...`)
skipped++
} else {
console.log(`🔄 Entschlüssele ${file}...`)
const decrypted = await decryptWithFallback(content, oldKeys)
@@ -219,6 +265,7 @@ async function reencryptMembershipApplications(backupDir, oldKeys) {
await fs.writeFile(filePath, reencrypted, 'utf-8')
console.log(`${file} erfolgreich neu verschlüsselt`)
processed++
}
} else {
console.log(` ${file} enthält keine verschlüsselten Daten, überspringe...`)
skipped++

300
scripts/set-admin-password.js Executable file
View File

@@ -0,0 +1,300 @@
#!/usr/bin/env node
/**
* Script zum Setzen des Admin-Passworts
*
* Verwendung:
* node scripts/set-admin-password.js <neues-passwort>
*
* Oder interaktiv:
* node scripts/set-admin-password.js
*/
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
import bcrypt from 'bcryptjs'
import { decryptObject, encryptObject } from '../server/utils/encryption.js'
import dotenv from 'dotenv'
import readline from 'readline'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Lade .env Datei
dotenv.config({ path: path.join(__dirname, '..', '.env') })
const ADMIN_EMAIL = 'admin@harheimertc.de'
// Pfade bestimmen
function 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 USERS_FILE = getDataPath('users.json')
// Get encryption key from environment
function getEncryptionKey() {
return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
}
// Prüft ob Daten verschlüsselt sind
function isEncrypted(data) {
try {
const parsed = JSON.parse(data.trim())
if (Array.isArray(parsed)) {
return false
}
if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) {
return false
}
return false
} catch (e) {
return true
}
}
// Liest Benutzer aus Datei
async function readUsers() {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8')
const encrypted = isEncrypted(data)
if (encrypted) {
const encryptionKey = getEncryptionKey()
try {
return decryptObject(data, encryptionKey)
} catch (decryptError) {
console.error('Fehler beim Entschlüsseln der Benutzerdaten:', decryptError.message)
throw new Error('Konnte Benutzerdaten nicht entschlüsseln. Bitte prüfe ENCRYPTION_KEY in .env')
}
} else {
return JSON.parse(data)
}
} catch (error) {
if (error.code === 'ENOENT') {
return []
}
throw error
}
}
// Schreibt Benutzer in Datei (immer verschlüsselt)
async function writeUsers(users) {
try {
const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(users, encryptionKey)
await fs.writeFile(USERS_FILE, encryptedData, 'utf-8')
return true
} catch (error) {
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
return false
}
}
// Hash-Passwort generieren
async function hashPassword(password) {
const salt = await bcrypt.genSalt(10)
return await bcrypt.hash(password, salt)
}
// Fragt nach Passwort (wenn nicht als Argument übergeben)
function askPassword() {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
rl.question('Neues Passwort für admin@harheimertc.de: ', (password) => {
rl.close()
resolve(password)
})
})
}
// Fragt nach Bestätigung
function askConfirmation(question) {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
rl.question(`${question} (j/n): `, (answer) => {
rl.close()
resolve(answer.toLowerCase() === 'j' || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'ja' || answer.toLowerCase() === 'yes')
})
})
}
// Erstellt ein Backup der users.json
async function createBackup() {
try {
await fs.access(USERS_FILE)
const backupDir = path.join(__dirname, '..', 'backups', `users-${Date.now()}`)
await fs.mkdir(backupDir, { recursive: true })
const backupPath = path.join(backupDir, 'users.json')
await fs.copyFile(USERS_FILE, backupPath)
console.log(`📦 Backup erstellt: ${backupPath}`)
return backupPath
} catch (error) {
if (error.code === 'ENOENT') {
console.log(' users.json existiert nicht, kein Backup nötig')
return null
}
throw error
}
}
// Erstellt eine neue users.json mit Admin-User
async function createNewUsersFile(password) {
const hashedPassword = await hashPassword(password)
const adminUser = {
id: Date.now().toString(),
email: ADMIN_EMAIL,
password: hashedPassword,
name: 'Administrator',
role: 'admin',
active: true,
created: new Date().toISOString(),
lastLogin: null
}
return [adminUser]
}
// Hauptfunktion
async function main() {
console.log('🔐 Admin-Passwort setzen\n')
// Prüfe ob --recreate Flag gesetzt ist
const recreateFlag = process.argv.includes('--recreate') || process.argv.includes('-r')
// Passwort aus Kommandozeilenargumenten oder interaktiv abfragen
// Filtere Flags heraus und nimm das erste verbleibende Argument als Passwort
const args = process.argv.slice(2).filter(arg => !arg.startsWith('--') && !arg.startsWith('-'))
let newPassword = args.length > 0 ? args[0] : null
if (!newPassword) {
newPassword = await askPassword()
}
if (!newPassword || newPassword.trim().length === 0) {
console.error('❌ FEHLER: Passwort darf nicht leer sein!')
process.exit(1)
}
if (newPassword.length < 8) {
console.error('❌ FEHLER: Passwort muss mindestens 8 Zeichen lang sein!')
process.exit(1)
}
try {
// Benutzer laden
console.log('📖 Lade Benutzerdaten...')
let users
let shouldRecreate = recreateFlag
try {
users = await readUsers()
} catch (error) {
if (error.message.includes('entschlüsseln') || error.message.includes('Entschlüsselung')) {
console.error('\n⚠ WARNUNG: Konnte Benutzerdaten nicht entschlüsseln!')
console.error(' Dies bedeutet, dass der ENCRYPTION_KEY nicht mit dem verwendeten Schlüssel übereinstimmt.\n')
if (!shouldRecreate) {
const confirmed = await askConfirmation('Möchten Sie die users.json Datei neu erstellen? (Alte Datei wird als Backup gespeichert)')
if (!confirmed) {
console.log('\n❌ Abgebrochen. Bitte setzen Sie ENCRYPTION_KEY in der .env Datei auf den korrekten Wert.')
process.exit(1)
}
shouldRecreate = true
}
// Backup erstellen
await createBackup()
// Neue Datei erstellen
console.log('\n🆕 Erstelle neue users.json Datei...')
users = await createNewUsersFile(newPassword)
// Direkt speichern
console.log('💾 Speichere neue Benutzerdaten...')
const success = await writeUsers(users)
if (success) {
console.log('\n✅ Neue users.json Datei erfolgreich erstellt!')
console.log(`📧 E-Mail: ${ADMIN_EMAIL}`)
console.log(`👤 Rolle: admin`)
console.log(`✅ Status: Aktiv`)
console.log(`🔐 Passwort: Gesetzt`)
} else {
console.error('\n❌ FEHLER: Konnte Benutzerdaten nicht speichern!')
process.exit(1)
}
return
} else {
throw error
}
}
// Admin-User finden oder erstellen
let adminUser = users.find(u => u.email.toLowerCase() === ADMIN_EMAIL.toLowerCase())
if (!adminUser) {
console.log(` Admin-User (${ADMIN_EMAIL}) nicht gefunden, erstelle neuen Benutzer...`)
adminUser = {
id: Date.now().toString(),
email: ADMIN_EMAIL,
name: 'Administrator',
role: 'admin',
active: true,
created: new Date().toISOString(),
lastLogin: null
}
users.push(adminUser)
} else {
console.log(`✅ Admin-User gefunden: ${adminUser.name || ADMIN_EMAIL}`)
}
// Passwort hashen
console.log('🔐 Hashe Passwort...')
const hashedPassword = await hashPassword(newPassword)
// Passwort setzen
adminUser.password = hashedPassword
adminUser.updated = new Date().toISOString()
// Benutzer speichern
console.log('💾 Speichere Benutzerdaten...')
const success = await writeUsers(users)
if (success) {
console.log('\n✅ Passwort erfolgreich gesetzt!')
console.log(`📧 E-Mail: ${ADMIN_EMAIL}`)
console.log(`👤 Rolle: ${adminUser.role}`)
console.log(`✅ Status: ${adminUser.active ? 'Aktiv' : 'Inaktiv'}`)
} else {
console.error('\n❌ FEHLER: Konnte Benutzerdaten nicht speichern!')
process.exit(1)
}
} catch (error) {
console.error('\n❌ FEHLER:', error.message)
console.error(error.stack)
process.exit(1)
}
}
// Script ausführen
main().catch(error => {
console.error('Unerwarteter Fehler:', error)
process.exit(1)
})

View File

@@ -1,4 +1,4 @@
import { readUsers, writeUsers, verifyPassword, generateToken, createSession } from '../../utils/auth.js'
import { readUsers, writeUsers, verifyPassword, generateToken, createSession, migrateUserRoles } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
@@ -59,6 +59,10 @@ export default defineEventHandler(async (event) => {
maxAge: 60 * 60 * 24 * 7 // 7 days
})
// Migriere Rollen falls nötig
const migratedUser = migrateUserRoles({ ...user })
const roles = Array.isArray(migratedUser.roles) ? migratedUser.roles : (migratedUser.role ? [migratedUser.role] : ['mitglied'])
// Return user data (without password) and token for API usage
return {
success: true,
@@ -67,8 +71,10 @@ export default defineEventHandler(async (event) => {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
roles: roles
},
// Rückwärtskompatibilität: erste Rolle als role
role: roles[0] || 'mitglied'
}
} catch (error) {
console.error('Login-Fehler:', error)

View File

@@ -23,15 +23,19 @@ export default defineEventHandler(async (event) => {
}
}
const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : ['mitglied'])
return {
isLoggedIn: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
roles: roles
},
role: user.role
roles: roles,
// Rückwärtskompatibilität: erste Rolle als role
role: roles[0] || 'mitglied'
}
} catch (error) {
console.error('Auth-Status-Fehler:', error)

View File

@@ -3,7 +3,7 @@ import fs from 'fs/promises'
import path from 'path'
import { exec } from 'child_process'
import { promisify } from 'util'
import { getUserFromToken } from '../../utils/auth.js'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
const execAsync = promisify(exec)
@@ -62,7 +62,7 @@ export default defineEventHandler(async (event) => {
})
}
if (currentUser.role !== 'admin' && currentUser.role !== 'vorstand') {
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung'
@@ -238,8 +238,8 @@ function convertTextToHtml(text) {
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>`
if (item.match(/^[•-]\s/) || item.match(/^\d+\.\s/)) {
return `<li>${item.replace(/^[•-]\s/, '').replace(/^\d+\.\s/, '')}</li>`
}
return `<li>${item}</li>`
}).join('')

View File

@@ -1,6 +1,6 @@
import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken } from '../../utils/auth.js'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
})
}
if (currentUser.role !== 'admin' && currentUser.role !== 'vorstand') {
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung'

View File

@@ -1,7 +1,7 @@
import multer from 'multer'
import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken } from '../../utils/auth.js'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
// Multer-Konfiguration für PDF-Uploads
const storage = multer.diskStorage({
@@ -57,7 +57,7 @@ export default defineEventHandler(async (event) => {
})
}
if (currentUser.role !== 'admin' && currentUser.role !== 'vorstand') {
if (!hasAnyRole(currentUser, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung'

View File

@@ -1,4 +1,4 @@
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
import nodemailer from 'nodemailer'
export default defineEventHandler(async (event) => {
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
const { userId, role } = body
const { userId, roles } = body
const users = await readUsers()
const user = users.find(u => u.id === userId)
@@ -26,9 +26,17 @@ export default defineEventHandler(async (event) => {
})
}
// Activate user and set role
// Migriere Benutzer falls nötig
migrateUserRoles(user)
// Activate user and set roles
user.active = true
user.role = role || 'mitglied'
if (Array.isArray(roles) && roles.length > 0) {
user.roles = roles
} else {
// Fallback: einzelne Rolle als Array
user.roles = roles ? [roles] : ['mitglied']
}
const updatedUsers = users.map(u => u.id === userId ? user : u)
await writeUsers(updatedUsers)

View File

@@ -1,11 +1,11 @@
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'

View File

@@ -1,11 +1,11 @@
import { getUserFromToken, readUsers } from '../../../utils/auth.js'
import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'
@@ -15,16 +15,21 @@ export default defineEventHandler(async (event) => {
const users = await readUsers()
// Return users without passwords
const safeUsers = users.map(u => ({
const safeUsers = users.map(u => {
const migrated = migrateUserRoles({ ...u })
const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied'])
return {
id: u.id,
email: u.email,
name: u.name,
role: u.role,
roles: roles,
role: roles[0] || 'mitglied', // Rückwärtskompatibilität
phone: u.phone || '',
active: u.active,
created: u.created,
lastLogin: u.lastLogin
}))
}
})
return {
users: safeUsers

View File

@@ -1,11 +1,11 @@
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'

View File

@@ -1,11 +1,11 @@
import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => {
try {
const token = getCookie(event, 'auth_token')
const currentUser = await getUserFromToken(token)
if (!currentUser || (currentUser.role !== 'admin' && currentUser.role !== 'vorstand')) {
if (!currentUser || !hasAnyRole(currentUser, 'admin')) {
throw createError({
statusCode: 403,
message: 'Zugriff verweigert'
@@ -13,12 +13,15 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
const { userId, role } = body
const { userId, roles } = body
if (!['mitglied', 'vorstand', 'admin'].includes(role)) {
const validRoles = ['mitglied', 'vorstand', 'admin', 'newsletter']
const rolesArray = Array.isArray(roles) ? roles : (roles ? [roles] : ['mitglied'])
if (!rolesArray.every(r => validRoles.includes(r))) {
throw createError({
statusCode: 400,
message: 'Ungültige Rolle'
message: 'Ungültige Rolle(n)'
})
}
@@ -32,7 +35,11 @@ export default defineEventHandler(async (event) => {
})
}
user.role = role
// Migriere Benutzer falls nötig
migrateUserRoles(user)
// Setze Rollen
user.roles = rolesArray
const updatedUsers = users.map(u => u.id === userId ? user : u)
await writeUsers(updatedUsers)

View File

@@ -1,4 +1,4 @@
import { verifyToken, getUserById } from '../utils/auth.js'
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
import { promises as fs } from 'fs'
import path from 'path'
@@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can edit config
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Bearbeiten der Konfiguration.'

View File

@@ -51,7 +51,7 @@ export default defineEventHandler(async (event) => {
}
const user = await getUserFromToken(token)
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung zum Löschen von Bildern'

View File

@@ -2,7 +2,7 @@ import multer from 'multer'
import fs from 'fs/promises'
import path from 'path'
import sharp from 'sharp'
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
import { getUserFromToken, verifyToken, hasAnyRole } from '../../utils/auth.js'
import { randomUUID } from 'crypto'
// Handle both dev and production paths
@@ -90,7 +90,7 @@ export default defineEventHandler(async (event) => {
}
const user = await getUserFromToken(token)
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
statusMessage: 'Keine Berechtigung zum Hochladen von Bildern'
@@ -119,7 +119,9 @@ export default defineEventHandler(async (event) => {
// Titel ist Pflichtfeld
if (!body.title || !body.title.trim()) {
// Lösche die hochgeladene Datei
await fs.unlink(file.path).catch(() => {})
await fs.unlink(file.path).catch(() => {
// Datei bereits gelöscht oder nicht vorhanden, ignorieren
})
throw createError({
statusCode: 400,
statusMessage: 'Titel ist ein Pflichtfeld'

View File

@@ -1,4 +1,4 @@
import { verifyToken, getUserById } from '../utils/auth.js'
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
import { deleteMember } from '../utils/members.js'
export default defineEventHandler(async (event) => {
@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
const user = await getUserById(decoded.id)
// Only admin and vorstand can delete members
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
if (!user || !hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({
statusCode: 403,
message: 'Keine Berechtigung zum Löschen von Mitgliedern.'

Some files were not shown because too many files have changed in this diff Show More