Initial commit: PHP/MySQL ESV Bludenz calendar migration

This commit is contained in:
Kay Türtscher 2026-04-13 09:25:32 +02:00
commit 6dc1a1094a
58 changed files with 21500 additions and 0 deletions

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
ADMIN_PASSWORD_DITTES=bitte-aendern
ADMIN_PASSWORD_KEGELN=bitte-aendern
SESSION_SECRET=bitte-langes-zufallspasswort-setzen
API_PORT=3002
REACT_APP_API_BASE=http://localhost:3002

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
/data/*.sqlite
npm-debug.log*
yarn-debug.log*
yarn-error.log*

106
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,106 @@
# Deployment für klassischen Webspace
## Zielbild
- React-Frontend als statisches Build
- PHP-API unter `/api`
- MySQL-Datenbank beim Hoster
## Erwartete Zielstruktur auf dem Webspace
```bash
public_html/
├── index.html
├── static/
├── favicon.ico
├── manifest.json
├── robots.txt
├── .htaccess
└── api/
├── .htaccess
├── config.php
├── config.example.php
├── db.php
├── helpers.php
├── events.php
├── login.php
├── logout.php
├── save-event.php
├── update-event.php
├── delete-event.php
└── schema.sql
```
## Build lokal erzeugen
```bash
cd /home/nepharius/git.nepharius.at/esv-bludenz-php
npm install
npm run build
```
## Lokaler PHP-Testserver
Für Offline-Debugging ist jetzt ein kleiner Startscript dabei:
```bash
cd /home/nepharius/git.nepharius.at/esv-bludenz-php
./start-php-test.sh
```
Dann läuft lokal ein PHP-Testserver auf:
- `http://127.0.0.1:8080`
API-Beispiele lokal:
- `http://127.0.0.1:8080/api/events.php?calendar=dittes`
## Dann hochladen
### 1. Frontend-Build hochladen
Inhalt aus `build/` nach `public_html/` kopieren.
### 2. PHP-API hochladen
Ordner `api/` nach `public_html/api/` kopieren.
### 3. MySQL-Datenbank vorbereiten
- MySQL-Datenbank beim Hoster anlegen
- Zugangsdaten in `api/config.php` eintragen
- optional `api/config.example.php` als Vorlage verwenden
### 4. Tabellen anlegen
Variante A:
- `api/schema.sql` über phpMyAdmin importieren
Variante B:
- erster API-Aufruf legt die Tabellen automatisch an
## Voraussetzungen beim Hoster
Es muss vorhanden sein:
- PHP
- PDO_MYSQL
- MySQL-Datenbank
- Sessions aktiviert
- `.htaccess` / mod_rewrite idealerweise verfügbar
## Schnelltest
Nach dem Upload testen:
### Kalender laden
`https://deine-domain.tld/api/events.php?calendar=dittes`
### Erwartung
JSON-Antwort mit:
- `calendar`
- `events`
- `isAdmin`
## Zugangsdaten
In `api/config.php` müssen diese Werte gesetzt werden:
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
Danach sollte dieselbe React-Oberfläche mit der PHP-MySQL-API laufen.

9
Dependencys Normal file
View file

@ -0,0 +1,9 @@
npx create-react-app esv-bludenz-linksammlung
cd esv-bludenz-linksammlung
npm install bootstrap
npm install react-router-dom
npm start
npm run build

207
INSTALLATION.md Normal file
View file

@ -0,0 +1,207 @@
# Installationsanleitung ESV Bludenz PHP Kalender
Diese Version ist für klassischen Webspace mit:
- PHP
- MySQL / MariaDB
- statischem React-Build
gedacht.
---
## 1. Projekt lokal vorbereiten
```bash
cd /home/nepharius/git.nepharius.at/esv-bludenz-php
npm install
npm run build
```
---
## 2. Dateien hochladen
### In den Webspace-Root / `public_html`
Den **Inhalt** von `build/` hochladen:
- `index.html`
- `asset-manifest.json`
- `favicon.ico`
- `manifest.json`
- `robots.txt`
- `static/`
### Zusätzlich hochladen
Den Ordner `api/` ebenfalls hochladen, z. B. nach:
```bash
public_html/api/
```
---
## 3. MySQL-Datenbank anlegen
Beim Hoster:
- neue MySQL-Datenbank anlegen
- Datenbankname notieren
- Benutzername notieren
- Passwort notieren
- Host notieren (oft `localhost`, manchmal eigener DB-Host)
---
## 4. Zugangsdaten in `api/config.php` eintragen
Diese Werte anpassen:
```php
const DB_HOST = '127.0.0.1';
const DB_PORT = 3306;
const DB_NAME = 'esv_bludenz';
const DB_USER = 'esv_user';
const DB_PASSWORD = 'esv_local_dev_123';
```
### Für Produktion ersetzen durch echte Hoster-Daten.
Beispiel:
```php
const DB_HOST = 'localhost';
const DB_PORT = 3306;
const DB_NAME = 'db123456';
const DB_USER = 'u123456';
const DB_PASSWORD = 'SEHR_STARKES_PASSWORT';
```
---
## 5. Datenbanktabellen anlegen
### Variante A, empfohlen
`api/schema.sql` in phpMyAdmin importieren.
### Variante B
Die API legt Tabellen beim ersten Aufruf selbst an, wenn die DB-Verbindung klappt.
---
## 6. Kalender-Passwörter ändern
In `api/config.php` gibt es diese Funktion:
```php
function calendar_passwords(): array
{
return [
'dittes' => 'dittes123',
'kegeln' => 'kegeln123',
];
}
```
### Diese Standardpasswörter unbedingt ändern.
Zum Beispiel so:
```php
function calendar_passwords(): array
{
return [
'dittes' => 'HIER-EIN-STARKES-DITTES-PASSWORT',
'kegeln' => 'HIER-EIN-STARKES-KEGELN-PASSWORT',
];
}
```
### Empfehlung
- nicht `123`
- keine Vereinsnamen als Passwort
- lieber lang und eindeutig
- für jeden Kalender ein eigenes Passwort
---
## 7. React-API-Ziel prüfen
Standardmäßig nutzt das Frontend:
```js
window.location.origin + '/api'
```
Das passt, wenn:
- Frontend und API auf derselben Domain liegen
- `api/` direkt unter derselben Website erreichbar ist
---
## 8. Funktionstest
### API-Test
Im Browser öffnen:
```bash
https://deine-domain.tld/api/events.php?calendar=dittes
```
Wenn alles passt, kommt JSON zurück.
### Frontend-Test
Dann die Seite öffnen:
```bash
https://deine-domain.tld/dittes
https://deine-domain.tld/kegeln
```
---
## 9. Nach dem Upload prüfen
- lädt die Startseite?
- laden `/dittes` und `/kegeln`?
- klappt Admin-Login?
- lassen sich Termine anlegen?
- lassen sich Termine bearbeiten/löschen?
- bleiben Daten nach Reload erhalten?
---
## 10. Sicherheits-Check vor Go-Live
Vor echtem Betrieb bitte prüfen:
- Standardpasswörter geändert?
- DB-Passwort geändert?
- `config.php` nicht öffentlich verlinkt?
- keine Testdaten mehr drin?
- HTTPS aktiv?
---
## Lokales Debugging
### PHP-Testserver
```bash
cd /home/nepharius/git.nepharius.at/esv-bludenz-php
./start-php-test.sh
```
### React-Devserver
```bash
cd /home/nepharius/git.nepharius.at/esv-bludenz-php
HOST=0.0.0.0 PORT=3003 BROWSER=none REACT_APP_API_BASE=http://192.168.0.149:8080/api npm start
```
---
## Wichtige Projektdateien
- `api/config.php` → DB + Kalender-Passwörter
- `api/schema.sql` → MySQL-Tabellen
- `DEPLOYMENT.md` → Upload-/Webspace-Struktur
- `README.md` → Projektüberblick

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# ESV Bludenz PHP Migration
Dieses Projekt ist die **PHP-Migrationsbasis** für den ESV-Bludenz-Kalender.
Ziel:
- bestehendes Frontend / bestehende Optik möglichst behalten
- Kalender-Backend von **Node + SQLite** auf **PHP + MySQL** umstellen
- webspace-tauglich bleiben
## Aktueller Stand
Bereits angelegt:
- `api/config.php`
- `api/db.php`
- `api/helpers.php`
- `api/events.php`
- `api/login.php`
- `api/logout.php`
- `api/save-event.php`
- `api/update-event.php`
- `api/delete-event.php`
Außerdem ist `src/Dittes.jsx` schon auf die PHP-API umgebogen.
## Aktueller Stand
Funktional vorbereitet sind jetzt:
- Kalender laden
- Login / Logout
- Termin anlegen
- Termin bearbeiten
- Termin löschen
- MySQL-Tabellen automatisch anlegen
## Hosting-Stand
Zusätzlich angelegt:
- `api/.htaccess`
- `public/.htaccess`
- `api/config.example.php`
- `api/schema.sql`
- `DEPLOYMENT.md`
- `INSTALLATION.md`
Damit ist die Zielrichtung für klassischen Webspace bereits vorbereitet.
## Geplanter nächster Schritt
Als Nächstes folgen:
- echte PHP-Laufzeit beim Zielhost prüfen
- MySQL-Zugangsdaten eintragen
- Deploy-Pfade auf den echten Webspace anpassen

15
api/.htaccess Normal file
View file

@ -0,0 +1,15 @@
Options -Indexes
DirectoryIndex events.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} =OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
</IfModule>
<IfModule mod_headers.c>
Header always set Access-Control-Allow-Origin "*"
Header always set Access-Control-Allow-Credentials "true"
Header always set Access-Control-Allow-Headers "Content-Type"
Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
</IfModule>

10
api/config.example.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
const DB_HOST = 'localhost';
const DB_PORT = 3306;
const DB_NAME = 'deine_datenbank';
const DB_USER = 'dein_user';
const DB_PASSWORD = 'dein_passwort';
const SESSION_COOKIE_NAME = 'esv_bludenz_calendar_session';

91
api/config.php Normal file
View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
const SESSION_COOKIE_NAME = 'esv_bludenz_calendar_session';
function env_or_default(string $key, string $default): string
{
$value = getenv($key);
return $value === false || $value === '' ? $default : $value;
}
const DB_HOST = '127.0.0.1';
const DB_PORT = 3306;
const DB_NAME = 'esv_bludenz';
const DB_USER = 'esv_user';
const DB_PASSWORD = 'esv_local_dev_123';
function db_host(): string { return env_or_default('ESV_DB_HOST', DB_HOST); }
function db_port(): int { return (int) env_or_default('ESV_DB_PORT', (string) DB_PORT); }
function db_name(): string { return env_or_default('ESV_DB_NAME', DB_NAME); }
function db_user(): string { return env_or_default('ESV_DB_USER', DB_USER); }
function db_password(): string { return env_or_default('ESV_DB_PASSWORD', DB_PASSWORD); }
function calendar_passwords(): array
{
return [
'dittes' => 'dittes123',
'kegeln' => 'kegeln123',
];
}
function send_api_headers(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Vary: Origin');
}
function json_response(array $payload, int $status = 200): void
{
send_api_headers();
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function read_json_body(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
function start_calendar_session(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_name(SESSION_COOKIE_NAME);
session_start();
}
}
function current_admin_calendar_slug(): ?string
{
start_calendar_session();
return $_SESSION['admin_calendar_slug'] ?? null;
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
send_api_headers();
http_response_code(204);
exit;
}
function assert_db_config_present(): void
{
if (db_host() === '' || db_name() === '' || db_user() === '') {
json_response([
'error' => 'Datenbank ist noch nicht konfiguriert',
'required' => ['ESV_DB_HOST', 'ESV_DB_PORT', 'ESV_DB_NAME', 'ESV_DB_USER', 'ESV_DB_PASSWORD'],
], 500);
}
}

68
api/db.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
function calendar_db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
assert_db_config_present();
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', db_host(), db_port(), db_name());
$pdo = new PDO($dsn, db_user(), db_password(), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
initialize_calendar_db($pdo);
return $pdo;
}
function initialize_calendar_db(PDO $pdo): void
{
$pdo->exec('CREATE TABLE IF NOT EXISTS calendars (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$pdo->exec('CREATE TABLE IF NOT EXISTS events (
id INT AUTO_INCREMENT PRIMARY KEY,
calendar_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
location VARCHAR(255) NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
color VARCHAR(20) DEFAULT "#1a73e8",
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_events_calendar FOREIGN KEY (calendar_id) REFERENCES calendars(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
$seedCalendars = [
['slug' => 'dittes', 'name' => 'Buchungskalender Dittes Hütte', 'description' => ''],
['slug' => 'kegeln', 'name' => 'Buchungskalender ESV-Bludenz Sektion Kegeln', 'description' => ''],
];
$insertCalendar = $pdo->prepare('INSERT IGNORE INTO calendars (slug, name, description) VALUES (:slug, :name, :description)');
foreach ($seedCalendars as $calendar) {
$insertCalendar->execute($calendar);
}
}
function find_calendar_by_slug(PDO $pdo, string $slug): ?array
{
$stmt = $pdo->prepare('SELECT * FROM calendars WHERE slug = :slug LIMIT 1');
$stmt->execute(['slug' => $slug]);
$row = $stmt->fetch();
return $row ?: null;
}

24
api/delete-event.php Normal file
View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/helpers.php';
$body = read_json_body();
$id = (int)($body['id'] ?? ($_GET['id'] ?? 0));
if ($id <= 0) {
json_response(['error' => 'Termin fehlt'], 400);
}
$pdo = calendar_db();
$existing = find_event_by_id($pdo, $id);
if (!$existing) {
json_response(['error' => 'Termin nicht gefunden'], 404);
}
require_admin_for_slug($existing['calendar_slug']);
$stmt = $pdo->prepare('DELETE FROM events WHERE id = :id');
$stmt->execute(['id' => $id]);
json_response(['ok' => true]);

34
api/events.php Normal file
View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
$pdo = calendar_db();
$slug = $_GET['calendar'] ?? '';
if ($slug === '') {
json_response(['error' => 'Kalender fehlt'], 400);
}
$calendar = find_calendar_by_slug($pdo, $slug);
if (!$calendar) {
json_response(['error' => 'Kalender nicht gefunden'], 404);
}
$stmt = $pdo->prepare('SELECT id, title, description, location, start_at AS start, end_at AS end, color
FROM events
WHERE calendar_id = :calendar_id
ORDER BY start_at ASC');
$stmt->execute(['calendar_id' => $calendar['id']]);
$events = $stmt->fetchAll();
json_response([
'calendar' => [
'slug' => $calendar['slug'],
'name' => $calendar['name'],
'description' => $calendar['description'],
],
'events' => $events,
'isAdmin' => current_admin_calendar_slug() === $calendar['slug'],
]);

37
api/helpers.php Normal file
View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
function validate_event_payload(array $body): ?string
{
$title = trim((string)($body['title'] ?? ''));
$start = (string)($body['start'] ?? '');
$end = (string)($body['end'] ?? '');
if ($title === '') return 'Titel fehlt';
if ($start === '' || $end === '') return 'Start oder Ende fehlt';
$startTs = strtotime($start);
$endTs = strtotime($end);
if ($startTs === false || $endTs === false) return 'Ungültiges Datum';
if ($endTs < $startTs) return 'Ende liegt vor dem Start';
return null;
}
function require_admin_for_slug(string $slug): void
{
if (current_admin_calendar_slug() !== $slug) {
json_response(['error' => 'Nicht eingeloggt'], 401);
}
}
function find_event_by_id(PDO $pdo, int $id): ?array
{
$stmt = $pdo->prepare('SELECT events.*, calendars.slug AS calendar_slug FROM events JOIN calendars ON calendars.id = events.calendar_id WHERE events.id = :id LIMIT 1');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}

23
api/login.php Normal file
View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
$body = read_json_body();
$calendarSlug = (string)($body['calendarSlug'] ?? '');
$password = (string)($body['password'] ?? '');
$passwords = calendar_passwords();
if (!isset($passwords[$calendarSlug])) {
json_response(['error' => 'Unbekannter Kalender'], 400);
}
if ($passwords[$calendarSlug] !== $password) {
json_response(['error' => 'Passwort falsch'], 401);
}
start_calendar_session();
$_SESSION['admin_calendar_slug'] = $calendarSlug;
json_response(['ok' => true]);

10
api/logout.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
start_calendar_session();
session_destroy();
json_response(['ok' => true]);

48
api/save-event.php Normal file
View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/helpers.php';
$body = read_json_body();
$calendarSlug = (string)($body['calendarSlug'] ?? '');
if ($calendarSlug === '') {
json_response(['error' => 'Kalender fehlt'], 400);
}
require_admin_for_slug($calendarSlug);
$error = validate_event_payload($body);
if ($error) {
json_response(['error' => $error], 400);
}
$pdo = calendar_db();
$calendar = find_calendar_by_slug($pdo, $calendarSlug);
if (!$calendar) {
json_response(['error' => 'Kalender nicht gefunden'], 404);
}
$stmt = $pdo->prepare('INSERT INTO events (calendar_id, title, description, location, start_at, end_at, color) VALUES (:calendar_id, :title, :description, :location, :start_at, :end_at, :color)');
$stmt->execute([
'calendar_id' => $calendar['id'],
'title' => trim((string)$body['title']),
'description' => (string)($body['description'] ?? ''),
'location' => (string)($body['location'] ?? ''),
'start_at' => (string)$body['start'],
'end_at' => (string)$body['end'],
'color' => (string)($body['color'] ?? '#1a73e8'),
]);
$id = (int)$pdo->lastInsertId();
$created = find_event_by_id($pdo, $id);
json_response([
'id' => $created['id'],
'title' => $created['title'],
'description' => $created['description'],
'location' => $created['location'],
'start' => $created['start_at'],
'end' => $created['end_at'],
'color' => $created['color'],
], 201);

24
api/schema.sql Normal file
View file

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS calendars (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS events (
id INT AUTO_INCREMENT PRIMARY KEY,
calendar_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
location VARCHAR(255) NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
color VARCHAR(20) DEFAULT '#1a73e8',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_events_calendar FOREIGN KEY (calendar_id) REFERENCES calendars(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO calendars (slug, name, description) VALUES
('dittes', 'Buchungskalender Dittes Hütte', ''),
('kegeln', 'Buchungskalender ESV-Bludenz Sektion Kegeln', '');

46
api/update-event.php Normal file
View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/helpers.php';
$body = read_json_body();
$id = (int)($body['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => 'Termin fehlt'], 400);
}
$error = validate_event_payload($body);
if ($error) {
json_response(['error' => $error], 400);
}
$pdo = calendar_db();
$existing = find_event_by_id($pdo, $id);
if (!$existing) {
json_response(['error' => 'Termin nicht gefunden'], 404);
}
require_admin_for_slug($existing['calendar_slug']);
$stmt = $pdo->prepare('UPDATE events SET title = :title, description = :description, location = :location, start_at = :start_at, end_at = :end_at, color = :color, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
$stmt->execute([
'id' => $id,
'title' => trim((string)$body['title']),
'description' => (string)($body['description'] ?? ''),
'location' => (string)($body['location'] ?? ''),
'start_at' => (string)$body['start'],
'end_at' => (string)$body['end'],
'color' => (string)($body['color'] ?? '#1a73e8'),
]);
$updated = find_event_by_id($pdo, $id);
json_response([
'id' => $updated['id'],
'title' => $updated['title'],
'description' => $updated['description'],
'location' => $updated['location'],
'start' => $updated['start_at'],
'end' => $updated['end_at'],
'color' => $updated['color'],
]);

7
index.js Normal file
View file

@ -0,0 +1,7 @@
// src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

19061
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

50
package.json Normal file
View file

@ -0,0 +1,50 @@
{
"name": "esv-bludenz",
"version": "0.1.0",
"private": true,
"homepage": ".",
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"bcryptjs": "^3.0.3",
"bootstrap": "^5.3.8",
"cors": "^2.8.6",
"dotenv": "^17.4.1",
"express": "^5.2.1",
"express-session": "^1.19.0",
"react": "^19.1.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.1.0",
"react-router-dom": "^7.14.0",
"react-scripts": "^5.0.1",
"sqlite3": "^6.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"api": "node server/index.js",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

8
public/.htaccess Normal file
View file

@ -0,0 +1,8 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
public/images/askoe.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View file

@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

BIN
public/images/oees.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://eisenbahnersport.at/
HostUrl=https://eisenbahnersport.at/sites/all/themes/eisenbahner/logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1 @@
<svg version='1.1' xmlns='http://www.w3.org/2000/svg' width='27' height='32' viewBox='0 0 27 32' fill='rgb(255, 255, 255)'><title>screenshot</title><path d='M21.376 18.272h-1.952q-0.448 0-0.8-0.32t-0.352-0.8v-2.304q0-0.448 0.352-0.8t0.8-0.32h1.952q-0.576-1.952-2.016-3.392t-3.36-1.984v1.92q0 0.48-0.352 0.832t-0.8 0.32h-2.272q-0.48 0-0.8-0.32t-0.352-0.832v-1.92q-1.92 0.544-3.36 1.984t-2.016 3.392h1.952q0.48 0 0.8 0.32t0.352 0.8v2.304q0 0.448-0.352 0.8t-0.8 0.352h-1.952q0.576 1.92 2.016 3.36t3.36 1.984v-1.92q0-0.48 0.352-0.8t0.8-0.352h2.272q0.48 0 0.8 0.352t0.352 0.8v1.92q1.92-0.544 3.36-1.984t2.016-3.36zM27.424 14.848v2.304q0 0.448-0.32 0.8t-0.832 0.32h-2.528q-0.672 2.88-2.784 4.992t-4.96 2.752v2.56q0 0.448-0.352 0.8t-0.8 0.352h-2.272q-0.48 0-0.8-0.352t-0.352-0.8v-2.56q-2.88-0.672-4.96-2.752t-2.752-4.992h-2.56q-0.48 0-0.8-0.32t-0.352-0.8v-2.304q0-0.448 0.352-0.8t0.8-0.32h2.56q0.64-2.88 2.752-4.992t4.96-2.752v-2.56q0-0.448 0.352-0.8t0.8-0.352h2.272q0.48 0 0.8 0.352t0.352 0.8v2.56q2.88 0.672 4.96 2.752t2.784 4.992h2.528q0.48 0 0.832 0.32t0.32 0.8z'/></svg>

After

Width:  |  Height:  |  Size: 1 KiB

43
public/index.html Normal file
View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>ESV-Bludenz</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

25
public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

21
router.php Normal file
View file

@ -0,0 +1,21 @@
<?php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$root = __DIR__;
$public = $root . '/public';
if ($path !== '/' && file_exists($public . $path) && !is_dir($public . $path)) {
return false;
}
if (str_starts_with($path, '/api/')) {
$apiFile = $root . $path;
if (file_exists($apiFile) && !is_dir($apiFile)) {
require $apiFile;
return true;
}
http_response_code(404);
echo 'API route not found';
return true;
}
require $public . '/index.html';

4
secrets Normal file
View file

@ -0,0 +1,4 @@
web16.wh20.easyname.systems
h101559_kay
PW: ESV-Fantasy8

102
server/db.js Normal file
View file

@ -0,0 +1,102 @@
const fs = require('fs');
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const { calendarSeed } = require('./seed');
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, 'calendar.sqlite');
const db = new sqlite3.Database(dbPath);
function run(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve(this);
});
});
}
function all(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
function get(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
async function initDb() {
await run(`
CREATE TABLE IF NOT EXISTS calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await run(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calendar_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
color TEXT DEFAULT '#1a73e8',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (calendar_id) REFERENCES calendars (id) ON DELETE CASCADE
)
`);
const calendars = [
{
slug: 'dittes',
name: 'Dittes Kalender',
description: 'Buchungskalender ESV-Bludenz Sektion Bergwandern',
isPublic: 1,
},
{
slug: 'kegeln',
name: 'Kegeln Kalender',
description: 'Zweiter Kalender für spätere Nutzung',
isPublic: 1,
},
];
for (const calendar of calendars) {
await run(
`INSERT OR IGNORE INTO calendars (slug, name, description, is_public) VALUES (?, ?, ?, ?)`,
[calendar.slug, calendar.name, calendar.description, calendar.isPublic]
);
}
const dittes = await get(`SELECT id FROM calendars WHERE slug = ?`, ['dittes']);
const countRow = await get(`SELECT COUNT(*) as count FROM events WHERE calendar_id = ?`, [dittes.id]);
if (countRow.count === 0) {
for (const event of calendarSeed) {
await run(
`INSERT INTO events (calendar_id, title, description, location, start_at, end_at, color) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[dittes.id, event.title, event.description, event.location, event.start, event.end, event.color]
);
}
}
}
module.exports = { db, run, all, get, initDb };

171
server/index.js Normal file
View file

@ -0,0 +1,171 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const { all, get, run, initDb } = require('./db');
const app = express();
const PORT = process.env.API_PORT || 3002;
const SESSION_SECRET = process.env.SESSION_SECRET || 'esv-bludenz-dev-secret';
const CALENDAR_PASSWORDS = {
dittes: process.env.ADMIN_PASSWORD_DITTES || 'dittes123',
kegeln: process.env.ADMIN_PASSWORD_KEGELN || 'kegeln123',
};
const CALENDAR_PASSWORD_HASHES = Object.fromEntries(
Object.entries(CALENDAR_PASSWORDS).map(([slug, password]) => [slug, bcrypt.hashSync(password, 10)])
);
function validateEventPayload(body = {}) {
const { title, start, end } = body;
if (!title || !String(title).trim()) return 'Titel fehlt';
if (!start || !end) return 'Start oder Ende fehlt';
const startDate = new Date(start);
const endDate = new Date(end);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return 'Ungültiges Datum';
if (endDate < startDate) return 'Ende liegt vor dem Start';
return null;
}
app.use(
cors({
origin(origin, callback) {
if (!origin) return callback(null, true);
if (/^http:\/\/(localhost|127\.0\.0\.1|192\.168\.0\.149):3001$/.test(origin)) {
return callback(null, true);
}
return callback(null, false);
},
credentials: true,
})
);
app.use(express.json());
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: false, maxAge: 1000 * 60 * 60 * 8 },
})
);
function requireAdmin(req, res, next) {
const targetSlug = req.params.slug || req.body.calendarSlug || req.session.adminCalendarSlug;
if (!req.session.isAdmin || !targetSlug || req.session.adminCalendarSlug !== targetSlug) {
return res.status(401).json({ error: 'Nicht eingeloggt' });
}
next();
}
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
app.get('/api/calendars/:slug/events', async (req, res) => {
try {
const calendar = await get(`SELECT * FROM calendars WHERE slug = ?`, [req.params.slug]);
if (!calendar) return res.status(404).json({ error: 'Kalender nicht gefunden' });
const events = await all(
`SELECT id, title, description, location, start_at as start, end_at as end, color FROM events WHERE calendar_id = ? ORDER BY start_at ASC`,
[calendar.id]
);
res.json({
calendar: {
slug: calendar.slug,
name: calendar.name,
description: calendar.description,
},
events,
isAdmin: Boolean(req.session.isAdmin && req.session.adminCalendarSlug === calendar.slug),
});
} catch (error) {
res.status(500).json({ error: 'Fehler beim Laden der Termine' });
}
});
app.post('/api/login', async (req, res) => {
const { password, calendarSlug } = req.body || {};
const hash = CALENDAR_PASSWORD_HASHES[calendarSlug];
if (!hash) return res.status(400).json({ error: 'Unbekannter Kalender' });
const ok = await bcrypt.compare(password || '', hash);
if (!ok) return res.status(401).json({ error: 'Passwort falsch' });
req.session.isAdmin = true;
req.session.adminCalendarSlug = calendarSlug;
res.json({ ok: true });
});
app.post('/api/logout', (req, res) => {
req.session.destroy(() => {
res.json({ ok: true });
});
});
app.post('/api/calendars/:slug/events', requireAdmin, async (req, res) => {
try {
const calendar = await get(`SELECT * FROM calendars WHERE slug = ?`, [req.params.slug]);
if (!calendar) return res.status(404).json({ error: 'Kalender nicht gefunden' });
req.body.calendarSlug = req.params.slug;
const validationError = validateEventPayload(req.body);
if (validationError) return res.status(400).json({ error: validationError });
const { title, description, location, start, end, color } = req.body;
const result = await run(
`INSERT INTO events (calendar_id, title, description, location, start_at, end_at, color) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[calendar.id, title, description, location, start, end, color || '#1a73e8']
);
const created = await get(
`SELECT id, title, description, location, start_at as start, end_at as end, color FROM events WHERE id = ?`,
[result.lastID]
);
res.status(201).json(created);
} catch (error) {
res.status(500).json({ error: 'Fehler beim Anlegen' });
}
});
app.put('/api/events/:id', requireAdmin, async (req, res) => {
try {
const existing = await get(`SELECT calendars.slug as calendar_slug FROM events JOIN calendars ON calendars.id = events.calendar_id WHERE events.id = ?`, [req.params.id]);
if (!existing) return res.status(404).json({ error: 'Termin nicht gefunden' });
req.body.calendarSlug = existing.calendar_slug;
if (req.session.adminCalendarSlug !== existing.calendar_slug) return res.status(401).json({ error: 'Nicht eingeloggt' });
const validationError = validateEventPayload(req.body);
if (validationError) return res.status(400).json({ error: validationError });
const { title, description, location, start, end, color } = req.body;
await run(
`UPDATE events SET title = ?, description = ?, location = ?, start_at = ?, end_at = ?, color = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[title, description, location, start, end, color || '#1a73e8', req.params.id]
);
const updated = await get(
`SELECT id, title, description, location, start_at as start, end_at as end, color FROM events WHERE id = ?`,
[req.params.id]
);
res.json(updated);
} catch (error) {
res.status(500).json({ error: 'Fehler beim Speichern' });
}
});
app.delete('/api/events/:id', async (req, res) => {
try {
const existing = await get(`SELECT calendars.slug as calendar_slug FROM events JOIN calendars ON calendars.id = events.calendar_id WHERE events.id = ?`, [req.params.id]);
if (!existing) return res.status(404).json({ error: 'Termin nicht gefunden' });
if (!req.session.isAdmin || req.session.adminCalendarSlug !== existing.calendar_slug) {
return res.status(401).json({ error: 'Nicht eingeloggt' });
}
await run(`DELETE FROM events WHERE id = ?`, [req.params.id]);
res.json({ ok: true });
} catch (error) {
res.status(500).json({ error: 'Fehler beim Löschen' });
}
});
initDb().then(() => {
app.listen(PORT, () => {
console.log(`Calendar API läuft auf http://localhost:${PORT}`);
});
});

28
server/seed.js Normal file
View file

@ -0,0 +1,28 @@
const calendarSeed = [
{
title: 'Frühlingswanderung',
description: 'Treffpunkt Bahnhof Bludenz, gemeinsame Tour mit Einkehr.',
start: '2026-04-18T09:00',
end: '2026-04-18T16:00',
location: 'Bludenz Bahnhof',
color: '#1a73e8',
},
{
title: 'Hüttentour Planung',
description: 'Vorbesprechung für die Sommertour.',
start: '2026-04-22T19:00',
end: '2026-04-22T20:30',
location: 'Vereinsheim',
color: '#188038',
},
{
title: 'Seniorenrunde gemütlich',
description: 'Leichte Runde, danach Kaffee.',
start: '2026-04-28T14:00',
end: '2026-04-28T17:00',
location: 'Muttersberg Talstation',
color: '#9334e6',
},
];
module.exports = { calendarSeed };

8
src/.htaccess Normal file
View file

@ -0,0 +1,8 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>

14
src/App.css Normal file
View file

@ -0,0 +1,14 @@
:root {
--esv-red: #e2001a;
--esv-gray: #f8f9fa;
--esv-dark: #212529;
--esv-muted: #6c757d;
--esv-font: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
body {
font-family: var(--esv-font);
margin: 0;
background-color: var(--esv-gray);
}

26
src/App.jsx Normal file
View file

@ -0,0 +1,26 @@
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "./Home";
import Dittes from "./Dittes";
import Kegeln from "./Kegeln";
import Impressum from "./Impressum";
import Bergwandern from "./Bergwandern"; // Großbuchstabe! React-Komponenten beginnen immer mit einem Großbuchstaben
import "./App.css";
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dittes" element={<Dittes />} />
<Route path="/kegeln" element={<Kegeln />} />
<Route path="/impressum" element={<Impressum />} />
<Route path="/bergwandern" element={<Bergwandern />} />
</Routes>
</Router>
);
}
export default App;

45
src/Bergwandern.jsx Normal file
View file

@ -0,0 +1,45 @@
import React from "react";
import { Link } from "react-router-dom";
import "./Home.css";
function Bergwandern() {
return (
<div className="page-bg">
<header className="header text-center">
<h1 className="display-4">ESV Bludenz - Sektion Bergwandern</h1>
</header>
<main className="container my-5 d-flex justify-content-center align-items-center" style={{ minHeight: "70vh" }}>
<div className="bg-white p-4 rounded shadow-sm text-center" style={{ maxWidth: "600px", width: "100%" }}>
<p>
<strong>ESV - Bludenz</strong><br />
Eisenbahnersportverein Bludenz<br />
Sektion Bergwandern<br />
</p>
<p>
<strong>Sektionsleiter:</strong><br />
Gerhard Vonbank <br />
E-Mail: gerhardvonbank@gmx.at<br />
</p>
<img
src="images/logo_dittes.png"
alt="Dittes Logo"
className="img-fluid mx-auto d-block"
style={{ maxWidth: "300px", margin: "2rem auto" }}
/>
<Link to="/" className="btn btn-impressum mt-3">
Zurück zur Sektionsübersicht
</Link>
</div>
</main>
<footer className="footer text-center">
© {new Date().getFullYear()} ESV Bludenz Ein Verein für alle Eisenbahner:innen
</footer>
</div>
);
}
export default Bergwandern;

300
src/Dittes.css Normal file
View file

@ -0,0 +1,300 @@
.dittes-header {
background: linear-gradient(135deg, #e2001a 0%, #b80015 100%);
border-bottom: 1px solid #b80015;
color: white;
text-align: center;
padding: 2rem 1rem 1.5rem;
}
.dittes-container {
margin-top: 1rem;
max-width: 1180px;
}
.calendar-shell {
background: #fff;
border: 1px solid #f0c7cc;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(226, 0, 26, 0.08);
}
.calendar-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid #eceff1;
}
.calendar-title {
margin: 0;
font-size: 1.15rem;
color: #e2001a;
}
.calendar-subtitle {
color: #5f6368;
font-size: 0.85rem;
}
.calendar-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.month-label {
min-width: 150px;
text-align: center;
color: #202124;
font-size: 0.95rem;
}
.calendar-grid-wrapper {
padding: 0 0.8rem 0.8rem;
}
.weekday-row,
.month-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.weekday-cell {
text-align: center;
color: #5f6368;
font-size: 0.78rem;
padding: 0.55rem 0.2rem;
}
.day-cell {
min-height: 105px;
border: 1px solid #eceff1;
padding: 0.35rem 0;
background: #fff;
}
.day-cell-clickable {
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.day-cell-clickable:hover {
background-color: #fff2f4;
box-shadow: inset 0 0 0 1px #f4b5bf;
}
.day-cell-selected {
background-color: #ffe5e9;
box-shadow: inset 0 0 0 2px #e2001a;
}
.day-cell-empty {
background: #f8f9fa;
}
.day-number {
font-weight: 600;
color: #e2001a;
margin-bottom: 0.25rem;
font-size: 0.9rem;
padding: 0 0.35rem;
}
.day-events {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.calendar-event-pill {
color: white;
font-size: 0.7rem;
padding: 0.15rem 0.35rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 0;
margin-right: 0;
width: calc(100% + 2px);
position: relative;
left: -1px;
z-index: 2;
text-align: center;
min-height: 22px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.calendar-event-pill.event-single {
border-radius: 999px;
}
.calendar-event-pill.event-start {
border-radius: 999px 0 0 999px;
}
.calendar-event-pill.event-middle {
border-radius: 0;
color: white;
}
.calendar-event-pill.event-end {
border-radius: 0 999px 999px 0;
}
.calendar-event-pill-clickable {
cursor: pointer;
}
.calendar-more {
font-size: 0.8rem;
color: #e2001a;
}
.list-header {
padding: 1rem 1.1rem 0.6rem;
}
.event-list {
padding: 0 0.8rem 0.8rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.event-card {
display: flex;
gap: 0.6rem;
border: 1px solid #f1d2d6;
border-radius: 14px;
padding: 0.55rem 0.65rem;
background: linear-gradient(180deg, #fff 0%, #fff9fa 100%);
}
.event-color {
width: 10px;
border-radius: 999px;
min-height: 100%;
}
.event-content {
flex: 1;
}
.event-header-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.event-title-line {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.event-separator {
color: #9aa0a6;
font-weight: 500;
}
.event-meta-inline,
.event-location {
color: #5f6368;
font-size: 0.78rem;
margin-bottom: 0.1rem;
}
.event-meta-inline {
white-space: nowrap;
font-size: 1rem;
font-weight: 500;
color: #202124;
}
.event-description {
color: #202124;
font-size: 0.82rem;
}
.event-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.admin-login,
.event-form {
padding: 0 1.1rem 1.1rem;
}
.modal-form {
padding: 0;
}
.auth-error {
color: #b3261e;
margin-top: 0.5rem;
font-size: 0.92rem;
}
.btn-danger {
background-color: #e2001a;
border-color: #e2001a;
}
.btn-danger:hover,
.btn-danger:focus {
background-color: #b80015;
border-color: #b80015;
}
.admin-hint {
color: #5f6368;
font-size: 0.9rem;
}
.event-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.form-buttons {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
@media (max-width: 991px) {
.calendar-topbar {
flex-direction: column;
align-items: flex-start;
}
.month-label {
min-width: auto;
}
}
@media (max-width: 767px) {
.calendar-grid-wrapper {
overflow-x: auto;
}
.weekday-row,
.month-grid {
min-width: 700px;
}
.event-header-row {
flex-direction: column;
}
}

477
src/Dittes.jsx Normal file
View file

@ -0,0 +1,477 @@
import React, { useEffect, useMemo, useState } from "react";
import { Modal } from "react-bootstrap";
import { Link } from "react-router-dom";
import "./Dittes.css";
const API_BASE =
process.env.REACT_APP_API_BASE ||
`${window.location.origin}/api`;
function formatDateTime(value) {
if (!value) return "";
return new Intl.DateTimeFormat("de-AT", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatEventMeta(startValue, endValue) {
const start = new Date(startValue);
const end = new Date(endValue);
const sameDate = start.toDateString() === end.toDateString();
if (sameDate) {
const datePart = new Intl.DateTimeFormat("de-AT", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(start);
const timePart = `${new Intl.DateTimeFormat("de-AT", {
hour: "2-digit",
minute: "2-digit",
}).format(start)}-${new Intl.DateTimeFormat("de-AT", {
hour: "2-digit",
minute: "2-digit",
}).format(end)} Uhr`;
return `${datePart}, ${timePart}`;
}
return `${formatDateTime(startValue)} bis ${formatDateTime(endValue)}`;
}
function sameDay(date, isoValue) {
const d = new Date(isoValue);
return d.getFullYear() === date.getFullYear() && d.getMonth() === date.getMonth() && d.getDate() === date.getDate();
}
function normalizeDay(value) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
return date;
}
function isDateWithinEvent(date, startIso, endIso) {
const current = normalizeDay(date);
const start = normalizeDay(startIso);
const end = normalizeDay(endIso);
return current >= start && current <= end;
}
function getEventSpanClass(date, startIso, endIso) {
const current = normalizeDay(date);
const start = normalizeDay(startIso);
const end = normalizeDay(endIso);
const isStart = current.getTime() === start.getTime();
const isEnd = current.getTime() === end.getTime();
if (isStart && isEnd) return "event-single";
if (isStart) return "event-start";
if (isEnd) return "event-end";
return "event-middle";
}
function getEventLabel(date, entry) {
const start = normalizeDay(entry.start);
const end = normalizeDay(entry.end);
const current = normalizeDay(date);
if (start.getTime() === end.getTime()) return entry.title;
const middleTs = start.getTime() + Math.floor((end.getTime() - start.getTime()) / 2);
const middle = new Date(middleTs);
middle.setHours(0, 0, 0, 0);
return current.getTime() === middle.getTime() ? entry.title : "";
}
function padDate(date) {
return date.toISOString().split("T")[0];
}
function createMonthGrid(currentMonth) {
const start = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1);
const end = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);
const firstDay = (start.getDay() + 6) % 7;
const cells = [];
for (let i = 0; i < firstDay; i += 1) cells.push(null);
for (let day = 1; day <= end.getDate(); day += 1) {
cells.push(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day));
}
while (cells.length % 7 !== 0) cells.push(null);
return cells;
}
const emptyForm = {
id: "",
title: "",
description: "",
start: "",
end: "",
location: "",
color: "#1a73e8",
};
function Dittes({
calendarSlug = "dittes",
pageTitle = "Buchungskalender ESV-Bludenz",
pageSubtitle = "Sektion Bergwandern",
fallbackName = "Buchungskalender Dittes Hütte",
}) {
const [events, setEvents] = useState([]);
const [calendarInfo, setCalendarInfo] = useState({ name: fallbackName, description: "" });
const [currentMonth, setCurrentMonth] = useState(new Date());
const [adminMode, setAdminMode] = useState(false);
const [passwordInput, setPasswordInput] = useState("");
const [authError, setAuthError] = useState("");
const [form, setForm] = useState(emptyForm);
const [status, setStatus] = useState("Lade Kalender...");
const [showModal, setShowModal] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [dragStart, setDragStart] = useState(null);
const [dragEnd, setDragEnd] = useState(null);
const monthGrid = useMemo(() => createMonthGrid(currentMonth), [currentMonth]);
const sortedEvents = useMemo(() => [...events].sort((a, b) => new Date(a.start) - new Date(b.start)), [events]);
const visibleMonthLabel = new Intl.DateTimeFormat("de-AT", { month: "long", year: "numeric" }).format(currentMonth);
async function loadEvents() {
const response = await fetch(`${API_BASE}/events.php?calendar=${encodeURIComponent(calendarSlug)}`, {
credentials: "include",
});
const data = await response.json();
setEvents(data.events || []);
setCalendarInfo(data.calendar || { name: fallbackName, description: "" });
setAdminMode(Boolean(data.isAdmin));
setStatus("");
}
useEffect(() => {
loadEvents().catch(() => setStatus("Kalender konnte nicht geladen werden. Läuft das Backend schon?"));
}, [calendarSlug, fallbackName]);
function changeMonth(direction) {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + direction, 1));
}
async function handleLogin(event) {
event.preventDefault();
const response = await fetch(`${API_BASE}/login.php`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ password: passwordInput, calendarSlug }),
});
if (!response.ok) {
setAuthError("Passwort stimmt nicht.");
return;
}
setPasswordInput("");
setAuthError("");
setShowLoginModal(false);
await loadEvents();
}
async function handleLogout() {
await fetch(`${API_BASE}/logout.php`, {
method: "POST",
credentials: "include",
});
setAdminMode(false);
setForm(emptyForm);
}
async function handleSubmit(event) {
event.preventDefault();
if (!form.title || !form.start || !form.end) return;
const payload = {
title: form.title,
description: form.description,
location: form.location,
start: form.start,
end: form.end,
color: form.color,
};
const url = form.id ? `${API_BASE}/update-event.php` : `${API_BASE}/save-event.php`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ...payload, id: form.id || undefined, calendarSlug }),
});
if (!response.ok) {
setStatus("Speichern fehlgeschlagen.");
return;
}
setForm(emptyForm);
setShowModal(false);
await loadEvents();
}
function handleEdit(eventItem) {
setForm(eventItem);
setShowModal(true);
}
function openCreateModal(date, endDate = null) {
if (!adminMode || !date) return;
const start = new Date(date);
start.setHours(9, 0, 0, 0);
const end = new Date(endDate || date);
end.setHours(11, 0, 0, 0);
if (end < start) {
const tmp = new Date(start);
start.setTime(end.getTime());
end.setTime(tmp.getTime());
}
setForm({
...emptyForm,
start: `${padDate(start)}T09:00`,
end: `${padDate(end)}T11:00`,
});
setShowModal(true);
}
function isDateInDragRange(date) {
if (!date || !dragStart || !dragEnd) return false;
const current = new Date(date);
current.setHours(0, 0, 0, 0);
const start = new Date(dragStart);
const end = new Date(dragEnd);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
const min = start < end ? start : end;
const max = start > end ? start : end;
return current >= min && current <= max;
}
function handleDayMouseDown(date) {
if (!adminMode || !date) return;
setDragStart(date);
setDragEnd(date);
}
function handleDayMouseEnter(date) {
if (!adminMode || !dragStart || !date) return;
setDragEnd(date);
}
function handleDayMouseUp(date) {
if (!adminMode || !dragStart || !date) return;
const sameStartEnd = padDate(dragStart) === padDate(date);
const startDate = dragStart;
const endDate = date;
setDragStart(null);
setDragEnd(null);
if (sameStartEnd) {
openCreateModal(startDate);
return;
}
openCreateModal(startDate, endDate);
}
async function handleDelete(id) {
const response = await fetch(`${API_BASE}/delete-event.php`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ id }),
});
if (!response.ok) {
setStatus("Löschen fehlgeschlagen.");
return;
}
if (form.id === id) setForm(emptyForm);
setShowModal(false);
await loadEvents();
}
return (
<div className="page-bg">
<header className="dittes-header">
<h2 className="mb-2">{pageTitle}</h2>
<p className="mb-0">{pageSubtitle}</p>
</header>
<main className="container dittes-container pb-5">
<section className="calendar-shell shadow-sm">
<div className="calendar-topbar">
<div>
<h3 className="calendar-title">{calendarInfo.name}</h3>
{calendarInfo.description ? <p className="calendar-subtitle mb-0">{calendarInfo.description}</p> : null}
</div>
<div className="calendar-controls">
<button className="btn btn-light" onClick={() => changeMonth(-1)}></button>
<strong className="month-label text-capitalize">{visibleMonthLabel}</strong>
<button className="btn btn-light" onClick={() => changeMonth(1)}></button>
{adminMode ? (
<button className="btn btn-outline-secondary btn-sm" onClick={handleLogout}>Logout</button>
) : (
<button className="btn btn-outline-danger btn-sm" onClick={() => setShowLoginModal(true)}>Admin</button>
)}
</div>
</div>
{status && <div className="px-3 py-2 text-muted">{status}</div>}
<div className="calendar-grid-wrapper">
<div className="weekday-row">
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map((day) => (
<div key={day} className="weekday-cell">{day}</div>
))}
</div>
<div className="month-grid">
{monthGrid.map((date, index) => {
const dayEvents = date ? sortedEvents.filter((entry) => isDateWithinEvent(date, entry.start, entry.end)) : [];
return (
<div
key={`${date ? padDate(date) : `empty-${index}`}`}
className={`day-cell ${date ? "" : "day-cell-empty"} ${adminMode && date ? "day-cell-clickable" : ""} ${isDateInDragRange(date) ? "day-cell-selected" : ""}`}
onMouseDown={() => handleDayMouseDown(date)}
onMouseEnter={() => handleDayMouseEnter(date)}
onMouseUp={() => handleDayMouseUp(date)}
>
{date && (
<>
<div className="day-number">{date.getDate()}</div>
<div className="day-events">
{dayEvents.slice(0, 3).map((entry) => (
<div
key={entry.id}
className={`calendar-event-pill ${getEventSpanClass(date, entry.start, entry.end)} ${adminMode ? "calendar-event-pill-clickable" : ""}`}
style={{ backgroundColor: entry.color || "#1a73e8" }}
title={`${entry.title} | ${formatDateTime(entry.start)}`}
onClick={(event) => {
event.stopPropagation();
if (adminMode) handleEdit(entry);
}}
>
{getEventLabel(date, entry)}
</div>
))}
{dayEvents.length > 3 && <div className="calendar-more">+{dayEvents.length - 3} mehr</div>}
</div>
</>
)}
</div>
);
})}
</div>
</div>
</section>
<section className="calendar-shell shadow-sm h-100 mt-4">
<div className="list-header">
<h4 className="mb-1">Nächste Termine</h4>
</div>
<div className="event-list event-list-wide">
{sortedEvents.slice(0, 10).map((entry) => (
<article key={entry.id} className="event-card">
<div className="event-color" style={{ backgroundColor: entry.color || "#1a73e8" }} />
<div className="event-content">
<div className="event-header-row">
<div className="event-title-line">
<h5 className="mb-0">{entry.title}</h5>
<span className="event-separator">|</span>
<div className="event-meta-inline">{formatEventMeta(entry.start, entry.end)}</div>
</div>
{adminMode && (
<div className="event-actions">
<button className="btn btn-sm btn-outline-secondary" onClick={() => handleEdit(entry)}>Bearbeiten</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDelete(entry.id)}>Löschen</button>
</div>
)}
</div>
{entry.location && <div className="event-location">📍 {entry.location}</div>}
{entry.description && <p className="event-description mb-0">{entry.description}</p>}
</div>
</article>
))}
</div>
</section>
<div className="d-flex justify-content-center mt-4">
<Link to="/" className="btn btn-impressum px-4">
Zurück zur Startseite
</Link>
</div>
<Modal show={showLoginModal} onHide={() => setShowLoginModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Admin Login</Modal.Title>
</Modal.Header>
<Modal.Body>
<form className="event-form modal-form" onSubmit={handleLogin}>
<div>
<label className="form-label">Passwort</label>
<input
type="password"
className="form-control"
value={passwordInput}
onChange={(event) => setPasswordInput(event.target.value)}
placeholder="Passwort eingeben"
/>
</div>
{authError && <div className="auth-error">{authError}</div>}
<div className="form-buttons">
<button className="btn btn-danger" type="submit">Einloggen</button>
<button className="btn btn-outline-secondary" type="button" onClick={() => setShowLoginModal(false)}>Abbrechen</button>
</div>
</form>
</Modal.Body>
</Modal>
<Modal show={showModal} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>{form.id ? "Termin bearbeiten" : "Neuen Termin anlegen"}</Modal.Title>
</Modal.Header>
<Modal.Body>
<form className="event-form modal-form" onSubmit={handleSubmit}>
<div>
<label className="form-label">Titel</label>
<input className="form-control" value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} />
</div>
<div>
<label className="form-label">Ort</label>
<input className="form-control" value={form.location} onChange={(event) => setForm({ ...form, location: event.target.value })} />
</div>
<div>
<label className="form-label">Start</label>
<input type="datetime-local" className="form-control" value={form.start} onChange={(event) => setForm({ ...form, start: event.target.value })} />
</div>
<div>
<label className="form-label">Ende</label>
<input type="datetime-local" className="form-control" value={form.end} onChange={(event) => setForm({ ...form, end: event.target.value })} />
</div>
<div>
<label className="form-label">Farbe</label>
<input type="color" className="form-control form-control-color" value={form.color} onChange={(event) => setForm({ ...form, color: event.target.value })} />
</div>
<div>
<label className="form-label">Beschreibung</label>
<textarea className="form-control" rows="4" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
</div>
<div className="form-buttons">
<button className="btn btn-danger" type="submit">{form.id ? "Termin speichern" : "Termin anlegen"}</button>
{form.id && <button className="btn btn-outline-danger" type="button" onClick={() => handleDelete(form.id)}>Löschen</button>}
<button className="btn btn-outline-secondary" type="button" onClick={() => { setForm(emptyForm); setShowModal(false); }}>Abbrechen</button>
</div>
</form>
</Modal.Body>
</Modal>
</main>
</div>
);
}
export default Dittes;

67
src/Home.css Normal file
View file

@ -0,0 +1,67 @@
.header {
background-color: #e2001a;
color: white;
padding: 3rem 0;
text-align: center;
}
.section-button {
border-color: #e2001a;
color: #e2001a;
font-weight: bold;
padding: 1rem;
justify-content: center;
}
.section-button:hover {
background-color: #e2001a;
color: white;
justify-content: center;
}
.section-icon {
height: 40px;
margin-right: 1rem;
}
.page-bg {
background-color: #f8f9fa;
min-height: 100vh;
}
.footer {
text-align: center;
color: #6c757d;
font-size: 0.9rem;
padding: 1rem 0;
}
.btn-impressum {
background-color: #cc0000;
color: white;
border: none;
}
.btn-impressum:hover {
background-color: #a30000;
color: white;
}
/* Responsive Optimierungen */
@media (max-width: 576px) {
.section-button {
font-size: 1rem;
flex-direction: column;
align-items: center;
text-align: center;
}
.section-icon {
margin-right: 0;
margin-bottom: 0.5rem;
}
}

