Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eea814fad | |||
| 0597c73690 | |||
| 0a2aa84a8c | |||
| 1120472af8 | |||
| db3a38ed45 | |||
| 4f8feb8652 | |||
| 0c291079ff | |||
| fe2f61cdc2 | |||
| af4877300f | |||
| 4a103cf7d6 | |||
| 97e7f88906 | |||
| 807c56de5a | |||
| 8ca30ae5f3 | |||
| 8f1254840c | |||
| 8b2d00385a | |||
| c55e274718 | |||
| e9a95ccf8d | |||
| b16ac76620 | |||
| 0e03b9dea9 | |||
| da3a950a1a |
1
.gitignore
vendored
@ -22,3 +22,4 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
/ai/
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
steps:
|
steps:
|
||||||
deploy:
|
deploy_frontend:
|
||||||
image: node:20
|
image: node:20
|
||||||
environment:
|
environment:
|
||||||
FLY_API_TOKEN:
|
FLY_API_TOKEN:
|
||||||
@ -7,10 +7,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- curl -L https://fly.io/install.sh | sh
|
- curl -L https://fly.io/install.sh | sh
|
||||||
- export PATH="$HOME/.fly/bin:$PATH"
|
- export PATH="$HOME/.fly/bin:$PATH"
|
||||||
- flyctl deploy --config fly.toml --app gallus-pub
|
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
|
||||||
|
when:
|
||||||
when:
|
branch: main
|
||||||
branch:
|
event: push
|
||||||
- main
|
|
||||||
event:
|
|
||||||
- push
|
|
||||||
142
MIGRATION_README.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# Migration der alten Events und Gallery-Bilder
|
||||||
|
|
||||||
|
## ✅ Was wurde migriert?
|
||||||
|
|
||||||
|
### Events (7 Stück):
|
||||||
|
- Karaoke (wiederkehrend)
|
||||||
|
- Pub Quiz (wiederkehrend)
|
||||||
|
- Schlager Hüttenzauber Karaoke
|
||||||
|
- Adventskalender
|
||||||
|
- Santa Karaoke-Party
|
||||||
|
- Weihnachtsferien
|
||||||
|
- Neujahrs-Apero
|
||||||
|
|
||||||
|
### Gallery-Bilder (9 Stück):
|
||||||
|
- Gallery1.webp bis Gallery9.webp
|
||||||
|
|
||||||
|
## 📁 Wo liegen die Bilder?
|
||||||
|
|
||||||
|
Alle Bilder wurden konvertiert und liegen jetzt in:
|
||||||
|
- **Events:** `backend/data/images/events/`
|
||||||
|
- **Gallery:** `backend/data/images/gallery/`
|
||||||
|
|
||||||
|
Die Bilder wurden automatisch:
|
||||||
|
- Von PNG/JPG/JPEG zu WebP konvertiert
|
||||||
|
- Auf max. 1600px Breite skaliert
|
||||||
|
- Mit 85% Qualität optimiert
|
||||||
|
|
||||||
|
## 🚀 Deployment-Schritte
|
||||||
|
|
||||||
|
### 1. Lokale Vorbereitung (bereits erledigt ✓)
|
||||||
|
- ✓ Migrations-Script erstellt
|
||||||
|
- ✓ Bilder konvertiert und in `backend/data/images/` kopiert
|
||||||
|
- ✓ Public API-Endpunkte erstellt (`/api/events/public`, `/api/gallery/public`)
|
||||||
|
- ✓ Frontend aktualisiert, um Events und Gallery dynamisch zu laden
|
||||||
|
|
||||||
|
### 2. Auf Fly.io deployen
|
||||||
|
|
||||||
|
Alle Änderungen committen und pushen:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: Migrate old events and gallery images to CMS"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Woodpecker CI wird automatisch beide Services deployen.
|
||||||
|
|
||||||
|
### 3. Nach dem ersten Deploy - Datenbank initialisieren
|
||||||
|
|
||||||
|
**Wichtig:** Die Bilder sind bereits im Repository in `backend/data/images/`, aber die Datenbank muss noch mit den Event- und Gallery-Einträgen befüllt werden.
|
||||||
|
|
||||||
|
#### Via fly ssh (Empfohlen):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In das Backend einloggen
|
||||||
|
fly ssh console -a gallus-cms-backend
|
||||||
|
|
||||||
|
# Prüfen ob Bilder da sind
|
||||||
|
ls -la /app/data/images/events/
|
||||||
|
ls -la /app/data/images/gallery/
|
||||||
|
|
||||||
|
# Migrations-Script ausführen
|
||||||
|
cd /app
|
||||||
|
npm run migrate:old-data
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Alternative: Manuell via Admin-Panel
|
||||||
|
|
||||||
|
1. Gehe zu https://gallus-pub.ch/admin
|
||||||
|
2. Melde dich an
|
||||||
|
3. Für jedes Event:
|
||||||
|
- Klicke auf "Neues Event"
|
||||||
|
- Gib Titel, Datum und Beschreibung ein
|
||||||
|
- Statt Bild hochzuladen, trage manuell die imageUrl ein:
|
||||||
|
- z.B. `/images/events/event_karaoke.webp`
|
||||||
|
- Speichere das Event
|
||||||
|
|
||||||
|
## 🔍 Verifikation
|
||||||
|
|
||||||
|
Nach dem Deployment prüfen:
|
||||||
|
|
||||||
|
1. **Frontend:** https://gallus-pub.ch/
|
||||||
|
- Events sollten angezeigt werden
|
||||||
|
- Gallery sollte Bilder zeigen
|
||||||
|
|
||||||
|
2. **Admin:** https://gallus-pub.ch/admin
|
||||||
|
- Events können bearbeitet werden
|
||||||
|
- Neue Events können hinzugefügt werden
|
||||||
|
|
||||||
|
3. **Backend Health:** https://cms.gallus-pub.ch/health
|
||||||
|
- Status sollte "ok" sein
|
||||||
|
|
||||||
|
## 📝 Event-Daten für manuelles Einfügen
|
||||||
|
|
||||||
|
Falls du die Events manuell via Admin-Panel einfügen möchtest:
|
||||||
|
|
||||||
|
### Karaoke
|
||||||
|
- **Titel:** Karaoke
|
||||||
|
- **Datum:** 2025-12-31
|
||||||
|
- **Beschreibung:** Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
|
||||||
|
- **Bild-URL:** `/images/events/event_karaoke.webp`
|
||||||
|
|
||||||
|
### Pub Quiz
|
||||||
|
- **Titel:** Pub Quiz
|
||||||
|
- **Datum:** 2025-12-31
|
||||||
|
- **Beschreibung:** Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
|
||||||
|
- **Bild-URL:** `/images/events/event_pub-quiz.webp`
|
||||||
|
|
||||||
|
### Schlager Hüttenzauber Karaoke
|
||||||
|
- **Titel:** Schlager Hüttenzauber Karaoke
|
||||||
|
- **Datum:** 2025-11-27
|
||||||
|
- **Beschreibung:** Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
|
||||||
|
- **Bild-URL:** `/images/events/event_schlager-karaoke.webp`
|
||||||
|
|
||||||
|
### Adventskalender
|
||||||
|
- **Titel:** Adventskalender
|
||||||
|
- **Datum:** 2025-12-20
|
||||||
|
- **Beschreibung:** Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
|
||||||
|
- **Bild-URL:** `/images/events/event_advents-kalender.webp`
|
||||||
|
|
||||||
|
### Santa Karaoke-Party
|
||||||
|
- **Titel:** Santa Karaoke-Party
|
||||||
|
- **Datum:** 2025-12-06
|
||||||
|
- **Beschreibung:** 🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻
|
||||||
|
- **Bild-URL:** `/images/events/event_santa_karaoke.webp`
|
||||||
|
|
||||||
|
### Weihnachtsferien
|
||||||
|
- **Titel:** Weihnachtsferien
|
||||||
|
- **Datum:** 2025-12-21
|
||||||
|
- **Beschreibung:** Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
|
||||||
|
- **Bild-URL:** `/images/events/event_ferien.webp`
|
||||||
|
|
||||||
|
### Neujahrs-Apero
|
||||||
|
- **Titel:** Neujahrs-Apero
|
||||||
|
- **Datum:** 2026-01-02
|
||||||
|
- **Beschreibung:** 18:00-20:00 Uhr
|
||||||
|
- **Bild-URL:** `/images/events/event_neujahrs-apero.webp`
|
||||||
|
|
||||||
|
## ⚠️ Wichtige Hinweise
|
||||||
|
|
||||||
|
1. **Bilder sind im Volume persistent:** Alle Bilder in `/app/data/` bleiben bei Restarts erhalten
|
||||||
|
2. **Datenbank ist persistent:** Die SQLite-DB in `/app/data/gallus_cms.db` bleibt erhalten
|
||||||
|
3. **Alte Bilder in `public/images/`:** Die alten Original-Bilder bleiben im Frontend-Repository, werden aber nicht mehr verwendet
|
||||||
@ -45,3 +45,4 @@ All commands are run from the root of the project, from a terminal:
|
|||||||
## 👀 Want to learn more?
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
|
# Test commit to trigger Woodpecker
|
||||||
|
|||||||
220
SYSTEM_ERKLAERUNG.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
# 🎯 Gallus Pub CMS - System-Erklärung
|
||||||
|
|
||||||
|
## 📐 Architektur-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ Backend CMS │ │ Fly.io Volume │
|
||||||
|
│ (Astro SSG) │◄────────┤ (Fastify API) │◄────────┤ /app/data/ │
|
||||||
|
│ gallus-pub.ch │ fetch │ cms.gallus-pub.ch│ mount │ - SQLite DB │
|
||||||
|
│ │ │ │ │ - images/ │
|
||||||
|
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Wie funktioniert der Upload-Flow?
|
||||||
|
|
||||||
|
### 1. **Admin lädt Bild hoch** (admin.astro)
|
||||||
|
```
|
||||||
|
User wählt Bild → uploadImage() → POST /api/gallery/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Backend verarbeitet Upload** (backend/src/routes/gallery.ts)
|
||||||
|
```javascript
|
||||||
|
// Line 60-134 in gallery.ts
|
||||||
|
1. Empfange Multipart-File
|
||||||
|
2. Validiere Mimetype (nur images/*)
|
||||||
|
3. Lese Stream in Buffer
|
||||||
|
4. sharp() konvertiert zu WebP:
|
||||||
|
- Auto-rotate (EXIF)
|
||||||
|
- Resize auf max 1600px
|
||||||
|
- WebP quality 82%
|
||||||
|
5. Speichere in /app/data/images/gallery/
|
||||||
|
6. Erstelle DB-Eintrag mit imageUrl
|
||||||
|
7. Return imageUrl an Frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Bilder werden serviert** (backend/src/index.ts)
|
||||||
|
```javascript
|
||||||
|
// Line 63-69
|
||||||
|
fastifyStatic → /static/ → /app/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
- Bild liegt in: `/app/data/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
- URL ist: `/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
- Wird serviert als: `https://cms.gallus-pub.ch/static/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
|
||||||
|
### 4. **Frontend zeigt Bilder** (src/pages/index.astro)
|
||||||
|
```javascript
|
||||||
|
// Line 30-43
|
||||||
|
1. Fetch von /api/gallery/public
|
||||||
|
2. Map imageUrl: `${API_BASE}${img.imageUrl}`
|
||||||
|
3. Result: https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💾 Warum funktioniert SQLite mit Fly.io?
|
||||||
|
|
||||||
|
**Fly.io Volumes** sind persistente Speicher:
|
||||||
|
- Gemountet als: `/app/data/`
|
||||||
|
- Konfiguration in: `backend/fly.toml` (Line 40-42)
|
||||||
|
- Bleibt bei Restarts/Deploys erhalten
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mounts]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Was liegt wo?
|
||||||
|
|
||||||
|
```
|
||||||
|
/app/data/
|
||||||
|
├── gallus_cms.db # SQLite Datenbank
|
||||||
|
├── gallus_cms.db-wal # Write-Ahead Log
|
||||||
|
├── gallus_cms.db-shm # Shared Memory
|
||||||
|
└── images/
|
||||||
|
├── events/
|
||||||
|
│ ├── event_karaoke.webp
|
||||||
|
│ └── ...
|
||||||
|
└── gallery/
|
||||||
|
├── Gallery1.webp
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Datenfluss im Detail
|
||||||
|
|
||||||
|
### Event erstellen:
|
||||||
|
```
|
||||||
|
1. Admin: Bild auswählen + Formular ausfüllen
|
||||||
|
2. uploadImage(file) → /api/gallery/upload
|
||||||
|
↓
|
||||||
|
3. Backend:
|
||||||
|
- Sharp konvertiert → WebP
|
||||||
|
- Speichert in /app/data/images/gallery/
|
||||||
|
- Returnt: { image: { imageUrl: "/images/gallery/xyz.webp" } }
|
||||||
|
↓
|
||||||
|
4. Admin: POST /api/events
|
||||||
|
Body: { title, date, description, imageUrl: "/images/gallery/xyz.webp" }
|
||||||
|
↓
|
||||||
|
5. Backend:
|
||||||
|
- INSERT INTO events (imageUrl = "/images/gallery/xyz.webp")
|
||||||
|
↓
|
||||||
|
6. Frontend: GET /api/events/public
|
||||||
|
- Fetcht Events aus DB
|
||||||
|
- Mapped imageUrl zu voller URL
|
||||||
|
- Zeigt an: <img src="https://cms.gallus-pub.ch/static/images/gallery/xyz.webp">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warum separate Uploads für Gallery?
|
||||||
|
|
||||||
|
Events nutzen den Gallery-Upload, weil:
|
||||||
|
- Beide brauchen WebP-Konvertierung
|
||||||
|
- Beide nutzen gleichen Storage
|
||||||
|
- Vermeidet Code-Duplikation
|
||||||
|
- Gallery kann eigenständige Bildergalerie haben
|
||||||
|
|
||||||
|
## 🎨 Admin-Panel Features
|
||||||
|
|
||||||
|
### Events-Verwaltung:
|
||||||
|
- ✅ Event erstellen (mit Bild-Upload)
|
||||||
|
- ✅ Events auflisten
|
||||||
|
- ✅ Events löschen
|
||||||
|
- ✅ Reihenfolge ändern (Drag & Drop)
|
||||||
|
- ✅ Events veröffentlichen/verstecken (isPublished)
|
||||||
|
|
||||||
|
### Gallery-Verwaltung: (NEU!)
|
||||||
|
- ✅ Bild hochladen
|
||||||
|
- ✅ Gallery auflisten
|
||||||
|
- ✅ Bilder löschen
|
||||||
|
- ✅ Reihenfolge ändern (Drag & Drop)
|
||||||
|
- ✅ Bilder veröffentlichen/verstecken (isPublished)
|
||||||
|
|
||||||
|
### Publish:
|
||||||
|
- Git-Integration (für statische Seiten-Updates)
|
||||||
|
- Commit & Push zu Repository
|
||||||
|
|
||||||
|
## 🚀 Deployment-Prozess
|
||||||
|
|
||||||
|
### Was passiert beim Deploy?
|
||||||
|
|
||||||
|
1. **Woodpecker CI** triggered bei Push zu `main`
|
||||||
|
2. **Frontend Deploy** (gallus-pub):
|
||||||
|
- Build Astro SSG
|
||||||
|
- Deploy zu Fly.io
|
||||||
|
|
||||||
|
3. **Backend Deploy** (gallus-cms-backend):
|
||||||
|
- Docker Build:
|
||||||
|
```dockerfile
|
||||||
|
# Backend-Code kompilieren
|
||||||
|
npm run build → dist/
|
||||||
|
|
||||||
|
# Migrierte Bilder einpacken
|
||||||
|
COPY backend/data/images → /app/migration-images
|
||||||
|
|
||||||
|
# Migration-Script kopieren
|
||||||
|
COPY migrate-production.js → /app/
|
||||||
|
```
|
||||||
|
- Deploy zu Fly.io
|
||||||
|
- **Volume bleibt erhalten** (SQLite DB + hochgeladene Bilder)
|
||||||
|
|
||||||
|
4. **Nach erstem Deploy**: Migration ausführen
|
||||||
|
```bash
|
||||||
|
fly ssh console -a gallus-cms-backend
|
||||||
|
node migrate-production.js
|
||||||
|
```
|
||||||
|
- Kopiert Bilder: `/app/migration-images/` → `/app/data/images/`
|
||||||
|
- Befüllt DB mit Event/Gallery-Einträgen
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### "Bilder werden nicht angezeigt"
|
||||||
|
|
||||||
|
**Prüfe:**
|
||||||
|
1. Backend logs: `fly logs -a gallus-cms-backend`
|
||||||
|
2. Bild existiert: `fly ssh console -a gallus-cms-backend`
|
||||||
|
```bash
|
||||||
|
ls -la /app/data/images/gallery/
|
||||||
|
```
|
||||||
|
3. DB-Eintrag korrekt:
|
||||||
|
```bash
|
||||||
|
sqlite3 /app/data/gallus_cms.db
|
||||||
|
SELECT * FROM gallery_images;
|
||||||
|
```
|
||||||
|
4. Static-Route funktioniert:
|
||||||
|
```bash
|
||||||
|
curl https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
### "SQLite locked" Fehler
|
||||||
|
|
||||||
|
- Nur ein Writer zur Zeit erlaubt
|
||||||
|
- Bei hohem Traffic: Wechsel zu PostgreSQL empfohlen
|
||||||
|
- Für Gallus Pub: ausreichend (wenig Writes)
|
||||||
|
|
||||||
|
### "Volume voll"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fly volumes list -a gallus-cms-backend
|
||||||
|
fly volumes extend <volume-id> -s <new-size>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Wichtige Dateien
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|-------|
|
||||||
|
| `backend/src/routes/gallery.ts` | Gallery-Upload & CRUD |
|
||||||
|
| `backend/src/routes/events.ts` | Events CRUD + Public API |
|
||||||
|
| `backend/src/index.ts` | Static File Serving |
|
||||||
|
| `backend/migrate-production.js` | Initiale Daten-Migration |
|
||||||
|
| `src/pages/admin.astro` | Admin-Interface |
|
||||||
|
| `src/pages/index.astro` | Frontend (fetched von API) |
|
||||||
|
| `backend/fly.toml` | Backend Fly.io Config |
|
||||||
|
| `fly.toml` | Frontend Fly.io Config |
|
||||||
|
|
||||||
|
## 🎯 Nächste Schritte
|
||||||
|
|
||||||
|
1. ✅ Gallery-Verwaltung implementiert
|
||||||
|
2. ⏳ Migration ausführen (nach Deploy)
|
||||||
|
3. ⏳ Testen: Bild hochladen → Frontend anzeigen
|
||||||
|
4. 📋 Optional: Image-Editing Features
|
||||||
|
5. 📋 Optional: Bulk-Upload für Gallery
|
||||||
10
backend/.gitignore
vendored
@ -4,7 +4,9 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/tmp
|
/tmp
|
||||||
/data
|
/data/*.db
|
||||||
*.db
|
/data/*.db-wal
|
||||||
*.db-wal
|
/data/*.db-shm
|
||||||
*.db-shm
|
/data/workspace
|
||||||
|
# Allow images to be committed
|
||||||
|
!/data/images
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisite
|
||||||
|
|
||||||
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
|
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
|
||||||
2. Fly.io account: `flyctl auth login`
|
2. Fly.io account: `flyctl auth login`
|
||||||
|
|||||||
BIN
backend/data/images/events/event_advents-kalender.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/data/images/events/event_ferien.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
backend/data/images/events/event_karaoke.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
backend/data/images/events/event_neujahrs-apero.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
backend/data/images/events/event_pub-quiz.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
backend/data/images/events/event_santa_karaoke.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
backend/data/images/events/event_schlager-karaoke.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
backend/data/images/gallery/Gallery1.webp
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
backend/data/images/gallery/Gallery2.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
backend/data/images/gallery/Gallery3.webp
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
backend/data/images/gallery/Gallery4.webp
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
backend/data/images/gallery/Gallery5.webp
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
backend/data/images/gallery/Gallery6.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
backend/data/images/gallery/Gallery7.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
backend/data/images/gallery/Gallery8.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
backend/data/images/gallery/Gallery9.webp
Normal file
|
After Width: | Height: | Size: 162 KiB |
@ -3,6 +3,8 @@ app = "gallus-cms-backend"
|
|||||||
primary_region = "ams"
|
primary_region = "ams"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
|
# Ensure Fly uses the Dockerfile in this backend directory
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "8080"
|
PORT = "8080"
|
||||||
@ -10,6 +12,10 @@ primary_region = "ams"
|
|||||||
GITEA_URL = "https://git.bookageek.ch"
|
GITEA_URL = "https://git.bookageek.ch"
|
||||||
DATABASE_PATH = "/app/data/gallus_cms.db"
|
DATABASE_PATH = "/app/data/gallus_cms.db"
|
||||||
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||||
|
# Cross-site frontend and OAuth
|
||||||
|
FRONTEND_URL = "https://gallus-pub.ch"
|
||||||
|
CORS_ORIGIN = "https://gallus-pub.ch"
|
||||||
|
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 8080
|
internal_port = 8080
|
||||||
|
|||||||
@ -9,12 +9,14 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^9.3.1",
|
"@fastify/cookie": "^9.3.1",
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/jwt": "^8.0.0",
|
"@fastify/jwt": "^8.0.0",
|
||||||
|
"@fastify/static": "^6.12.0",
|
||||||
"@fastify/multipart": "^8.1.0",
|
"@fastify/multipart": "^8.1.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
|
|||||||
@ -2,14 +2,103 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import * as schema from '../db/schema.js';
|
import * as schema from '../db/schema.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
if (!env.DATABASE_PATH) {
|
if (!env.DATABASE_PATH) {
|
||||||
throw new Error('DATABASE_PATH environment variable is not set');
|
throw new Error('DATABASE_PATH environment variable is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists BEFORE opening the database file
|
||||||
|
const dbDir = path.dirname(env.DATABASE_PATH);
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
const sqlite = new Database(env.DATABASE_PATH);
|
const sqlite = new Database(env.DATABASE_PATH);
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent access
|
// Enable WAL mode for better concurrent access
|
||||||
sqlite.pragma('journal_mode = WAL');
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Auto-create tables if they don't exist
|
||||||
|
export function initDatabase() {
|
||||||
|
console.log('🔧 Initializing database...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if users table exists (acts as a sentinel for initial setup)
|
||||||
|
const tableCheck = sqlite
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!tableCheck) {
|
||||||
|
console.log('📝 Creating database schema...');
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
gitea_id TEXT UNIQUE NOT NULL,
|
||||||
|
gitea_username TEXT NOT NULL,
|
||||||
|
gitea_email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT DEFAULT 'admin',
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
last_login INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
is_published INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gallery_images (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
alt_text TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
is_published INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_sections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
section_name TEXT UNIQUE NOT NULL,
|
||||||
|
content_json TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS publish_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
commit_hash TEXT,
|
||||||
|
commit_message TEXT,
|
||||||
|
published_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ Database schema created successfully!');
|
||||||
|
} else {
|
||||||
|
console.log('✅ Database already initialized.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error initializing database:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import multipart from '@fastify/multipart';
|
|||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
import { authenticate } from './middleware/auth.middleware.js';
|
import { authenticate } from './middleware/auth.middleware.js';
|
||||||
import { env, validateEnv } from './config/env.js';
|
import { env, validateEnv } from './config/env.js';
|
||||||
|
import { db, initDatabase } from './config/database.js';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
import authRoute from './routes/auth.js';
|
import authRoute from './routes/auth.js';
|
||||||
@ -57,6 +60,14 @@ fastify.register(multipart, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve static files (uploaded images, etc.) from persistent volume
|
||||||
|
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
fastify.register(fastifyStatic, {
|
||||||
|
root: dataDir,
|
||||||
|
prefix: '/static/',
|
||||||
|
decorateReply: false
|
||||||
|
});
|
||||||
|
|
||||||
// Decorate fastify with authenticate method
|
// Decorate fastify with authenticate method
|
||||||
fastify.decorate('authenticate', authenticate);
|
fastify.decorate('authenticate', authenticate);
|
||||||
|
|
||||||
@ -99,6 +110,8 @@ fastify.setErrorHandler((error, request, reply) => {
|
|||||||
// Start server
|
// Start server
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Initialize database before starting server
|
||||||
|
initDatabase();
|
||||||
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
|
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||||
console.log(`🚀 Server listening on port ${env.PORT}`);
|
console.log(`🚀 Server listening on port ${env.PORT}`);
|
||||||
console.log(`📝 Environment: ${env.NODE_ENV}`);
|
console.log(`📝 Environment: ${env.NODE_ENV}`);
|
||||||
|
|||||||
@ -31,9 +31,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
reply.setCookie('oauth_state', state, {
|
reply.setCookie('oauth_state', state, {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'none',
|
||||||
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
|
secure: true,
|
||||||
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
|
||||||
maxAge: 10 * 60, // 10 minutes
|
maxAge: 10 * 60, // 10 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -59,7 +58,7 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Verify CSRF state from cookie
|
// Verify CSRF state from cookie
|
||||||
const expectedState = request.cookies?.oauth_state as string | undefined;
|
const expectedState = request.cookies?.oauth_state as string | undefined;
|
||||||
if (!expectedState || state !== expectedState) {
|
if (!expectedState || state !== expectedState) {
|
||||||
return reply.code(400).send({ error: 'Invalid state parameter' });
|
return reply.code(400).send({ error: 'Invalid state parameter' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear state cookie
|
// Clear state cookie
|
||||||
@ -122,11 +121,12 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
|
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
|
||||||
|
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
|
||||||
reply.setCookie('token', token, {
|
reply.setCookie('token', token, {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
|
||||||
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
|
||||||
maxAge: 60 * 60 * 24, // 24h
|
maxAge: 60 * 60 * 24, // 24h
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,15 @@ const reorderBodyJsonSchema = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
// List all events (by displayOrder)
|
// PUBLIC: List published events (no auth required)
|
||||||
|
fastify.get('/events/public', async () => {
|
||||||
|
const all = await db.select().from(events)
|
||||||
|
.where(eq(events.isPublished, true))
|
||||||
|
.orderBy(events.displayOrder);
|
||||||
|
return { events: all };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all events (by displayOrder) - admin only
|
||||||
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
||||||
const all = await db.select().from(events).orderBy(events.displayOrder);
|
const all = await db.select().from(events).orderBy(events.displayOrder);
|
||||||
return { events: all };
|
return { events: all };
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import { z } from 'zod';
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { galleryImages } from '../db/schema.js';
|
import { galleryImages } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
// Fastify JSON schema for gallery image body
|
// Fastify JSON schema for gallery image body
|
||||||
const galleryBodyJsonSchema = {
|
const galleryBodyJsonSchema = {
|
||||||
@ -18,7 +21,15 @@ const galleryBodyJsonSchema = {
|
|||||||
|
|
||||||
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
// List all gallery images
|
// PUBLIC: List published gallery images (no auth required)
|
||||||
|
fastify.get('/gallery/public', async () => {
|
||||||
|
const images = await db.select().from(galleryImages)
|
||||||
|
.where(eq(galleryImages.isPublished, true))
|
||||||
|
.orderBy(galleryImages.displayOrder);
|
||||||
|
return { images };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all gallery images - admin only
|
||||||
fastify.get('/gallery', {
|
fastify.get('/gallery', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
@ -54,6 +65,82 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(201).send({ image: newImage });
|
return reply.code(201).send({ image: newImage });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload image file (multipart)
|
||||||
|
fastify.post('/gallery/upload', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
// Expect a single file field named "file"
|
||||||
|
const file = await (request as any).file();
|
||||||
|
if (!file) {
|
||||||
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const altText = (file.fields?.altText?.value as string | undefined) || '';
|
||||||
|
const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0';
|
||||||
|
const displayOrder = Number.parseInt(displayOrderRaw) || 0;
|
||||||
|
|
||||||
|
const mime = file.mimetype as string | undefined;
|
||||||
|
if (!mime || !mime.startsWith('image/')) {
|
||||||
|
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare directories - use persistent volume for Fly.io
|
||||||
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const uploadDir = path.join(dataDir, 'images', 'gallery');
|
||||||
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
// Read uploaded stream into buffer
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of file.file) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const inputBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const rand = Math.random().toString(36).slice(2, 8);
|
||||||
|
const baseName = `${stamp}-${rand}`;
|
||||||
|
|
||||||
|
// Try to convert to webp and limit size; fallback to original
|
||||||
|
let outBuffer: Buffer | null = null;
|
||||||
|
let outExt = '.webp';
|
||||||
|
try {
|
||||||
|
outBuffer = await sharp(inputBuffer)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch {
|
||||||
|
outBuffer = inputBuffer;
|
||||||
|
// naive extension from mimetype
|
||||||
|
const extFromMime = mime.split('/')[1] || 'bin';
|
||||||
|
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = baseName + outExt;
|
||||||
|
const destPath = path.join(uploadDir, filename);
|
||||||
|
fs.writeFileSync(destPath, outBuffer);
|
||||||
|
|
||||||
|
// Public URL (served via /static)
|
||||||
|
const publicUrl = `/static/images/gallery/${filename}`;
|
||||||
|
|
||||||
|
// Store in DB (optional but useful)
|
||||||
|
const [row] = await db.insert(galleryImages).values({
|
||||||
|
imageUrl: publicUrl,
|
||||||
|
altText: altText || filename,
|
||||||
|
displayOrder,
|
||||||
|
isPublished: true,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({ image: row });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
|
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update gallery image
|
// Update gallery image
|
||||||
fastify.put('/gallery/:id', {
|
fastify.put('/gallery/:id', {
|
||||||
schema: {
|
schema: {
|
||||||
|
|||||||
190
backend/src/scripts/migrate-old-data.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { events, galleryImages } from '../db/schema.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
// Old events data
|
||||||
|
const oldEvents = [
|
||||||
|
{
|
||||||
|
image: "/images/events/event_karaoke.jpg",
|
||||||
|
title: "Karaoke",
|
||||||
|
date: "2025-12-31", // Set as ongoing event
|
||||||
|
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>
|
||||||
|
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
|
||||||
|
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
|
||||||
|
displayOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_pub-quiz.jpg",
|
||||||
|
title: "Pub Quiz",
|
||||||
|
date: "2025-12-31", // Set as ongoing event
|
||||||
|
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
|
||||||
|
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
|
||||||
|
Auch Einzelpersonen sind herzlich willkommen! <br>
|
||||||
|
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_schlager-karaoke.jpeg",
|
||||||
|
title: "Schlager Hüttenzauber Karaoke",
|
||||||
|
date: "2025-11-27",
|
||||||
|
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_advents-kalender.jpeg",
|
||||||
|
title: "Adventskalender",
|
||||||
|
date: "2025-12-20",
|
||||||
|
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_santa_karaoke.jpeg",
|
||||||
|
title: "Santa Karaoke-Party",
|
||||||
|
date: "2025-12-06",
|
||||||
|
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
|
||||||
|
displayOrder: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_ferien.jpeg",
|
||||||
|
title: "Weihnachtsferien",
|
||||||
|
date: "2025-12-21",
|
||||||
|
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
|
||||||
|
displayOrder: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_neujahrs-apero.jpeg",
|
||||||
|
title: "Neujahrs-Apero",
|
||||||
|
date: "2026-01-02",
|
||||||
|
description: `18:00-20:00 Uhr`,
|
||||||
|
displayOrder: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Old gallery images
|
||||||
|
const oldGalleryImages = [
|
||||||
|
{ src: "/images/gallery/Gallery7.png", alt: "Gallery 7" },
|
||||||
|
{ src: "/images/gallery/Gallery8.png", alt: "Gallery 8" },
|
||||||
|
{ src: "/images/gallery/Gallery9.png", alt: "Gallery 9" },
|
||||||
|
{ src: "/images/gallery/Gallery6.png", alt: "Gallery 6" },
|
||||||
|
{ src: "/images/gallery/Gallery1.png", alt: "Gallery 1" },
|
||||||
|
{ src: "/images/gallery/Gallery2.png", alt: "Gallery 2" },
|
||||||
|
{ src: "/images/gallery/Gallery3.png", alt: "Gallery 3" },
|
||||||
|
{ src: "/images/gallery/Gallery4.png", alt: "Gallery 4" },
|
||||||
|
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function copyAndConvertImage(
|
||||||
|
sourcePath: string,
|
||||||
|
destDir: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<string> {
|
||||||
|
const projectRoot = path.join(process.cwd(), '..');
|
||||||
|
const fullSourcePath = path.join(projectRoot, 'public', sourcePath);
|
||||||
|
|
||||||
|
// Ensure destination directory exists
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filename);
|
||||||
|
const baseName = path.basename(filename, ext);
|
||||||
|
const webpFilename = `${baseName}.webp`;
|
||||||
|
const destPath = path.join(destDir, webpFilename);
|
||||||
|
|
||||||
|
console.log(`Processing: ${fullSourcePath} -> ${destPath}`);
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
if (!fs.existsSync(fullSourcePath)) {
|
||||||
|
console.error(`Source file not found: ${fullSourcePath}`);
|
||||||
|
throw new Error(`Source file not found: ${fullSourcePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to webp and copy
|
||||||
|
await sharp(fullSourcePath)
|
||||||
|
.rotate() // Auto-rotate based on EXIF
|
||||||
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.toFile(destPath);
|
||||||
|
|
||||||
|
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateEvents() {
|
||||||
|
console.log('\n=== Migrating Events ===\n');
|
||||||
|
|
||||||
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const eventsImageDir = path.join(dataDir, 'images', 'events');
|
||||||
|
|
||||||
|
for (const event of oldEvents) {
|
||||||
|
try {
|
||||||
|
const filename = path.basename(event.image);
|
||||||
|
const newImageUrl = await copyAndConvertImage(
|
||||||
|
event.image,
|
||||||
|
eventsImageDir,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newEvent] = await db.insert(events).values({
|
||||||
|
title: event.title,
|
||||||
|
date: event.date,
|
||||||
|
description: event.description,
|
||||||
|
imageUrl: newImageUrl,
|
||||||
|
displayOrder: event.displayOrder,
|
||||||
|
isPublished: true,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
console.log(`✓ Migrated event: ${newEvent.title}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Failed to migrate event "${event.title}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateGallery() {
|
||||||
|
console.log('\n=== Migrating Gallery Images ===\n');
|
||||||
|
|
||||||
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const galleryImageDir = path.join(dataDir, 'images', 'gallery');
|
||||||
|
|
||||||
|
for (let i = 0; i < oldGalleryImages.length; i++) {
|
||||||
|
const img = oldGalleryImages[i];
|
||||||
|
try {
|
||||||
|
const filename = path.basename(img.src);
|
||||||
|
const newImageUrl = await copyAndConvertImage(
|
||||||
|
img.src,
|
||||||
|
galleryImageDir,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newImage] = await db.insert(galleryImages).values({
|
||||||
|
imageUrl: newImageUrl,
|
||||||
|
altText: img.alt,
|
||||||
|
displayOrder: i,
|
||||||
|
isPublished: true,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting migration of old data...\n');
|
||||||
|
console.log('Working directory:', process.cwd());
|
||||||
|
console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrateEvents();
|
||||||
|
await migrateGallery();
|
||||||
|
console.log('\n✓ Migration completed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n✗ Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
16
fly.toml
@ -4,14 +4,14 @@ kill_signal = "SIGINT"
|
|||||||
kill_timeout = 5
|
kill_timeout = 5
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
dockerfile = "Dockerfile.fly"
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "3000" # Caddy (serves frontend + proxies /api/*)
|
PORT = "3000"
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
BACKEND_PORT = "8080" # Fastify backend will listen here
|
BACKEND_PORT = "8080"
|
||||||
DATABASE_PATH = "/app/data/gallus_cms.db"
|
DATABASE_PATH = "/app/data/db/gallus_cms.db"
|
||||||
GIT_WORKSPACE_DIR = "/app/workspace"
|
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3000
|
internal_port = 3000
|
||||||
@ -46,8 +46,4 @@ kill_timeout = 5
|
|||||||
|
|
||||||
[[mounts]]
|
[[mounts]]
|
||||||
source = "gallus_data"
|
source = "gallus_data"
|
||||||
destination = "/app/data"
|
destination = "/app/data"
|
||||||
|
|
||||||
[[mounts]]
|
|
||||||
source = "gallus_workspace"
|
|
||||||
destination = "/app/workspace"
|
|
||||||
@ -36,7 +36,7 @@ const title = 'Admin';
|
|||||||
<h2>Authentifizierung</h2>
|
<h2>Authentifizierung</h2>
|
||||||
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
|
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a id="login-link" class="btn" href="/api/auth/gitea">Mit Gitea anmelden</a>
|
<a id="login-link" class="btn" href="https://cms.gallus-pub.ch/api/auth/gitea">Mit Gitea anmelden</a>
|
||||||
<button id="btn-relogin">Neu anmelden</button>
|
<button id="btn-relogin">Neu anmelden</button>
|
||||||
<button id="btn-logout">Abmelden</button>
|
<button id="btn-logout">Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
@ -68,6 +68,28 @@ const title = 'Admin';
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="sec-gallery" style="display:none">
|
||||||
|
<h2>Gallery verwalten</h2>
|
||||||
|
<div class="events-row">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Neues Gallery-Bild</h3>
|
||||||
|
<label>Bild-Datei<input id="gal-file" type="file" accept="image/*" /></label>
|
||||||
|
<label>Alt-Text<input id="gal-alt" placeholder="Bildbeschreibung" /></label>
|
||||||
|
<button id="btn-create-gal">Bild hochladen</button>
|
||||||
|
<div id="gal-create-msg" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="min-width:380px;">
|
||||||
|
<h3>Liste</h3>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="btn-toggle-gal-reorder">Reihenfolge bearbeiten</button>
|
||||||
|
<button id="btn-save-gal-order" style="display:none">Reihenfolge speichern</button>
|
||||||
|
<span id="gal-order-msg" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<div id="gallery-list" class="grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="sec-publish" style="display:none">
|
<section id="sec-publish" style="display:none">
|
||||||
<h2>Veröffentlichen</h2>
|
<h2>Veröffentlichen</h2>
|
||||||
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
||||||
@ -76,8 +98,11 @@ const title = 'Admin';
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Base-URL des Backends (separate Subdomain)
|
||||||
|
const API_BASE = 'https://cms.gallus-pub.ch';
|
||||||
|
|
||||||
const api = async (path, opts = {}) => {
|
const api = async (path, opts = {}) => {
|
||||||
const res = await fetch(path, { credentials: 'include', ...opts });
|
const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const ct = res.headers.get('content-type') || '';
|
const ct = res.headers.get('content-type') || '';
|
||||||
return ct.includes('application/json') ? res.json() : res.text();
|
return ct.includes('application/json') ? res.json() : res.text();
|
||||||
@ -89,9 +114,11 @@ const title = 'Admin';
|
|||||||
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
|
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
|
||||||
// UI-Bereiche für eingeloggte Nutzer einblenden
|
// UI-Bereiche für eingeloggte Nutzer einblenden
|
||||||
document.getElementById('sec-events').style.display = '';
|
document.getElementById('sec-events').style.display = '';
|
||||||
|
document.getElementById('sec-gallery').style.display = '';
|
||||||
document.getElementById('sec-publish').style.display = '';
|
document.getElementById('sec-publish').style.display = '';
|
||||||
// Direkt Events laden und auf Sektion fokussieren
|
// Direkt Events laden und auf Sektion fokussieren
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
|
await loadGallery();
|
||||||
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const el = document.getElementById('auth-status');
|
const el = document.getElementById('auth-status');
|
||||||
@ -107,13 +134,13 @@ const title = 'Admin';
|
|||||||
loginLink.addEventListener('click', (e) => {
|
loginLink.addEventListener('click', (e) => {
|
||||||
try {
|
try {
|
||||||
// Stelle sicher, dass Navigieren erzwungen wird
|
// Stelle sicher, dass Navigieren erzwungen wird
|
||||||
window.location.assign('/api/auth/gitea');
|
window.location.assign(API_BASE + '/api/auth/gitea');
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
document.getElementById('btn-relogin').addEventListener('click', async () => {
|
document.getElementById('btn-relogin').addEventListener('click', async () => {
|
||||||
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
||||||
document.cookie = 'token=; Path=/; Max-Age=0;';
|
document.cookie = 'token=; Path=/; Max-Age=0;';
|
||||||
window.location.assign('/api/auth/gitea');
|
window.location.assign(API_BASE + '/api/auth/gitea');
|
||||||
});
|
});
|
||||||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||||
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
||||||
@ -127,7 +154,7 @@ const title = 'Admin';
|
|||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
if (altText) fd.append('altText', altText);
|
if (altText) fd.append('altText', altText);
|
||||||
fd.append('displayOrder', '0');
|
fd.append('displayOrder', '0');
|
||||||
const res = await fetch('/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
|
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@ -285,6 +312,131 @@ const title = 'Admin';
|
|||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Gallery Management ==========
|
||||||
|
let galReorderMode = false;
|
||||||
|
let lastGallery = [];
|
||||||
|
|
||||||
|
async function loadGallery() {
|
||||||
|
const listEl = document.getElementById('gallery-list');
|
||||||
|
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
||||||
|
try {
|
||||||
|
const data = await api('/api/gallery');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
lastGallery = (data.images || []).slice();
|
||||||
|
let renderList = lastGallery.slice();
|
||||||
|
|
||||||
|
if (galReorderMode) {
|
||||||
|
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList.forEach((img, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
card.setAttribute('draggable', String(galReorderMode));
|
||||||
|
card.dataset.id = img.id;
|
||||||
|
card.dataset.displayOrder = String(img.displayOrder ?? idx);
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="row" style="justify-content:space-between;align-items:center">
|
||||||
|
<div><strong>${img.altText}</strong></div>
|
||||||
|
${galReorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" style="max-width:100%;height:auto;margin:0.5rem 0;" />
|
||||||
|
<div class="muted">URL: ${img.imageUrl}</div>
|
||||||
|
<div class="row-buttons">
|
||||||
|
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
|
||||||
|
</div>`;
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
if (!id) return;
|
||||||
|
if (!confirm('Bild wirklich löschen?')) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/gallery/${id}`, { method: 'DELETE' });
|
||||||
|
await loadGallery();
|
||||||
|
} catch(e){ alert('Fehler: '+e.message); }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (galReorderMode) {
|
||||||
|
enableGalleryDragAndDrop(listEl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableGalleryDragAndDrop(container){
|
||||||
|
let draggingEl = null;
|
||||||
|
container.querySelectorAll('.card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', (e) => {
|
||||||
|
draggingEl = card; card.classList.add('dragging');
|
||||||
|
e.dataTransfer.setData('text/plain', card.dataset.id || '');
|
||||||
|
});
|
||||||
|
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
|
||||||
|
card.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||||
|
card.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = card;
|
||||||
|
if (!draggingEl || draggingEl === target) return;
|
||||||
|
const cards = Array.from(container.querySelectorAll('.card'));
|
||||||
|
const draggingIdx = cards.indexOf(draggingEl);
|
||||||
|
const targetIdx = cards.indexOf(target);
|
||||||
|
if (draggingIdx < targetIdx) {
|
||||||
|
target.after(draggingEl);
|
||||||
|
} else {
|
||||||
|
target.before(draggingEl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-create-gal').addEventListener('click', async () => {
|
||||||
|
const file = document.getElementById('gal-file').files[0];
|
||||||
|
const alt = document.getElementById('gal-alt').value.trim();
|
||||||
|
const msg = document.getElementById('gal-create-msg');
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
msg.textContent = 'Bitte wähle ein Bild aus';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
|
try {
|
||||||
|
const up = await uploadImage(file, alt || 'Gallery Image');
|
||||||
|
msg.textContent = 'Bild hochgeladen';
|
||||||
|
document.getElementById('gal-file').value = '';
|
||||||
|
document.getElementById('gal-alt').value = '';
|
||||||
|
await loadGallery();
|
||||||
|
} catch(e){
|
||||||
|
msg.textContent = 'Fehler: '+e.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-toggle-gal-reorder').addEventListener('click', async () => {
|
||||||
|
galReorderMode = !galReorderMode;
|
||||||
|
document.getElementById('btn-save-gal-order').style.display = galReorderMode ? '' : 'none';
|
||||||
|
await loadGallery();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-save-gal-order').addEventListener('click', async () => {
|
||||||
|
const container = document.getElementById('gallery-list');
|
||||||
|
const cards = Array.from(container.querySelectorAll('.card'));
|
||||||
|
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
|
||||||
|
const msg = document.getElementById('gal-order-msg');
|
||||||
|
msg.textContent = 'Speichere Reihenfolge...';
|
||||||
|
try {
|
||||||
|
await api('/api/gallery/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
|
||||||
|
msg.textContent = 'Reihenfolge gespeichert';
|
||||||
|
galReorderMode = false;
|
||||||
|
document.getElementById('btn-save-gal-order').style.display = 'none';
|
||||||
|
await loadGallery();
|
||||||
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
refreshAuth();
|
refreshAuth();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -5,82 +5,42 @@ import Welcome from "../components/Welcome.astro";
|
|||||||
import EventsGrid from "../components/EventsGrid.astro";
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
import Drinks from "../components/Drinks.astro";
|
import Drinks from "../components/Drinks.astro";
|
||||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||||
|
import Contact from "../components/Contact.astro";
|
||||||
|
import About from "../components/About.astro";
|
||||||
|
|
||||||
const events = [
|
const API_BASE = 'https://cms.gallus-pub.ch';
|
||||||
{
|
|
||||||
image: "/images/events/event_karaoke.jpg",
|
|
||||||
title: "Karaoke",
|
|
||||||
date: "Mittwoch - Samstag",
|
|
||||||
description: `
|
|
||||||
Bei uns gibt es Karaoke Mi-Sa!! <br>
|
|
||||||
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
|
|
||||||
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_pub-quiz.jpg",
|
|
||||||
title: "Pub Quiz",
|
|
||||||
date: "Jeden Freitag",
|
|
||||||
description: `
|
|
||||||
Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
|
|
||||||
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
|
|
||||||
Auch Einzelpersonen sind herzlich willkommen! <br>
|
|
||||||
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_schlager-karaoke.jpeg",
|
|
||||||
title: "Schlager Hüttenzauber Karaoke",
|
|
||||||
date: "27. November - 19:00 Uhr",
|
|
||||||
description: `
|
|
||||||
Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_advents-kalender.jpeg",
|
|
||||||
title: "Adventskalender",
|
|
||||||
date: "03. Dezember - 20. Dezember 2025",
|
|
||||||
description: `
|
|
||||||
Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_santa_karaoke.jpeg",
|
|
||||||
title: "Santa Karaoke-Party",
|
|
||||||
date: "06. Dezember 2025",
|
|
||||||
description: `
|
|
||||||
🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻 `,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_ferien.jpeg",
|
|
||||||
title: "Weihnachtsferien",
|
|
||||||
date: "21. Dezember 2025 - 01. Januar 2026",
|
|
||||||
description: `
|
|
||||||
Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/event_neujahrs-apero.jpeg",
|
|
||||||
title: "Neujahrs-Apero",
|
|
||||||
date: "02. Januar 2026 - 18:00-20:00 Uhr",
|
|
||||||
description: `
|
|
||||||
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
// Fetch events from backend API
|
||||||
|
let events = [];
|
||||||
|
try {
|
||||||
|
const eventsResponse = await fetch(`${API_BASE}/api/events/public`);
|
||||||
|
if (eventsResponse.ok) {
|
||||||
|
const eventsData = await eventsResponse.json();
|
||||||
|
events = (eventsData.events || []).map((ev: any) => ({
|
||||||
|
image: `${API_BASE}${ev.imageUrl}`,
|
||||||
|
title: ev.title,
|
||||||
|
date: ev.date,
|
||||||
|
description: ev.description
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch events:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const images = [
|
// Fetch gallery images from backend API
|
||||||
{ src: "/images/gallery/Gallery7.png", alt: "Siebtes Bild" },
|
let images = [];
|
||||||
{ src: "/images/gallery/Gallery8.png", alt: "Achtes Bild" },
|
try {
|
||||||
{ src: "/images/gallery/Gallery9.png", alt: "Neuntes Bild" },
|
const galleryResponse = await fetch(`${API_BASE}/api/gallery/public`);
|
||||||
{ src: "/images/gallery/Gallery6.png", alt: "Sechstes Bild" },
|
if (galleryResponse.ok) {
|
||||||
{ src: "/images/gallery/Gallery1.png", alt: "Erstes Bild" },
|
const galleryData = await galleryResponse.json();
|
||||||
{ src: "/images/gallery/Gallery2.png", alt: "Zweites Bild" },
|
images = (galleryData.images || []).map((img: any) => ({
|
||||||
{ src: "/images/gallery/Gallery3.png", alt: "Drittes Bild" },
|
src: `${API_BASE}${img.imageUrl}`,
|
||||||
{ src: "/images/gallery/Gallery4.png", alt: "Viertes Bild" },
|
alt: img.altText
|
||||||
{ src: "/images/gallery/Gallery5.png", alt: "Fünftes Bild" },
|
}));
|
||||||
];
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch gallery:', error);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||