Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ad50b367 |
@@ -1,29 +0,0 @@
|
|||||||
# Ohne diese Datei wandert das komplette Repo zum Fly-Remote-Builder,
|
|
||||||
# inklusive der ~50 MB Git-Historie und des gesamten Backends. Der
|
|
||||||
# Frontend-Build braucht davon nichts.
|
|
||||||
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
.astro
|
|
||||||
|
|
||||||
# Das Backend wird als eigene Fly-App aus backend/ heraus gebaut
|
|
||||||
backend
|
|
||||||
|
|
||||||
# Nicht vom Frontend-Build verwendet
|
|
||||||
.woodpecker.yml
|
|
||||||
Dockerfile.caddy
|
|
||||||
docker-compose.yml
|
|
||||||
pnpm-lock.yaml
|
|
||||||
pnpm-workspace.yaml
|
|
||||||
MIGRATION_README.md
|
|
||||||
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.log
|
|
||||||
@@ -22,4 +22,3 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
/ai/
|
|
||||||
|
|||||||
@@ -1,106 +1,5 @@
|
|||||||
steps:
|
steps:
|
||||||
audit_dependencies:
|
deploy:
|
||||||
image: node:20
|
|
||||||
commands:
|
|
||||||
- npm install --package-lock-only
|
|
||||||
- npm audit --audit-level=moderate --json > audit-result.json 2>&1 || echo "Audit completed"
|
|
||||||
- npm audit --audit-level=moderate > audit-output.txt 2>&1 || echo "Audit completed"
|
|
||||||
# Nur wenn sich Abhaengigkeiten geaendert haben - bei reinen
|
|
||||||
# Inhaltsaenderungen kaeme sonst jedes Mal derselbe Bericht
|
|
||||||
when:
|
|
||||||
- branch: main
|
|
||||||
event: push
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- '.woodpecker.yml'
|
|
||||||
ignore_message: '[ALL]'
|
|
||||||
|
|
||||||
discord_notify_audit:
|
|
||||||
image: alpine:latest
|
|
||||||
environment:
|
|
||||||
DISCORD_WEBHOOK:
|
|
||||||
from_secret: discord_webhook
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl jq
|
|
||||||
- |
|
|
||||||
if [ -f audit-result.json ]; then
|
|
||||||
TOTAL=$(jq -r '.metadata.vulnerabilities.total // 0' audit-result.json 2>/dev/null || echo "0")
|
|
||||||
CRITICAL=$(jq -r '.metadata.vulnerabilities.critical // 0' audit-result.json 2>/dev/null || echo "0")
|
|
||||||
HIGH=$(jq -r '.metadata.vulnerabilities.high // 0' audit-result.json 2>/dev/null || echo "0")
|
|
||||||
MODERATE=$(jq -r '.metadata.vulnerabilities.moderate // 0' audit-result.json 2>/dev/null || echo "0")
|
|
||||||
LOW=$(jq -r '.metadata.vulnerabilities.low // 0' audit-result.json 2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ] || [ "$MODERATE" -gt 0 ]; then
|
|
||||||
COLOR=16744448
|
|
||||||
STATUS="⚠️ Vulnerabilities Found"
|
|
||||||
else
|
|
||||||
COLOR=3066993
|
|
||||||
STATUS="✅ No Vulnerabilities"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f audit-output.txt ]; then
|
|
||||||
VULNS=$(head -50 audit-output.txt | tail -40 || echo "No details")
|
|
||||||
else
|
|
||||||
VULNS="No audit output available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s' "$VULNS" > /tmp/vulns.txt
|
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
|
||||||
--arg title "🔒 Security Audit - Build #${CI_BUILD_NUMBER}" \
|
|
||||||
--arg status "$STATUS" \
|
|
||||||
--arg total "$TOTAL" \
|
|
||||||
--arg critical "$CRITICAL" \
|
|
||||||
--arg high "$HIGH" \
|
|
||||||
--arg moderate "$MODERATE" \
|
|
||||||
--arg low "$LOW" \
|
|
||||||
--arg commit "${CI_COMMIT_SHA:0:7}" \
|
|
||||||
--rawfile details /tmp/vulns.txt \
|
|
||||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
|
||||||
--argjson color "$COLOR" \
|
|
||||||
'{
|
|
||||||
embeds: [{
|
|
||||||
title: $title,
|
|
||||||
description: $status,
|
|
||||||
color: $color,
|
|
||||||
fields: [
|
|
||||||
{ name: "Total", value: $total, inline: true },
|
|
||||||
{ name: "Critical", value: $critical, inline: true },
|
|
||||||
{ name: "High", value: $high, inline: true },
|
|
||||||
{ name: "Moderate", value: $moderate, inline: true },
|
|
||||||
{ name: "Low", value: $low, inline: true },
|
|
||||||
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
|
|
||||||
{ name: "Details", value: ("```\n" + ($details[:800]) + (if ($details | length) > 800 then "\n... (truncated)" else "" end) + "\n```"), inline: false }
|
|
||||||
],
|
|
||||||
timestamp: $timestamp
|
|
||||||
}]
|
|
||||||
}')
|
|
||||||
|
|
||||||
curl -H "Content-Type: application/json" -X POST \
|
|
||||||
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
|
|
||||||
else
|
|
||||||
echo "No audit results found - listing workspace files:"
|
|
||||||
ls -la
|
|
||||||
fi
|
|
||||||
# Gleicher Filter wie der Audit-Schritt, sonst meldet Discord bei jedem
|
|
||||||
# Inhalts-Commit "keine Ergebnisse gefunden"
|
|
||||||
when:
|
|
||||||
- branch: main
|
|
||||||
event: push
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- '.woodpecker.yml'
|
|
||||||
ignore_message: '[ALL]'
|
|
||||||
|
|
||||||
# Backend zuerst - die Admin-Seite braucht die API.
|
|
||||||
# Laeuft nur, wenn sich am Backend etwas geaendert hat. Die haeufigsten
|
|
||||||
# Commits sind Inhaltsaenderungen aus dem CMS und fassen nur src/ und
|
|
||||||
# public/ an - die brauchen kein Backend-Deployment.
|
|
||||||
deploy_backend:
|
|
||||||
image: node:20
|
image: node:20
|
||||||
environment:
|
environment:
|
||||||
FLY_API_TOKEN:
|
FLY_API_TOKEN:
|
||||||
@@ -108,122 +7,10 @@ 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"
|
||||||
# aus backend/ heraus, damit Build-Kontext und Dockerfile stimmen
|
- flyctl deploy --config fly.toml --app gallus-pub
|
||||||
- cd backend && flyctl deploy --app gallus-cms-backend --remote-only
|
|
||||||
when:
|
|
||||||
- branch: main
|
|
||||||
event: push
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'backend/**'
|
|
||||||
- '.woodpecker.yml'
|
|
||||||
# "[ALL]" in der Commit-Message erzwingt den vollen Durchlauf
|
|
||||||
ignore_message: '[ALL]'
|
|
||||||
|
|
||||||
deploy_frontend:
|
when:
|
||||||
image: node:20
|
branch:
|
||||||
environment:
|
- main
|
||||||
FLY_API_TOKEN:
|
event:
|
||||||
from_secret: FLY_API_TOKEN
|
- push
|
||||||
commands:
|
|
||||||
- curl -L https://fly.io/install.sh | sh
|
|
||||||
- export PATH="$HOME/.fly/bin:$PATH"
|
|
||||||
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
|
|
||||||
when:
|
|
||||||
- branch: main
|
|
||||||
event: push
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'src/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'astro.config.mjs'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
- 'fly.toml'
|
|
||||||
- '.woodpecker.yml'
|
|
||||||
ignore_message: '[ALL]'
|
|
||||||
|
|
||||||
notify_success:
|
|
||||||
image: alpine:latest
|
|
||||||
environment:
|
|
||||||
DISCORD_WEBHOOK:
|
|
||||||
from_secret: discord_webhook
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl jq
|
|
||||||
- |
|
|
||||||
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
|
|
||||||
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
|
|
||||||
|
|
||||||
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
|
|
||||||
--arg title "✅ Build #${CI_BUILD_NUMBER} - Success" \
|
|
||||||
--arg repo "${CI_REPO}" \
|
|
||||||
--arg branch "${CI_COMMIT_BRANCH}" \
|
|
||||||
--arg commit "${CI_COMMIT_SHA:0:7}" \
|
|
||||||
--arg author "${CI_COMMIT_AUTHOR}" \
|
|
||||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
|
||||||
'. as $message | {
|
|
||||||
embeds: [{
|
|
||||||
title: $title,
|
|
||||||
description: "Build und Deployment erfolgreich abgeschlossen!",
|
|
||||||
color: 3066993,
|
|
||||||
fields: [
|
|
||||||
{ name: "Repository", value: $repo, inline: true },
|
|
||||||
{ name: "Branch", value: $branch, inline: true },
|
|
||||||
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
|
|
||||||
{ name: "Author", value: $author, inline: true },
|
|
||||||
{ name: "Commit Message", value: $message, inline: false }
|
|
||||||
],
|
|
||||||
timestamp: $timestamp
|
|
||||||
}]
|
|
||||||
}')
|
|
||||||
|
|
||||||
curl -H "Content-Type: application/json" -X POST \
|
|
||||||
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
|
|
||||||
when:
|
|
||||||
- branch: main
|
|
||||||
event: push
|
|
||||||
status: success
|
|
||||||
|
|
||||||
notify_failure:
|
|
||||||
image: alpine:latest
|
|
||||||
environment:
|
|
||||||
DISCORD_WEBHOOK:
|
|
||||||
from_secret: discord_webhook
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl jq
|
|
||||||
- |
|
|
||||||
# Schreibe Commit-Message in Datei (sicher gegen Shell-Sonderzeichen)
|
|
||||||
printf '%s\n' "$CI_COMMIT_MESSAGE" > /tmp/commit_msg.txt
|
|
||||||
|
|
||||||
PAYLOAD=$(cat /tmp/commit_msg.txt | jq -Rs \
|
|
||||||
--arg title "❌ Build #${CI_BUILD_NUMBER} - Failure" \
|
|
||||||
--arg repo "${CI_REPO}" \
|
|
||||||
--arg branch "${CI_COMMIT_BRANCH}" \
|
|
||||||
--arg commit "${CI_COMMIT_SHA:0:7}" \
|
|
||||||
--arg author "${CI_COMMIT_AUTHOR}" \
|
|
||||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
|
||||||
'. as $message | {
|
|
||||||
embeds: [{
|
|
||||||
title: $title,
|
|
||||||
description: "Build oder Deployment ist fehlgeschlagen!",
|
|
||||||
color: 15158332,
|
|
||||||
fields: [
|
|
||||||
{ name: "Repository", value: $repo, inline: true },
|
|
||||||
{ name: "Branch", value: $branch, inline: true },
|
|
||||||
{ name: "Commit", value: ("`" + $commit + "`"), inline: true },
|
|
||||||
{ name: "Author", value: $author, inline: true },
|
|
||||||
{ name: "Commit Message", value: $message, inline: false }
|
|
||||||
],
|
|
||||||
timestamp: $timestamp
|
|
||||||
}]
|
|
||||||
}')
|
|
||||||
|
|
||||||
curl -H "Content-Type: application/json" -X POST \
|
|
||||||
-d "$PAYLOAD" "$DISCORD_WEBHOOK"
|
|
||||||
when:
|
|
||||||
- branch: main
|
|
||||||
event: push
|
|
||||||
status: failure
|
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# Fallback to npm install if no lockfile is present
|
RUN npm ci
|
||||||
RUN npm ci || npm install
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Ensure CSS variables are present
|
||||||
|
RUN mkdir -p public/styles
|
||||||
|
RUN cp -r styles/* public/styles/ || true
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm install -g serve
|
RUN npm install -g serve
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
|
CMD ["serve", "-s", "dist", "-l", "3000"]
|
||||||
CMD ["serve", "-l", "3000", "dist"]
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
FROM caddy:2-alpine
|
|
||||||
|
|
||||||
# Embed Caddyfile directly to avoid host path issues on Windows
|
|
||||||
RUN mkdir -p /etc/caddy \
|
|
||||||
&& printf ":80\nlog\nroute {\n handle /api/* {\n reverse_proxy backend:8080\n }\n handle {\n reverse_proxy frontend:3000\n }\n}\n" > /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# 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,4 +45,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Local development environment for Gallus CMS Backend
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_CLIENT=sqlite
|
|
||||||
DATABASE_URL=
|
|
||||||
DATABASE_PATH=./data/gallus_cms.db
|
|
||||||
|
|
||||||
# Gitea OAuth
|
|
||||||
GITEA_URL=https://git.bookageek.ch
|
|
||||||
GITEA_CLIENT_ID=bcddfe24-3099-41bc-bb7a-6c6d80bd9048
|
|
||||||
GITEA_CLIENT_SECRET=gto_me7hsswrsdq2ygey65edlc6qa2xxr3i5nrq7q4jvjwx654ytrh7q
|
|
||||||
# Frontend proxy callback in local dev
|
|
||||||
GITEA_REDIRECT_URI=http://localhost:4321/api/auth/callback
|
|
||||||
GITEA_ALLOWED_USERS=Gallus-maintanance
|
|
||||||
|
|
||||||
# Git repository for content versioning
|
|
||||||
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub
|
|
||||||
GIT_TOKEN=1482ae7bcdbd7610bf0cfd468b6757722d16a2a2
|
|
||||||
GIT_USER_NAME=Gallus-maintanance
|
|
||||||
GIT_USER_EMAIL=[email protected]
|
|
||||||
GIT_WORKSPACE_DIR=./data/workspace
|
|
||||||
|
|
||||||
# JWT & Session secrets (use strong random strings in real deployments)
|
|
||||||
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
|
|
||||||
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
|
|
||||||
|
|
||||||
# Server & CORS
|
|
||||||
PORT=3000
|
|
||||||
NODE_ENV=development
|
|
||||||
FRONTEND_URL=http://localhost:4321
|
|
||||||
CORS_ORIGIN=http://localhost:4321
|
|
||||||
|
|
||||||
# Upload limits
|
|
||||||
MAX_FILE_SIZE=5242880
|
|
||||||
@@ -4,9 +4,7 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/tmp
|
/tmp
|
||||||
/data/*.db
|
/data
|
||||||
/data/*.db-wal
|
*.db
|
||||||
/data/*.db-shm
|
*.db-wal
|
||||||
/data/workspace
|
*.db-shm
|
||||||
# Allow images to be committed
|
|
||||||
!/data/images
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|
||||||
## Prerequisite
|
## Prerequisites
|
||||||
|
|
||||||
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`
|
||||||
|
|||||||
@@ -1,46 +1,52 @@
|
|||||||
# Multi-stage build for Gallus CMS Backend
|
# Multi-stage build for Gallus CMS Backend
|
||||||
|
|
||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
# Hier werden die nativen Module EINMAL uebersetzt. Vorher lief npm ci in
|
|
||||||
# beiden Stages, better-sqlite3 wurde also doppelt kompiliert.
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Nur fuer better-sqlite3 - fuer musl gibt es davon keine Fertigbauten.
|
# Install build dependencies for native modules (better-sqlite3)
|
||||||
# sharp ab 0.33 bringt eigene Binaries samt libvips mit, vips-dev wird
|
|
||||||
# dadurch nicht mehr gebraucht.
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Eigener Layer: bleibt im Cache, solange sich das Lockfile nicht aendert
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Dev-Abhaengigkeiten entfernen. Was uebrig bleibt, ist fertig uebersetzt
|
|
||||||
# und wandert unveraendert ins Laufzeit-Image.
|
|
||||||
RUN npm prune --omit=dev
|
|
||||||
|
|
||||||
# Stage 2: Production
|
# Stage 2: Production
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# git fuer simple-git, sqlite fuer die CLI. Keine Build-Werkzeuge mehr -
|
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
||||||
# die blieben vorher als Layer im Image liegen, auch nach dem apk del.
|
|
||||||
RUN apk add --no-cache git sqlite
|
RUN apk add --no-cache git sqlite
|
||||||
|
|
||||||
COPY --from=builder /app/package*.json ./
|
# Install build dependencies for better-sqlite3 (needed for npm ci)
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
RUN apk add --no-cache python3 make g++
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/migrate-production.js ./migrate-production.js
|
|
||||||
COPY --from=builder /app/data/images ./data/images
|
|
||||||
|
|
||||||
# Create directories and ensure proper permissions
|
# Copy package files
|
||||||
RUN mkdir -p /app/workspace /app/data && chown -R node:node /app
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
RUN npm ci --production
|
||||||
|
|
||||||
|
# Remove build dependencies after install
|
||||||
|
RUN apk del python3 make g++
|
||||||
|
|
||||||
|
# Copy built files from builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
RUN mkdir -p /app/workspace /app/data
|
||||||
|
|
||||||
|
# Ensure proper permissions
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER node
|
USER node
|
||||||
@@ -57,5 +63,5 @@ ENV DATABASE_PATH=/app/data/gallus_cms.db
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
# Run DB migrations if present, then start application
|
# Start application
|
||||||
CMD ["/bin/sh", "-lc", "mkdir -p /app/data/images/events /app/data/images/gallery && [ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ See parent directory for complete documentation:
|
|||||||
- `CMS_CONCEPT.md` - System architecture
|
- `CMS_CONCEPT.md` - System architecture
|
||||||
- `CMS_GITEA_AUTH.md` - Authentication details
|
- `CMS_GITEA_AUTH.md` - Authentication details
|
||||||
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
|
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
|
||||||
- `CMS_SETUP_GUIDE.md` - Deployment guide
|
- `CMS_SETUP_GUIDE.md` - Deployment guide
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 162 KiB |
@@ -3,8 +3,6 @@ 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"
|
||||||
@@ -12,10 +10,6 @@ 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,https://www.gallus-pub.ch"
|
|
||||||
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
|
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 8080
|
internal_port = 8080
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
// Production migration script - can be run directly with node
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
||||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
// Database schema
|
|
||||||
const events = sqliteTable('events', {
|
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
||||||
title: text('title').notNull(),
|
|
||||||
date: text('date').notNull(),
|
|
||||||
description: text('description').notNull(),
|
|
||||||
imageUrl: text('image_url').notNull(),
|
|
||||||
displayOrder: integer('display_order').notNull(),
|
|
||||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const galleryImages = sqliteTable('gallery_images', {
|
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
||||||
imageUrl: text('image_url').notNull(),
|
|
||||||
altText: text('alt_text').notNull(),
|
|
||||||
displayOrder: integer('display_order').notNull(),
|
|
||||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Old events data
|
|
||||||
const oldEvents = [
|
|
||||||
{
|
|
||||||
title: "Karaoke",
|
|
||||||
date: "2025-12-31",
|
|
||||||
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>`,
|
|
||||||
imageUrl: "/images/events/event_karaoke.webp",
|
|
||||||
displayOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Pub Quiz",
|
|
||||||
date: "2025-12-31",
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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>`,
|
|
||||||
imageUrl: "/images/events/event_schlager-karaoke.webp",
|
|
||||||
displayOrder: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Adventskalender",
|
|
||||||
date: "2025-12-20",
|
|
||||||
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
|
|
||||||
imageUrl: "/images/events/event_advents-kalender.webp",
|
|
||||||
displayOrder: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Santa Karaoke-Party",
|
|
||||||
date: "2025-12-06",
|
|
||||||
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
|
|
||||||
imageUrl: "/images/events/event_santa_karaoke.webp",
|
|
||||||
displayOrder: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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`,
|
|
||||||
imageUrl: "/images/events/event_ferien.webp",
|
|
||||||
displayOrder: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Neujahrs-Apero",
|
|
||||||
date: "2026-01-02",
|
|
||||||
description: `18:00-20:00 Uhr`,
|
|
||||||
imageUrl: "/images/events/event_neujahrs-apero.webp",
|
|
||||||
displayOrder: 6,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Old gallery images
|
|
||||||
const oldGalleryImages = [
|
|
||||||
{ imageUrl: "/images/gallery/Gallery7.webp", alt: "Gallery 7", order: 0 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery8.webp", alt: "Gallery 8", order: 1 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery9.webp", alt: "Gallery 9", order: 2 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery6.webp", alt: "Gallery 6", order: 3 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery1.webp", alt: "Gallery 1", order: 4 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery2.webp", alt: "Gallery 2", order: 5 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery3.webp", alt: "Gallery 3", order: 6 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery4.webp", alt: "Gallery 4", order: 7 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery5.webp", alt: "Gallery 5", order: 8 },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('=== Production Migration Script ===\n');
|
|
||||||
|
|
||||||
const dbPath = process.env.DATABASE_PATH || '/app/data/gallus_cms.db';
|
|
||||||
console.log('Database path:', dbPath);
|
|
||||||
|
|
||||||
// Check if database exists
|
|
||||||
if (!fs.existsSync(dbPath)) {
|
|
||||||
console.error('ERROR: Database not found at:', dbPath);
|
|
||||||
console.error('Please ensure the backend has been started at least once to create the database.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if images exist
|
|
||||||
const dataDir = process.env.GIT_WORKSPACE_DIR || '/app/data';
|
|
||||||
const eventsDir = path.join(dataDir, 'images', 'events');
|
|
||||||
const galleryDir = path.join(dataDir, 'images', 'gallery');
|
|
||||||
|
|
||||||
console.log('Events images directory:', eventsDir);
|
|
||||||
console.log('Gallery images directory:', galleryDir);
|
|
||||||
|
|
||||||
if (!fs.existsSync(eventsDir)) {
|
|
||||||
console.error('ERROR: Events images directory not found:', eventsDir);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(galleryDir)) {
|
|
||||||
console.error('ERROR: Gallery images directory not found:', galleryDir);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// List available images
|
|
||||||
console.log('\nAvailable event images:', fs.readdirSync(eventsDir));
|
|
||||||
console.log('Available gallery images:', fs.readdirSync(galleryDir));
|
|
||||||
|
|
||||||
// Connect to database
|
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
const db = drizzle(sqlite);
|
|
||||||
|
|
||||||
console.log('\n=== Migrating Events ===\n');
|
|
||||||
|
|
||||||
for (const event of oldEvents) {
|
|
||||||
try {
|
|
||||||
const [newEvent] = await db.insert(events).values({
|
|
||||||
title: event.title,
|
|
||||||
date: event.date,
|
|
||||||
description: event.description,
|
|
||||||
imageUrl: event.imageUrl,
|
|
||||||
displayOrder: event.displayOrder,
|
|
||||||
isPublished: true,
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
console.log(`✓ Migrated event: ${newEvent.title}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`✗ Failed to migrate event "${event.title}":`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n=== Migrating Gallery Images ===\n');
|
|
||||||
|
|
||||||
for (const img of oldGalleryImages) {
|
|
||||||
try {
|
|
||||||
const [newImage] = await db.insert(galleryImages).values({
|
|
||||||
imageUrl: img.imageUrl,
|
|
||||||
altText: img.alt,
|
|
||||||
displayOrder: img.order,
|
|
||||||
isPublished: true,
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlite.close();
|
|
||||||
|
|
||||||
console.log('\n✓ Migration completed successfully!');
|
|
||||||
console.log('\nYou can verify the migration by visiting:');
|
|
||||||
console.log('- Frontend: https://gallus-pub.ch/');
|
|
||||||
console.log('- Admin: https://gallus-pub.ch/admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(error => {
|
|
||||||
console.error('\n✗ Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -9,15 +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",
|
||||||
|
"@fastify/session": "^10.8.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
|
|||||||
@@ -2,108 +2,14 @@ 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 {
|
|
||||||
// Nur zur Protokollierung - angelegt wird immer, siehe unten
|
|
||||||
const tableCheck = sqlite
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
|
||||||
.get();
|
|
||||||
|
|
||||||
console.log(tableCheck ? '📝 Checking database schema...' : '📝 Creating database schema...');
|
|
||||||
|
|
||||||
// Laeuft bei JEDEM Start. Alle Anweisungen sind IF NOT EXISTS, und nur so
|
|
||||||
// bekommen bestehende Datenbanken spaeter ergaenzte Tabellen ueberhaupt.
|
|
||||||
{
|
|
||||||
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 managed_assets (
|
|
||||||
path TEXT PRIMARY KEY,
|
|
||||||
created_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 is up to date.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error initializing database:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,12 +28,7 @@ export const env = {
|
|||||||
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
// Upload
|
// Upload
|
||||||
// Bilder werden ohnehin auf 1600px heruntergerechnet, die Grenze muss nur
|
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
|
||||||
// gross genug fuer ein unbearbeitetes Handyfoto sein.
|
|
||||||
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || String(20 * 1024 * 1024), 10),
|
|
||||||
// PDFs werden unveraendert abgelegt. Eine Getraenkekarte mit Bildern liegt
|
|
||||||
// schnell bei 15-20 MB, deshalb ein eigener, groesserer Wert.
|
|
||||||
MAX_PDF_SIZE: parseInt(process.env.MAX_PDF_SIZE || String(40 * 1024 * 1024), 10),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate required environment variables
|
// Validate required environment variables
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Users table - stores Gitea user info for audit and access control
|
||||||
export const users = sqliteTable('users', {
|
export const users = sqliteTable('users', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
giteaId: text('gitea_id').notNull().unique(),
|
giteaId: text('gitea_id').notNull().unique(),
|
||||||
@@ -59,26 +60,3 @@ export const publishHistory = sqliteTable('publish_history', {
|
|||||||
commitMessage: text('commit_message'),
|
commitMessage: text('commit_message'),
|
||||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Vom CMS selbst angelegte Dateien.
|
|
||||||
//
|
|
||||||
// Frueher wurde am Dateinamen erkannt, ob das CMS eine Datei loeschen darf.
|
|
||||||
// Seit die Namen aus Alt-Text bzw. Original-Dateiname abgeleitet werden,
|
|
||||||
// traegt der Name diese Information nicht mehr - ein hochgeladenes
|
|
||||||
// karaoke-abend.avif sieht aus wie ein handgepflegtes event_karaoke.jpg.
|
|
||||||
// Deshalb wird der Besitz hier festgehalten.
|
|
||||||
export const managedAssets = sqliteTable('managed_assets', {
|
|
||||||
path: text('path').primaryKey(), // z.B. /images/events/karaoke-abend.avif
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Banner table (for announcements like holidays, special info)
|
|
||||||
export const banners = sqliteTable('banners', {
|
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
||||||
text: text('text').notNull(),
|
|
||||||
startDate: text('start_date').notNull(), // ISO date string
|
|
||||||
endDate: text('end_date').notNull(), // ISO date string
|
|
||||||
isActive: integer('is_active', { mode: 'boolean' }).default(true),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import cors from '@fastify/cors';
|
|||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import multipart from '@fastify/multipart';
|
import multipart from '@fastify/multipart';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
|
import session from '@fastify/session';
|
||||||
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 fs from 'fs';
|
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
import authRoute from './routes/auth.js';
|
import authRoute from './routes/auth.js';
|
||||||
@@ -17,8 +14,6 @@ import galleryRoute from './routes/gallery.js';
|
|||||||
import contentRoute from './routes/content.js';
|
import contentRoute from './routes/content.js';
|
||||||
import settingsRoute from './routes/settings.js';
|
import settingsRoute from './routes/settings.js';
|
||||||
import publishRoute from './routes/publish.js';
|
import publishRoute from './routes/publish.js';
|
||||||
import bannersRoute from './routes/banners.js';
|
|
||||||
import pdfRoute from './routes/pdf.js';
|
|
||||||
|
|
||||||
// Validate environment variables
|
// Validate environment variables
|
||||||
try {
|
try {
|
||||||
@@ -42,75 +37,32 @@ const fastify = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register plugins
|
// Register plugins
|
||||||
// Support multiple origins for CORS
|
|
||||||
const allowedOrigins = env.CORS_ORIGIN.split(',').map(o => o.trim());
|
|
||||||
fastify.register(cors, {
|
fastify.register(cors, {
|
||||||
origin: (origin, cb) => {
|
origin: env.CORS_ORIGIN,
|
||||||
// Allow requests with no origin (like mobile apps or curl)
|
|
||||||
if (!origin) {
|
|
||||||
return cb(null, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if origin is in allowed list
|
|
||||||
const isAllowed = allowedOrigins.includes(origin);
|
|
||||||
|
|
||||||
if (isAllowed) {
|
|
||||||
return cb(null, true);
|
|
||||||
} else {
|
|
||||||
return cb(null, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.register(cookie);
|
fastify.register(cookie);
|
||||||
|
|
||||||
|
fastify.register(session, {
|
||||||
|
secret: env.SESSION_SECRET,
|
||||||
|
cookie: {
|
||||||
|
secure: env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 600000, // 10 minutes (only needed for OAuth flow)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
fastify.register(jwt, {
|
fastify.register(jwt, {
|
||||||
secret: env.JWT_SECRET,
|
secret: env.JWT_SECRET,
|
||||||
cookie: {
|
|
||||||
cookieName: 'token',
|
|
||||||
signed: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Der globale Wert ist die Obergrenze fuer alles. Die Routen setzen ihn per
|
|
||||||
// request.file({ limits }) auf ihren eigenen, engeren Wert herunter.
|
|
||||||
fastify.register(multipart, {
|
fastify.register(multipart, {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: Math.max(env.MAX_FILE_SIZE, env.MAX_PDF_SIZE),
|
fileSize: env.MAX_FILE_SIZE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve static files (uploaded images, etc.) from persistent volume
|
|
||||||
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
|
||||||
// Muss existieren, sonst verweigert @fastify/static die Registrierung
|
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
fastify.register(fastifyStatic, {
|
|
||||||
root: dataDir,
|
|
||||||
prefix: '/static/',
|
|
||||||
decorateReply: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Uploads liegen unter <workspace>/public/images. In der DB stehen sie als
|
|
||||||
// /images/... - das ist die URL auf der publizierten Astro-Seite. Damit die
|
|
||||||
// Admin-Oberflaeche dieselben Pfade verwenden kann, hier ebenso ausliefern.
|
|
||||||
const imagesDir = path.join(dataDir, 'public', 'images');
|
|
||||||
fs.mkdirSync(imagesDir, { recursive: true });
|
|
||||||
fastify.register(fastifyStatic, {
|
|
||||||
root: imagesDir,
|
|
||||||
prefix: '/images/',
|
|
||||||
decorateReply: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dasselbe fuer die hochgeladenen PDFs (Getraenkekarte)
|
|
||||||
const pdfDir = path.join(dataDir, 'public', 'pdf');
|
|
||||||
fs.mkdirSync(pdfDir, { recursive: true });
|
|
||||||
fastify.register(fastifyStatic, {
|
|
||||||
root: pdfDir,
|
|
||||||
prefix: '/pdf/',
|
|
||||||
decorateReply: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Decorate fastify with authenticate method
|
// Decorate fastify with authenticate method
|
||||||
fastify.decorate('authenticate', authenticate);
|
fastify.decorate('authenticate', authenticate);
|
||||||
|
|
||||||
@@ -121,8 +73,6 @@ fastify.register(galleryRoute, { prefix: '/api' });
|
|||||||
fastify.register(contentRoute, { prefix: '/api' });
|
fastify.register(contentRoute, { prefix: '/api' });
|
||||||
fastify.register(settingsRoute, { prefix: '/api' });
|
fastify.register(settingsRoute, { prefix: '/api' });
|
||||||
fastify.register(publishRoute, { prefix: '/api' });
|
fastify.register(publishRoute, { prefix: '/api' });
|
||||||
fastify.register(bannersRoute, { prefix: '/api' });
|
|
||||||
fastify.register(pdfRoute, { prefix: '/api' });
|
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
@@ -155,8 +105,6 @@ 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}`);
|
||||||
|
|||||||
@@ -6,14 +6,10 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { GiteaService } from '../services/gitea.service.js';
|
import { GiteaService } from '../services/gitea.service.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
const callbackQueryJsonSchema = {
|
const callbackSchema = z.object({
|
||||||
type: 'object',
|
code: z.string(),
|
||||||
required: ['code', 'state'],
|
state: z.string(),
|
||||||
properties: {
|
});
|
||||||
code: { type: 'string' },
|
|
||||||
state: { type: 'string' },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const authRoute: FastifyPluginAsync = async (fastify) => {
|
const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
const giteaService = new GiteaService();
|
const giteaService = new GiteaService();
|
||||||
@@ -26,14 +22,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Generate CSRF state token
|
// Generate CSRF state token
|
||||||
const state = giteaService.generateState();
|
const state = giteaService.generateState();
|
||||||
|
|
||||||
// Store state in a short-lived cookie
|
// Store state in session
|
||||||
reply.setCookie('oauth_state', state, {
|
request.session.set('oauth_state', state);
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'none',
|
|
||||||
secure: true,
|
|
||||||
maxAge: 10 * 60, // 10 minutes
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate authorization URL
|
// Generate authorization URL
|
||||||
const authUrl = giteaService.getAuthorizationUrl(state);
|
const authUrl = giteaService.getAuthorizationUrl(state);
|
||||||
@@ -48,20 +38,20 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
*/
|
*/
|
||||||
fastify.get('/auth/callback', {
|
fastify.get('/auth/callback', {
|
||||||
schema: {
|
schema: {
|
||||||
querystring: callbackQueryJsonSchema,
|
querystring: callbackSchema,
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { code, state } = request.query as { code: string; state: string };
|
const { code, state } = request.query as z.infer<typeof callbackSchema>;
|
||||||
|
|
||||||
// Verify CSRF state from cookie
|
// Verify CSRF state
|
||||||
const expectedState = request.cookies?.oauth_state as string | undefined;
|
const expectedState = request.session.get('oauth_state');
|
||||||
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 from session
|
||||||
reply.clearCookie('oauth_state', { path: '/' });
|
request.session.delete('oauth_state');
|
||||||
|
|
||||||
// Exchange code for access token
|
// Exchange code for access token
|
||||||
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
||||||
@@ -113,28 +103,18 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
{
|
{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
giteaId: user.giteaId,
|
giteaId: user.giteaId,
|
||||||
username: user.giteaUsername || '',
|
username: user.giteaUsername,
|
||||||
role: user.role ?? 'admin',
|
role: user.role,
|
||||||
},
|
},
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
|
// Redirect to frontend with token
|
||||||
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
|
|
||||||
reply.setCookie('token', token, {
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
|
|
||||||
secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
|
|
||||||
maxAge: 60 * 60 * 24, // 24h
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to admin dashboard
|
|
||||||
const frontendUrl = env.FRONTEND_URL;
|
const frontendUrl = env.FRONTEND_URL;
|
||||||
return reply.redirect(`${frontendUrl}/admin`);
|
return reply.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error({ err: error }, 'OAuth callback error');
|
fastify.log.error('OAuth callback error:', error);
|
||||||
return reply.code(500).send({ error: 'Authentication failed' });
|
return reply.code(500).send({ error: 'Authentication failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -159,14 +139,12 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
id: user.id,
|
||||||
id: user.id,
|
username: user.giteaUsername,
|
||||||
giteaUsername: user.giteaUsername,
|
email: user.giteaEmail,
|
||||||
giteaEmail: user.giteaEmail,
|
displayName: user.displayName,
|
||||||
displayName: user.displayName,
|
avatarUrl: user.avatarUrl,
|
||||||
avatarUrl: user.avatarUrl,
|
role: user.role,
|
||||||
role: user.role,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +157,6 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
// For JWT, logout is primarily client-side (delete token)
|
// For JWT, logout is primarily client-side (delete token)
|
||||||
// You could maintain a token blacklist in Redis for production
|
// You could maintain a token blacklist in Redis for production
|
||||||
reply.clearCookie('token', { path: '/' });
|
|
||||||
return { message: 'Logged out successfully' };
|
return { message: 'Logged out successfully' };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
|
||||||
import { db } from '../config/database.js';
|
|
||||||
import { banners } from '../db/schema.js';
|
|
||||||
import { eq, and, lte, gte, desc } from 'drizzle-orm';
|
|
||||||
|
|
||||||
const bannerBodyJsonSchema = {
|
|
||||||
type: 'object',
|
|
||||||
required: ['text', 'startDate', 'endDate'],
|
|
||||||
properties: {
|
|
||||||
text: { type: 'string' },
|
|
||||||
startDate: { type: 'string' },
|
|
||||||
endDate: { type: 'string' },
|
|
||||||
isActive: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const bannersRoute: FastifyPluginAsync = async (fastify) => {
|
|
||||||
|
|
||||||
// Get active banner (public endpoint)
|
|
||||||
fastify.get('/banners/active', async (request, reply) => {
|
|
||||||
// Use local date to avoid timezone issues
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getTime() - (now.getTimezoneOffset() * 60000))
|
|
||||||
.toISOString()
|
|
||||||
.split('T')[0]; // YYYY-MM-DD
|
|
||||||
|
|
||||||
const [activeBanner] = await db
|
|
||||||
.select()
|
|
||||||
.from(banners)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(banners.isActive, true),
|
|
||||||
lte(banners.startDate, today),
|
|
||||||
gte(banners.endDate, today)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(banners.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!activeBanner) {
|
|
||||||
return { banner: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
banner: {
|
|
||||||
id: activeBanner.id,
|
|
||||||
text: activeBanner.text,
|
|
||||||
startDate: activeBanner.startDate,
|
|
||||||
endDate: activeBanner.endDate,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all banners (admin only)
|
|
||||||
fastify.get('/banners', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const allBanners = await db.select().from(banners);
|
|
||||||
|
|
||||||
return {
|
|
||||||
banners: allBanners.map((b: any) => ({
|
|
||||||
id: b.id,
|
|
||||||
text: b.text,
|
|
||||||
startDate: b.startDate,
|
|
||||||
endDate: b.endDate,
|
|
||||||
isActive: b.isActive,
|
|
||||||
createdAt: b.createdAt,
|
|
||||||
updatedAt: b.updatedAt,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create banner (admin only)
|
|
||||||
fastify.post('/banners', {
|
|
||||||
schema: {
|
|
||||||
body: bannerBodyJsonSchema,
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { text, startDate, endDate, isActive = true } = request.body as any;
|
|
||||||
|
|
||||||
const [newBanner] = await db
|
|
||||||
.insert(banners)
|
|
||||||
.values({
|
|
||||||
text,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
isActive,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return {
|
|
||||||
banner: {
|
|
||||||
id: newBanner.id,
|
|
||||||
text: newBanner.text,
|
|
||||||
startDate: newBanner.startDate,
|
|
||||||
endDate: newBanner.endDate,
|
|
||||||
isActive: newBanner.isActive,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update banner (admin only)
|
|
||||||
fastify.put('/banners/:id', {
|
|
||||||
schema: {
|
|
||||||
body: bannerBodyJsonSchema,
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const { text, startDate, endDate, isActive } = request.body as any;
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(banners)
|
|
||||||
.set({
|
|
||||||
text,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
isActive,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(banners.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return reply.code(404).send({ error: 'Banner not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
banner: {
|
|
||||||
id: updated.id,
|
|
||||||
text: updated.text,
|
|
||||||
startDate: updated.startDate,
|
|
||||||
endDate: updated.endDate,
|
|
||||||
isActive: updated.isActive,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete banner (admin only)
|
|
||||||
fastify.delete('/banners/:id', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
|
|
||||||
await db.delete(banners).where(eq(banners.id, id));
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default bannersRoute;
|
|
||||||
@@ -3,18 +3,10 @@ import { z } from 'zod';
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { contentSections } from '../db/schema.js';
|
import { contentSections } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { saveUploadedImage } from '../services/upload.service.js';
|
|
||||||
import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js';
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
|
|
||||||
// Fastify JSON schema for content section body
|
const contentSectionSchema = z.object({
|
||||||
const contentBodyJsonSchema = {
|
contentJson: z.record(z.any()),
|
||||||
type: 'object',
|
});
|
||||||
required: ['contentJson'],
|
|
||||||
properties: {
|
|
||||||
contentJson: {}, // allow any JSON
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
@@ -44,12 +36,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Update content section
|
// Update content section
|
||||||
fastify.put('/content/:section', {
|
fastify.put('/content/:section', {
|
||||||
schema: {
|
schema: {
|
||||||
body: contentBodyJsonSchema,
|
body: contentSectionSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { section } = request.params as { section: string };
|
const { section } = request.params as { section: string };
|
||||||
const { contentJson } = request.body as any;
|
const { contentJson } = request.body as z.infer<typeof contentSectionSchema>;
|
||||||
|
|
||||||
// Check if section exists
|
// Check if section exists
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -81,20 +73,6 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bilder, die durch die Aenderung herausgefallen sind, wegraeumen
|
|
||||||
const before = extractImageUrls((existing as any)?.contentJson);
|
|
||||||
const after = extractImageUrls(result.contentJson);
|
|
||||||
for (const url of before) {
|
|
||||||
if (after.has(url)) continue;
|
|
||||||
try {
|
|
||||||
if (await dropImageIfUnused(url)) {
|
|
||||||
fastify.log.info(`Removed unused content image ${url}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn({ err }, 'Could not remove unused content image');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
section: result.sectionName,
|
section: result.sectionName,
|
||||||
content: result.contentJson,
|
content: result.contentJson,
|
||||||
@@ -102,39 +80,6 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bild fuer einen Textbereich hochladen (Welcome-Bild, Monatshit, Whiskey)
|
|
||||||
fastify.post('/content/upload', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
|
|
||||||
if (!file) {
|
|
||||||
return reply.code(400).send({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mime = file.mimetype as string | undefined;
|
|
||||||
if (!mime || !mime.startsWith('image/')) {
|
|
||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredName = (file.fields?.name?.value as string | undefined) || '';
|
|
||||||
|
|
||||||
const saved = await saveUploadedImage(file, 'content', {
|
|
||||||
preferredName,
|
|
||||||
log: fastify.log,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.statusCode === 413) {
|
|
||||||
return reply.code(413).send({ error: err.message });
|
|
||||||
}
|
|
||||||
fastify.log.error({ err }, 'Upload failed');
|
|
||||||
return reply.code(500).send({ error: 'Failed to upload image' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// List all content sections
|
// List all content sections
|
||||||
fastify.get('/content', {
|
fastify.get('/content', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
@@ -142,7 +87,7 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
const sections = await db.select().from(contentSections);
|
const sections = await db.select().from(contentSections);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sections: (sections as any[]).map((s: any) => ({
|
sections: sections.map(s => ({
|
||||||
section: s.sectionName,
|
section: s.sectionName,
|
||||||
content: s.contentJson,
|
content: s.contentJson,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
|
|||||||
@@ -1,158 +1,121 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events } from '../db/schema.js';
|
import { events } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { dropImageIfUnused } from '../services/image-refs.service.js';
|
|
||||||
import { saveUploadedImage } from '../services/upload.service.js';
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
|
|
||||||
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
|
const eventSchema = z.object({
|
||||||
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
|
title: z.string().min(1).max(200),
|
||||||
try {
|
date: z.string().min(1).max(100),
|
||||||
if (await dropImageIfUnused(url)) {
|
description: z.string().min(1),
|
||||||
fastify.log.info(`Removed ${reason} ${url}`);
|
imageUrl: z.string().url(),
|
||||||
}
|
displayOrder: z.number().int().min(0),
|
||||||
} catch (err) {
|
isPublished: z.boolean().optional().default(true),
|
||||||
fastify.log.warn({ err }, `Could not remove ${reason}`);
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fastify JSON schema for event body
|
|
||||||
const eventBodyJsonSchema = {
|
|
||||||
type: 'object',
|
|
||||||
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
|
|
||||||
properties: {
|
|
||||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
|
||||||
date: { type: 'string', minLength: 1, maxLength: 100 },
|
|
||||||
description: { type: 'string', minLength: 1 },
|
|
||||||
imageUrl: { type: 'string', minLength: 1 },
|
|
||||||
displayOrder: { type: 'integer', minimum: 0 },
|
|
||||||
isPublished: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const reorderBodyJsonSchema = {
|
|
||||||
type: 'object',
|
|
||||||
required: ['orders'],
|
|
||||||
properties: {
|
|
||||||
orders: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['id', 'displayOrder'],
|
|
||||||
properties: {
|
|
||||||
id: { type: 'string' },
|
|
||||||
displayOrder: { type: 'integer', minimum: 0 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
// 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
|
// List all events
|
||||||
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
fastify.get('/events', {
|
||||||
const all = await db.select().from(events).orderBy(events.displayOrder);
|
preHandler: [fastify.authenticate],
|
||||||
return { events: all };
|
}, async (request, reply) => {
|
||||||
|
const allEvents = await db.select().from(events).orderBy(events.displayOrder);
|
||||||
|
return { events: allEvents };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single event
|
// Get single event
|
||||||
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.get('/events/:id', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
const event = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||||
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
|
|
||||||
return { event: rows[0] };
|
if (event.length === 0) {
|
||||||
|
return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { event: event[0] };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create event
|
// Create event
|
||||||
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.post('/events', {
|
||||||
const data = request.body as any;
|
schema: {
|
||||||
const [row] = await db.insert(events).values(data).returning();
|
body: eventSchema,
|
||||||
return reply.code(201).send({ event: row });
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const data = request.body as z.infer<typeof eventSchema>;
|
||||||
|
|
||||||
|
const [newEvent] = await db.insert(events).values(data).returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({ event: newEvent });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update event
|
// Update event
|
||||||
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.put('/events/:id', {
|
||||||
const { id } = request.params as { id: string };
|
schema: {
|
||||||
const data = request.body as any;
|
body: eventSchema,
|
||||||
|
},
|
||||||
const [previous] = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
|
||||||
|
|
||||||
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
|
||||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
|
||||||
|
|
||||||
// Ausgetauschtes Bild wegraeumen, sonst bleibt es fuer immer liegen
|
|
||||||
if (previous && previous.imageUrl !== row.imageUrl) {
|
|
||||||
await dropUnusedImage(fastify, previous.imageUrl, 'replaced event image');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { event: row };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upload event image file (multipart)
|
|
||||||
fastify.post('/events/upload', {
|
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
const { id } = request.params as { id: string };
|
||||||
// Expect a single file field named "file"
|
const data = request.body as z.infer<typeof eventSchema>;
|
||||||
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
|
|
||||||
if (!file) {
|
|
||||||
return reply.code(400).send({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mime = file.mimetype as string | undefined;
|
const [updated] = await db
|
||||||
if (!mime || !mime.startsWith('image/')) {
|
.update(events)
|
||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
.set({ ...data, updatedAt: new Date() })
|
||||||
}
|
.where(eq(events.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Der Titel kommt als Formularfeld VOR der Datei, sonst ist er hier
|
if (!updated) {
|
||||||
// noch nicht geparst
|
return reply.code(404).send({ error: 'Event not found' });
|
||||||
const preferredName = (file.fields?.title?.value as string | undefined) || '';
|
|
||||||
|
|
||||||
const saved = await saveUploadedImage(file, 'events', {
|
|
||||||
preferredName,
|
|
||||||
log: fastify.log,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.statusCode === 413) {
|
|
||||||
return reply.code(413).send({ error: err.message });
|
|
||||||
}
|
|
||||||
fastify.log.error({ err }, 'Upload failed');
|
|
||||||
return reply.code(500).send({ error: 'Failed to upload image' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { event: updated };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete event
|
// Delete event
|
||||||
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.delete('/events/:id', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
|
||||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
|
||||||
|
|
||||||
// Zugehoerige Bilddatei mitnehmen
|
const [deleted] = await db
|
||||||
await dropUnusedImage(fastify, row.imageUrl, 'event image');
|
.delete(events)
|
||||||
|
.where(eq(events.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
return { message: 'Event deleted successfully' };
|
return { message: 'Event deleted successfully' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reorder events (synchronous transaction for better-sqlite3)
|
// Reorder events
|
||||||
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
|
fastify.put('/events/reorder', {
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
orders: z.array(z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
displayOrder: z.number().int().min(0),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
db.transaction((tx: any) => {
|
|
||||||
|
// Update all in transaction
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
for (const { id, displayOrder } of orders) {
|
for (const { id, displayOrder } of orders) {
|
||||||
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
|
await tx
|
||||||
|
.update(events)
|
||||||
|
.set({ displayOrder })
|
||||||
|
.where(eq(events.id, id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { message: 'Events reordered successfully' };
|
return { message: 'Events reordered successfully' };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,44 +3,17 @@ 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 { dropImageIfUnused } from '../services/image-refs.service.js';
|
|
||||||
import { saveUploadedImage } from '../services/upload.service.js';
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
|
|
||||||
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
|
const galleryImageSchema = z.object({
|
||||||
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
|
imageUrl: z.string().url(),
|
||||||
try {
|
altText: z.string().min(1).max(200),
|
||||||
if (await dropImageIfUnused(url)) {
|
displayOrder: z.number().int().min(0),
|
||||||
fastify.log.info(`Removed ${reason} ${url}`);
|
isPublished: z.boolean().optional().default(true),
|
||||||
}
|
});
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn({ err }, `Could not remove ${reason}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fastify JSON schema for gallery image body
|
|
||||||
const galleryBodyJsonSchema = {
|
|
||||||
type: 'object',
|
|
||||||
required: ['imageUrl', 'altText', 'displayOrder'],
|
|
||||||
properties: {
|
|
||||||
imageUrl: { type: 'string', minLength: 1 },
|
|
||||||
altText: { type: 'string', minLength: 1, maxLength: 200 },
|
|
||||||
displayOrder: { type: 'integer', minimum: 0 },
|
|
||||||
isPublished: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
// PUBLIC: List published gallery images (no auth required)
|
// List all gallery images
|
||||||
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) => {
|
||||||
@@ -65,72 +38,26 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Create gallery image
|
// Create gallery image
|
||||||
fastify.post('/gallery', {
|
fastify.post('/gallery', {
|
||||||
schema: {
|
schema: {
|
||||||
body: galleryBodyJsonSchema,
|
body: galleryImageSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const data = request.body as any;
|
const data = request.body as z.infer<typeof galleryImageSchema>;
|
||||||
|
|
||||||
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
||||||
|
|
||||||
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({ limits: { fileSize: env.MAX_FILE_SIZE } });
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await saveUploadedImage(file, 'gallery', {
|
|
||||||
preferredName: altText,
|
|
||||||
log: fastify.log,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store in DB (optional but useful)
|
|
||||||
const [row] = await db.insert(galleryImages).values({
|
|
||||||
imageUrl: saved.imageUrl,
|
|
||||||
altText: altText || saved.filename,
|
|
||||||
displayOrder,
|
|
||||||
isPublished: true,
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
return reply.code(201).send({ image: row });
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.statusCode === 413) {
|
|
||||||
return reply.code(413).send({ error: err.message });
|
|
||||||
}
|
|
||||||
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: {
|
||||||
body: galleryBodyJsonSchema,
|
body: galleryImageSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const data = request.body as any;
|
const data = request.body as z.infer<typeof galleryImageSchema>;
|
||||||
|
|
||||||
const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(galleryImages)
|
.update(galleryImages)
|
||||||
@@ -142,11 +69,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(404).send({ error: 'Image not found' });
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ausgetauschte Datei wegraeumen
|
|
||||||
if (previous && previous.imageUrl !== updated.imageUrl) {
|
|
||||||
await dropUnusedImage(fastify, previous.imageUrl, 'replaced gallery image');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { image: updated };
|
return { image: updated };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,41 +87,30 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(404).send({ error: 'Image not found' });
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zugehoerige Bilddatei mitnehmen
|
|
||||||
await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image');
|
|
||||||
|
|
||||||
return { message: 'Image deleted successfully' };
|
return { message: 'Image deleted successfully' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reorder gallery images
|
// Reorder gallery images
|
||||||
fastify.put('/gallery/reorder', {
|
fastify.put('/gallery/reorder', {
|
||||||
schema: {
|
schema: {
|
||||||
body: {
|
body: z.object({
|
||||||
type: 'object',
|
orders: z.array(z.object({
|
||||||
required: ['orders'],
|
id: z.string().uuid(),
|
||||||
properties: {
|
displayOrder: z.number().int().min(0),
|
||||||
orders: {
|
})),
|
||||||
type: 'array',
|
}),
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['id', 'displayOrder'],
|
|
||||||
properties: {
|
|
||||||
id: { type: 'string' },
|
|
||||||
displayOrder: { type: 'integer', minimum: 0 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
|
|
||||||
// Update all in synchronous transaction (better-sqlite3 requirement)
|
// Update all in transaction
|
||||||
db.transaction((tx: any) => {
|
await db.transaction(async (tx) => {
|
||||||
for (const { id, displayOrder } of orders) {
|
for (const { id, displayOrder } of orders) {
|
||||||
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
|
await tx
|
||||||
|
.update(galleryImages)
|
||||||
|
.set({ displayOrder })
|
||||||
|
.where(eq(galleryImages.id, id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { db } from '../config/database.js';
|
|
||||||
import { contentSections } from '../db/schema.js';
|
|
||||||
import { AssetService } from '../services/asset.service.js';
|
|
||||||
import { isManagedAsset, forgetManagedAsset } from '../services/managed-assets.service.js';
|
|
||||||
import { saveUploadedPdf } from '../services/upload.service.js';
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
|
|
||||||
const assets = new AssetService();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feste PDF-Plaetze. Die URL landet jeweils in einer Content-Section, damit
|
|
||||||
* der Generator sie beim Publish in die Astro-Komponente schreiben kann.
|
|
||||||
*/
|
|
||||||
const PDF_SLOTS: Record<string, { section: string; field: string }> = {
|
|
||||||
drinks: { section: 'drinks', field: 'pdfUrl' },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** contentJson kommt je nach Treiber als Objekt oder als String zurueck. */
|
|
||||||
function asObject(value: any): Record<string, any> {
|
|
||||||
if (!value) return {};
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return typeof value === 'object' ? value : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfRoute: FastifyPluginAsync = async (fastify) => {
|
|
||||||
|
|
||||||
// PDF fuer einen festen Platz hochladen und verlinken
|
|
||||||
fastify.post('/pdf/:slot', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { slot } = request.params as { slot: string };
|
|
||||||
const target = PDF_SLOTS[slot];
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
return reply.code(404).send({ error: `Unknown PDF slot "${slot}"` });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const file = await (request as any).file({ limits: { fileSize: env.MAX_PDF_SIZE } });
|
|
||||||
if (!file) {
|
|
||||||
return reply.code(400).send({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mime = file.mimetype as string | undefined;
|
|
||||||
const originalName = (file.filename as string | undefined) || '';
|
|
||||||
if (mime !== 'application/pdf' && !originalName.toLowerCase().endsWith('.pdf')) {
|
|
||||||
return reply.code(400).send({ error: 'Only PDF uploads are allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of file.file) {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
||||||
}
|
|
||||||
const buffer = Buffer.concat(chunks);
|
|
||||||
|
|
||||||
if ((file.file as any)?.truncated) {
|
|
||||||
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
|
|
||||||
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inhalt gegenpruefen, damit nicht irgendetwas mit .pdf-Endung landet
|
|
||||||
if (buffer.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
|
||||||
return reply.code(400).send({ error: 'File is not a valid PDF' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfUrl = await saveUploadedPdf(file, buffer);
|
|
||||||
|
|
||||||
// URL in der Content-Section hinterlegen
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(contentSections)
|
|
||||||
.where(eq(contentSections.sectionName, target.section))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const previousContent = asObject((existing as any)?.contentJson);
|
|
||||||
const previousUrl = previousContent[target.field];
|
|
||||||
const content = { ...previousContent, [target.field]: pdfUrl };
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db
|
|
||||||
.update(contentSections)
|
|
||||||
.set({ contentJson: content, updatedAt: new Date() })
|
|
||||||
.where(eq(contentSections.sectionName, target.section));
|
|
||||||
} else {
|
|
||||||
await db
|
|
||||||
.insert(contentSections)
|
|
||||||
.values({ sectionName: target.section, contentJson: content });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
|
|
||||||
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
|
|
||||||
if (previousUrl && previousUrl !== pdfUrl && (await isManagedAsset(previousUrl))) {
|
|
||||||
try {
|
|
||||||
if (assets.deletePdf(previousUrl)) {
|
|
||||||
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
|
|
||||||
}
|
|
||||||
await forgetManagedAsset(previousUrl);
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn({ err }, 'Could not remove replaced PDF');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(201).send({ pdfUrl, section: target.section });
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === 'FST_REQ_FILE_TOO_LARGE') {
|
|
||||||
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
|
|
||||||
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
|
||||||
}
|
|
||||||
fastify.log.error({ err }, 'PDF upload failed');
|
|
||||||
return reply.code(500).send({ error: 'Failed to upload PDF' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default pdfRoute;
|
|
||||||
@@ -2,29 +2,23 @@ import { FastifyPluginAsync } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { GitService } from '../services/git.service.js';
|
import { GitService } from '../services/git.service.js';
|
||||||
import { FileGeneratorService } from '../services/file-generator.service.js';
|
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||||
import { sweepOrphanedImages } from '../services/image-refs.service.js';
|
|
||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
// Fastify JSON schema for publish body
|
const publishSchema = z.object({
|
||||||
const publishBodyJsonSchema = {
|
commitMessage: z.string().min(1).max(200),
|
||||||
type: 'object',
|
});
|
||||||
required: ['commitMessage'],
|
|
||||||
properties: {
|
|
||||||
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.post('/publish', {
|
fastify.post('/publish', {
|
||||||
schema: {
|
schema: {
|
||||||
body: publishBodyJsonSchema,
|
body: publishSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { commitMessage } = request.body as any;
|
const { commitMessage } = request.body as z.infer<typeof publishSchema>;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
|
|
||||||
fastify.log.info('Starting publish process...');
|
fastify.log.info('Starting publish process...');
|
||||||
@@ -35,13 +29,6 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
fastify.log.info('Git repository initialized');
|
fastify.log.info('Git repository initialized');
|
||||||
|
|
||||||
// Verwaiste Uploads entfernen, bevor committet wird
|
|
||||||
const removedImages = await sweepOrphanedImages();
|
|
||||||
|
|
||||||
if (removedImages.length > 0) {
|
|
||||||
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all content from database
|
// Fetch all content from database
|
||||||
const eventsData = await db
|
const eventsData = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -56,8 +43,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
.orderBy(galleryImages.displayOrder);
|
.orderBy(galleryImages.displayOrder);
|
||||||
|
|
||||||
const sectionsData = await db.select().from(contentSections);
|
const sectionsData = await db.select().from(contentSections);
|
||||||
const sectionsMap = new Map<string, any>(
|
const sectionsMap = new Map(
|
||||||
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
|
sectionsData.map(s => [s.sectionName, s.contentJson as any])
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
|
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
|
||||||
@@ -66,13 +53,13 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
const fileGenerator = new FileGeneratorService();
|
const fileGenerator = new FileGeneratorService();
|
||||||
await fileGenerator.writeFiles(
|
await fileGenerator.writeFiles(
|
||||||
gitService.getWorkspacePath(''),
|
gitService.getWorkspacePath(''),
|
||||||
(eventsData as any[]).map((e: any) => ({
|
eventsData.map(e => ({
|
||||||
title: e.title,
|
title: e.title,
|
||||||
date: e.date,
|
date: e.date,
|
||||||
description: e.description,
|
description: e.description,
|
||||||
imageUrl: e.imageUrl,
|
imageUrl: e.imageUrl,
|
||||||
})),
|
})),
|
||||||
(galleryData as any[]).map((g: any) => ({
|
galleryData.map(g => ({
|
||||||
imageUrl: g.imageUrl,
|
imageUrl: g.imageUrl,
|
||||||
altText: g.altText,
|
altText: g.altText,
|
||||||
})),
|
})),
|
||||||
@@ -86,35 +73,28 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
fastify.log.info(`Changes committed: ${commitHash}`);
|
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||||
|
|
||||||
// Record in history. Der Push ist an dieser Stelle bereits durch -
|
// Record in history
|
||||||
// ein Fehler im Protokoll darf die Veroeffentlichung nicht als
|
await db.insert(publishHistory).values({
|
||||||
// gescheitert melden und den Workspace zuruecksetzen.
|
userId,
|
||||||
try {
|
commitHash,
|
||||||
await db.insert(publishHistory).values({
|
commitMessage,
|
||||||
userId,
|
});
|
||||||
commitHash,
|
|
||||||
commitMessage,
|
|
||||||
});
|
|
||||||
} catch (historyError) {
|
|
||||||
fastify.log.warn({ err: historyError }, 'Could not record publish history');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
commitHash,
|
commitHash,
|
||||||
removedImages: removedImages.length,
|
|
||||||
message: 'Changes published successfully',
|
message: 'Changes published successfully',
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error({ err: error }, 'Publish error');
|
fastify.log.error('Publish error:', error);
|
||||||
|
|
||||||
// Attempt to reset git state on error
|
// Attempt to reset git state on error
|
||||||
try {
|
try {
|
||||||
const gitService = new GitService();
|
const gitService = new GitService();
|
||||||
await gitService.reset();
|
await gitService.reset();
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
fastify.log.error({ err: resetError }, 'Failed to reset git state');
|
fastify.log.error('Failed to reset git state:', resetError);
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ import { db } from '../config/database.js';
|
|||||||
import { siteSettings } from '../db/schema.js';
|
import { siteSettings } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
// Fastify JSON schema for settings body
|
const settingSchema = z.object({
|
||||||
const settingBodyJsonSchema = {
|
value: z.string(),
|
||||||
type: 'object',
|
});
|
||||||
required: ['value'],
|
|
||||||
properties: {
|
|
||||||
value: { type: 'string' },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
@@ -55,12 +50,12 @@ const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Update setting
|
// Update setting
|
||||||
fastify.put('/settings/:key', {
|
fastify.put('/settings/:key', {
|
||||||
schema: {
|
schema: {
|
||||||
body: settingBodyJsonSchema,
|
body: settingSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { key } = request.params as { key: string };
|
const { key } = request.params as { key: string };
|
||||||
const { value } = request.body as any;
|
const { value } = request.body as z.infer<typeof settingSchema>;
|
||||||
|
|
||||||
// Check if setting exists
|
// Check if setting exists
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
/**
|
|
||||||
* Einmalige Umwandlung des Altbestands nach AVIF.
|
|
||||||
*
|
|
||||||
* Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert
|
|
||||||
* und im Originalformat im Repo - damals war sharp im Container kaputt, es
|
|
||||||
* gab also weder Verkleinerung noch Formatwandlung. Dieses Skript schickt
|
|
||||||
* sie durch dieselbe Verarbeitung wie einen frischen Upload, benennt sie
|
|
||||||
* nach ihrem Inhalt und zieht die Verweise in der Datenbank mit.
|
|
||||||
*
|
|
||||||
* Aufruf im Container:
|
|
||||||
* node dist/scripts/convert-images-to-avif.js # nur anzeigen
|
|
||||||
* node dist/scripts/convert-images-to-avif.js --apply # wirklich tun
|
|
||||||
*
|
|
||||||
* Ohne --apply wird nichts geschrieben.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { db, initDatabase } from '../config/database.js';
|
|
||||||
import { events, galleryImages, contentSections } from '../db/schema.js';
|
|
||||||
import { GitService } from '../services/git.service.js';
|
|
||||||
import { AssetService, MANAGED_IMAGE_DIRS } from '../services/asset.service.js';
|
|
||||||
import { saveImageBuffer, UploadSubdir } from '../services/upload.service.js';
|
|
||||||
import { replaceImageUrls, dropImageIfUnused } from '../services/image-refs.service.js';
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
|
|
||||||
const APPLY = process.argv.includes('--apply');
|
|
||||||
|
|
||||||
const assets = new AssetService();
|
|
||||||
|
|
||||||
interface Candidate {
|
|
||||||
url: string;
|
|
||||||
preferredName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** /images/events/foo.jpg -> events */
|
|
||||||
function subdirOf(url: string): UploadSubdir | null {
|
|
||||||
const match = /^\/images\/(events|gallery|content)\//.exec(url);
|
|
||||||
return match ? (match[1] as UploadSubdir) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function absolutePathOf(url: string): string {
|
|
||||||
return path.join(env.GIT_WORKSPACE_DIR, 'public', url.replace(/^\/+/, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sprechender Name fuer ein Bild aus einem Textbereich. */
|
|
||||||
function contentImageName(section: string, key: string, content: any): string {
|
|
||||||
if (section === 'welcome' && key === 'imageUrl') return 'willkommen';
|
|
||||||
if (section === 'drinks' && key === 'monthlySpecialImage') {
|
|
||||||
return content?.monthlySpecialName || 'monats-hit';
|
|
||||||
}
|
|
||||||
const whiskey = /^whiskeyImage(\d)$/.exec(key);
|
|
||||||
if (whiskey) return `whiskey-${whiskey[1]}`;
|
|
||||||
return `${section}-bild`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asObject(value: any): Record<string, any> {
|
|
||||||
if (!value) return {};
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return typeof value === 'object' ? value : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectCandidates(): Promise<Candidate[]> {
|
|
||||||
const byUrl = new Map<string, string>();
|
|
||||||
|
|
||||||
const remember = (url: any, name: string) => {
|
|
||||||
if (typeof url !== 'string' || !url.startsWith('/images/')) return;
|
|
||||||
if (!byUrl.has(url)) byUrl.set(url, name);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(events)) as any[]) {
|
|
||||||
remember(row.imageUrl, row.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
|
||||||
remember(row.imageUrl, row.altText);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(contentSections)) as any[]) {
|
|
||||||
const content = asObject(row.contentJson);
|
|
||||||
for (const [key, value] of Object.entries(content)) {
|
|
||||||
remember(value, contentImageName(row.sectionName, key, content));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...byUrl.entries()].map(([url, preferredName]) => ({ url, preferredName }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log(APPLY ? '=== Umwandlung nach AVIF ===' : '=== Vorschau (nichts wird geschrieben) ===\n');
|
|
||||||
|
|
||||||
initDatabase();
|
|
||||||
|
|
||||||
// Workspace auf den Stand des Repos bringen
|
|
||||||
const git = new GitService();
|
|
||||||
await git.initialize();
|
|
||||||
console.log(`Workspace: ${env.GIT_WORKSPACE_DIR}\n`);
|
|
||||||
|
|
||||||
const candidates = await collectCandidates();
|
|
||||||
const mapping = new Map<string, string>();
|
|
||||||
|
|
||||||
let bytesBefore = 0;
|
|
||||||
let bytesAfter = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
let missing = 0;
|
|
||||||
|
|
||||||
for (const { url, preferredName } of candidates) {
|
|
||||||
const subdir = subdirOf(url);
|
|
||||||
|
|
||||||
if (!subdir) {
|
|
||||||
console.log(` uebersprungen (ausserhalb der Upload-Ordner): ${url}`);
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.toLowerCase().endsWith('.avif')) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = absolutePathOf(url);
|
|
||||||
if (!assets.resolveInDirs(url, MANAGED_IMAGE_DIRS) || !fs.existsSync(source)) {
|
|
||||||
console.log(` FEHLT auf der Platte, Verweis bleibt: ${url}`);
|
|
||||||
missing++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = fs.readFileSync(source);
|
|
||||||
bytesBefore += input.length;
|
|
||||||
|
|
||||||
if (!APPLY) {
|
|
||||||
console.log(` ${url} (${(input.length / 1024).toFixed(0)} KB) -> benannt nach "${preferredName}"`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await saveImageBuffer(input, subdir, { preferredName });
|
|
||||||
const outSize = fs.statSync(absolutePathOf(saved.imageUrl)).size;
|
|
||||||
bytesAfter += outSize;
|
|
||||||
mapping.set(url, saved.imageUrl);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
` ${url} ${(input.length / 1024).toFixed(0)} KB -> ${saved.imageUrl} ${(outSize / 1024).toFixed(0)} KB`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!APPLY) {
|
|
||||||
console.log(`\n${candidates.length - skipped - missing} Bild(er) wuerden umgewandelt.`);
|
|
||||||
console.log(`Gesamtgroesse aktuell: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB`);
|
|
||||||
console.log('\nZum Ausfuehren: node dist/scripts/convert-images-to-avif.js --apply');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mapping.size === 0) {
|
|
||||||
console.log('\nNichts umzuwandeln.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verweise in der Datenbank nachziehen
|
|
||||||
for (const row of (await db.select().from(events)) as any[]) {
|
|
||||||
const next = mapping.get(row.imageUrl);
|
|
||||||
if (next) await db.update(events).set({ imageUrl: next }).where(eq(events.id, row.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
|
||||||
const next = mapping.get(row.imageUrl);
|
|
||||||
if (next) await db.update(galleryImages).set({ imageUrl: next }).where(eq(galleryImages.id, row.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(contentSections)) as any[]) {
|
|
||||||
const updated = replaceImageUrls(asObject(row.contentJson), mapping);
|
|
||||||
await db
|
|
||||||
.update(contentSections)
|
|
||||||
.set({ contentJson: updated, updatedAt: new Date() })
|
|
||||||
.where(eq(contentSections.sectionName, row.sectionName));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nVerweise in der Datenbank aktualisiert.');
|
|
||||||
|
|
||||||
// Vorgaenger wegraeumen - dropImageIfUnused loescht nur, was das CMS
|
|
||||||
// selbst angelegt hat. Handgepflegte Dateien wie event_karaoke.jpg
|
|
||||||
// bleiben liegen, obwohl jetzt eine AVIF-Fassung existiert.
|
|
||||||
let removed = 0;
|
|
||||||
let kept = 0;
|
|
||||||
|
|
||||||
for (const oldUrl of mapping.keys()) {
|
|
||||||
if (await dropImageIfUnused(oldUrl)) {
|
|
||||||
removed++;
|
|
||||||
} else if (fs.existsSync(absolutePathOf(oldUrl))) {
|
|
||||||
console.log(` Original behalten (nicht vom CMS angelegt): ${oldUrl}`);
|
|
||||||
kept++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n${mapping.size} Bild(er) umgewandelt, ${removed} Original(e) entfernt, ${kept} behalten.`);
|
|
||||||
console.log(
|
|
||||||
`Groesse: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB -> ${(bytesAfter / 1024 / 1024).toFixed(2)} MB` +
|
|
||||||
` (${(100 - (bytesAfter / bytesBefore) * 100).toFixed(1)} % gespart)`
|
|
||||||
);
|
|
||||||
|
|
||||||
const hash = await git.commitAndPush('Bestandsbilder nach AVIF umgewandelt');
|
|
||||||
console.log(`\nCommit: ${hash}`);
|
|
||||||
console.log('Fertig. Woodpecker baut die Seite jetzt neu.');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => process.exit(0))
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('\nFehlgeschlagen:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kennt die Verzeichnisse, in die das CMS schreibt, und sorgt dafuer, dass
|
|
||||||
* kein Pfad ausserhalb davon angefasst wird.
|
|
||||||
*
|
|
||||||
* Ob eine konkrete Datei geloescht werden DARF, entscheidet diese Klasse
|
|
||||||
* bewusst nicht - das steht in managed-assets.service.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Unterordner unterhalb von public/
|
|
||||||
export const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
|
|
||||||
export const MANAGED_PDF_DIR = 'pdf';
|
|
||||||
|
|
||||||
export class AssetService {
|
|
||||||
private publicDir: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb. */
|
|
||||||
private resolveInPublic(url: string): string | null {
|
|
||||||
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
|
|
||||||
|
|
||||||
const relative = url.replace(/^\/+/, '').split('?')[0].split('#')[0];
|
|
||||||
const absolute = path.resolve(this.publicDir, relative);
|
|
||||||
|
|
||||||
// Traversal-Schutz: muss unterhalb von public/ bleiben
|
|
||||||
if (absolute !== this.publicDir && !absolute.startsWith(this.publicDir + path.sep)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Absoluter Pfad, sofern die Datei in einem der erlaubten Ordner liegt. */
|
|
||||||
resolveInDirs(url: string, dirs: string[]): string | null {
|
|
||||||
const absolute = this.resolveInPublic(url);
|
|
||||||
if (!absolute) return null;
|
|
||||||
|
|
||||||
const relative = path.relative(this.publicDir, absolute);
|
|
||||||
const dir = path.dirname(relative).split(path.sep).join('/');
|
|
||||||
|
|
||||||
return dirs.includes(dir) ? absolute : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Existiert die Datei bereits? Fuer die Namensvergabe. */
|
|
||||||
exists(url: string, dirs: string[]): boolean {
|
|
||||||
const absolute = this.resolveInDirs(url, dirs);
|
|
||||||
return absolute ? fs.existsSync(absolute) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loescht eine Bilddatei. Die Besitzfrage muss vorher geklaert sein. */
|
|
||||||
deleteImage(url: string): boolean {
|
|
||||||
return this.unlink(this.resolveInDirs(url, MANAGED_IMAGE_DIRS));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loescht ein PDF. Die Besitzfrage muss vorher geklaert sein. */
|
|
||||||
deletePdf(url: string): boolean {
|
|
||||||
return this.unlink(this.resolveInDirs(url, [MANAGED_PDF_DIR]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private unlink(absolute: string | null): boolean {
|
|
||||||
if (!absolute) return false;
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(absolute);
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === 'ENOENT') return false;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alle Bilddateien, die in den verwalteten Ordnern liegen, als URL-Pfade. */
|
|
||||||
listImageFiles(): string[] {
|
|
||||||
const found: string[] = [];
|
|
||||||
|
|
||||||
for (const dir of MANAGED_IMAGE_DIRS) {
|
|
||||||
const absoluteDir = path.join(this.publicDir, dir);
|
|
||||||
if (!fs.existsSync(absoluteDir)) continue;
|
|
||||||
|
|
||||||
for (const name of fs.readdirSync(absoluteDir)) {
|
|
||||||
const absolute = path.join(absoluteDir, name);
|
|
||||||
if (!fs.statSync(absolute).isFile()) continue;
|
|
||||||
found.push(`/${dir}/${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,27 +27,6 @@ export class FileGeneratorService {
|
|||||||
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Texte aus dem Adminbereich landen direkt im Astro-Markup. Ohne Maskierung
|
|
||||||
* reicht ein "<" oder "&" in einem Feld, damit der Build der Seite scheitert
|
|
||||||
* und der Deploy stehen bleibt.
|
|
||||||
*/
|
|
||||||
escapeHtml(value: any): string {
|
|
||||||
if (value === undefined || value === null) return '';
|
|
||||||
return String(value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wie escapeHtml, aber mit Rueckfallwert wenn nichts gesetzt ist. */
|
|
||||||
text(value: any, fallback = ''): string {
|
|
||||||
const raw = value === undefined || value === null || value === '' ? fallback : value;
|
|
||||||
return this.escapeHtml(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||||
const eventsCode = events.map(e => `\t{
|
const eventsCode = events.map(e => `\t{
|
||||||
\t\timage: "${e.imageUrl}",
|
\t\timage: "${e.imageUrl}",
|
||||||
@@ -64,7 +43,6 @@ export class FileGeneratorService {
|
|||||||
|
|
||||||
return `---
|
return `---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
import Hero from "../components/Hero.astro";
|
import Hero from "../components/Hero.astro";
|
||||||
import Welcome from "../components/Welcome.astro";
|
import Welcome from "../components/Welcome.astro";
|
||||||
import EventsGrid from "../components/EventsGrid.astro";
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
@@ -84,7 +62,6 @@ ${imagesCode}
|
|||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
\t<Hero id="hero" />
|
\t<Hero id="hero" />
|
||||||
\t<Banner />
|
|
||||||
\t<Welcome id="welcome" />
|
\t<Welcome id="welcome" />
|
||||||
\t<EventsGrid id="events" events={events} />
|
\t<EventsGrid id="events" events={events} />
|
||||||
\t<ImageCarousel id="gallery" images={images} />
|
\t<ImageCarousel id="gallery" images={images} />
|
||||||
@@ -107,9 +84,9 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
\t\t<div class="hero-content">
|
\t\t<div class="hero-content">
|
||||||
|
|
||||||
\t\t\t<h1>${this.text(content.heading, 'Dein Irish Pub')}</h1>
|
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
||||||
|
|
||||||
\t\t\t<p>${this.text(content.subheading, 'Im Herzen von St.Gallen')}</p>
|
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
||||||
|
|
||||||
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||||
\t\t</div>
|
\t\t</div>
|
||||||
@@ -126,7 +103,7 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
generateWelcomeComponent(content: ContentSection): string {
|
generateWelcomeComponent(content: ContentSection): string {
|
||||||
const highlightsList = (content.highlights || []).map((h: any) =>
|
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||||
`\t\t\t<li>\n\t\t\t\t<b>${this.escapeHtml(h?.title)}:</b> ${this.escapeHtml(h?.description)}\n\t\t\t</li>`
|
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
||||||
).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
return `---
|
return `---
|
||||||
@@ -140,11 +117,11 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
\t<div class="welcome-text">
|
\t<div class="welcome-text">
|
||||||
|
|
||||||
\t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
|
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
||||||
\t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
|
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
||||||
|
|
||||||
\t\t<p>
|
\t\t<p>
|
||||||
\t\t\t${this.text(content.introText)}
|
\t\t\t${content.introText || ''}
|
||||||
\t\t</p>
|
\t\t</p>
|
||||||
|
|
||||||
\t\t<p><b>Unsere Highlights:</b></p>
|
\t\t<p><b>Unsere Highlights:</b></p>
|
||||||
@@ -154,14 +131,14 @@ ${highlightsList}
|
|||||||
\t\t</ul>
|
\t\t</ul>
|
||||||
|
|
||||||
\t\t<p>
|
\t\t<p>
|
||||||
\t\t\t${this.text(content.closingText)}
|
\t\t\t${content.closingText || ''}
|
||||||
\t\t</p>
|
\t\t</p>
|
||||||
|
|
||||||
\t</div>
|
\t</div>
|
||||||
|
|
||||||
|
|
||||||
\t<div class="welcome-image">
|
\t<div class="welcome-image">
|
||||||
\t\t<img src="${this.text(content.imageUrl, '/images/Welcome.png')}" alt="Welcome background image" />
|
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
||||||
\t</div>
|
\t</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -178,36 +155,36 @@ const { id } = Astro.props;
|
|||||||
<h2 class="title">Drinks</h2>
|
<h2 class="title">Drinks</h2>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
${this.text(content.introText, 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.')}
|
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="${this.text(content.pdfUrl, '/pdf/Getraenke_Gallus_2025.pdf')}" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||||
|
|
||||||
<h3 class="monats-hit">Monats Hit</h3>
|
<h3 class="monats-hit">Monats Hit</h3>
|
||||||
|
|
||||||
<div class="mate-vodka">
|
<div class="mate-vodka">
|
||||||
<div class="circle" title="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
|
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
||||||
<img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
|
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
|
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
${this.text(content.whiskeyText, 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.')}
|
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="circle-row">
|
<div class="circle-row">
|
||||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
<img src="${this.text(content.whiskeyImage1, '/images/Whiskey1.png')}" alt="Whiskey 1" class="circle-image" />
|
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
<img src="${this.text(content.whiskeyImage2, '/images/Whiskey2.png')}" alt="Whiskey 2" class="circle-image" />
|
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
<img src="${this.text(content.whiskeyImage3, '/images/Whiskey3.png')}" alt="Whiskey 3" class="circle-image" />
|
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,94 +1,43 @@
|
|||||||
import simpleGit, { SimpleGit } from 'simple-git';
|
import simpleGit, { SimpleGit } from 'simple-git';
|
||||||
import { mkdir, rm, cp } from 'fs/promises';
|
import { mkdir, rm } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
// Verzeichnisse, in die das CMS hochlaedt. Die muessen einen Neu-Clone des
|
|
||||||
// Workspace ueberleben, sonst sind Bilder und Getraenkekarte weg.
|
|
||||||
const UPLOAD_DIRS = [
|
|
||||||
path.join('public', 'images'),
|
|
||||||
path.join('public', 'pdf'),
|
|
||||||
];
|
|
||||||
|
|
||||||
export class GitService {
|
export class GitService {
|
||||||
private git: SimpleGit;
|
private git: SimpleGit;
|
||||||
private workspaceDir: string;
|
private workspaceDir: string;
|
||||||
private parentDir: string;
|
|
||||||
private repoUrl: string;
|
private repoUrl: string;
|
||||||
private token: string;
|
private token: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.workspaceDir = env.GIT_WORKSPACE_DIR;
|
this.workspaceDir = env.GIT_WORKSPACE_DIR;
|
||||||
this.parentDir = path.dirname(this.workspaceDir);
|
|
||||||
this.repoUrl = env.GIT_REPO_URL;
|
this.repoUrl = env.GIT_REPO_URL;
|
||||||
this.token = env.GIT_TOKEN;
|
this.token = env.GIT_TOKEN;
|
||||||
this.git = simpleGit();
|
this.git = simpleGit();
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// Elternverzeichnis muss existieren - simple-git startet git von dort aus
|
// Ensure workspace directory exists
|
||||||
await mkdir(this.parentDir, { recursive: true });
|
await mkdir(this.workspaceDir, { recursive: true });
|
||||||
|
|
||||||
// Add token to repo URL for authentication
|
// Add token to repo URL for authentication
|
||||||
const authenticatedUrl = this.repoUrl.replace(
|
const authenticatedUrl = this.repoUrl.replace(
|
||||||
'https://',
|
'https://',
|
||||||
`https://oauth2:${encodeURIComponent(this.token)}@`
|
`https://oauth2:${this.token}@`
|
||||||
);
|
);
|
||||||
|
|
||||||
let usable = false;
|
try {
|
||||||
|
// Check if repo already exists
|
||||||
if (existsSync(path.join(this.workspaceDir, '.git'))) {
|
await this.git.cwd(this.workspaceDir);
|
||||||
try {
|
await this.git.status();
|
||||||
this.git = simpleGit(this.workspaceDir);
|
console.log('Repository already exists, pulling latest...');
|
||||||
await this.git.status();
|
await this.git.pull();
|
||||||
console.log('Repository already exists, pulling latest...');
|
} catch {
|
||||||
await this.git.pull();
|
// Clone if doesn't exist
|
||||||
usable = true;
|
|
||||||
} catch (error) {
|
|
||||||
// Token aus der Meldung entfernen - git gibt die Remote-URL mit aus
|
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
|
||||||
console.warn(
|
|
||||||
'Existing workspace unusable, re-cloning:',
|
|
||||||
msg.replace(/\/\/[^@\s/]*@/g, '//***@')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usable) {
|
|
||||||
console.log('Cloning repository...');
|
console.log('Cloning repository...');
|
||||||
|
|
||||||
// Hochgeladene Dateien liegen im Workspace und wuerden beim Loeschen
|
|
||||||
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
|
|
||||||
const backupRoot = path.join(this.parentDir, '.workspace-upload-backup');
|
|
||||||
await rm(backupRoot, { recursive: true, force: true });
|
|
||||||
|
|
||||||
const saved: string[] = [];
|
|
||||||
for (const relative of UPLOAD_DIRS) {
|
|
||||||
const source = path.join(this.workspaceDir, relative);
|
|
||||||
if (!existsSync(source)) continue;
|
|
||||||
await cp(source, path.join(backupRoot, relative), { recursive: true });
|
|
||||||
saved.push(relative);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rm(this.workspaceDir, { recursive: true, force: true });
|
await rm(this.workspaceDir, { recursive: true, force: true });
|
||||||
|
|
||||||
// Aus dem existierenden Elternverzeichnis klonen, nicht aus dem
|
|
||||||
// soeben geloeschten Zielverzeichnis (sonst: spawn git ENOENT)
|
|
||||||
this.git = simpleGit(this.parentDir);
|
|
||||||
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
||||||
this.git = simpleGit(this.workspaceDir);
|
await this.git.cwd(this.workspaceDir);
|
||||||
|
|
||||||
for (const relative of saved) {
|
|
||||||
// force: false -> was schon im Repo liegt, bleibt unangetastet
|
|
||||||
await cp(path.join(backupRoot, relative), path.join(this.workspaceDir, relative), {
|
|
||||||
recursive: true,
|
|
||||||
force: false,
|
|
||||||
errorOnExist: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await rm(backupRoot, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure git user
|
// Configure git user
|
||||||
@@ -98,14 +47,6 @@ export class GitService {
|
|||||||
|
|
||||||
async commitAndPush(message: string): Promise<string> {
|
async commitAndPush(message: string): Promise<string> {
|
||||||
await this.git.add('.');
|
await this.git.add('.');
|
||||||
|
|
||||||
const status = await this.git.status();
|
|
||||||
if (status.isClean()) {
|
|
||||||
// Nichts zu committen - aktuellen Stand zurueckgeben statt zu scheitern
|
|
||||||
const current = await this.git.log({ maxCount: 1 });
|
|
||||||
return current.latest?.hash || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.git.commit(message);
|
await this.git.commit(message);
|
||||||
await this.git.push('origin', 'main');
|
await this.git.push('origin', 'main');
|
||||||
|
|
||||||
@@ -118,16 +59,7 @@ export class GitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
// Nur zuruecksetzen wenn wirklich ein Repo da ist - sonst laeuft git
|
await this.git.reset(['--hard', 'HEAD']);
|
||||||
// im Prozess-Arbeitsverzeichnis und raeumt dort auf
|
await this.git.clean('f', ['-d']);
|
||||||
if (!existsSync(path.join(this.workspaceDir, '.git'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const git = simpleGit(this.workspaceDir);
|
|
||||||
await git.reset(['--hard', 'HEAD']);
|
|
||||||
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
|
|
||||||
const excludes = UPLOAD_DIRS.flatMap((dir) => ['-e', dir.split(path.sep).join('/')]);
|
|
||||||
await git.clean('f', ['-d', ...excludes]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { db } from '../config/database.js';
|
|
||||||
import { events, galleryImages, contentSections } from '../db/schema.js';
|
|
||||||
import { AssetService } from './asset.service.js';
|
|
||||||
import { isManagedAsset, forgetManagedAsset } from './managed-assets.service.js';
|
|
||||||
|
|
||||||
const assets = new AssetService();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loescht eine Bilddatei, sofern das CMS sie selbst angelegt hat und kein
|
|
||||||
* Datensatz sie mehr benutzt. Muss NACH dem Loeschen bzw. Aktualisieren der
|
|
||||||
* Zeile aufgerufen werden.
|
|
||||||
*/
|
|
||||||
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
|
|
||||||
if (!url) return false;
|
|
||||||
if (!(await isManagedAsset(url))) return false;
|
|
||||||
|
|
||||||
const referenced = await collectReferencedImageUrls();
|
|
||||||
if (referenced.has(url)) return false;
|
|
||||||
|
|
||||||
const deleted = assets.deleteImage(url);
|
|
||||||
await forgetManagedAsset(url);
|
|
||||||
return deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entfernt alle vom CMS angelegten Bilder, die nirgends mehr referenziert
|
|
||||||
* werden. Die Referenzliste deckt bewusst ALLE Zeilen ab, auch
|
|
||||||
* unveroeffentlichte - sonst verlieren die ihr Bild.
|
|
||||||
*/
|
|
||||||
export async function sweepOrphanedImages(): Promise<string[]> {
|
|
||||||
const referenced = await collectReferencedImageUrls();
|
|
||||||
const removed: string[] = [];
|
|
||||||
|
|
||||||
for (const url of assets.listImageFiles()) {
|
|
||||||
if (referenced.has(url)) continue;
|
|
||||||
if (!(await isManagedAsset(url))) continue;
|
|
||||||
|
|
||||||
if (assets.deleteImage(url)) removed.push(url);
|
|
||||||
await forgetManagedAsset(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sammelt jede Bild-URL, die irgendwo in der Datenbank vorkommt.
|
|
||||||
*
|
|
||||||
* Bewusst ueber ALLE Zeilen, nicht nur die veroeffentlichten - sonst wuerde
|
|
||||||
* ein unveroeffentlichtes Event sein Bild verlieren, sobald jemand publisht.
|
|
||||||
*
|
|
||||||
* Die Content-Sections enthalten beliebiges JSON (Welcome-Bild, Monatshit,
|
|
||||||
* Whiskey-Bilder), deshalb wird es rekursiv nach Bildpfaden durchsucht.
|
|
||||||
*/
|
|
||||||
export async function collectReferencedImageUrls(): Promise<Set<string>> {
|
|
||||||
const urls = new Set<string>();
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(events)) as any[]) {
|
|
||||||
if (row.imageUrl) urls.add(row.imageUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
|
||||||
if (row.imageUrl) urls.add(row.imageUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of (await db.select().from(contentSections)) as any[]) {
|
|
||||||
collectFromJson(row.contentJson, urls);
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ersetzt Bildpfade in einem beliebigen Content-JSON anhand einer Zuordnung
|
|
||||||
* alt -> neu. Die Struktur bleibt dabei unveraendert.
|
|
||||||
*/
|
|
||||||
export function replaceImageUrls(value: any, mapping: Map<string, string>): any {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return mapping.get(value) ?? value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((entry) => replaceImageUrls(entry, mapping));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
const out: Record<string, any> = {};
|
|
||||||
for (const [key, entry] of Object.entries(value)) {
|
|
||||||
out[key] = replaceImageUrls(entry, mapping);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
|
|
||||||
export function extractImageUrls(value: any): Set<string> {
|
|
||||||
const out = new Set<string>();
|
|
||||||
collectFromJson(value, out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectFromJson(value: any, out: Set<string>): void {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
if (value.startsWith('/images/')) {
|
|
||||||
out.add(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Je nach Treiber kommt das JSON als String zurueck
|
|
||||||
if (value.startsWith('{') || value.startsWith('[')) {
|
|
||||||
try {
|
|
||||||
collectFromJson(JSON.parse(value), out);
|
|
||||||
} catch {
|
|
||||||
// kein JSON - ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const entry of value) collectFromJson(entry, out);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
for (const entry of Object.values(value)) collectFromJson(entry, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { db } from '../config/database.js';
|
|
||||||
import { managedAssets } from '../db/schema.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fuehrt Buch darueber, welche Dateien das CMS selbst angelegt hat.
|
|
||||||
* Nur diese darf es spaeter wieder loeschen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Altbestand: vor der Umstellung auf sprechende Namen hiessen Uploads
|
|
||||||
// <base36-zeitstempel>-<6 zeichen>.<ext>. Diese Dateien stehen nicht in der
|
|
||||||
// Tabelle, sollen aber weiterhin aufgeraeumt werden koennen. Handgepflegte
|
|
||||||
// Assets wie event_karaoke.jpg oder Gallery1.webp passen nicht auf das Muster.
|
|
||||||
const LEGACY_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
|
|
||||||
|
|
||||||
function basename(urlPath: string): string {
|
|
||||||
return urlPath.split('/').pop() || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Merkt sich eine neu angelegte Datei. */
|
|
||||||
export async function registerManagedAsset(urlPath: string): Promise<void> {
|
|
||||||
await db.insert(managedAssets).values({ path: urlPath }).onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vergisst eine Datei wieder (nach dem Loeschen). */
|
|
||||||
export async function forgetManagedAsset(urlPath: string): Promise<void> {
|
|
||||||
await db.delete(managedAssets).where(eq(managedAssets.path, urlPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Darf das CMS diese Datei loeschen? */
|
|
||||||
export async function isManagedAsset(urlPath: string): Promise<boolean> {
|
|
||||||
if (!urlPath) return false;
|
|
||||||
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(managedAssets)
|
|
||||||
.where(eq(managedAssets.path, urlPath))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (row) return true;
|
|
||||||
|
|
||||||
return LEGACY_NAME.test(basename(urlPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alle vom CMS angelegten Pfade aus der Tabelle. */
|
|
||||||
export async function listManagedAssets(): Promise<string[]> {
|
|
||||||
const rows = (await db.select().from(managedAssets)) as any[];
|
|
||||||
return rows.map((row) => row.path as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Trifft der Altbestands-Namensstil zu? */
|
|
||||||
export function hasLegacyName(urlPath: string): boolean {
|
|
||||||
return LEGACY_NAME.test(basename(urlPath));
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
import { AssetService, MANAGED_IMAGE_DIRS, MANAGED_PDF_DIR } from './asset.service.js';
|
|
||||||
import { registerManagedAsset } from './managed-assets.service.js';
|
|
||||||
|
|
||||||
export type UploadSubdir = 'events' | 'gallery' | 'content';
|
|
||||||
|
|
||||||
export interface SavedImage {
|
|
||||||
filename: string;
|
|
||||||
imageUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assets = new AssetService();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Macht aus "Karaoke-Abend im Gallus Pub!" -> "karaoke-abend-im-gallus-pub".
|
|
||||||
* Umlaute werden ausgeschrieben, nicht entfernt, damit aus "Getränke" nicht
|
|
||||||
* "getrnke" wird.
|
|
||||||
*/
|
|
||||||
export function slugify(value: string): string {
|
|
||||||
const slug = String(value || '')
|
|
||||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
|
||||||
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
|
|
||||||
.replace(/ß/g, 'ss')
|
|
||||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 60)
|
|
||||||
.replace(/-+$/, '');
|
|
||||||
|
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dateiname ohne Endung, wie er vor dem Upload hiess. */
|
|
||||||
function originalBaseName(file: any): string {
|
|
||||||
const name = (file?.filename as string | undefined) || '';
|
|
||||||
return name.replace(/\.[^.]+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sucht einen freien Namen. Gibt es <basis>.avif schon, wird -2, -3, ...
|
|
||||||
* angehaengt, damit zwei Events namens "Karaoke" sich nicht ueberschreiben.
|
|
||||||
*/
|
|
||||||
function findFreeName(base: string, ext: string, urlDir: string, dirs: string[]): string {
|
|
||||||
const safeBase = base || `datei-${Date.now().toString(36)}`;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 500; attempt++) {
|
|
||||||
const candidate = attempt === 1 ? `${safeBase}${ext}` : `${safeBase}-${attempt}${ext}`;
|
|
||||||
if (!assets.exists(`${urlDir}/${candidate}`, dirs)) return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sollte nie eintreten - lieber ein haesslicher Name als eine Endlosschleife
|
|
||||||
return `${safeBase}-${Date.now().toString(36)}${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nimmt einen Multipart-Upload entgegen, rechnet ihn auf 1600px herunter,
|
|
||||||
* wandelt nach AVIF und legt ihn unter public/images/<subdir> ab.
|
|
||||||
*
|
|
||||||
* Der Name kommt aus preferredName (Event-Titel bzw. Alt-Text) und faellt
|
|
||||||
* sonst auf den urspruenglichen Dateinamen zurueck.
|
|
||||||
*/
|
|
||||||
export async function saveUploadedImage(
|
|
||||||
file: any,
|
|
||||||
subdir: UploadSubdir,
|
|
||||||
options: { preferredName?: string; log?: { warn: (obj: any, msg: string) => void } } = {}
|
|
||||||
): Promise<SavedImage> {
|
|
||||||
const { preferredName, log } = options;
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of file.file) {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
||||||
}
|
|
||||||
const inputBuffer = Buffer.concat(chunks);
|
|
||||||
|
|
||||||
// Ohne diese Pruefung landet bei zu grossen Dateien ein abgeschnittenes,
|
|
||||||
// kaputtes Bild auf der Platte
|
|
||||||
if (file.file?.truncated) {
|
|
||||||
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
|
|
||||||
const error: any = new Error(`Image too large. Maximum is ${limit} MB`);
|
|
||||||
error.statusCode = 413;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return saveImageBuffer(inputBuffer, subdir, {
|
|
||||||
preferredName: preferredName || originalBaseName(file),
|
|
||||||
fallbackExtension: '.' + ((file.mimetype || '').split('/')[1] || 'bin')
|
|
||||||
.replace(/[^a-z0-9]/gi, '').toLowerCase(),
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kern der Bildverarbeitung: verkleinern, nach AVIF wandeln, unter einem
|
|
||||||
* sprechenden Namen ablegen und als CMS-eigene Datei vermerken.
|
|
||||||
*
|
|
||||||
* Wird sowohl vom Upload als auch vom Umwandlungsskript fuer den Altbestand
|
|
||||||
* benutzt, damit beide Wege identisch arbeiten.
|
|
||||||
*/
|
|
||||||
export async function saveImageBuffer(
|
|
||||||
inputBuffer: Buffer,
|
|
||||||
subdir: UploadSubdir,
|
|
||||||
options: {
|
|
||||||
preferredName?: string;
|
|
||||||
fallbackExtension?: string;
|
|
||||||
log?: { warn: (obj: any, msg: string) => void };
|
|
||||||
} = {}
|
|
||||||
): Promise<SavedImage> {
|
|
||||||
const { preferredName, fallbackExtension = '.bin', log } = options;
|
|
||||||
|
|
||||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
|
||||||
|
|
||||||
let outBuffer: Buffer;
|
|
||||||
let outExt = '.avif';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Sharp erst laden wenn wirklich gebraucht
|
|
||||||
const sharp = (await import('sharp')).default;
|
|
||||||
outBuffer = await sharp(inputBuffer)
|
|
||||||
.rotate()
|
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
|
||||||
.avif({ quality: 55 })
|
|
||||||
.toBuffer();
|
|
||||||
} catch (err) {
|
|
||||||
log?.warn({ err }, 'Sharp processing failed, using original image');
|
|
||||||
outBuffer = inputBuffer;
|
|
||||||
outExt = fallbackExtension;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlDir = `/images/${subdir}`;
|
|
||||||
const filename = findFreeName(slugify(preferredName || ''), outExt, urlDir, MANAGED_IMAGE_DIRS);
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
|
|
||||||
|
|
||||||
const imageUrl = `${urlDir}/${filename}`;
|
|
||||||
await registerManagedAsset(imageUrl);
|
|
||||||
|
|
||||||
return { filename, imageUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legt ein hochgeladenes PDF unter public/pdf ab, benannt nach dem
|
|
||||||
* urspruenglichen Dateinamen.
|
|
||||||
*/
|
|
||||||
export async function saveUploadedPdf(file: any, buffer: Buffer, preferredName?: string): Promise<string> {
|
|
||||||
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
|
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
|
||||||
|
|
||||||
const base = slugify(preferredName || '') || slugify(originalBaseName(file)) || 'dokument';
|
|
||||||
const filename = findFreeName(base, '.pdf', '/pdf', [MANAGED_PDF_DIR]);
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
|
||||||
|
|
||||||
const pdfUrl = `/pdf/${filename}`;
|
|
||||||
await registerManagedAsset(pdfUrl);
|
|
||||||
|
|
||||||
return pdfUrl;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
services:
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
environment:
|
|
||||||
- BACKEND_URL=http://proxy:4321
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
env_file:
|
|
||||||
- ./backend/.env.local
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=production
|
|
||||||
- PORT=8080
|
|
||||||
- DATABASE_PATH=/app/data/gallus_cms.db
|
|
||||||
- GIT_WORKSPACE_DIR=/app/workspace
|
|
||||||
volumes:
|
|
||||||
- backend_data:/app/data
|
|
||||||
- backend_workspace:/app/workspace
|
|
||||||
|
|
||||||
proxy:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.caddy
|
|
||||||
depends_on:
|
|
||||||
- frontend
|
|
||||||
- backend
|
|
||||||
ports:
|
|
||||||
- "4321:80"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
backend_data:
|
|
||||||
backend_workspace:
|
|
||||||
@@ -9,9 +9,6 @@ kill_timeout = 5
|
|||||||
[env]
|
[env]
|
||||||
PORT = "3000"
|
PORT = "3000"
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
BACKEND_PORT = "8080"
|
|
||||||
DATABASE_PATH = "/app/data/db/gallus_cms.db"
|
|
||||||
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3000
|
internal_port = 3000
|
||||||
@@ -42,8 +39,4 @@ kill_timeout = 5
|
|||||||
[[vm]]
|
[[vm]]
|
||||||
memory = "512MB"
|
memory = "512MB"
|
||||||
cpu_kind = "shared"
|
cpu_kind = "shared"
|
||||||
cpus = 1
|
cpus = 1
|
||||||
|
|
||||||
[[mounts]]
|
|
||||||
source = "gallus_data"
|
|
||||||
destination = "/app/data"
|
|
||||||
@@ -9,6 +9,6 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^7.2.1"
|
"astro": "^5.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 90 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="f68ef4028f"><path d="M 47.203125 40 L 322 40 L 322 335 L 47.203125 335 Z M 47.203125 40 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#f68ef4028f)"><path fill="#1d6b32" d="M 48.097656 139.574219 C 48.097656 128.285156 48.660156 119.429688 55.417969 109.972656 C 60.960938 102.21875 68.625 96.976562 75.371094 90.4375 C 90.03125 76.242188 102.058594 52.597656 122.386719 45.964844 C 140.441406 40.078125 161.71875 50.683594 170.175781 67.148438 C 174.488281 75.5625 175.570312 84.835938 176.609375 94.097656 C 178.023438 106.769531 179.671875 119.417969 181.199219 132.078125 C 186.097656 127.304688 184.324219 113.089844 184.800781 106.679688 C 185.414062 98.355469 185.589844 89.773438 187.167969 81.554688 C 190.136719 66.023438 202.230469 53.867188 215.535156 46.148438 C 230.378906 37.53125 245.867188 37.894531 259.476562 48.902344 C 265.457031 53.734375 269.027344 59.921875 273.558594 65.980469 C 278.105469 72.058594 283.742188 77.164062 289.878906 81.59375 C 301.03125 89.632812 313.222656 97.105469 314.707031 111.992188 C 315.859375 123.574219 312.664062 136.851562 306.261719 146.585938 C 298.824219 157.886719 283.566406 165.484375 271.140625 169.84375 C 263.703125 172.453125 256.171875 173.589844 248.375 174.402344 C 243.988281 174.855469 239.605469 175.347656 235.230469 175.863281 C 233.320312 176.085938 220.464844 177.1875 222.21875 178.914062 C 223.414062 180.097656 232.449219 178.90625 234.25 178.929688 C 238.816406 178.984375 243.378906 179.058594 247.9375 179.101562 C 258.195312 179.199219 268.472656 179.238281 278.703125 179.984375 C 294.078125 181.101562 308.125 186.960938 316.167969 200.828125 C 324.128906 214.566406 322.324219 235.332031 313.648438 248.554688 C 309.550781 254.800781 302.960938 258.933594 298.382812 264.773438 C 292.863281 271.808594 289.753906 280.417969 284.703125 287.777344 C 277.070312 298.902344 265.578125 308.597656 251.328125 307.664062 C 237.253906 306.738281 223.84375 295.984375 214.992188 285.644531 C 204.679688 273.609375 202.425781 260.40625 198.433594 245.617188 C 197.992188 243.980469 196.5 234.824219 195.105469 234.082031 C 192.15625 232.503906 193.15625 242.25 193.140625 243.652344 C 193.039062 252.289062 193.183594 260.898438 194.160156 269.492188 C 195.984375 285.542969 200.703125 301.367188 211.675781 313.625 C 215.640625 318.050781 226.738281 326.539062 219.757812 333.71875 C 216.042969 337.535156 203.546875 329.980469 199.726562 326.988281 C 169.355469 303.21875 175.738281 257.648438 175.738281 223.953125 C 158.667969 247.144531 172.769531 289.511719 141.984375 304.472656 C 130.417969 310.089844 113.792969 310.679688 102.148438 305.070312 C 88.4375 298.476562 81.277344 282.652344 73.429688 270.453125 C 69.234375 263.933594 63.84375 258.296875 58.707031 252.453125 C 55.953125 249.324219 52.542969 246.261719 50.21875 242.816406 C 47.457031 238.734375 47.503906 233.292969 48.097656 228.109375 C 49.5 215.78125 54.359375 203.136719 63.367188 194.40625 C 72.792969 185.277344 84.792969 181.804688 97.621094 181.160156 C 106.09375 180.734375 114.582031 180.5625 123.0625 180.191406 C 127.40625 180.003906 131.675781 179.730469 135.988281 179.113281 C 141.285156 178.359375 145.921875 177.105469 138.335938 175.230469 C 109.652344 168.167969 59.332031 176.03125 48.097656 139.574219 C 48.097656 135.066406 52.933594 155.265625 48.097656 139.574219 " fill-opacity="1" fill-rule="nonzero"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
// src/components/Banner.astro
|
|
||||||
import "../styles/components/Banner.css"
|
|
||||||
---
|
|
||||||
|
|
||||||
<div id="banner-container"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_BASE = 'https://cms.gallus-pub.ch';
|
|
||||||
|
|
||||||
async function loadBanner() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/banners/active`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.banner) {
|
|
||||||
const container = document.getElementById('banner-container');
|
|
||||||
if (container) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="banner-wrapper">
|
|
||||||
<div class="banner container">
|
|
||||||
<p>${data.banner.text}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch banner:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load banner when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', loadBanner);
|
|
||||||
} else {
|
|
||||||
loadBanner();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -7,19 +7,20 @@ const { id } = Astro.props;
|
|||||||
<h2 class="title">Drinks</h2>
|
<h2 class="title">Drinks</h2>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.
|
Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter
|
||||||
|
Atmosphäre das Leben genießen. Natürlich dürfen auch Cocktails nicht fehlen. Vieles kreieren wir auch selber - Sláinte!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="/pdf/gallus-getraenkekarte-2026.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||||
|
|
||||||
<h3 class="monats-hit">Monats Hit</h3>
|
<h3 class="monats-hit">Monats Hit</h3>
|
||||||
|
|
||||||
<div class="mate-vodka">
|
<div class="mate-vodka">
|
||||||
<div class="circle" title="mit oder ohne Schuss ;)">
|
<div class="circle" title="Mate Vodka">
|
||||||
<img src="/images/content/mit-oder-ohne-schuss.avif" alt="Monats Hit" class="circle-image" />
|
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>mit oder ohne Schuss ;)</div>
|
<div>Mate Vodka</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
@@ -28,15 +29,15 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
<div class="circle-row">
|
<div class="circle-row">
|
||||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
<img src="/images/content/whiskey-1.avif" alt="Whiskey 1" class="circle-image" />
|
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
<img src="/images/content/whiskey-2.avif" alt="Whiskey 2" class="circle-image" />
|
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
<img src="/images/content/whiskey-3.avif" alt="Whiskey 3" class="circle-image" />
|
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import "../styles/index.css"
|
|||||||
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
||||||
<title>Gallus Pub</title>
|
<title>Gallus Pub</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -13,32 +13,41 @@ const { id } = Astro.props;
|
|||||||
<h2>Gallus Pub!</h2>
|
<h2>Gallus Pub!</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Bei uns ist jede*r willkommen – ob jung oder alt, Rocker, Nerd, Meerjungfrau oder einfach du selbst. Unsere Türen stehen allen offen, die Spass und gute Gesellschaft suchen.
|
Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung
|
||||||
|
oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du
|
||||||
|
selbst. Unsere Türen stehen offen für alle, die Spass haben wollen
|
||||||
|
und gute Gesellschaft suchen!
|
||||||
</p>
|
</p>
|
||||||
<br />
|
|
||||||
<p><b>Unsere Highlights:</b></p>
|
<p><b>Unsere Highlights:</b></p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<b>Karaoke:</b> Von Mittwoch bis Samstag kannst du auf zwei Stockwerken deine Stimme zum Besten geben – ganz ohne Perfektionsdruck, Hauptsache Spass. Du singst lieber im kleinen Rahmen?
|
<b>Karaoke:</b> Von Mittwoch bis Samstag kannst du deine
|
||||||
Dann kannst du den Sangallerruum im 2.OG auch privat mieten.
|
Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht
|
||||||
|
nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst
|
||||||
|
gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat
|
||||||
|
mieten.
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<b>Pub Quiz:</b> Jeden Freitag ab 20:00 Uhr testet ihr euer Wissen in mehreren Runden. Jede Woche warten ein neues Thema und ein neuer Sieger der Herzen.
|
<b>Pub Quiz:</b> Jeden Freitag ab 20:00 Uhr testet ihr
|
||||||
|
euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues
|
||||||
|
Thema und einen neuen Champion.
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<b>Getränke:</b> Geniesst frisches Guinness, Smithwick’s, Gallus Old Style Ale, leckere Cocktails und ausgewählte Whiskys aus Schottland und Irland.
|
<b>Getränke:</b> Geniesst frisches Guinness, Smithwicks,
|
||||||
|
Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber
|
||||||
|
haben wir erlesene Sorten aus Schottland und Irland im Angebot.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Wir freuen uns, euch bald bei uns zu begrüssen – auf viele unvergessliche Abende!
|
Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst
|
||||||
</p>
|
uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael
|
||||||
<p>
|
|
||||||
Sabrina & Raphael
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Banner />
|
|
||||||
|
|
||||||
<h1>Gallery</h1>
|
<h1>Gallery</h1>
|
||||||
|
|
||||||
<p>Hier findest du alle aktuellen und kommenden Gallery im Gallus Pub.</p>
|
<p>Hier findest du alle aktuellen und kommenden Gallery im Gallus Pub.</p>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Banner />
|
|
||||||
|
|
||||||
<h1>Openings</h1>
|
<h1>Openings</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -1,783 +0,0 @@
|
|||||||
---
|
|
||||||
const title = 'Admin';
|
|
||||||
---
|
|
||||||
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>{title}</title>
|
|
||||||
{/*
|
|
||||||
is:global ist hier zwingend. Astro wuerde die Regeln sonst auf
|
|
||||||
[data-astro-cid-...] einschraenken, und dieses Attribut setzt es nur auf
|
|
||||||
Elemente, die zur Bauzeit im Template stehen. Die Karten und Bilder der
|
|
||||||
Listen entstehen aber erst zur Laufzeit per innerHTML - fuer die haette
|
|
||||||
kein einziger Selektor gegriffen.
|
|
||||||
*/}
|
|
||||||
<style is:global>
|
|
||||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
|
|
||||||
h1, h2 { margin: 0.5rem 0; }
|
|
||||||
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
|
||||||
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
|
||||||
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
|
|
||||||
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
|
|
||||||
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
|
|
||||||
.muted { color: #666; }
|
|
||||||
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
|
|
||||||
.btn:hover { background: #444; }
|
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 0.75rem; }
|
|
||||||
.card { border: 1px solid #eee; padding: 0.75rem; border-radius: 6px; }
|
|
||||||
label { display:block; margin-top: 0.5rem; }
|
|
||||||
input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; }
|
|
||||||
/* Feste Box: Uploads sind bis 1600px breit und wuerden die Liste sonst
|
|
||||||
je nach Seitenverhaeltnis beliebig hoch ziehen. */
|
|
||||||
img.thumb { width: 100%; height: 160px; object-fit: cover; display: block;
|
|
||||||
border-radius: 4px; background: #f2f2f2; margin-top: 0.5rem; }
|
|
||||||
img.thumb-sm { width: 110px; height: 110px; object-fit: cover; display: block;
|
|
||||||
border-radius: 4px; background: #f2f2f2; border: 1px solid #e5e5e5;
|
|
||||||
margin-top: 0.25rem; }
|
|
||||||
.field-row { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
|
|
||||||
.field-row > label { flex: 1 1 260px; }
|
|
||||||
.highlight-row { display: grid; grid-template-columns: 1fr 2fr auto; gap: .5rem;
|
|
||||||
align-items: end; margin-top: .5rem; }
|
|
||||||
@media (max-width: 700px){ .highlight-row { grid-template-columns: 1fr; } }
|
|
||||||
.stack > .card { margin-bottom: 1rem; }
|
|
||||||
.ok { color: #17692b; }
|
|
||||||
.err { color: #a11; }
|
|
||||||
.toolbar { display:flex; gap:.5rem; align-items:center; margin:.5rem 0; }
|
|
||||||
.pill { font-size:.85rem; padding:.25rem .5rem; border:1px solid #ddd; border-radius:999px; background:#f7f7f7; }
|
|
||||||
.dragging { opacity:.5; }
|
|
||||||
.drag-handle { cursor:grab; padding:.25rem .5rem; border:1px dashed #bbb; border-radius:6px; font-size:.85rem; }
|
|
||||||
.row-buttons { display:flex; gap:.5rem; margin-top:.5rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Admin</h1>
|
|
||||||
<section>
|
|
||||||
<h2>Authentifizierung</h2>
|
|
||||||
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
|
|
||||||
<div class="row">
|
|
||||||
<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-logout">Abmelden</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sec-events" style="display:none">
|
|
||||||
<h2>Events verwalten</h2>
|
|
||||||
<div class="events-row">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Neues Event</h3>
|
|
||||||
<label>Titel<input id="ev-title" /></label>
|
|
||||||
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
|
|
||||||
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
|
|
||||||
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
|
|
||||||
<button id="btn-create-ev">Event anlegen</button>
|
|
||||||
<div id="ev-create-msg" class="muted"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="min-width:380px;">
|
|
||||||
<h3>Liste</h3>
|
|
||||||
<div class="toolbar">
|
|
||||||
<span class="pill" id="lbl-order">Anzeige: automatisch nach Datum (neueste zuerst)</span>
|
|
||||||
<button id="btn-toggle-reorder">Reihenfolge bearbeiten</button>
|
|
||||||
<button id="btn-save-order" style="display:none">Reihenfolge speichern</button>
|
|
||||||
<span id="order-msg" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
<div id="events-list" class="grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>Gallery-Liste</h3>
|
|
||||||
<div id="gallery-list" class="grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sec-banner" style="display:none">
|
|
||||||
<h2>Banner verwalten</h2>
|
|
||||||
<div class="events-row">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Neuen Banner erstellen</h3>
|
|
||||||
<label>Text<textarea id="banner-text" rows="3" placeholder="z.B. Wir sind vom 24.12. bis 02.01. geschlossen"></textarea></label>
|
|
||||||
<label>Von (Datum)<input id="banner-start" type="date" /></label>
|
|
||||||
<label>Bis (Datum)<input id="banner-end" type="date" /></label>
|
|
||||||
<button id="btn-create-banner">Banner erstellen</button>
|
|
||||||
<div id="banner-create-msg" class="muted"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="min-width:380px;">
|
|
||||||
<h3>Banner-Liste</h3>
|
|
||||||
<div id="banner-list" class="grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sec-content" style="display:none">
|
|
||||||
<h2>Texte bearbeiten</h2>
|
|
||||||
<div class="muted">Änderungen werden erst mit „Publish“ auf die Website übernommen.</div>
|
|
||||||
|
|
||||||
<div class="stack">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Startseite oben (Hero)</h3>
|
|
||||||
<div class="field-row">
|
|
||||||
<label>Überschrift<input id="hero-heading" placeholder="Dein Irish Pub" /></label>
|
|
||||||
<label>Unterzeile<input id="hero-subheading" placeholder="Im Herzen von St.Gallen" /></label>
|
|
||||||
</div>
|
|
||||||
<button id="btn-save-hero">Hero speichern</button>
|
|
||||||
<span id="hero-msg" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>Willkommen</h3>
|
|
||||||
<div class="field-row">
|
|
||||||
<label>Überschrift Zeile 1<input id="wel-heading1" placeholder="Herzlich willkommen im" /></label>
|
|
||||||
<label>Überschrift Zeile 2<input id="wel-heading2" placeholder="Gallus Pub!" /></label>
|
|
||||||
</div>
|
|
||||||
<label>Einleitungstext<textarea id="wel-intro" rows="4"></textarea></label>
|
|
||||||
|
|
||||||
<label style="margin-top:1rem"><strong>Highlights</strong></label>
|
|
||||||
<div id="wel-highlights"></div>
|
|
||||||
<button id="btn-add-highlight" type="button">Highlight hinzufügen</button>
|
|
||||||
|
|
||||||
<label>Schlusstext<textarea id="wel-closing" rows="3"></textarea></label>
|
|
||||||
|
|
||||||
<label>Bild ersetzen<input id="wel-file" type="file" accept="image/*" /></label>
|
|
||||||
<img id="wel-preview" class="thumb-sm" alt="Aktuelles Willkommen-Bild" style="display:none" />
|
|
||||||
|
|
||||||
<button id="btn-save-welcome">Willkommen speichern</button>
|
|
||||||
<span id="wel-msg" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>Drinks</h3>
|
|
||||||
<label>Einleitungstext<textarea id="dr-intro" rows="3"></textarea></label>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<label>Monats-Hit Name<input id="dr-special-name" placeholder="Mate Vodka" /></label>
|
|
||||||
<label>Monats-Hit Bild ersetzen<input id="dr-special-file" type="file" accept="image/*" /></label>
|
|
||||||
</div>
|
|
||||||
<img id="dr-special-preview" class="thumb-sm" alt="Aktuelles Monats-Hit-Bild" style="display:none" />
|
|
||||||
|
|
||||||
<label>Whiskey-Text<textarea id="dr-whiskey" rows="3"></textarea></label>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<label>Whiskey-Bild 1<input id="dr-whiskey-file1" type="file" accept="image/*" />
|
|
||||||
<img id="dr-whiskey-preview1" class="thumb-sm" alt="Whiskey 1" style="display:none" /></label>
|
|
||||||
<label>Whiskey-Bild 2<input id="dr-whiskey-file2" type="file" accept="image/*" />
|
|
||||||
<img id="dr-whiskey-preview2" class="thumb-sm" alt="Whiskey 2" style="display:none" /></label>
|
|
||||||
<label>Whiskey-Bild 3<input id="dr-whiskey-file3" type="file" accept="image/*" />
|
|
||||||
<img id="dr-whiskey-preview3" class="thumb-sm" alt="Whiskey 3" style="display:none" /></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style="margin-top:1rem">Getränkekarte (PDF) ersetzen<input id="dr-pdf-file" type="file" accept="application/pdf" /></label>
|
|
||||||
<div id="dr-pdf-current" class="muted"></div>
|
|
||||||
|
|
||||||
<button id="btn-save-drinks">Drinks speichern</button>
|
|
||||||
<span id="dr-msg" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sec-publish" style="display:none">
|
|
||||||
<h2>Veröffentlichen</h2>
|
|
||||||
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
|
||||||
<button id="btn-publish">Publish</button>
|
|
||||||
<div id="pub-status" class="muted"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Base-URL des Backends (separate Subdomain)
|
|
||||||
const API_BASE = 'https://cms.gallus-pub.ch';
|
|
||||||
|
|
||||||
const api = async (path, opts = {}) => {
|
|
||||||
const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const ct = res.headers.get('content-type') || '';
|
|
||||||
return ct.includes('application/json') ? res.json() : res.text();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inhalte landen per innerHTML in der Seite - ohne Maskierung zerlegt
|
|
||||||
// ein Anführungszeichen im Titel die Darstellung
|
|
||||||
const esc = (v) => String(v ?? '')
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
|
|
||||||
async function refreshAuth() {
|
|
||||||
try {
|
|
||||||
const me = await api('/api/auth/me');
|
|
||||||
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
|
|
||||||
// UI-Bereiche für eingeloggte Nutzer einblenden
|
|
||||||
document.getElementById('sec-events').style.display = '';
|
|
||||||
document.getElementById('sec-gallery').style.display = '';
|
|
||||||
document.getElementById('sec-banner').style.display = '';
|
|
||||||
document.getElementById('sec-content').style.display = '';
|
|
||||||
document.getElementById('sec-publish').style.display = '';
|
|
||||||
// Direkt Events laden und auf Sektion fokussieren
|
|
||||||
await loadEvents();
|
|
||||||
await loadGallery();
|
|
||||||
await loadBanners();
|
|
||||||
await loadContent();
|
|
||||||
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
|
||||||
} catch (e) {
|
|
||||||
const el = document.getElementById('auth-status');
|
|
||||||
el.textContent = 'Nicht angemeldet';
|
|
||||||
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
|
|
||||||
document.getElementById('sec-events').style.display = 'none';
|
|
||||||
document.getElementById('sec-gallery').style.display = 'none';
|
|
||||||
document.getElementById('sec-banner').style.display = 'none';
|
|
||||||
document.getElementById('sec-content').style.display = 'none';
|
|
||||||
document.getElementById('sec-publish').style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: falls der Link von Browser/Extensions blockiert wäre
|
|
||||||
const loginLink = document.getElementById('login-link');
|
|
||||||
loginLink.addEventListener('click', (e) => {
|
|
||||||
try {
|
|
||||||
// Stelle sicher, dass Navigieren erzwungen wird
|
|
||||||
window.location.assign(API_BASE + '/api/auth/gitea');
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
document.getElementById('btn-relogin').addEventListener('click', async () => {
|
|
||||||
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
|
||||||
document.cookie = 'token=; Path=/; Max-Age=0;';
|
|
||||||
window.location.assign(API_BASE + '/api/auth/gitea');
|
|
||||||
});
|
|
||||||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
|
||||||
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
|
||||||
document.cookie = 'token=; Path=/; Max-Age=0;';
|
|
||||||
await refreshAuth();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== Events & Publish ==========
|
|
||||||
// Wichtig: Textfelder IMMER vor der Datei anhaengen. Der Server liest
|
|
||||||
// sie beim Empfang der Datei aus; was danach kommt, ist zu spaet.
|
|
||||||
async function uploadEventImage(file, title) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('title', title || '');
|
|
||||||
fd.append('file', file);
|
|
||||||
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadGalleryImage(file, altText) {
|
|
||||||
const fd = new FormData();
|
|
||||||
if (altText) fd.append('altText', altText);
|
|
||||||
fd.append('displayOrder', '0');
|
|
||||||
fd.append('file', file);
|
|
||||||
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
let reorderMode = false;
|
|
||||||
let lastEvents = [];
|
|
||||||
|
|
||||||
function parseDateSafe(s){
|
|
||||||
const d = new Date(s);
|
|
||||||
return isNaN(+d) ? new Date(0) : d;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEvents() {
|
|
||||||
const listEl = document.getElementById('events-list');
|
|
||||||
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
|
||||||
try {
|
|
||||||
const data = await api('/api/events');
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
// Merken, globale Liste aktualisieren
|
|
||||||
lastEvents = (data.events || []).slice();
|
|
||||||
let renderList = lastEvents.slice();
|
|
||||||
if (!reorderMode) {
|
|
||||||
// Automatisch nach Datum sortieren (neueste zuerst)
|
|
||||||
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
|
|
||||||
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
|
|
||||||
} else {
|
|
||||||
// Nach displayOrder aufsteigend
|
|
||||||
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
|
||||||
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
|
|
||||||
}
|
|
||||||
|
|
||||||
renderList.forEach((ev, idx) => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card';
|
|
||||||
card.setAttribute('draggable', String(reorderMode));
|
|
||||||
card.dataset.id = ev.id;
|
|
||||||
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="row" style="justify-content:space-between;align-items:center">
|
|
||||||
<div><strong>${esc(ev.title)}</strong></div>
|
|
||||||
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div class="muted">${esc(ev.date)}</div>
|
|
||||||
<div>${esc(ev.description || '')}</div>
|
|
||||||
${ev.imageUrl ? `<img src="${API_BASE}${esc(ev.imageUrl)}" alt="${esc(ev.title)}" class="thumb" />` : '<div class="muted">Kein Bild</div>'}
|
|
||||||
<div class="row-buttons">
|
|
||||||
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
|
|
||||||
</div>`;
|
|
||||||
listEl.appendChild(card);
|
|
||||||
});
|
|
||||||
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const id = btn.getAttribute('data-id');
|
|
||||||
if (!id) return;
|
|
||||||
if (!confirm('Event wirklich löschen?')) return;
|
|
||||||
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (reorderMode) {
|
|
||||||
enableDragAndDrop(listEl);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag & Drop Reorder
|
|
||||||
function enableDragAndDrop(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-ev').addEventListener('click', async () => {
|
|
||||||
const title = (document.getElementById('ev-title')).value.trim();
|
|
||||||
const date = (document.getElementById('ev-date')).value.trim();
|
|
||||||
const desc = (document.getElementById('ev-desc')).value.trim();
|
|
||||||
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
|
|
||||||
const msg = document.getElementById('ev-create-msg');
|
|
||||||
msg.textContent = 'Lade Bild hoch...';
|
|
||||||
try {
|
|
||||||
let imageUrl = '';
|
|
||||||
if (file) {
|
|
||||||
// Titel mitgeben, damit die Datei danach benannt wird
|
|
||||||
const up = await uploadEventImage(file, title);
|
|
||||||
imageUrl = up?.imageUrl || '';
|
|
||||||
}
|
|
||||||
msg.textContent = 'Lege Event an...';
|
|
||||||
await api('/api/events', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title, date, description: desc, imageUrl, displayOrder: 0, isPublished: true })
|
|
||||||
});
|
|
||||||
msg.textContent = 'Event erstellt';
|
|
||||||
(document.getElementById('ev-title')).value = '';
|
|
||||||
(document.getElementById('ev-date')).value = '';
|
|
||||||
(document.getElementById('ev-desc')).value = '';
|
|
||||||
(document.getElementById('ev-file')).value = '';
|
|
||||||
await loadEvents();
|
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-publish').addEventListener('click', async () => {
|
|
||||||
const s = document.getElementById('pub-status');
|
|
||||||
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
|
|
||||||
s.textContent = 'Veröffentliche...';
|
|
||||||
try {
|
|
||||||
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
|
|
||||||
const cleaned = res?.removedImages
|
|
||||||
? ` (${res.removedImages} ungenutzte${res.removedImages === 1 ? 's Bild' : ' Bilder'} entfernt)`
|
|
||||||
: '';
|
|
||||||
s.textContent = (res?.message || 'Veröffentlicht') + cleaned;
|
|
||||||
} catch(e){ s.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle Reorder
|
|
||||||
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
|
|
||||||
reorderMode = !reorderMode;
|
|
||||||
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
|
|
||||||
await loadEvents();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save Order
|
|
||||||
document.getElementById('btn-save-order').addEventListener('click', async () => {
|
|
||||||
const container = document.getElementById('events-list');
|
|
||||||
const cards = Array.from(container.querySelectorAll('.card'));
|
|
||||||
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
|
|
||||||
const msg = document.getElementById('order-msg');
|
|
||||||
msg.textContent = 'Speichere Reihenfolge...';
|
|
||||||
try {
|
|
||||||
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
|
|
||||||
msg.textContent = 'Reihenfolge gespeichert';
|
|
||||||
// Bleibe in Reorder-Mode oder verlasse ihn? Hier: verlassen
|
|
||||||
reorderMode = false;
|
|
||||||
document.getElementById('btn-save-order').style.display = 'none';
|
|
||||||
await loadEvents();
|
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== Gallery ==========
|
|
||||||
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 = '';
|
|
||||||
const galleryImages = (data.images || []).slice();
|
|
||||||
galleryImages.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
|
||||||
|
|
||||||
galleryImages.forEach((img) => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card';
|
|
||||||
card.innerHTML = `
|
|
||||||
<img src="${API_BASE}${esc(img.imageUrl)}" alt="${esc(img.altText)}" class="thumb" />
|
|
||||||
<div class="muted">${esc(img.altText || '')}</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); }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-create-gal').addEventListener('click', async () => {
|
|
||||||
const file = /** @type {HTMLInputElement} */ (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 Datei auswählen';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
msg.textContent = 'Lade Bild hoch...';
|
|
||||||
try {
|
|
||||||
await uploadGalleryImage(file, alt);
|
|
||||||
msg.textContent = 'Bild hochgeladen';
|
|
||||||
(document.getElementById('gal-file')).value = '';
|
|
||||||
(document.getElementById('gal-alt')).value = '';
|
|
||||||
await loadGallery();
|
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== Banner ==========
|
|
||||||
async function loadBanners() {
|
|
||||||
const listEl = document.getElementById('banner-list');
|
|
||||||
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
|
||||||
try {
|
|
||||||
const data = await api('/api/banners');
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
const bannersList = (data.banners || []).slice();
|
|
||||||
bannersList.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
||||||
|
|
||||||
bannersList.forEach((banner) => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card';
|
|
||||||
const statusText = banner.isActive ? '✓ Aktiv' : '✗ Inaktiv';
|
|
||||||
card.innerHTML = `
|
|
||||||
<div><strong>${esc(banner.text.substring(0, 60))}${banner.text.length > 60 ? '...' : ''}</strong></div>
|
|
||||||
<div class="muted">Von: ${esc(banner.startDate)}</div>
|
|
||||||
<div class="muted">Bis: ${esc(banner.endDate)}</div>
|
|
||||||
<div class="pill">${statusText}</div>
|
|
||||||
<div class="row-buttons">
|
|
||||||
<button data-id="${banner.id}" class="btn-toggle-banner">${banner.isActive ? 'Deaktivieren' : 'Aktivieren'}</button>
|
|
||||||
<button data-id="${banner.id}" class="btn-del-banner">Löschen</button>
|
|
||||||
</div>`;
|
|
||||||
listEl.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.btn-toggle-banner').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const id = btn.getAttribute('data-id');
|
|
||||||
if (!id) return;
|
|
||||||
const banner = bannersList.find(b => b.id === id);
|
|
||||||
if (!banner) return;
|
|
||||||
try {
|
|
||||||
await api(`/api/banners/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: banner.text,
|
|
||||||
startDate: banner.startDate,
|
|
||||||
endDate: banner.endDate,
|
|
||||||
isActive: !banner.isActive
|
|
||||||
})
|
|
||||||
});
|
|
||||||
await loadBanners();
|
|
||||||
} catch(e){ alert('Fehler: '+e.message); }
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.btn-del-banner').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const id = btn.getAttribute('data-id');
|
|
||||||
if (!id) return;
|
|
||||||
if (!confirm('Banner wirklich löschen?')) return;
|
|
||||||
try { await api(`/api/banners/${id}`, { method: 'DELETE' }); await loadBanners(); } catch(e){ alert('Fehler: '+e.message); }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-create-banner').addEventListener('click', async () => {
|
|
||||||
const text = (document.getElementById('banner-text')).value.trim();
|
|
||||||
const startDate = (document.getElementById('banner-start')).value.trim();
|
|
||||||
const endDate = (document.getElementById('banner-end')).value.trim();
|
|
||||||
const msg = document.getElementById('banner-create-msg');
|
|
||||||
|
|
||||||
if (!text || !startDate || !endDate) {
|
|
||||||
msg.textContent = 'Bitte alle Felder ausfüllen';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.textContent = 'Erstelle Banner...';
|
|
||||||
try {
|
|
||||||
await api('/api/banners', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ text, startDate, endDate, isActive: true })
|
|
||||||
});
|
|
||||||
msg.textContent = 'Banner erstellt';
|
|
||||||
(document.getElementById('banner-text')).value = '';
|
|
||||||
(document.getElementById('banner-start')).value = '';
|
|
||||||
(document.getElementById('banner-end')).value = '';
|
|
||||||
await loadBanners();
|
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== Texte (Content-Sections) ==========
|
|
||||||
// Der geladene Stand wird gemerkt, damit beim Speichern keine Felder
|
|
||||||
// verloren gehen, die die Oberfläche gar nicht anzeigt (z.B. pdfUrl).
|
|
||||||
let contentState = { hero: {}, welcome: {}, drinks: {} };
|
|
||||||
|
|
||||||
const byId = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
function setPreview(imgId, url) {
|
|
||||||
const img = byId(imgId);
|
|
||||||
if (url) {
|
|
||||||
img.src = API_BASE + url;
|
|
||||||
img.style.display = '';
|
|
||||||
} else {
|
|
||||||
img.removeAttribute('src');
|
|
||||||
img.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHighlights(highlights) {
|
|
||||||
const box = byId('wel-highlights');
|
|
||||||
box.innerHTML = '';
|
|
||||||
(highlights || []).forEach((h, idx) => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'highlight-row';
|
|
||||||
row.innerHTML = `
|
|
||||||
<label>Titel<input class="hl-title" value="${esc(h?.title)}" /></label>
|
|
||||||
<label>Beschreibung<input class="hl-desc" value="${esc(h?.description)}" /></label>
|
|
||||||
<button type="button" class="hl-remove" data-idx="${idx}">Entfernen</button>`;
|
|
||||||
box.appendChild(row);
|
|
||||||
});
|
|
||||||
box.querySelectorAll('.hl-remove').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
btn.closest('.highlight-row').remove();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function readHighlights() {
|
|
||||||
return Array.from(byId('wel-highlights').querySelectorAll('.highlight-row'))
|
|
||||||
.map(row => ({
|
|
||||||
title: row.querySelector('.hl-title').value.trim(),
|
|
||||||
description: row.querySelector('.hl-desc').value.trim(),
|
|
||||||
}))
|
|
||||||
.filter(h => h.title || h.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContent() {
|
|
||||||
try {
|
|
||||||
const data = await api('/api/content');
|
|
||||||
const map = {};
|
|
||||||
(data.sections || []).forEach(s => {
|
|
||||||
let c = s.content;
|
|
||||||
if (typeof c === 'string') { try { c = JSON.parse(c); } catch { c = {}; } }
|
|
||||||
map[s.section] = c || {};
|
|
||||||
});
|
|
||||||
contentState = {
|
|
||||||
hero: map.hero || {},
|
|
||||||
welcome: map.welcome || {},
|
|
||||||
drinks: map.drinks || {},
|
|
||||||
};
|
|
||||||
|
|
||||||
byId('hero-heading').value = contentState.hero.heading || '';
|
|
||||||
byId('hero-subheading').value = contentState.hero.subheading || '';
|
|
||||||
|
|
||||||
byId('wel-heading1').value = contentState.welcome.heading1 || '';
|
|
||||||
byId('wel-heading2').value = contentState.welcome.heading2 || '';
|
|
||||||
byId('wel-intro').value = contentState.welcome.introText || '';
|
|
||||||
byId('wel-closing').value = contentState.welcome.closingText || '';
|
|
||||||
renderHighlights(contentState.welcome.highlights);
|
|
||||||
setPreview('wel-preview', contentState.welcome.imageUrl);
|
|
||||||
|
|
||||||
byId('dr-intro').value = contentState.drinks.introText || '';
|
|
||||||
byId('dr-special-name').value = contentState.drinks.monthlySpecialName || '';
|
|
||||||
byId('dr-whiskey').value = contentState.drinks.whiskeyText || '';
|
|
||||||
setPreview('dr-special-preview', contentState.drinks.monthlySpecialImage);
|
|
||||||
setPreview('dr-whiskey-preview1', contentState.drinks.whiskeyImage1);
|
|
||||||
setPreview('dr-whiskey-preview2', contentState.drinks.whiskeyImage2);
|
|
||||||
setPreview('dr-whiskey-preview3', contentState.drinks.whiskeyImage3);
|
|
||||||
|
|
||||||
const pdf = contentState.drinks.pdfUrl;
|
|
||||||
byId('dr-pdf-current').innerHTML = pdf
|
|
||||||
? `Aktuell: <a href="${API_BASE}${esc(pdf)}" target="_blank" rel="noopener noreferrer">${esc(pdf.split('/').pop())}</a>`
|
|
||||||
: 'Noch keine eigene Getränkekarte hochgeladen (es gilt die im Repo hinterlegte).';
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadContentImage(file, name) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('name', name || '');
|
|
||||||
fd.append('file', file);
|
|
||||||
const res = await fetch(API_BASE + '/api/content/upload', { method: 'POST', body: fd, credentials: 'include' });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt die Datei aus einem Input hoch, sofern eine gewählt wurde. */
|
|
||||||
async function maybeUpload(inputId, current, name) {
|
|
||||||
const file = byId(inputId).files[0];
|
|
||||||
if (!file) return current;
|
|
||||||
const up = await uploadContentImage(file, name);
|
|
||||||
byId(inputId).value = '';
|
|
||||||
return up?.imageUrl || current;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSection(name, content, msgId) {
|
|
||||||
const msg = byId(msgId);
|
|
||||||
msg.textContent = 'Speichere...';
|
|
||||||
msg.className = 'muted';
|
|
||||||
try {
|
|
||||||
await api(`/api/content/${name}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ contentJson: content }),
|
|
||||||
});
|
|
||||||
msg.textContent = 'Gespeichert. Mit „Publish“ übernehmen.';
|
|
||||||
msg.className = 'ok';
|
|
||||||
await loadContent();
|
|
||||||
} catch (e) {
|
|
||||||
msg.textContent = 'Fehler: ' + e.message;
|
|
||||||
msg.className = 'err';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
byId('btn-add-highlight').addEventListener('click', () => {
|
|
||||||
const current = readHighlights();
|
|
||||||
current.push({ title: '', description: '' });
|
|
||||||
renderHighlights(current);
|
|
||||||
});
|
|
||||||
|
|
||||||
byId('btn-save-hero').addEventListener('click', () => {
|
|
||||||
saveSection('hero', {
|
|
||||||
...contentState.hero,
|
|
||||||
heading: byId('hero-heading').value.trim(),
|
|
||||||
subheading: byId('hero-subheading').value.trim(),
|
|
||||||
}, 'hero-msg');
|
|
||||||
});
|
|
||||||
|
|
||||||
byId('btn-save-welcome').addEventListener('click', async () => {
|
|
||||||
const msg = byId('wel-msg');
|
|
||||||
msg.textContent = 'Lade Bild hoch...';
|
|
||||||
msg.className = 'muted';
|
|
||||||
try {
|
|
||||||
const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl, 'willkommen');
|
|
||||||
await saveSection('welcome', {
|
|
||||||
...contentState.welcome,
|
|
||||||
heading1: byId('wel-heading1').value.trim(),
|
|
||||||
heading2: byId('wel-heading2').value.trim(),
|
|
||||||
introText: byId('wel-intro').value.trim(),
|
|
||||||
closingText: byId('wel-closing').value.trim(),
|
|
||||||
highlights: readHighlights(),
|
|
||||||
...(imageUrl ? { imageUrl } : {}),
|
|
||||||
}, 'wel-msg');
|
|
||||||
} catch (e) {
|
|
||||||
msg.textContent = 'Fehler: ' + e.message;
|
|
||||||
msg.className = 'err';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
byId('btn-save-drinks').addEventListener('click', async () => {
|
|
||||||
const msg = byId('dr-msg');
|
|
||||||
msg.textContent = 'Lade Dateien hoch...';
|
|
||||||
msg.className = 'muted';
|
|
||||||
try {
|
|
||||||
const specialName = byId('dr-special-name').value.trim();
|
|
||||||
const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage, specialName || 'monats-hit');
|
|
||||||
const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1, 'whiskey-1');
|
|
||||||
const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2, 'whiskey-2');
|
|
||||||
const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3, 'whiskey-3');
|
|
||||||
|
|
||||||
// PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt
|
|
||||||
const pdfFile = byId('dr-pdf-file').files[0];
|
|
||||||
if (pdfFile) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', pdfFile);
|
|
||||||
const res = await fetch(API_BASE + '/api/pdf/drinks', { method: 'POST', body: fd, credentials: 'include' });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const up = await res.json();
|
|
||||||
contentState.drinks.pdfUrl = up?.pdfUrl || contentState.drinks.pdfUrl;
|
|
||||||
byId('dr-pdf-file').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveSection('drinks', {
|
|
||||||
...contentState.drinks,
|
|
||||||
introText: byId('dr-intro').value.trim(),
|
|
||||||
monthlySpecialName: byId('dr-special-name').value.trim(),
|
|
||||||
whiskeyText: byId('dr-whiskey').value.trim(),
|
|
||||||
...(special ? { monthlySpecialImage: special } : {}),
|
|
||||||
...(w1 ? { whiskeyImage1: w1 } : {}),
|
|
||||||
...(w2 ? { whiskeyImage2: w2 } : {}),
|
|
||||||
...(w3 ? { whiskeyImage3: w3 } : {}),
|
|
||||||
}, 'dr-msg');
|
|
||||||
} catch (e) {
|
|
||||||
msg.textContent = 'Fehler: ' + e.message;
|
|
||||||
msg.className = 'err';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
refreshAuth();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
const title = 'Anmeldung wird abgeschlossen...';
|
|
||||||
---
|
|
||||||
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>{title}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>{title}</p>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const token = params.get('token');
|
|
||||||
if (token) {
|
|
||||||
const secure = window.location.protocol === 'https:';
|
|
||||||
document.cookie = `token=${encodeURIComponent(token)}; Path=/; Max-Age=${60*60*24}; SameSite=Lax; ${secure ? 'Secure' : ''}`.trim();
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Failed to process OAuth token', e);
|
|
||||||
}
|
|
||||||
window.location.replace('/admin');
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,109 +1,90 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
import Hero from "../components/Hero.astro";
|
import Hero from "../components/Hero.astro";
|
||||||
import Welcome from "../components/Welcome.astro";
|
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 events = [
|
||||||
{
|
{
|
||||||
image: "/images/events/mmnm5yob-w9xjor.jpeg",
|
image: "/images/events/event_karaoke.jpg",
|
||||||
title: "Karaoke",
|
title: "Karaoke",
|
||||||
date: "2026-03-12",
|
date: "Mittwoch - Samstag",
|
||||||
description: `
|
description: `
|
||||||
Du singst gerne, aber lieber für dich? Dann kannst du den Sangallerruum im 2OG auch privat mieten. (Optimal für 10-20Pers). Mehr Info's🍀via WA 077 232 27 70
|
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/mj67800i-6ng82x.jpeg",
|
image: "/images/events/event_pub-quiz.jpg",
|
||||||
title: "Pub Quiz",
|
title: "Pub Quiz",
|
||||||
date: "2025-12-02",
|
date: "Jeden Freitag",
|
||||||
description: `
|
description: `
|
||||||
Jeden Freitag 20:00Uhr-ca 21:30Uhr.
|
Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
|
||||||
Plätze sind begrenzt! Jetzt reservieren unter 🍀WA 077 232 27 70
|
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/noeg.avif",
|
image: "/images/events/event_santa_karaoke.jpeg",
|
||||||
title: "NOeg",
|
title: "Santa Karaoke-Party",
|
||||||
date: "2026-09-16",
|
date: "06. Dezember 2025",
|
||||||
description: `
|
description: `
|
||||||
🎤 Singe die größten Hits auf Spanisch, Italienisch, Französisch, Türkisch oder vielleicht sogar Japanisch?!
|
🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻 `,
|
||||||
✨ Lass das Gallus zum Leben erwachen – bei unserer multilingualen Karaoke-Nacht!
|
},
|
||||||
|
{
|
||||||
|
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/yutaro.avif",
|
image: "/images/events/event_neujahrs-apero.jpeg",
|
||||||
title: "Yutaro",
|
title: "Neujahrs-Apero",
|
||||||
date: "2026-09-17",
|
date: "02. Januar 2026 - 18:00-20:00 Uhr",
|
||||||
description: `
|
description: `
|
||||||
Mit seiner Gitarre und seiner markanten Stimme verbindet er Singer-Songwriter-Sound mit Einflüssen aus Rock, Blues und Progressive Rock. 🍀Reservationen🍀via WA 077 232 27 70
|
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
image: "/images/events/mrngoyyy-49rz7u.jpeg",
|
|
||||||
title: "Danielle&Nina",
|
|
||||||
date: "2026-09-24",
|
|
||||||
description: `
|
|
||||||
SOLD OUT! - First time on stage – und das direkt im Gallus Pub!🎤✨
|
|
||||||
Danielle & Nina bringen Irish Folk & liebevolle Cover Songs.🍀Warteliste: WA 077 232 27 70
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/dirty-karaoke.avif",
|
|
||||||
title: "Dirty Karaoke",
|
|
||||||
date: "2026-10-07",
|
|
||||||
description: `
|
|
||||||
*Nicht für alle Ohren geeignet! Ob eindeutig zweideutig, hemmungslos daneben oder einfach nur zum Fremdschämen – an diesem Abend gilt: Was gesungen wird, bleibt auf der Bühne.😉
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/voices.avif",
|
|
||||||
title: "Voices",
|
|
||||||
date: "2026-10-22",
|
|
||||||
description: `
|
|
||||||
Ein Abend voller Songs, Stimmen & Live Music.
|
|
||||||
Mit besonderen Stimmen, guter Stimmung und ganz viel musikalischer Energie. 🍀Reservationen🍀via WA 077 232 27 70
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/disney-karaoke.avif",
|
|
||||||
title: "Disney Karaoke",
|
|
||||||
date: "2026-10-28",
|
|
||||||
description: `
|
|
||||||
Die nächste Disney Karaoke Night steht an – und passend zur Gruselweek: wer mit Grusel-Kostüm kommt, kriegt einen Glitzer-Shot auf's Haus.🎤✨
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/katy-elias.avif",
|
|
||||||
title: "Katy & Elias",
|
|
||||||
date: "2026-11-21",
|
|
||||||
description: `
|
|
||||||
ACHTUNG Kurz-Konzert 19:30-20:30! Danach geht’s für die beiden direkt weiter zu einem zweiten Konzert in der Lukas Bar in St. Georgen.
|
|
||||||
🍀Reservationen🍀via WA 077 232 27 70
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const images = [
|
const images = [
|
||||||
{ src: "/images/gallery/mndhtjvz-965yra.jpeg", alt: "mndhtjvz-965yra.jpeg" },
|
{ src: "/images/gallery/Gallery7.png", alt: "Siebtes Bild" },
|
||||||
{ src: "/images/gallery/mndi7upa-qxukqg.jpeg", alt: "mndi7upa-qxukqg.jpeg" },
|
{ src: "/images/gallery/Gallery8.png", alt: "Achtes Bild" },
|
||||||
{ src: "/images/gallery/mndif8nv-aptjf6.jpeg", alt: "mndif8nv-aptjf6.jpeg" },
|
{ src: "/images/gallery/Gallery9.png", alt: "Neuntes Bild" },
|
||||||
{ src: "/images/gallery/halloween.avif", alt: "Halloween" },
|
{ src: "/images/gallery/Gallery6.png", alt: "Sechstes Bild" },
|
||||||
{ src: "/images/gallery/sangallerruum.avif", alt: "Sangallerruum" },
|
{ src: "/images/gallery/Gallery1.png", alt: "Erstes Bild" },
|
||||||
{ src: "/images/gallery/welovegalluspub.avif", alt: "WeLoveGallusPub" },
|
{ src: "/images/gallery/Gallery2.png", alt: "Zweites Bild" },
|
||||||
{ src: "/images/gallery/raphi-sabrina.jpeg", alt: "Raphi&Sabrina" },
|
{ src: "/images/gallery/Gallery3.png", alt: "Drittes Bild" },
|
||||||
{ src: "/images/gallery/pubquiz.avif", alt: "PubQuiz" }
|
{ src: "/images/gallery/Gallery4.png", alt: "Viertes Bild" },
|
||||||
|
{ src: "/images/gallery/Gallery5.png", alt: "Fünftes Bild" },
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Hero id="hero" />
|
<Hero id="hero" />
|
||||||
<Banner />
|
|
||||||
<Welcome id="welcome" />
|
<Welcome id="welcome" />
|
||||||
<EventsGrid id="events" events={events} />
|
<EventsGrid id="events" events={events} />
|
||||||
<ImageCarousel id="gallery" images={images} />
|
<ImageCarousel id="gallery" images={images} />
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
.banner-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--color-orange1);
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
max-width: var(--container-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 var(--padding-horizontal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner p {
|
|
||||||
color: #000;
|
|
||||||
font-size: var(--font-size-small-medium);
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.banner p {
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
}
|
|
||||||
}
|
|
||||||