59
src/Home.jsx Normal file
View file

@ -0,0 +1,59 @@
import React from "react";
import "bootstrap/dist/css/bootstrap.min.css";
import "./Home.css";
import { esvSections } from "./esvSections";
import { Link } from "react-router-dom";
function Home() {
return (
<div className="page-bg">
<header className="header">
<h1 className="display-4">ESV - Bludenz</h1>
<p className="lead">Eisenbahnersportverein Sektions Übersicht</p>
</header>
<main className="container my-5">
<div className="row justify-content-center">
{esvSections.map((section, idx) => (
<div className="col-12 mb-4" key={idx}>
{section.link.startsWith("http") ? (
<a
href={section.link}
target="_blank"
rel="noopener noreferrer"
className="btn btn-outline-dark btn-lg w-100 d-flex align-items-center shadow-sm section-button"
>
<img
src={`${process.env.PUBLIC_URL}/${section.icon}`}
alt={`${section.name} Icon`}
className="section-icon"
/>
<span>{section.name}</span>
</a>
) : (
<Link
to={section.link}
className="btn btn-outline-dark btn-lg w-100 d-flex align-items-center shadow-sm section-button"
>
<img
src={`${process.env.PUBLIC_URL}/${section.icon}`}
alt={`${section.name} Icon`}
className="section-icon"
/>
<span>{section.name}</span>
</Link>
)}
</div>
))}
</div>
</main>
<footer className="footer">
© {new Date().getFullYear()} ESV Bludenz Eine Linksammlung zu allen ESV-Bludenz Sektionen <Link to="/impressum">Impressum</Link>
</footer>
</div>
);
}
export default Home;

