Initial commit: PHP/MySQL ESV Bludenz calendar migration
5
.env.example
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
50
package.json
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/askoe.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
3
public/images/askoe.jpg:Zone.Identifier
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
HostUrl=about:internet
|
||||
BIN
public/images/logo_dittes.png
Normal file
|
After Width: | Height: | Size: 737 KiB |
BIN
public/images/oees.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
4
public/images/oees.png:Zone.Identifier
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=https://eisenbahnersport.at/
|
||||
HostUrl=https://eisenbahnersport.at/sites/all/themes/eisenbahner/logo.png
|
||||
BIN
public/images/sektion_bergwandern.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/images/sektion_foto.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
0
public/images/sektion_kegeln - Kopie.png:Zone.Identifier
Normal file
BIN
public/images/sektion_kegeln.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/sektion_schuetzen.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
1
public/images/sektion_schuetzen.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
21
router.php
Normal 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
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
web16.wh20.easyname.systems
|
||||
h101559_kay
|
||||
PW: ESV-Fantasy8
|
||||
102
server/db.js
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
export const calendarSeed = [];
|
||||
30
src/esvSections.js
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||