58
src/Impressum.jsx Normal file
View file

@ -0,0 +1,58 @@
import React from "react";
import { Link } from "react-router-dom";
import "./Home.css";
function Impressum() {
return (
<div className="page-bg">
<header className="header">
<h1 className="display-4">Impressum</h1>
<p className="lead">gemäß § 14 UGB und § 5 ECG</p>
</header>
<main className="container my-5">
<div className="bg-white p-4 rounded shadow-sm">
<p>
<strong>ESV - Bludenz</strong><br />
Eisenbahnersportverein Bludenz<br />
Landstraße 10<br />
6714 Nüziders<br />
Österreich
</p>
<p>
<strong>Obmann:</strong><br />
Ernst Lerch <br />
ZVR-Zahl: 301152401<br />
</p>
<p>
Vereinssitz: Bludenz, Österreich<br />
Vereinszweck: Förderung des Sports im Sinne der Statuten <br />
E-Mail: ernst.lerch@lampertmail.at<br />
</p>
<p>
<strong>Medieninhaber & für den Inhalt verantwortlich: </strong><br />
Ernst Lerch<br />
Landstraße 10<br />
6714 Nüziders<br />
</p>
<p>
Plattform der EU-Kommission zur Online-Streitbeilegung: <br />
https://ec.europa.eu/odr <br />
</p>
<div className="mt-4 text-center">
<Link to="/" className="btn btn-impressum">
Zurück zur Sektionsübersicht
</Link>
</div>
</div>
</main>
<footer className="footer">
© {new Date().getFullYear()} ESV Bludenz Ein Verein für alle Eisenbahner:innen
</footer>
</div>
);
}
export default Impressum;

15
src/Kegeln.jsx Normal file
View file

@ -0,0 +1,15 @@
import React from "react";
import Dittes from "./Dittes";
function Kegeln() {
return (
<Dittes
calendarSlug="kegeln"
pageTitle="Buchungskalender ESV-Bludenz"
pageSubtitle="Sektion Kegeln"
fallbackName="Buchungskalender ESV-Bludenz Sektion Kegeln"
/>
);
}
export default Kegeln;

1
src/calendarSeed.js Normal file
View file

@ -0,0 +1 @@
export const calendarSeed = [];

30
src/esvSections.js Normal file
View file

@ -0,0 +1,30 @@
export const esvSections = [
{
name: "ESV Bludenz - Sektion Kegeln",
link: "/kegeln",
icon: "images/sektion_kegeln.png",
},
{
name: "ESV Bludenz - Sektion Schützen",
link: "https://www.esv-schuetzen.at",
icon: "images/sektion_schuetzen.png",
},
{
name: "ESV Bludenz - Sektion Bergwandern",
link: "/bergwandern", // Nur der Pfad,
icon: "images/sektion_bergwandern.png",
},
{
name: "Österreichischer Eisenbahnersport",
link: "https://eisenbahnersport.at/",
icon: "images/oees.png",
},
{
name: "ASKÖ: Arbeitsgemeinschaft für Sport und Körperkultur in Österreich",
link: "https://www.askoe-vorarlberg.at/de",
icon: "images/askoe.jpg",
},
];

11
src/index.js Normal file
View file

@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

13
src/reportWebVitals.js Normal file
View file

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

5
src/setupTests.js Normal file
View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

6
start-php-test.sh Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8080}"
php -S ${HOST}:${PORT} router.php