68 Commits

Author SHA1 Message Date
6eea814fad feat: Add gallery management to admin panel + system documentation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Features:
- Gallery upload, delete, reorder (drag & drop)
- Preview images in admin panel
- Same upload flow as events (WebP conversion via sharp)
- Complete system documentation explaining:
  - Architecture (Frontend/Backend/Volume)
  - Upload flow (Multipart → Sharp → WebP → Storage)
  - Why SQLite works on Fly.io (persistent volumes)
  - Troubleshooting guide

Admin now has full CRUD for both Events and Gallery!
2025-12-09 17:08:36 +01:00
0597c73690 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 16:46:10 +01:00
0a2aa84a8c Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth. 2025-12-09 16:45:28 +01:00
1120472af8 feat: Migrate old events and gallery images to persistent storage
- Add migration script to convert and copy images
- Include 7 events (Karaoke, Pub Quiz, etc.) in WebP format
- Include 9 gallery images in WebP format
- Update .gitignore to allow images in data/images/
- Add migration documentation in MIGRATION_README.md

Images are stored in backend/data/images/ which maps to
the persistent Fly.io volume at /app/data/
2025-12-09 16:41:57 +01:00
db3a38ed45 Revert "Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth."
This reverts commit 4f8feb8652.
2025-12-09 16:36:16 +01:00
4f8feb8652 Refactor Woodpecker pipeline: consolidate when conditions, replace secrets with environment for Fly.io auth.
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is running
2025-12-09 15:56:55 +01:00
0c291079ff Test: Trigger Woodpecker CI 2025-12-09 15:56:10 +01:00
fe2f61cdc2 Simplify Woodpecker pipeline by consolidating when conditions and using secrets for Fly.io authentication 2025-12-09 15:55:51 +01:00
af4877300f Add public endpoints and refactor deployments
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Implemented public `/gallery/public` and `/events/public` endpoints for fetching published data without authentication.
- Updated persistent volume configuration for Fly.io across backend and static file serving.
- Adjusted frontend to dynamically fetch events and gallery images from backend API.
- Refined Woodpecker pipeline for clearer separation of backend and frontend deployments.
2025-12-09 15:53:39 +01:00
4a103cf7d6 Merge remote-tracking branch 'origin/main'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:17:25 +01:00
97e7f88906 Revert "ned soll endlich pushen"
This reverts commit 8ca30ae5f3.
2025-12-09 15:16:45 +01:00
807c56de5a Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:09:39 +00:00
8ca30ae5f3 ned soll endlich pushen
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:45:46 +01:00
8f1254840c anpassungen am woodpecker und fly .toml
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:36:31 +01:00
8b2d00385a test
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:03:36 +01:00
c55e274718 woodpecker soll nun auch das backend deployen
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 13:58:39 +01:00
e9a95ccf8d Implement cross-domain support for OAuth and API requests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Updated frontend to use `https://cms.gallus-pub.ch` as the API base URL.
- Configured cookies with `SameSite=None` and `Secure` for production in `auth.ts`.
- Enhanced `fly.toml` to include `FRONTEND_URL`, `CORS_ORIGIN`, and `GITEA_REDIRECT_URI`.
- Adjusted `.gitignore` to ignore `/ai/` directory.
2025-12-09 12:01:49 +01:00
b16ac76620 Update fly.toml to adjust [env] paths and simplify volume mounts configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-08 18:34:18 +01:00
0e03b9dea9 Fix typo in deployment guide section header
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:27:27 +01:00
da3a950a1a Update fly.toml to reference Dockerfile instead of Dockerfile.fly
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:19:01 +01:00
fb7eaa6bb2 Merge remote-tracking branch 'origin/feat/cms' into feat/cms
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:10:02 +01:00
daccc43677 Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 18:09:29 +01:00
3b6cb0a3fb Remove CLAUDE.md as it is no longer relevant
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 17:56:22 +01:00
6a3c77d7c5 Merge remote-tracking branch 'origin/feat/cms' into feat/cms 2025-12-08 17:53:06 +01:00
a28d43db45 Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 17:51:46 +01:00
af930f345c Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 16:00:40 +01:00
22494084ce Merge pull request 'feat(backend): initial setup for cms backend service' (#1) from feat/cms into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2025-12-08 09:03:00 +01:00
bc6c1e95d3 Merge branch 'main' into feat/cms 2025-12-08 09:02:52 +01:00
f2a0422f3b feat(events): add santa karaoke event
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-21 13:57:29 +01:00
2cae2e86ed Merge pull request 'feat(events): update event details and add new events for Karaoke and Pub Quiz' (#3) from feat/events-december-25 into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #3
2025-11-19 14:12:50 +01:00
636c7fc03a feat(events): update event details and add new events for Karaoke and Pub Quiz 2025-11-19 14:11:44 +01:00
5fdea37a90 Merge pull request 'Neue Events für Dezember 25' (#2) from feat/events-december-25 into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #2
2025-11-18 23:09:49 +01:00
11932d51ec feat(events): add new events for Adventskalender, Weihnachtsferien, and Neujahrs-Apero with corresponding images 2025-11-18 20:14:53 +01:00
803c7907f1 fix(HoverCard): add type assertion for event target in click handler 2025-11-18 20:05:17 +01:00
3d4bbf77bc feat(events): add new event schlager karaoke 2025-11-18 20:05:01 +01:00
71a586280e refactor(images): add new folders to categorize events, gallery, whiskey 2025-11-18 19:12:56 +01:00
1f4cea0c35 fix(hero): add correct id in Hero to fix scroll button 2025-11-18 18:42:21 +01:00
9adec32839 build(deps): add missing better-sqlite3 dependency 2025-11-15 17:25:45 +01:00
688b4de945 feat(backend): initial setup for cms backend service 2025-11-15 14:56:43 +01:00
193f3ff0bb Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-10 15:12:26 +01:00
292747d197 Remove outdated event entries from index.astro 2025-11-10 15:12:12 +01:00
18f7ea5da5 Remove outdated event entries from index.astro
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is running
2025-11-10 11:09:16 +01:00
1f94bbca15 Remove redundant event timing and reservation details from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:58:43 +02:00
5ef15f0b5c Update event time and pricing details in index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:57:29 +02:00
020bfca731 Remove commented file reference from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-25 16:47:59 +02:00
ac864ba054 Update event details and assets in index.astro and public/images
- Swapped event titles, dates, and images for better accuracy.
- Replaced `kevin_mcflannigan.png` with `Event4.png`.
- Updated `Menu.pdf`.
2025-10-25 16:29:39 +02:00
e93ba5d29b Remove outdated event details from index.astro
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-23 11:06:37 +02:00
k
feb137471d Update event details and images for improved content clarity and presentation.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-21 15:31:37 +02:00
k
0622d190d1 Remove comment.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-17 12:14:05 +02:00
k
2867678223 Remove tappable hint text from HoverCard styles. 2025-09-17 12:06:25 +02:00
k
096ac9f789 Update Getränkekarte PDF link to point to Getraenke_Gallus_2025.pdf.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-06 14:29:18 +02:00
k
3006ccd5a0 Merge remote-tracking branch 'origin/main'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 15:12:38 +02:00
k
8a8bcc304a Enhance HoverCard behavior and styles for better mobile interactivity and accessibility:
- **Hover support refinement**: Limited hover effects to devices with pointer precision and hover capability.
- **Active state improvements**: Added visual feedback for tap and ensured consistent card toggling on mobile, including outside-click handling.
- **Styling additions**: Introduced a tappable hint for better user guidance and refined cursor styles.
- **Script update**: Prevented multiple active cards and ensured seamless closing on external clicks.
2025-08-05 15:12:26 +02:00
k
54c6f205e0 Rename Menu.pdf to Getraenke_Gallus_2025.pdf in the public/pdf directory.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:18:10 +02:00
k
48fddf7b15 Rename Menu.pdf to Getraenke_Gallus_2025.pdf in the public/pdf directory.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:04:09 +02:00
k
2733c2e7f4 Remove redundant whiskey circle styles and update menu PDF.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 14:01:31 +02:00
k
9502123b89 Remove Kevin McFlannigan details from events and update circle dimensions to use vh for better responsiveness.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-08-05 13:47:09 +02:00
ca2d724bd8 Update Drinks.css and index.astro for style adjustments and event details update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Replaced `em` with `rem` in `.circle` dimensions for consistent scaling.
- Revised event descriptions and titles in `index.astro` for clarity.
- Updated `Menu.pdf` file.
2025-08-04 14:17:28 +02:00
k
38229ac5e9 Refine Drinks section text and styling:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Content updates**: Adjusted description text for a more engaging and detailed presentation.
- **Styling changes**: Reduced title margin, enlarged circle dimensions for better visual balance, and added spacing to card links.
- **Layout improvements**: Removed redundant whiskey circle styles for cleaner CSS.
2025-08-02 15:54:09 +02:00
k
a11c838d2a Update gallery images and refine Drinks section:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Gallery updates**: Added `Gallery9.png` and reordered images for better organization.
- **Drinks section tweak**: Removed redundant label text in "Mate Vodka" circle.
2025-08-02 15:43:46 +02:00
k
f9fe914c32 Update images, enhance Drinks section, and adjust styles:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Image updates**: Replaced placeholder images with new gallery images (`Gallery1.png` to `Gallery8.png`) and added whiskey images (`Whiskey1.png` to `Whiskey3.png`).
- **Drinks section**: Added description, updated drink types, and included new images for Whiskey options and Monthly Special.
- **Circular design refinements**: Enlarged circle dimensions, ensured image fit with `object-fit: cover`, and added responsive adjustments.
- **Style improvements**: Introduced overflow handling, z-index management, and tailored sizes for Whiskey circles.
2025-08-02 15:36:14 +02:00
k
21e09f7155 Update images, enhance Drinks section, and adjust styles:
- **Image updates**: Replaced placeholder images with new gallery images (`Gallery1.png` to `Gallery8.png`) and added whiskey images (`Whiskey1.png` to `Whiskey3.png`).
- **Drinks section**: Added description, updated drink types, and included new images for Whiskey options and Monthly Special.
- **Circular design refinements**: Enlarged circle dimensions, ensured image fit with `object-fit: cover`, and added responsive adjustments.
- **Style improvements**: Introduced overflow handling, z-index management, and tailored sizes for Whiskey circles.
2025-08-02 15:32:51 +02:00
k
0b37f73634 Enhance Header interactivity and clean up HoverCard structure:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Mobile menu improvements**: Added functionality to close the menu when navigation links are clicked.
- **HoverCard cleanup**: Removed unused title and date markup for a leaner structure.
2025-08-02 14:49:04 +02:00
k
c764f892a1 Add active state for mobile HoverCard
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **New functionality**: Introduced an active state for `HoverCard` on mobile devices, triggered via click.
- **Style updates**: Added styles to handle `active` class for text and image opacity.
- **Responsive aspect ratio**: Ensured square aspect ratio with the addition of `aspect-ratio: 1 / 1` in CSS.
- **Script enhancement**: Implemented JavaScript for toggling the active state on mobile.
2025-08-02 14:41:16 +02:00
k
78f367530a Refactor Header and HoverCard styles for consistency and responsiveness:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Standardized dimensions**: Converted `px` values to `rem` in `HoverCard.css`.
- **Improved header layout**: Enhanced mobile-first responsiveness with distinct layouts for desktop and mobile.
- **New navigation styles**: Adjusted alignment, spacing, and transitions for the burger menu and mobile dropdown.
- **Optimized media queries**: Simplified and organized breakpoints for better maintainability.
2025-08-02 14:35:34 +02:00
k
b539329420 Add responsive mobile menu with burger icon and animations:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Mobile menu implementation**: Introduced a burger menu for smaller screens, enabling navigation toggle.
- **Responsive styles**: Added CSS adjustments for headers and navigation under `768px` and `480px` breakpoints.
- **Interactive behavior**: Implemented toggle functionality using JavaScript for mobile menu activation.
- **Styling updates**: Enhanced mobile and desktop navigation distinction with scoped CSS changes.
2025-08-02 14:30:49 +02:00
k
3e93e8ce3b Update events, images, and improve layout:
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- **Events update**: Replaced existing events with new details and images.
- **Image updates**: Replaced outdated images with optimized ones (e.g., `Event1.png`, `Event2.png`, `Event3.png`).
- **Footer improvements**: Added `id` to enable smooth scrolling for "Contact".
- **Welcome section**: Adjusted heading structure for better readability.
- **Navigation fix**: Replaced "Openings" link with "Contact".
- **CSS cleanup**: Removed redundant comment in `Hero.css` and fixed typos in alt text.
2025-08-02 14:27:11 +02:00
k
2fab4bf70b Refactor styles and imports for consistency and maintainability:
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- **CSS restructuring**: Moved `styles` folder into `src` for better organization.
- **Updated imports**: Adjusted component CSS imports to reflect new paths.
- **Component tweaks**:
  - Increased HoverCard width from `350px` to `400px` for better visual balance.
  - Adjusted Footer layout: reorganized copyright and added email link.
  - Modified Drinks circle dimensions (from `6em` to `9em`) for improved design.
- **Footer styles**: Changed copyright section's layout with top spacing and border adjustments.
2025-08-02 13:50:56 +02:00
115 changed files with 7329 additions and 231 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
/ai/

View File

@ -1,5 +1,5 @@
steps:
deploy:
deploy_frontend:
image: node:20
environment:
FLY_API_TOKEN:
@ -7,10 +7,7 @@ steps:
commands:
- curl -L https://fly.io/install.sh | sh
- export PATH="$HOME/.fly/bin:$PATH"
- flyctl deploy --config fly.toml --app gallus-pub
when:
branch:
- main
event:
- push
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
when:
branch: main
event: push

View File

@ -2,7 +2,8 @@ FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Fallback to npm install if no lockfile is present
RUN npm ci || npm install
COPY . .
# Ensure CSS variables are present
RUN mkdir -p public/styles
@ -16,7 +17,8 @@ RUN npm install -g serve
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
CMD ["serve", "-l", "3000", "dist"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1

9
Dockerfile.caddy Normal file
View File

@ -0,0 +1,9 @@
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"]

142
MIGRATION_README.md Normal file
View File

@ -0,0 +1,142 @@
# Migration der alten Events und Gallery-Bilder
## ✅ Was wurde migriert?
### Events (7 Stück):
- Karaoke (wiederkehrend)
- Pub Quiz (wiederkehrend)
- Schlager Hüttenzauber Karaoke
- Adventskalender
- Santa Karaoke-Party
- Weihnachtsferien
- Neujahrs-Apero
### Gallery-Bilder (9 Stück):
- Gallery1.webp bis Gallery9.webp
## 📁 Wo liegen die Bilder?
Alle Bilder wurden konvertiert und liegen jetzt in:
- **Events:** `backend/data/images/events/`
- **Gallery:** `backend/data/images/gallery/`
Die Bilder wurden automatisch:
- Von PNG/JPG/JPEG zu WebP konvertiert
- Auf max. 1600px Breite skaliert
- Mit 85% Qualität optimiert
## 🚀 Deployment-Schritte
### 1. Lokale Vorbereitung (bereits erledigt ✓)
- ✓ Migrations-Script erstellt
- ✓ Bilder konvertiert und in `backend/data/images/` kopiert
- ✓ Public API-Endpunkte erstellt (`/api/events/public`, `/api/gallery/public`)
- ✓ Frontend aktualisiert, um Events und Gallery dynamisch zu laden
### 2. Auf Fly.io deployen
Alle Änderungen committen und pushen:
```bash
git add .
git commit -m "feat: Migrate old events and gallery images to CMS"
git push origin main
```
Woodpecker CI wird automatisch beide Services deployen.
### 3. Nach dem ersten Deploy - Datenbank initialisieren
**Wichtig:** Die Bilder sind bereits im Repository in `backend/data/images/`, aber die Datenbank muss noch mit den Event- und Gallery-Einträgen befüllt werden.
#### Via fly ssh (Empfohlen):
```bash
# In das Backend einloggen
fly ssh console -a gallus-cms-backend
# Prüfen ob Bilder da sind
ls -la /app/data/images/events/
ls -la /app/data/images/gallery/
# Migrations-Script ausführen
cd /app
npm run migrate:old-data
```
#### Alternative: Manuell via Admin-Panel
1. Gehe zu https://gallus-pub.ch/admin
2. Melde dich an
3. Für jedes Event:
- Klicke auf "Neues Event"
- Gib Titel, Datum und Beschreibung ein
- Statt Bild hochzuladen, trage manuell die imageUrl ein:
- z.B. `/images/events/event_karaoke.webp`
- Speichere das Event
## 🔍 Verifikation
Nach dem Deployment prüfen:
1. **Frontend:** https://gallus-pub.ch/
- Events sollten angezeigt werden
- Gallery sollte Bilder zeigen
2. **Admin:** https://gallus-pub.ch/admin
- Events können bearbeitet werden
- Neue Events können hinzugefügt werden
3. **Backend Health:** https://cms.gallus-pub.ch/health
- Status sollte "ok" sein
## 📝 Event-Daten für manuelles Einfügen
Falls du die Events manuell via Admin-Panel einfügen möchtest:
### Karaoke
- **Titel:** Karaoke
- **Datum:** 2025-12-31
- **Beschreibung:** Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_karaoke.webp`
### Pub Quiz
- **Titel:** Pub Quiz
- **Datum:** 2025-12-31
- **Beschreibung:** Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
- **Bild-URL:** `/images/events/event_pub-quiz.webp`
### Schlager Hüttenzauber Karaoke
- **Titel:** Schlager Hüttenzauber Karaoke
- **Datum:** 2025-11-27
- **Beschreibung:** Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
- **Bild-URL:** `/images/events/event_schlager-karaoke.webp`
### Adventskalender
- **Titel:** Adventskalender
- **Datum:** 2025-12-20
- **Beschreibung:** Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
- **Bild-URL:** `/images/events/event_advents-kalender.webp`
### Santa Karaoke-Party
- **Titel:** Santa Karaoke-Party
- **Datum:** 2025-12-06
- **Beschreibung:** 🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻
- **Bild-URL:** `/images/events/event_santa_karaoke.webp`
### Weihnachtsferien
- **Titel:** Weihnachtsferien
- **Datum:** 2025-12-21
- **Beschreibung:** Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
- **Bild-URL:** `/images/events/event_ferien.webp`
### Neujahrs-Apero
- **Titel:** Neujahrs-Apero
- **Datum:** 2026-01-02
- **Beschreibung:** 18:00-20:00 Uhr
- **Bild-URL:** `/images/events/event_neujahrs-apero.webp`
## ⚠️ Wichtige Hinweise
1. **Bilder sind im Volume persistent:** Alle Bilder in `/app/data/` bleiben bei Restarts erhalten
2. **Datenbank ist persistent:** Die SQLite-DB in `/app/data/gallus_cms.db` bleibt erhalten
3. **Alte Bilder in `public/images/`:** Die alten Original-Bilder bleiben im Frontend-Repository, werden aber nicht mehr verwendet

View File

@ -45,3 +45,4 @@ All commands are run from the root of the project, from a terminal:
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
# Test commit to trigger Woodpecker

220
SYSTEM_ERKLAERUNG.md Normal file
View File

@ -0,0 +1,220 @@
# 🎯 Gallus Pub CMS - System-Erklärung
## 📐 Architektur-Überblick
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend CMS │ │ Fly.io Volume │
│ (Astro SSG) │◄────────┤ (Fastify API) │◄────────┤ /app/data/ │
│ gallus-pub.ch │ fetch │ cms.gallus-pub.ch│ mount │ - SQLite DB │
│ │ │ │ │ - images/ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
```
## 🔄 Wie funktioniert der Upload-Flow?
### 1. **Admin lädt Bild hoch** (admin.astro)
```
User wählt Bild → uploadImage() → POST /api/gallery/upload
```
### 2. **Backend verarbeitet Upload** (backend/src/routes/gallery.ts)
```javascript
// Line 60-134 in gallery.ts
1. Empfange Multipart-File
2. Validiere Mimetype (nur images/*)
3. Lese Stream in Buffer
4. sharp() konvertiert zu WebP:
- Auto-rotate (EXIF)
- Resize auf max 1600px
- WebP quality 82%
5. Speichere in /app/data/images/gallery/
6. Erstelle DB-Eintrag mit imageUrl
7. Return imageUrl an Frontend
```
### 3. **Bilder werden serviert** (backend/src/index.ts)
```javascript
// Line 63-69
fastifyStatic /static/ /app/data/
```
Beispiel:
- Bild liegt in: `/app/data/images/gallery/miyma9zc-8he1di.webp`
- URL ist: `/images/gallery/miyma9zc-8he1di.webp`
- Wird serviert als: `https://cms.gallus-pub.ch/static/images/gallery/miyma9zc-8he1di.webp`
### 4. **Frontend zeigt Bilder** (src/pages/index.astro)
```javascript
// Line 30-43
1. Fetch von /api/gallery/public
2. Map imageUrl: `${API_BASE}${img.imageUrl}`
3. Result: https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
```
## 💾 Warum funktioniert SQLite mit Fly.io?
**Fly.io Volumes** sind persistente Speicher:
- Gemountet als: `/app/data/`
- Konfiguration in: `backend/fly.toml` (Line 40-42)
- Bleibt bei Restarts/Deploys erhalten
```toml
[mounts]
source = "gallus_data"
destination = "/app/data"
```
### Was liegt wo?
```
/app/data/
├── gallus_cms.db # SQLite Datenbank
├── gallus_cms.db-wal # Write-Ahead Log
├── gallus_cms.db-shm # Shared Memory
└── images/
├── events/
│ ├── event_karaoke.webp
│ └── ...
└── gallery/
├── Gallery1.webp
└── ...
```
## 🔒 Datenfluss im Detail
### Event erstellen:
```
1. Admin: Bild auswählen + Formular ausfüllen
2. uploadImage(file) → /api/gallery/upload
3. Backend:
- Sharp konvertiert → WebP
- Speichert in /app/data/images/gallery/
- Returnt: { image: { imageUrl: "/images/gallery/xyz.webp" } }
4. Admin: POST /api/events
Body: { title, date, description, imageUrl: "/images/gallery/xyz.webp" }
5. Backend:
- INSERT INTO events (imageUrl = "/images/gallery/xyz.webp")
6. Frontend: GET /api/events/public
- Fetcht Events aus DB
- Mapped imageUrl zu voller URL
- Zeigt an: <img src="https://cms.gallus-pub.ch/static/images/gallery/xyz.webp">
```
### Warum separate Uploads für Gallery?
Events nutzen den Gallery-Upload, weil:
- Beide brauchen WebP-Konvertierung
- Beide nutzen gleichen Storage
- Vermeidet Code-Duplikation
- Gallery kann eigenständige Bildergalerie haben
## 🎨 Admin-Panel Features
### Events-Verwaltung:
- ✅ Event erstellen (mit Bild-Upload)
- ✅ Events auflisten
- ✅ Events löschen
- ✅ Reihenfolge ändern (Drag & Drop)
- ✅ Events veröffentlichen/verstecken (isPublished)
### Gallery-Verwaltung: (NEU!)
- ✅ Bild hochladen
- ✅ Gallery auflisten
- ✅ Bilder löschen
- ✅ Reihenfolge ändern (Drag & Drop)
- ✅ Bilder veröffentlichen/verstecken (isPublished)
### Publish:
- Git-Integration (für statische Seiten-Updates)
- Commit & Push zu Repository
## 🚀 Deployment-Prozess
### Was passiert beim Deploy?
1. **Woodpecker CI** triggered bei Push zu `main`
2. **Frontend Deploy** (gallus-pub):
- Build Astro SSG
- Deploy zu Fly.io
3. **Backend Deploy** (gallus-cms-backend):
- Docker Build:
```dockerfile
# Backend-Code kompilieren
npm run build → dist/
# Migrierte Bilder einpacken
COPY backend/data/images → /app/migration-images
# Migration-Script kopieren
COPY migrate-production.js → /app/
```
- Deploy zu Fly.io
- **Volume bleibt erhalten** (SQLite DB + hochgeladene Bilder)
4. **Nach erstem Deploy**: Migration ausführen
```bash
fly ssh console -a gallus-cms-backend
node migrate-production.js
```
- Kopiert Bilder: `/app/migration-images/` → `/app/data/images/`
- Befüllt DB mit Event/Gallery-Einträgen
## 🔍 Troubleshooting
### "Bilder werden nicht angezeigt"
**Prüfe:**
1. Backend logs: `fly logs -a gallus-cms-backend`
2. Bild existiert: `fly ssh console -a gallus-cms-backend`
```bash
ls -la /app/data/images/gallery/
```
3. DB-Eintrag korrekt:
```bash
sqlite3 /app/data/gallus_cms.db
SELECT * FROM gallery_images;
```
4. Static-Route funktioniert:
```bash
curl https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
```
### "SQLite locked" Fehler
- Nur ein Writer zur Zeit erlaubt
- Bei hohem Traffic: Wechsel zu PostgreSQL empfohlen
- Für Gallus Pub: ausreichend (wenig Writes)
### "Volume voll"
```bash
fly volumes list -a gallus-cms-backend
fly volumes extend <volume-id> -s <new-size>
```
## 📁 Wichtige Dateien
| Datei | Zweck |
|-------|-------|
| `backend/src/routes/gallery.ts` | Gallery-Upload & CRUD |
| `backend/src/routes/events.ts` | Events CRUD + Public API |
| `backend/src/index.ts` | Static File Serving |
| `backend/migrate-production.js` | Initiale Daten-Migration |
| `src/pages/admin.astro` | Admin-Interface |
| `src/pages/index.astro` | Frontend (fetched von API) |
| `backend/fly.toml` | Backend Fly.io Config |
| `fly.toml` | Frontend Fly.io Config |
## 🎯 Nächste Schritte
1. ✅ Gallery-Verwaltung implementiert
2. ⏳ Migration ausführen (nach Deploy)
3. ⏳ Testen: Bild hochladen → Frontend anzeigen
4. 📋 Optional: Image-Editing Features
5. 📋 Optional: Bulk-Upload für Gallery

20
backend/.dockerignore Normal file
View File

@ -0,0 +1,20 @@
node_modules
dist
.env
.env.local
.env.*.local
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.git
.gitignore
.vscode
.idea
.DS_Store
*.md
!README.md
tmp
/tmp
coverage
.nyc_output

29
backend/.env.example Normal file
View File

@ -0,0 +1,29 @@
# Database (SQLite)
DATABASE_PATH=./data/gallus_cms.db
# Gitea OAuth
GITEA_URL=https://git.bookageek.ch
GITEA_CLIENT_ID=your-oauth-client-id-here
GITEA_CLIENT_SECRET=your-oauth-client-secret-here
GITEA_REDIRECT_URI=http://localhost:3000/api/auth/callback
GITEA_ALLOWED_USERS=sabrina,raphael,admin
# Git Configuration (use Gitea repository)
GIT_REPO_URL=https://git.bookageek.ch/yourusername/Gallus_Pub.git
GIT_TOKEN=your-gitea-personal-access-token-here
GIT_USER_NAME=Gallus CMS
GIT_USER_EMAIL=cms@galluspub.ch
GIT_WORKSPACE_DIR=./data/workspace
# JWT & Session
JWT_SECRET=your-super-secret-jwt-key-change-this
SESSION_SECRET=your-session-secret-change-this
# Server
PORT=3000
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
FRONTEND_URL=http://localhost:5173
# Upload
MAX_FILE_SIZE=5242880

34
backend/.env.local Normal file
View File

@ -0,0 +1,34 @@
# 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=Admin@gallus-pub.ch
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

12
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist
.env
*.log
.DS_Store
/tmp
/data/*.db
/data/*.db-wal
/data/*.db-shm
/data/workspace
# Allow images to be committed
!/data/images

195
backend/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,195 @@
# Deployment Guide
## Prerequisite
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
2. Fly.io account: `flyctl auth login`
3. Gitea OAuth app configured at git.bookageek.ch
4. Gitea Personal Access Token for git operations
## Initial Setup
### 1. Create Fly.io App
```bash
cd backend
flyctl apps create gallus-cms-backend
```
### 2. Create Volume for Data (SQLite DB + Git Workspace)
```bash
flyctl volumes create gallus_data --size 2 --region ams
```
This volume will store:
- SQLite database at `/app/data/gallus_cms.db`
- Git workspace at `/app/data/workspace`
### 3. Set Secrets
```bash
flyctl secrets set \
GITEA_CLIENT_ID="<your-gitea-oauth-client-id>" \
GITEA_CLIENT_SECRET="<your-gitea-oauth-client-secret>" \
GIT_TOKEN="<your-gitea-personal-access-token>" \
JWT_SECRET="$(openssl rand -base64 32)" \
SESSION_SECRET="$(openssl rand -base64 32)" \
GIT_REPO_URL="https://git.bookageek.ch/yourusername/Gallus_Pub.git" \
GIT_USER_NAME="Gallus CMS" \
GIT_USER_EMAIL="cms@galluspub.ch" \
GITEA_REDIRECT_URI="https://gallus-cms-backend.fly.dev/api/auth/callback" \
FRONTEND_URL="https://cms.galluspub.ch" \
CORS_ORIGIN="https://cms.galluspub.ch" \
GITEA_ALLOWED_USERS="sabrina,raphael"
```
### 4. Deploy
```bash
flyctl deploy
```
### 5. Initialize Database
After first deployment, SSH into the container and run migrations:
```bash
flyctl ssh console
cd /app
node dist/index.js # Start once to create the database file
# Then exit (Ctrl+C) and run migrations
npm run db:migrate
exit
```
Or simply let the app run - the database will be created automatically on first start.
## Gitea OAuth Configuration
Update your Gitea OAuth application redirect URI to include:
```
https://gallus-cms-backend.fly.dev/api/auth/callback
```
## Useful Commands
### View Logs
```bash
flyctl logs
```
### Check Status
```bash
flyctl status
```
### SSH into Container
```bash
flyctl ssh console
```
### Scale App
```bash
flyctl scale count 2
```
### View Secrets
```bash
flyctl secrets list
```
### Update a Secret
```bash
flyctl secrets set KEY=VALUE
```
### Restart App
```bash
flyctl apps restart
```
## Monitoring
### Health Check
```bash
curl https://gallus-cms-backend.fly.dev/health
```
### View Metrics
```bash
flyctl dashboard
```
## Troubleshooting
### Deployment Fails
- Check logs: `flyctl logs`
- Verify all secrets are set: `flyctl secrets list`
- Ensure Docker builds locally: `docker build -t test .`
### OAuth Not Working
- Verify GITEA_REDIRECT_URI matches Gitea settings exactly
- Check CORS_ORIGIN includes frontend domain
- Review logs for authentication errors
### Git Push Fails
- Verify GIT_TOKEN has correct permissions
- Check GIT_REPO_URL is accessible
- Ensure workspace volume is mounted
### Database Issues
- Verify DATABASE_PATH is set correctly
- Check volume is mounted: `flyctl ssh console` then `ls -la /app/data`
- Verify database file permissions
- Run migrations if needed: `flyctl ssh console` then `npm run db:migrate`
## Cost Optimization
Current configuration uses:
- `shared-cpu-1x` with 512MB RAM
- Auto-suspend when idle
- 2GB volume for SQLite database + git workspace
Estimated cost: ~$5-10/month (no separate database cost with SQLite!)
## Updating
To deploy updates:
```bash
git pull
flyctl deploy
```
## Rollback
To rollback to previous version:
```bash
flyctl releases list
flyctl releases rollback <version-number>
```
## Environment Variables
All sensitive environment variables should be set as Fly.io secrets (not in fly.toml):
Note: DATABASE_PATH and GIT_WORKSPACE_DIR are set in fly.toml as they're not sensitive.
- `GITEA_CLIENT_ID` - OAuth client ID
- `GITEA_CLIENT_SECRET` - OAuth client secret
- `GIT_TOKEN` - Gitea personal access token
- `JWT_SECRET` - JWT signing secret
- `SESSION_SECRET` - Session cookie secret
- `GIT_REPO_URL` - Full git repository URL
- `GITEA_REDIRECT_URI` - OAuth callback URL
- `FRONTEND_URL` - Frontend application URL
- `CORS_ORIGIN` - Allowed CORS origin
- `GITEA_ALLOWED_USERS` - Comma-separated list of allowed usernames
## Security Checklist
- [ ] All secrets set and not exposed in logs
- [ ] HTTPS enforced (fly.toml: force_https = true)
- [ ] CORS configured correctly
- [ ] GITEA_ALLOWED_USERS whitelist configured
- [ ] Database backups enabled
- [ ] Health checks configured
- [ ] Monitoring and alerts set up

59
backend/Dockerfile Normal file
View File

@ -0,0 +1,59 @@
# Multi-stage build for Gallus CMS Backend
# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Install build dependencies for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# Install dependencies
COPY package*.json ./
# Use npm ci when lockfile exists, fallback to npm install for local/dev
RUN npm ci || npm install
# Copy source
COPY . .
# Build TypeScript
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
RUN apk add --no-cache git sqlite
# Copy production dependencies from builder (already compiled native modules)
COPY --from=builder /app/node_modules ./node_modules
# 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
USER node
# Expose port
EXPOSE 8080
# Set environment
ENV NODE_ENV=production
ENV PORT=8080
ENV DATABASE_PATH=/app/data/gallus_cms.db
# Health check
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)})"
# Run DB migrations if present, then start application
CMD ["/bin/sh", "-lc", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]

55
backend/README.md Normal file
View File

@ -0,0 +1,55 @@
# Gallus Pub CMS Backend
Headless CMS backend for managing Gallus Pub website content with Gitea OAuth authentication.
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create `.env` file from `.env.example`:
```bash
cp .env.example .env
```
3. Update environment variables in `.env`:
- Set Gitea OAuth credentials
- Set Git repository URL and token
- JWT secrets are already generated
4. Create data directory and run migrations:
```bash
mkdir -p data
```
5. Generate and run migrations:
```bash
npm run db:generate
npm run db:migrate
```
6. Start development server:
```bash
npm run dev
```
Server will run at http://localhost:3000
## Available Scripts
- `npm run dev` - Start development server with watch mode
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run db:generate` - Generate database migrations
- `npm run db:migrate` - Run database migrations
- `npm run db:studio` - Open Drizzle Studio
## Documentation
See parent directory for complete documentation:
- `CMS_CONCEPT.md` - System architecture
- `CMS_GITEA_AUTH.md` - Authentication details
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
- `CMS_SETUP_GUIDE.md` - Deployment guide

View File

@ -0,0 +1,216 @@
# Quick Start Guide - SQLite Version
## ✅ Migration Complete: PostgreSQL → SQLite
The backend now uses **SQLite** instead of PostgreSQL for simplified deployment and lower costs.
## 🚀 Quick Start (3 Steps)
### 1. Configure Environment
Edit `.env` file (already created):
```bash
# Required: Update these values
GITEA_CLIENT_ID=<your-gitea-oauth-client-id>
GITEA_CLIENT_SECRET=<your-gitea-oauth-client-secret>
GIT_REPO_URL=https://git.bookageek.ch/<yourusername>/Gallus_Pub.git
GIT_TOKEN=<your-gitea-personal-access-token>
GITEA_ALLOWED_USERS=sabrina,raphael
# Already set (JWT secrets generated)
JWT_SECRET=dOrvUqifjBLvk68kkDOvWPQper/gjsNMlAbWlVBQIrc=
SESSION_SECRET=SD0ZrvLkv9GrtI8+3GDkxZXA1UnCN4CE3c4+2vA/fIM=
# Database (SQLite - no changes needed)
DATABASE_PATH=./data/gallus_cms.db
```
### 2. Initialize Database
```bash
# Generate migration files from schema
pnpm run db:generate
# Run migrations to create tables
pnpm run db:migrate
```
### 3. Start Development Server
```bash
pnpm run dev
```
Server will start at **http://localhost:3000**
## 📝 What Changed?
### Before (PostgreSQL)
- Required PostgreSQL installation
- Separate database service
- Connection string configuration
- ~$15/month hosting cost on Fly.io
### After (SQLite)
- Single file database (`./data/gallus_cms.db`)
- No separate database service needed
- Works out of the box
- **$0 database cost** (included in app volume)
## 🗂️ Database Location
- **Local:** `./data/gallus_cms.db`
- **Production (Fly.io):** `/app/data/gallus_cms.db` (on persistent volume)
- **Git Workspace:** Same `data/` directory
## 🧪 Test Authentication Flow
1. Make sure you have Gitea OAuth credentials configured
2. Start dev server: `pnpm run dev`
3. Visit: http://localhost:3000/api/auth/gitea
4. Login with your Gitea credentials
5. Should redirect back with JWT token
## 📚 Available Endpoints
### Health Check
```bash
curl http://localhost:3000/health
```
### OAuth Flow
```
GET /api/auth/gitea - Initiate OAuth
GET /api/auth/callback - OAuth callback
GET /api/auth/me - Get current user (requires JWT)
```
### Content Management (all require JWT)
```
GET/POST/PUT/DELETE /api/events
GET/POST/PUT/DELETE /api/gallery
GET/PUT /api/content/:section
GET/PUT /api/settings/:key
POST /api/publish
```
## 🔐 Getting Gitea OAuth Credentials
1. Go to https://git.bookageek.ch/user/settings/applications
2. Click "Manage OAuth2 Applications"
3. Create new OAuth2 application:
- **Name:** Gallus Pub CMS
- **Redirect URI:** `http://localhost:3000/api/auth/callback`
- **Confidential:** Yes
4. Copy Client ID and Client Secret to `.env`
## 🎫 Getting Gitea Personal Access Token
1. Go to https://git.bookageek.ch/user/settings/applications
2. Generate New Token
3. **Name:** Gallus CMS Backend
4. **Scopes:** Select `repo` (full repository access)
5. Copy token to `.env` as `GIT_TOKEN`
## 📦 Project Structure
```
backend/
├── data/ # SQLite database & git workspace (gitignored)
│ ├── gallus_cms.db # Database file
│ └── workspace/ # Git repository clone
├── src/
│ ├── config/
│ │ ├── database.ts # SQLite connection (updated)
│ │ └── env.ts # DATABASE_PATH instead of URL
│ ├── db/
│ │ └── schema.ts # SQLite schema (updated)
│ ├── routes/ # API routes
│ ├── services/ # Core services
│ └── index.ts # Main server
├── .env # Your configuration
├── package.json # Updated with better-sqlite3
└── drizzle.config.ts # SQLite dialect
```
## ⚙️ Scripts
```bash
pnpm install # Install dependencies (done)
pnpm run dev # Start dev server with watch
pnpm run build # Build TypeScript
pnpm run start # Start production server
pnpm run db:generate # Generate migrations
pnpm run db:migrate # Run migrations
pnpm run db:studio # Open Drizzle Studio
```
## 🚀 Deploy to Fly.io
See `DEPLOYMENT.md` for full deployment guide.
**Quick version:**
```bash
# Create volume for database & git workspace
flyctl volumes create gallus_data --size 2 --region ams
# Set secrets
flyctl secrets set GITEA_CLIENT_ID=... GITEA_CLIENT_SECRET=... # etc
# Deploy
flyctl deploy
```
**Cost:** ~$5-10/month (no separate database!)
## 🐛 Troubleshooting
### "tsx: command not found"
```bash
pnpm install
```
### "DATABASE_PATH not set"
Check `.env` file exists and has `DATABASE_PATH=./data/gallus_cms.db`
### "Database file not found"
```bash
mkdir -p data
pnpm run db:migrate
```
### "better-sqlite3" build errors
Make sure you have build tools:
- **Linux:** `apt-get install python3 make g++`
- **macOS:** Install Xcode Command Line Tools
- **Windows:** Install windows-build-tools
Then rebuild:
```bash
pnpm rebuild better-sqlite3
```
## ✨ Benefits of SQLite
1. **Simpler** - No database server to manage
2. **Faster** - No network overhead
3. **Portable** - Single file, easy backups
4. **Cost-effective** - No hosting fees
5. **Perfect fit** - Low concurrency, simple queries
## 📖 Documentation
- `SQLITE_MIGRATION.md` - Detailed migration notes
- `DEPLOYMENT.md` - Fly.io deployment guide
- `README.md` - General setup instructions
- `CMS_GITEA_AUTH.md` - OAuth authentication details (parent dir)
- `CMS_CONCEPT.md` - Full system architecture (parent dir)
## ✅ Ready to Go!
Your backend is now configured for SQLite. Just:
1. Add your Gitea credentials to `.env`
2. Run `pnpm run db:generate && pnpm run db:migrate`
3. Start with `pnpm run dev`
Happy coding! 🎉

217
backend/SQLITE_MIGRATION.md Normal file
View File

@ -0,0 +1,217 @@
# SQLite Migration Summary
## Changes Made
The backend has been migrated from PostgreSQL to SQLite for both local development and production (Fly.io).
### Benefits of SQLite
1. **Simplified Deployment** - No separate database service needed
2. **Lower Cost** - Save ~$15/month (no Postgres hosting)
3. **Easier Development** - No need to install/run PostgreSQL locally
4. **Single File Database** - Easy backups and migrations
5. **Perfect for this use case** - Low concurrent writes, simple queries
## Modified Files
### Dependencies
- **package.json**
- Removed: `pg`, `@types/pg`
- Added: `better-sqlite3`, `@types/better-sqlite3`
### Database Configuration
- **src/config/database.ts**
- Changed from `drizzle-orm/node-postgres` to `drizzle-orm/better-sqlite3`
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
- Enabled WAL mode for better concurrent access
- **src/config/env.ts**
- Changed `DATABASE_URL` to `DATABASE_PATH`
- Default: `./data/gallus_cms.db`
- **src/db/schema.ts**
- Changed from `pgTable` to `sqliteTable`
- Changed `uuid()` to `text()` with `crypto.randomUUID()`
- Changed `jsonb()` to `text(..., { mode: 'json' })`
- Changed `timestamp()` to `integer(..., { mode: 'timestamp' })`
- Changed `boolean()` to `integer(..., { mode: 'boolean' })`
- Uses `sql\`(unixepoch())\`` for default timestamps
- **drizzle.config.ts**
- Changed dialect from `postgresql` to `sqlite`
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
### Environment Files
- **.env** and **.env.example**
- Changed `DATABASE_URL=postgresql://...` to `DATABASE_PATH=./data/gallus_cms.db`
- Changed `GIT_WORKSPACE_DIR=/tmp/gallus-repo` to `./data/workspace`
### Docker Configuration
- **Dockerfile**
- Added build tools for `better-sqlite3` native module (python3, make, g++)
- Added `sqlite` CLI tool
- Creates `/app/data` directory for database
- Sets `DATABASE_PATH=/app/data/gallus_cms.db`
- Proper permissions for non-root user
- **fly.toml**
- Added `DATABASE_PATH` and `GIT_WORKSPACE_DIR` to [env]
- Changed volume mount from `gallus_repo_workspace` to `gallus_data`
- Mount destination: `/app/data` (contains both DB and git workspace)
### Documentation
- **README.md** - Updated setup instructions
- **DEPLOYMENT.md** - Removed Postgres setup, updated volume creation
- **SQLITE_MIGRATION.md** - This file!
## Local Development
### Setup
```bash
# Dependencies already installed
pnpm install
# Create data directory (done)
mkdir -p data
# Database will be created automatically at ./data/gallus_cms.db
```
### Generate and Run Migrations
```bash
# Generate migration files from schema
pnpm run db:generate
# Run migrations to create tables
pnpm run db:migrate
```
### Start Development Server
```bash
pnpm run dev
```
The database file will be created at `./data/gallus_cms.db` on first run.
## Production (Fly.io)
### Volume Setup
```bash
# Create single volume for both database and git workspace
flyctl volumes create gallus_data --size 2 --region ams
```
### Environment Variables
Set in fly.toml (non-sensitive):
- `DATABASE_PATH=/app/data/gallus_cms.db`
- `GIT_WORKSPACE_DIR=/app/data/workspace`
Set as secrets (sensitive):
- All other env vars (OAuth credentials, tokens, etc.)
### Deployment
```bash
flyctl deploy
```
Database will be created automatically on first start. No need for separate database service!
## Database Location
### Local Development
- **Database:** `./data/gallus_cms.db`
- **WAL files:** `./data/gallus_cms.db-wal`, `./data/gallus_cms.db-shm`
- **Git workspace:** `./data/workspace/`
### Production (Fly.io)
- **Database:** `/app/data/gallus_cms.db` (on volume)
- **Git workspace:** `/app/data/workspace/` (on volume)
- **Volume name:** `gallus_data` (2GB)
## Backup Strategy
### Manual Backup
```bash
# Local
cp data/gallus_cms.db data/gallus_cms.backup.db
# Production (Fly.io)
flyctl ssh console
sqlite3 /app/data/gallus_cms.db ".backup /app/data/backup.db"
# Then copy back: flyctl ssh sftp get /app/data/backup.db
```
### Automated Backup (Optional)
Consider setting up a cron job or Fly.io machine to periodically:
1. Create SQLite backup
2. Upload to S3/Backblaze/etc.
## Performance Notes
SQLite is perfect for this use case because:
- **Low write concurrency** - Single admin user making changes
- **Read-heavy** - Mostly reading content for publish operations
- **Small dataset** - Events, gallery images, content sections
- **Simple queries** - No complex joins or aggregations
WAL mode is enabled for:
- Better concurrent read access
- Safer writes (crash recovery)
- Improved performance
## Migration from Existing Data
If you had PostgreSQL data to migrate:
1. Export from Postgres:
```sql
\copy events TO 'events.csv' CSV HEADER;
\copy gallery_images TO 'gallery.csv' CSV HEADER;
-- etc.
```
2. Import to SQLite:
```sql
.mode csv
.import events.csv events
.import gallery.csv gallery_images
-- etc.
```
## Known Limitations
1. **No native UUID type** - Using TEXT with UUID format
2. **No native JSON type** - Using TEXT with JSON serialization (Drizzle handles this)
3. **No native TIMESTAMP** - Using INTEGER with Unix epoch (Drizzle handles this)
4. **Single writer** - Only one write transaction at a time (not an issue for this use case)
## Troubleshooting
### "Database is locked" error
- WAL mode should prevent this
- Check if multiple processes are accessing the database
- Ensure proper file permissions
### Native module build errors
- Make sure build tools are installed: `apt-get install python3 make g++` (Linux)
- On Alpine: `apk add python3 make g++`
- Try rebuilding: `pnpm rebuild better-sqlite3`
### Database file not found
- Check `DATABASE_PATH` is set correctly
- Ensure `data/` directory exists
- Check file permissions
## Next Steps
1. ✅ Update dependencies
2. ✅ Update database configuration
3. ✅ Update schema
4. ✅ Update Docker configuration
5. ⏳ Generate migrations: `pnpm run db:generate`
6. ⏳ Run migrations: `pnpm run db:migrate`
7. ⏳ Test development server: `pnpm run dev`
8. ⏳ Test publish flow
9. ⏳ Deploy to Fly.io
The migration is complete! Just need to generate/run migrations and test.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

10
backend/drizzle.config.ts Normal file
View File

@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_PATH || './data/gallus_cms.db',
},
} satisfies Config;

41
backend/fly.toml Normal file
View File

@ -0,0 +1,41 @@
# Fly.io configuration for Gallus CMS Backend
app = "gallus-cms-backend"
primary_region = "ams"
[build]
# Ensure Fly uses the Dockerfile in this backend directory
dockerfile = "Dockerfile"
[env]
PORT = "8080"
NODE_ENV = "production"
GITEA_URL = "https://git.bookageek.ch"
DATABASE_PATH = "/app/data/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
# Cross-site frontend and OAuth
FRONTEND_URL = "https://gallus-pub.ch"
CORS_ORIGIN = "https://gallus-pub.ch"
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "suspend"
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[http_service.checks]]
grace_period = "10s"
interval = "30s"
method = "GET"
timeout = "5s"
path = "/health"
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
[mounts]
source = "gallus_data"
destination = "/app/data"

37
backend/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "gallus-cms-backend",
"version": "1.0.0",
"type": "module",
"description": "Headless CMS backend for Gallus Pub website",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.0",
"@fastify/static": "^6.12.0",
"@fastify/multipart": "^8.1.0",
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.33.0",
"fastify": "^4.26.0",
"sharp": "^0.33.2",
"simple-git": "^3.22.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/better-sqlite3": "^7.6.9",
"@types/node": "^20.11.16",
"drizzle-kit": "^0.24.0",
"tsx": "^4.20.6",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,104 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from '../db/schema.js';
import { env } from './env.js';
import fs from 'fs';
import path from 'path';
if (!env.DATABASE_PATH) {
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);
// Enable WAL mode for better concurrent access
sqlite.pragma('journal_mode = WAL');
export const db = drizzle(sqlite, { schema });
// Auto-create tables if they don't exist
export function initDatabase() {
console.log('🔧 Initializing database...');
try {
// Check if users table exists (acts as a sentinel for initial setup)
const tableCheck = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get();
if (!tableCheck) {
console.log('📝 Creating database schema...');
sqlite.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
gitea_id TEXT UNIQUE NOT NULL,
gitea_username TEXT NOT NULL,
gitea_email TEXT,
display_name TEXT,
avatar_url TEXT,
role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (unixepoch()),
last_login INTEGER
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS gallery_images (
id TEXT PRIMARY KEY,
image_url TEXT NOT NULL,
alt_text TEXT NOT NULL,
display_order INTEGER NOT NULL,
is_published INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS content_sections (
id TEXT PRIMARY KEY,
section_name TEXT UNIQUE NOT NULL,
content_json TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS publish_history (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
commit_hash TEXT,
commit_message TEXT,
published_at INTEGER DEFAULT (unixepoch())
);
`);
console.log('✅ Database schema created successfully!');
} else {
console.log('✅ Database already initialized.');
}
} catch (error) {
console.error('❌ Error initializing database:', error);
throw error;
}
}

51
backend/src/config/env.ts Normal file
View File

@ -0,0 +1,51 @@
// Environment configuration with validation
export const env = {
// Database
DATABASE_PATH: process.env.DATABASE_PATH || './data/gallus_cms.db',
// Gitea OAuth
GITEA_URL: process.env.GITEA_URL || 'https://git.bookageek.ch',
GITEA_CLIENT_ID: process.env.GITEA_CLIENT_ID || '',
GITEA_CLIENT_SECRET: process.env.GITEA_CLIENT_SECRET || '',
GITEA_REDIRECT_URI: process.env.GITEA_REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
GITEA_ALLOWED_USERS: process.env.GITEA_ALLOWED_USERS || '',
// Git Configuration
GIT_REPO_URL: process.env.GIT_REPO_URL || '',
GIT_TOKEN: process.env.GIT_TOKEN || '',
GIT_USER_NAME: process.env.GIT_USER_NAME || 'Gallus CMS',
GIT_USER_EMAIL: process.env.GIT_USER_EMAIL || 'cms@galluspub.ch',
GIT_WORKSPACE_DIR: process.env.GIT_WORKSPACE_DIR || '/tmp/gallus-repo',
// JWT & Session
JWT_SECRET: process.env.JWT_SECRET || '',
SESSION_SECRET: process.env.SESSION_SECRET || '',
// Server
PORT: parseInt(process.env.PORT || '3000', 10),
NODE_ENV: process.env.NODE_ENV || 'development',
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
// Upload
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
};
// Validate required environment variables
export function validateEnv() {
const required = [
'DATABASE_PATH',
'GITEA_CLIENT_ID',
'GITEA_CLIENT_SECRET',
'GIT_REPO_URL',
'GIT_TOKEN',
'JWT_SECRET',
'SESSION_SECRET',
];
const missing = required.filter(key => !env[key as keyof typeof env]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
}

62
backend/src/db/schema.ts Normal file
View File

@ -0,0 +1,62 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
// Users table - stores Gitea user info for audit and access control
export const users = sqliteTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
giteaId: text('gitea_id').notNull().unique(),
giteaUsername: text('gitea_username').notNull(),
giteaEmail: text('gitea_email'),
displayName: text('display_name'),
avatarUrl: text('avatar_url'),
role: text('role').default('admin'),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
lastLogin: integer('last_login', { mode: 'timestamp' }),
});
// Events table
export 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())`),
});
// Gallery images table
export 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())`),
});
// Content sections table (for text-based sections)
export const contentSections = sqliteTable('content_sections', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
sectionName: text('section_name').notNull().unique(),
contentJson: text('content_json', { mode: 'json' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Site settings table (global config)
export const siteSettings = sqliteTable('site_settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Publish history (audit log)
export const publishHistory = sqliteTable('publish_history', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').references(() => users.id),
commitHash: text('commit_hash'),
commitMessage: text('commit_message'),
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});

125
backend/src/index.ts Normal file
View File

@ -0,0 +1,125 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import multipart from '@fastify/multipart';
import cookie from '@fastify/cookie';
import { authenticate } from './middleware/auth.middleware.js';
import { env, validateEnv } from './config/env.js';
import { db, initDatabase } from './config/database.js';
import fastifyStatic from '@fastify/static';
import path from 'path';
// Import routes
import authRoute from './routes/auth.js';
import eventsRoute from './routes/events.js';
import galleryRoute from './routes/gallery.js';
import contentRoute from './routes/content.js';
import settingsRoute from './routes/settings.js';
import publishRoute from './routes/publish.js';
// Validate environment variables
try {
validateEnv();
} catch (error) {
console.error('Environment validation failed:', error);
process.exit(1);
}
const fastify = Fastify({
logger: {
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
transport: env.NODE_ENV === 'development' ? {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
} : undefined,
},
});
// Register plugins
fastify.register(cors, {
origin: env.CORS_ORIGIN,
credentials: true,
});
fastify.register(cookie);
fastify.register(jwt, {
secret: env.JWT_SECRET,
cookie: {
cookieName: 'token',
signed: false,
},
});
fastify.register(multipart, {
limits: {
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');
fastify.register(fastifyStatic, {
root: dataDir,
prefix: '/static/',
decorateReply: false
});
// Decorate fastify with authenticate method
fastify.decorate('authenticate', authenticate);
// Register routes
fastify.register(authRoute, { prefix: '/api' });
fastify.register(eventsRoute, { prefix: '/api' });
fastify.register(galleryRoute, { prefix: '/api' });
fastify.register(contentRoute, { prefix: '/api' });
fastify.register(settingsRoute, { prefix: '/api' });
fastify.register(publishRoute, { prefix: '/api' });
// Health check
fastify.get('/health', async () => {
return {
status: 'ok',
timestamp: new Date().toISOString(),
environment: env.NODE_ENV,
};
});
// Root endpoint
fastify.get('/', async () => {
return {
name: 'Gallus Pub CMS Backend',
version: '1.0.0',
status: 'running',
};
});
// Error handler
fastify.setErrorHandler((error, request, reply) => {
fastify.log.error(error);
reply.status(error.statusCode || 500).send({
error: error.message || 'Internal Server Error',
statusCode: error.statusCode || 500,
});
});
// Start server
const start = async () => {
try {
// Initialize database before starting server
initDatabase();
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
console.log(`🚀 Server listening on port ${env.PORT}`);
console.log(`📝 Environment: ${env.NODE_ENV}`);
console.log(`🔐 CORS Origin: ${env.CORS_ORIGIN}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

View File

@ -0,0 +1,12 @@
import { FastifyRequest, FastifyReply } from 'fastify';
export async function authenticate(
request: FastifyRequest,
reply: FastifyReply
) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}

188
backend/src/routes/auth.ts Normal file
View File

@ -0,0 +1,188 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { GiteaService } from '../services/gitea.service.js';
import { env } from '../config/env.js';
// Use explicit JSON schema for Fastify route validation to avoid provider issues
const callbackQueryJsonSchema = {
type: 'object',
required: ['code', 'state'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
},
} as const;
const authRoute: FastifyPluginAsync = async (fastify) => {
const giteaService = new GiteaService();
/**
* GET /auth/gitea
* Initiate OAuth flow
*/
fastify.get('/auth/gitea', async (request, reply) => {
// Generate CSRF state token
const state = giteaService.generateState();
// Store state in a short-lived cookie
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
sameSite: 'none',
secure: true,
maxAge: 10 * 60, // 10 minutes
});
// Generate authorization URL
const authUrl = giteaService.getAuthorizationUrl(state);
// Redirect to Gitea
return reply.redirect(authUrl);
});
/**
* GET /auth/callback
* OAuth callback endpoint
*/
fastify.get('/auth/callback', {
schema: {
querystring: callbackQueryJsonSchema,
},
}, async (request, reply) => {
try {
const { code, state } = request.query as { code: string; state: string };
// Verify CSRF state from cookie
const expectedState = request.cookies?.oauth_state as string | undefined;
if (!expectedState || state !== expectedState) {
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state cookie
reply.clearCookie('oauth_state', { path: '/' });
// Exchange code for access token
const tokenResponse = await giteaService.exchangeCodeForToken(code);
// Fetch user info from Gitea
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
// Check if user is allowed
if (!giteaService.isUserAllowed(giteaUser.login)) {
return reply.code(403).send({
error: 'Access denied. You are not authorized to access this CMS.'
});
}
// Find or create user in database
let [user] = await db
.select()
.from(users)
.where(eq(users.giteaId, giteaUser.id.toString()))
.limit(1);
if (!user) {
// Create new user
[user] = await db.insert(users).values({
giteaId: giteaUser.id.toString(),
giteaUsername: giteaUser.login,
giteaEmail: giteaUser.email,
displayName: giteaUser.full_name,
avatarUrl: giteaUser.avatar_url,
lastLogin: new Date(),
}).returning();
} else {
// Update existing user
[user] = await db
.update(users)
.set({
giteaUsername: giteaUser.login,
giteaEmail: giteaUser.email,
displayName: giteaUser.full_name,
avatarUrl: giteaUser.avatar_url,
lastLogin: new Date(),
})
.where(eq(users.id, user.id))
.returning();
}
// Generate JWT for session management
const token = fastify.jwt.sign(
{
id: user.id,
giteaId: user.giteaId,
username: user.giteaUsername || '',
role: user.role ?? 'admin',
},
{ expiresIn: '24h' }
);
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
reply.setCookie('token', token, {
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;
return reply.redirect(`${frontendUrl}/admin`);
} catch (error) {
fastify.log.error({ err: error }, 'OAuth callback error');
return reply.code(500).send({ error: 'Authentication failed' });
}
});
/**
* GET /auth/me
* Get current user info
*/
fastify.get('/auth/me', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const userId = request.user.id;
const [user] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return {
user: {
id: user.id,
giteaUsername: user.giteaUsername,
giteaEmail: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
},
};
});
/**
* POST /auth/logout
* Logout (client-side token deletion)
*/
fastify.post('/auth/logout', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
// For JWT, logout is primarily client-side (delete token)
// You could maintain a token blacklist in Redis for production
reply.clearCookie('token', { path: '/' });
return { message: 'Logged out successfully' };
});
};
export default authRoute;

View File

@ -0,0 +1,104 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
type: 'object',
required: ['contentJson'],
properties: {
contentJson: {}, // allow any JSON
},
} as const;
const contentRoute: FastifyPluginAsync = async (fastify) => {
// Get content section
fastify.get('/content/:section', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const [content] = await db
.select()
.from(contentSections)
.where(eq(contentSections.sectionName, section))
.limit(1);
if (!content) {
return reply.code(404).send({ error: 'Content section not found' });
}
return {
section: content.sectionName,
content: content.contentJson,
updatedAt: content.updatedAt,
};
});
// Update content section
fastify.put('/content/:section', {
schema: {
body: contentBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { section } = request.params as { section: string };
const { contentJson } = request.body as any;
// Check if section exists
const [existing] = await db
.select()
.from(contentSections)
.where(eq(contentSections.sectionName, section))
.limit(1);
let result;
if (existing) {
// Update existing
[result] = await db
.update(contentSections)
.set({
contentJson,
updatedAt: new Date(),
})
.where(eq(contentSections.sectionName, section))
.returning();
} else {
// Create new
[result] = await db
.insert(contentSections)
.values({
sectionName: section,
contentJson,
})
.returning();
}
return {
section: result.sectionName,
content: result.contentJson,
updatedAt: result.updatedAt,
};
});
// List all content sections
fastify.get('/content', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const sections = await db.select().from(contentSections);
return {
sections: (sections as any[]).map((s: any) => ({
section: s.sectionName,
content: s.contentJson,
updatedAt: s.updatedAt,
})),
};
});
};
export default contentRoute;

View File

@ -0,0 +1,97 @@
import { FastifyPluginAsync } from 'fastify';
import { db } from '../config/database.js';
import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// 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) => {
// PUBLIC: List published events (no auth required)
fastify.get('/events/public', async () => {
const all = await db.select().from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
return { events: all };
});
// List all events (by displayOrder) - admin only
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
const all = await db.select().from(events).orderBy(events.displayOrder);
return { events: all };
});
// Get single event
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const rows = 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] };
});
// Create event
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const data = request.body as any;
const [row] = await db.insert(events).values(data).returning();
return reply.code(201).send({ event: row });
});
// Update event
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as any;
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' });
return { event: row };
});
// Delete event
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
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' });
return { message: 'Event deleted successfully' };
});
// Reorder events (synchronous transaction for better-sqlite3)
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
}
});
return { message: 'Events reordered successfully' };
});
};
export default eventsRoute;

View File

@ -0,0 +1,221 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// 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) => {
// PUBLIC: List published gallery images (no auth required)
fastify.get('/gallery/public', async () => {
const images = await db.select().from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
return { images };
});
// List all gallery images - admin only
fastify.get('/gallery', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
return { images };
});
// Get single gallery image
fastify.get('/gallery/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
if (image.length === 0) {
return reply.code(404).send({ error: 'Image not found' });
}
return { image: image[0] };
});
// Create gallery image
fastify.post('/gallery', {
schema: {
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const data = request.body as any;
const [newImage] = await db.insert(galleryImages).values(data).returning();
return reply.code(201).send({ image: newImage });
});
// Upload image file (multipart)
fastify.post('/gallery/upload', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
// Expect a single file field named "file"
const file = await (request as any).file();
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
const altText = (file.fields?.altText?.value as string | undefined) || '';
const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0';
const displayOrder = Number.parseInt(displayOrderRaw) || 0;
const mime = file.mimetype as string | undefined;
if (!mime || !mime.startsWith('image/')) {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
// Prepare directories - use persistent volume for Fly.io
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const uploadDir = path.join(dataDir, 'images', 'gallery');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
// Read uploaded stream into buffer
const chunks: Buffer[] = [];
for await (const chunk of file.file) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const inputBuffer = Buffer.concat(chunks);
// Generate filename
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
const baseName = `${stamp}-${rand}`;
// Try to convert to webp and limit size; fallback to original
let outBuffer: Buffer | null = null;
let outExt = '.webp';
try {
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
} catch {
outBuffer = inputBuffer;
// naive extension from mimetype
const extFromMime = mime.split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
const filename = baseName + outExt;
const destPath = path.join(uploadDir, filename);
fs.writeFileSync(destPath, outBuffer);
// Public URL (served via /static)
const publicUrl = `/static/images/gallery/${filename}`;
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({
imageUrl: publicUrl,
altText: altText || filename,
displayOrder,
isPublished: true,
}).returning();
return reply.code(201).send({ image: row });
} catch (err) {
fastify.log.error({ err }, 'Upload failed');
return reply.code(500).send({ error: 'Failed to upload image' });
}
});
// Update gallery image
fastify.put('/gallery/:id', {
schema: {
body: galleryBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const data = request.body as any;
const [updated] = await db
.update(galleryImages)
.set(data)
.where(eq(galleryImages.id, id))
.returning();
if (!updated) {
return reply.code(404).send({ error: 'Image not found' });
}
return { image: updated };
});
// Delete gallery image
fastify.delete('/gallery/:id', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params as { id: string };
const [deleted] = await db
.delete(galleryImages)
.where(eq(galleryImages.id, id))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Image not found' });
}
return { message: 'Image deleted successfully' };
});
// Reorder gallery images
fastify.put('/gallery/reorder', {
schema: {
body: {
type: 'object',
required: ['orders'],
properties: {
orders: {
type: 'array',
items: {
type: 'object',
required: ['id', 'displayOrder'],
properties: {
id: { type: 'string' },
displayOrder: { type: 'integer', minimum: 0 },
},
},
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
// Update all in synchronous transaction (better-sqlite3 requirement)
db.transaction((tx: any) => {
for (const { id, displayOrder } of orders) {
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
}
});
return { message: 'Gallery images reordered successfully' };
});
};
export default galleryRoute;

View File

@ -0,0 +1,127 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { GitService } from '../services/git.service.js';
import { FileGeneratorService } from '../services/file-generator.service.js';
import { db } from '../config/database.js';
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for publish body
const publishBodyJsonSchema = {
type: 'object',
required: ['commitMessage'],
properties: {
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
},
} as const;
const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.post('/publish', {
schema: {
body: publishBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const { commitMessage } = request.body as any;
const userId = request.user.id;
fastify.log.info('Starting publish process...');
// Initialize git service
const gitService = new GitService();
await gitService.initialize();
fastify.log.info('Git repository initialized');
// Fetch all content from database
const eventsData = await db
.select()
.from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
const galleryData = await db
.select()
.from(galleryImages)
.where(eq(galleryImages.isPublished, true))
.orderBy(galleryImages.displayOrder);
const sectionsData = await db.select().from(contentSections);
const sectionsMap = new Map<string, any>(
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
);
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
// Generate and write files
const fileGenerator = new FileGeneratorService();
await fileGenerator.writeFiles(
gitService.getWorkspacePath(''),
(eventsData as any[]).map((e: any) => ({
title: e.title,
date: e.date,
description: e.description,
imageUrl: e.imageUrl,
})),
(galleryData as any[]).map((g: any) => ({
imageUrl: g.imageUrl,
altText: g.altText,
})),
sectionsMap
);
fastify.log.info('Files generated successfully');
// Commit and push
const commitHash = await gitService.commitAndPush(commitMessage);
fastify.log.info(`Changes committed: ${commitHash}`);
// Record in history
await db.insert(publishHistory).values({
userId,
commitHash,
commitMessage,
});
return {
success: true,
commitHash,
message: 'Changes published successfully',
};
} catch (error) {
fastify.log.error({ err: error }, 'Publish error');
// Attempt to reset git state on error
try {
const gitService = new GitService();
await gitService.reset();
} catch (resetError) {
fastify.log.error({ err: resetError }, 'Failed to reset git state');
}
return reply.code(500).send({
success: false,
error: 'Failed to publish changes',
details: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// Get publish history
fastify.get('/publish/history', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const history = await db
.select()
.from(publishHistory)
.orderBy(publishHistory.publishedAt)
.limit(20);
return { history };
});
};
export default publishRoute;

View File

@ -0,0 +1,121 @@
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { siteSettings } from '../db/schema.js';
import { eq } from 'drizzle-orm';
// Fastify JSON schema for settings body
const settingBodyJsonSchema = {
type: 'object',
required: ['value'],
properties: {
value: { type: 'string' },
},
} as const;
const settingsRoute: FastifyPluginAsync = async (fastify) => {
// Get all settings
fastify.get('/settings', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const settings = await db.select().from(siteSettings);
return {
settings: settings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string>),
};
});
// Get single setting
fastify.get('/settings/:key', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const [setting] = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.key, key))
.limit(1);
if (!setting) {
return reply.code(404).send({ error: 'Setting not found' });
}
return {
key: setting.key,
value: setting.value,
updatedAt: setting.updatedAt,
};
});
// Update setting
fastify.put('/settings/:key', {
schema: {
body: settingBodyJsonSchema,
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const { value } = request.body as any;
// Check if setting exists
const [existing] = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.key, key))
.limit(1);
let result;
if (existing) {
// Update existing
[result] = await db
.update(siteSettings)
.set({
value,
updatedAt: new Date(),
})
.where(eq(siteSettings.key, key))
.returning();
} else {
// Create new
[result] = await db
.insert(siteSettings)
.values({
key,
value,
})
.returning();
}
return {
key: result.key,
value: result.value,
updatedAt: result.updatedAt,
};
});
// Delete setting
fastify.delete('/settings/:key', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { key } = request.params as { key: string };
const [deleted] = await db
.delete(siteSettings)
.where(eq(siteSettings.key, key))
.returning();
if (!deleted) {
return reply.code(404).send({ error: 'Setting not found' });
}
return { message: 'Setting deleted successfully' };
});
};
export default settingsRoute;

View File

@ -0,0 +1,190 @@
import { db } from '../config/database.js';
import { events, galleryImages } from '../db/schema.js';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
// Old events data
const oldEvents = [
{
image: "/images/events/event_karaoke.jpg",
title: "Karaoke",
date: "2025-12-31", // Set as ongoing event
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 0,
},
{
image: "/images/events/event_pub-quiz.jpg",
title: "Pub Quiz",
date: "2025-12-31", // Set as ongoing event
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
Auch Einzelpersonen sind herzlich willkommen! <br>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
displayOrder: 1,
},
{
image: "/images/events/event_schlager-karaoke.jpeg",
title: "Schlager Hüttenzauber Karaoke",
date: "2025-11-27",
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
displayOrder: 2,
},
{
image: "/images/events/event_advents-kalender.jpeg",
title: "Adventskalender",
date: "2025-12-20",
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
displayOrder: 3,
},
{
image: "/images/events/event_santa_karaoke.jpeg",
title: "Santa Karaoke-Party",
date: "2025-12-06",
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
displayOrder: 4,
},
{
image: "/images/events/event_ferien.jpeg",
title: "Weihnachtsferien",
date: "2025-12-21",
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
displayOrder: 5,
},
{
image: "/images/events/event_neujahrs-apero.jpeg",
title: "Neujahrs-Apero",
date: "2026-01-02",
description: `18:00-20:00 Uhr`,
displayOrder: 6,
},
];
// Old gallery images
const oldGalleryImages = [
{ src: "/images/gallery/Gallery7.png", alt: "Gallery 7" },
{ src: "/images/gallery/Gallery8.png", alt: "Gallery 8" },
{ src: "/images/gallery/Gallery9.png", alt: "Gallery 9" },
{ src: "/images/gallery/Gallery6.png", alt: "Gallery 6" },
{ src: "/images/gallery/Gallery1.png", alt: "Gallery 1" },
{ src: "/images/gallery/Gallery2.png", alt: "Gallery 2" },
{ src: "/images/gallery/Gallery3.png", alt: "Gallery 3" },
{ src: "/images/gallery/Gallery4.png", alt: "Gallery 4" },
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
];
async function copyAndConvertImage(
sourcePath: string,
destDir: string,
filename: string
): Promise<string> {
const projectRoot = path.join(process.cwd(), '..');
const fullSourcePath = path.join(projectRoot, 'public', sourcePath);
// Ensure destination directory exists
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const ext = path.extname(filename);
const baseName = path.basename(filename, ext);
const webpFilename = `${baseName}.webp`;
const destPath = path.join(destDir, webpFilename);
console.log(`Processing: ${fullSourcePath} -> ${destPath}`);
// Check if source exists
if (!fs.existsSync(fullSourcePath)) {
console.error(`Source file not found: ${fullSourcePath}`);
throw new Error(`Source file not found: ${fullSourcePath}`);
}
// Convert to webp and copy
await sharp(fullSourcePath)
.rotate() // Auto-rotate based on EXIF
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(destPath);
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
}
async function migrateEvents() {
console.log('\n=== Migrating Events ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const eventsImageDir = path.join(dataDir, 'images', 'events');
for (const event of oldEvents) {
try {
const filename = path.basename(event.image);
const newImageUrl = await copyAndConvertImage(
event.image,
eventsImageDir,
filename
);
const [newEvent] = await db.insert(events).values({
title: event.title,
date: event.date,
description: event.description,
imageUrl: newImageUrl,
displayOrder: event.displayOrder,
isPublished: true,
}).returning();
console.log(`✓ Migrated event: ${newEvent.title}`);
} catch (error) {
console.error(`✗ Failed to migrate event "${event.title}":`, error);
}
}
}
async function migrateGallery() {
console.log('\n=== Migrating Gallery Images ===\n');
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
const galleryImageDir = path.join(dataDir, 'images', 'gallery');
for (let i = 0; i < oldGalleryImages.length; i++) {
const img = oldGalleryImages[i];
try {
const filename = path.basename(img.src);
const newImageUrl = await copyAndConvertImage(
img.src,
galleryImageDir,
filename
);
const [newImage] = await db.insert(galleryImages).values({
imageUrl: newImageUrl,
altText: img.alt,
displayOrder: i,
isPublished: true,
}).returning();
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
} catch (error) {
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error);
}
}
}
async function main() {
console.log('Starting migration of old data...\n');
console.log('Working directory:', process.cwd());
console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'));
try {
await migrateEvents();
await migrateGallery();
console.log('\n✓ Migration completed successfully!');
} catch (error) {
console.error('\n✗ Migration failed:', error);
process.exit(1);
}
}
main();

View File

@ -0,0 +1,239 @@
import { writeFile } from 'fs/promises';
import path from 'path';
interface Event {
title: string;
date: string;
description: string;
imageUrl: string;
}
interface GalleryImage {
imageUrl: string;
altText: string;
}
interface ContentSection {
[key: string]: any;
}
export class FileGeneratorService {
escapeQuotes(str: string): string {
return str.replace(/"/g, '\\"');
}
escapeBackticks(str: string): string {
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
}
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
const eventsCode = events.map(e => `\t{
\t\timage: "${e.imageUrl}",
\t\ttitle: "${this.escapeQuotes(e.title)}",
\t\tdate: "${e.date}",
\t\tdescription: \`
\t\t\t${this.escapeBackticks(e.description)}
\t\t\`,
\t}`).join(',\n');
const imagesCode = images.map(g =>
`\t{ src: "${g.imageUrl}", alt: "${this.escapeQuotes(g.altText)}" }`
).join(',\n');
return `---
import Layout from "../components/Layout.astro";
import Hero from "../components/Hero.astro";
import Welcome from "../components/Welcome.astro";
import EventsGrid from "../components/EventsGrid.astro";
import Drinks from "../components/Drinks.astro";
import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
const events = [
${eventsCode}
];
const images = [
${imagesCode}
];
---
<Layout>
\t<Hero id="hero" />
\t<Welcome id="welcome" />
\t<EventsGrid id="events" events={events} />
\t<ImageCarousel id="gallery" images={images} />
\t<Drinks id="drinks" />
</Layout>
`;
}
generateHeroComponent(content: ContentSection): string {
return `---
// src/components/Hero.astro
import "../styles/components/Hero.css"
const { id } = Astro.props;
---
<section id={id} class="hero container">
\t<div class="hero-overlay">
\t\t<div class="hero-content">
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
\t\t</div>
\t</div>
</section>
<style>
</style>
`;
}
generateWelcomeComponent(content: ContentSection): string {
const highlightsList = (content.highlights || []).map((h: any) =>
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
).join('\n\n');
return `---
// src/components/Welcome.astro
import "../styles/components/Welcome.css"
const { id } = Astro.props;
---
<section id={id} class="welcome container">
\t<div class="welcome-text">
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
\t\t<p>
\t\t\t${content.introText || ''}
\t\t</p>
\t\t<p><b>Unsere Highlights:</b></p>
\t\t<ul>
${highlightsList}
\t\t</ul>
\t\t<p>
\t\t\t${content.closingText || ''}
\t\t</p>
\t</div>
\t<div class="welcome-image">
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
\t</div>
</section>
`;
}
generateDrinksComponent(content: ContentSection): string {
return `---
import "../styles/components/Drinks.css"
const { id } = Astro.props;
---
<section id={id} class="Drinks">
<h2 class="title">Drinks</h2>
<p class="note">
${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>
<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>
<div class="mate-vodka">
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
</div>
<p class="note">
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
</p>
<div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
</section>
`;
}
async writeFiles(
workspaceDir: string,
events: Event[],
images: GalleryImage[],
sections: Map<string, ContentSection>
) {
// Write index.astro
const indexContent = this.generateIndexAstro(events, images);
await writeFile(
path.join(workspaceDir, 'src/pages/index.astro'),
indexContent,
'utf-8'
);
// Write Hero component
if (sections.has('hero')) {
const heroContent = this.generateHeroComponent(sections.get('hero')!);
await writeFile(
path.join(workspaceDir, 'src/components/Hero.astro'),
heroContent,
'utf-8'
);
}
// Write Welcome component
if (sections.has('welcome')) {
const welcomeContent = this.generateWelcomeComponent(sections.get('welcome')!);
await writeFile(
path.join(workspaceDir, 'src/components/Welcome.astro'),
welcomeContent,
'utf-8'
);
}
// Write Drinks component
if (sections.has('drinks')) {
const drinksContent = this.generateDrinksComponent(sections.get('drinks')!);
await writeFile(
path.join(workspaceDir, 'src/components/Drinks.astro'),
drinksContent,
'utf-8'
);
}
}
}

View File

@ -0,0 +1,65 @@
import simpleGit, { SimpleGit } from 'simple-git';
import { mkdir, rm } from 'fs/promises';
import path from 'path';
import { env } from '../config/env.js';
export class GitService {
private git: SimpleGit;
private workspaceDir: string;
private repoUrl: string;
private token: string;
constructor() {
this.workspaceDir = env.GIT_WORKSPACE_DIR;
this.repoUrl = env.GIT_REPO_URL;
this.token = env.GIT_TOKEN;
this.git = simpleGit();
}
async initialize() {
// Ensure workspace directory exists
await mkdir(this.workspaceDir, { recursive: true });
// Add token to repo URL for authentication
const authenticatedUrl = this.repoUrl.replace(
'https://',
`https://oauth2:${this.token}@`
);
try {
// Check if repo already exists
await this.git.cwd(this.workspaceDir);
await this.git.status();
console.log('Repository already exists, pulling latest...');
await this.git.pull();
} catch {
// Clone if doesn't exist
console.log('Cloning repository...');
await rm(this.workspaceDir, { recursive: true, force: true });
await this.git.clone(authenticatedUrl, this.workspaceDir);
await this.git.cwd(this.workspaceDir);
}
// Configure git user
await this.git.addConfig('user.name', env.GIT_USER_NAME);
await this.git.addConfig('user.email', env.GIT_USER_EMAIL);
}
async commitAndPush(message: string): Promise<string> {
await this.git.add('.');
await this.git.commit(message);
await this.git.push('origin', 'main');
const log = await this.git.log({ maxCount: 1 });
return log.latest?.hash || '';
}
getWorkspacePath(relativePath: string): string {
return path.join(this.workspaceDir, relativePath);
}
async reset() {
await this.git.reset(['--hard', 'HEAD']);
await this.git.clean('f', ['-d']);
}
}

View File

@ -0,0 +1,112 @@
import crypto from 'crypto';
import { env } from '../config/env.js';
interface GiteaUser {
id: number;
login: string;
email: string;
full_name: string;
avatar_url: string;
}
interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
}
export class GiteaService {
private giteaUrl: string;
private clientId: string;
private clientSecret: string;
private redirectUri: string;
private allowedUsers: Set<string>;
constructor() {
this.giteaUrl = env.GITEA_URL;
this.clientId = env.GITEA_CLIENT_ID;
this.clientSecret = env.GITEA_CLIENT_SECRET;
this.redirectUri = env.GITEA_REDIRECT_URI;
const allowed = env.GITEA_ALLOWED_USERS;
this.allowedUsers = new Set(allowed.split(',').map(u => u.trim()).filter(Boolean));
}
/**
* Generate OAuth authorization URL
*/
getAuthorizationUrl(state: string): string {
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
state,
scope: 'read:user',
});
return `${this.giteaUrl}/login/oauth/authorize?${params.toString()}`;
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(code: string): Promise<OAuthTokenResponse> {
const response = await fetch(`${this.giteaUrl}/login/oauth/access_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
code,
redirect_uri: this.redirectUri,
grant_type: 'authorization_code',
}),
});
if (!response.ok) {
throw new Error(`Failed to exchange code: ${response.statusText}`);
}
return await response.json();
}
/**
* Fetch user info from Gitea using access token
*/
async getUserInfo(accessToken: string): Promise<GiteaUser> {
const response = await fetch(`${this.giteaUrl}/api/v1/user`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
return await response.json();
}
/**
* Check if user is allowed to access the CMS
*/
isUserAllowed(username: string): boolean {
// If no allowed users specified, allow all
if (this.allowedUsers.size === 0) {
return true;
}
return this.allowedUsers.has(username);
}
/**
* Generate random state for CSRF protection
*/
generateState(): string {
return crypto.randomBytes(32).toString('hex');
}
}

View File

@ -0,0 +1,87 @@
import sharp from 'sharp';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { env } from '../config/env.js';
export class MediaService {
private allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
private maxFileSize: number;
constructor() {
this.maxFileSize = env.MAX_FILE_SIZE;
}
/**
* Validate file type and size
*/
async validateFile(file: any): Promise<void> {
if (!this.allowedMimeTypes.includes(file.mimetype)) {
throw new Error(`Invalid file type. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
}
// Check file size
const buffer = await file.toBuffer();
if (buffer.length > this.maxFileSize) {
throw new Error(`File too large. Maximum size: ${this.maxFileSize / 1024 / 1024}MB`);
}
}
/**
* Generate safe filename
*/
generateFilename(originalName: string): string {
const ext = path.extname(originalName);
const hash = crypto.randomBytes(8).toString('hex');
const timestamp = Date.now();
return `${timestamp}-${hash}${ext}`;
}
/**
* Optimize and save image
*/
async processAndSaveImage(
file: any,
destinationDir: string
): Promise<{ filename: string; url: string }> {
await this.validateFile(file);
// Ensure destination directory exists
await mkdir(destinationDir, { recursive: true });
// Generate filename
const filename = this.generateFilename(file.filename);
const filepath = path.join(destinationDir, filename);
// Get file buffer
const buffer = await file.toBuffer();
// Process image with sharp (optimize and resize if needed)
await sharp(buffer)
.resize(2000, 2000, {
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({ quality: 85 })
.png({ quality: 85 })
.webp({ quality: 85 })
.toFile(filepath);
// Return filename and URL path
return {
filename,
url: `/images/${filename}`,
};
}
/**
* Save image to git workspace
*/
async saveToGitWorkspace(
file: any,
workspaceDir: string
): Promise<{ filename: string; url: string }> {
const imagesDir = path.join(workspaceDir, 'public', 'images');
return this.processAndSaveImage(file, imagesDir);
}
}

View File

@ -0,0 +1,25 @@
import { FastifyRequest } from 'fastify';
export interface JWTPayload {
id: string;
giteaId: string;
username: string;
role: string;
}
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
user: JWTPayload;
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: JWTPayload;
user: JWTPayload;
}
}

19
backend/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

38
docker-compose.yml Normal file
View File

@ -0,0 +1,38 @@
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:

View File

@ -9,6 +9,9 @@ kill_timeout = 5
[env]
PORT = "3000"
NODE_ENV = "production"
BACKEND_PORT = "8080"
DATABASE_PATH = "/app/data/db/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace"
[http_service]
internal_port = 3000
@ -39,4 +42,8 @@ kill_timeout = 5
[[vm]]
memory = "512MB"
cpu_kind = "shared"
cpus = 1
cpus = 1
[[mounts]]
source = "gallus_data"
destination = "/app/data"

3
package-lock.json generated
View File

@ -3988,6 +3988,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -4625,6 +4626,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -4839,6 +4841,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -1,5 +1,5 @@
{
"name": "",
"name": "Gallus Pub Site",
"type": "module",
"version": "0.0.1",
"scripts": {

3114
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- sharp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
---
import Layout from "../components/Layout.astro";
import "../../styles/components/ContactForm.css";
import "../styles/components/ContactForm.css";
---
<Layout>

View File

@ -1,35 +1,44 @@
---
import "../../styles/components/Drinks.css"
import "../styles/components/Drinks.css"
const { id } = Astro.props;
---
<section id={id} class="Drinks">
<h2 class="title">Drinks</h2>
<a href="/pdf/Menu.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<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. Natürlich dürfen auch Cocktails nicht fehlen. Vieles kreieren wir auch selber - Sláinte!
</p>
<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>
<div class="mate-vodka">
<div class="circle" title="Mate Vodka">
<span class="circle-label">Mate Vodka</span>
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>Mate Vodka</div>
</div>
<p class="note">
Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.
</p>
<div class="circle-row">
<div class="circle" title="Bier">
<span class="circle-label">Bier</span>
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle" title="Wein">
<span class="circle-label">Wein</span>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle" title="Cocktails">
<span class="circle-label">Cocktails</span>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
<p class="note">
Wir bieten eine Auswahl an erlesenen Getränken für jeden Geschmack. Besuche uns und entdecke unsere saisonalen Spezialitäten und Klassiker.
</p>
</section>

View File

@ -13,7 +13,7 @@ const { events = [], id }: { events?: Event[]; id?: string } = Astro.props as {
events?: Event[];
id?: string;
};
import "../../styles/components/EventsGrid.css";
import "../styles/components/EventsGrid.css";
---
<h2 class="section-title">Events</h2>

View File

@ -1,14 +1,12 @@
---
// src/components/Footer.astro
import "/styles/components/Footer.css"
import "../styles/components/Footer.css"
const currentYear = new Date().getFullYear();
---
<footer class="footer">
<footer class="footer" id="footer">
<div class="footer-content">
<div class="copyright">
&copy; {currentYear} Gallus Pub. Alle Rechte vorbehalten.
</div>
<div class="footer-sections">
<div class="footer-section">
@ -24,8 +22,8 @@ const currentYear = new Date().getFullYear();
<p>Gallus Pub</p>
<p>Metzgergasse 13</p>
<p>9000 St. Gallen</p>
<p>Reservierungen via Whatsapp</p>
<p><a href="tel:0772322770">077 232 27 70</a></p>
<p><a href="mailto:info@gallus-pub.ch">info@gallus-pub.ch</a></p>
</div>
<div class="footer-section">
@ -33,12 +31,12 @@ const currentYear = new Date().getFullYear();
<p>Du planst einen Event?</p>
<p>Der "St.Gallerruum" im 2.OG</p>
<p>kann gemietet werden.</p>
<br/>
<p>Gerne öffnen wir auf Anfrage</p>
<p>auch ausserhalb unserer</p>
<p>Betriebszeiten.</p>
<p>Reservierungen via Whatsapp</p>
</div>
</div>
<div class="copyright">
&copy; {currentYear} Gallus Pub. Alle Rechte vorbehalten.
</div>
</div>
</footer>

View File

@ -1,28 +1,81 @@
---
// src/components/Header.astro
const { url } = Astro;
import "../../styles/components/Header.css";
import "../styles/components/Header.css";
---
<header class="header">
<div class="logo-container">
<a href="/">
<img src="/images/Logo.png" alt="Logo" class="logo" />
</a>
<!-- Desktop Layout -->
<div class="desktop-layout">
<div class="logo-container">
<a href="/">
<img src="/images/Logo.png" alt="Logo" class="logo" />
</a>
</div>
<nav class="nav-main">
<div class="desktop-menu">
<a href="/#hero">Home</a>
<a href="/#events">Events</a>
<a href="/#gallery">Galerie</a>
<a href="/#drinks">Drinks</a>
<a href="/#footer">Contact</a>
<!--<a href="/#about">About</a>
<a href="/#contact">Contact</a>-->
</div>
</nav>
</div>
<!-- Hauptnavigation: immer Home, About, Contact -->
<nav class="nav-main">
<div>
<!-- Mobile Layout -->
<div class="mobile-layout">
<!-- Centered Logo -->
<div class="mobile-logo-container">
<a href="/">
<img src="/images/Logo.png" alt="Logo" class="logo" />
</a>
</div>
<!-- Burger Menu Below Logo -->
<div class="burger-menu">
<div class="burger-icon">
<span></span>
<span></span>
<span></span>
</div>
</div>
<!-- Mobile Navigation Menu (Dropdown) -->
<div class="mobile-menu">
<a href="/#hero">Home</a>
<a href="/#events">Events</a>
<a href="/#gallery">Galerie</a>
<a href="/#drinks">Drinks</a>
<a href="/#openings">Openings</a>
<!--<a href="/#about">About</a>
<a href="/#contact">Contact</a>-->
<a href="/#footer">Contact</a>
</div>
</nav>
</div>
</header>
<div class="header-spacer"></div>
<script>
// Toggle mobile menu when burger icon is clicked
document.addEventListener('DOMContentLoaded', () => {
const burgerIcon = document.querySelector('.burger-icon');
const mobileMenu = document.querySelector('.mobile-menu');
const mobileMenuLinks = document.querySelectorAll('.mobile-menu a');
// Toggle menu when burger icon is clicked
burgerIcon.addEventListener('click', () => {
burgerIcon.classList.toggle('active');
mobileMenu.classList.toggle('active');
});
// Close menu when a navigation link is clicked
mobileMenuLinks.forEach(link => {
link.addEventListener('click', () => {
burgerIcon.classList.remove('active');
mobileMenu.classList.remove('active');
});
});
});
</script>

View File

@ -1,6 +1,6 @@
---
// src/components/Hero.astro
import "../../styles/components/Hero.css"
import "../styles/components/Hero.css"
const { id } = Astro.props;
---
@ -15,7 +15,7 @@ const { id } = Astro.props;
<p>Im Herzen von St.Gallen</p>
<a href="#" class="button">Aktuelles ↓</a>
<a href="#welcome" class="button">Aktuelles ↓</a>
</div>
</div>

View File

@ -1,7 +1,6 @@
---
// src/components/HoverCard.astro
import "../../styles/components/HoverCard.css";
const { title, description, image = "", date } = Astro.props;
import "../styles/components/HoverCard.css";
const {title, description, image = "", date} = Astro.props;
---
<article class="hover-card">
@ -9,10 +8,41 @@ const { title, description, image = "", date } = Astro.props;
<img class="card-image" src={image} alt={title} />
</div>
<h3 class="card-title" set:html={title} />
<h4 class="card_date">{date}</h4>
<div class="hover-text">
<p set:html={description} />
<div>
<p set:html={description} />
</div>
</div>
</article>
<script>
document.addEventListener('DOMContentLoaded', () => {
const hoverCards = document.querySelectorAll('.hover-card');
hoverCards.forEach(card => {
card.addEventListener('click', (e) => {
// Only toggle on mobile devices
if (window.innerWidth <= 768) {
e.preventDefault();
// Close all other active cards first
hoverCards.forEach(otherCard => {
if (otherCard !== card) {
otherCard.classList.remove('active');
}
});
// Toggle current card
card.classList.toggle('active');
}
});
// Close card when clicking outside (mobile only)
document.addEventListener('click', (e) => {
if (window.innerWidth <= 768 && !card.contains(e.target as Node)) {
card.classList.remove('active');
}
});
});
});
</script>

View File

@ -1,6 +1,6 @@
---
// src/components/ImageCarousel.astro
import "../../styles/components/ImageCarousel.css";
import "../styles/components/ImageCarousel.css";
interface Image {
src: string;

View File

@ -2,9 +2,9 @@
// src/components/Layout.astro
import Header from "./Header.astro";
import Footer from "./Footer.astro";
import "../../styles/components/Layout.css"
import "../../styles/variables.css"
import "../../styles/index.css"
import "../styles/components/Layout.css"
import "../styles/variables.css"
import "../styles/index.css"
---
<!doctype html>

View File

@ -1,6 +1,6 @@
---
// src/components/Welcome.astro
import "../../styles/components/Welcome.css"
import "../styles/components/Welcome.css"
const { id } = Astro.props;
---
@ -9,7 +9,8 @@ const { id } = Astro.props;
<div class="welcome-text">
<h2>Herzlich willkommen im Gallus Pub!</h2>
<h2>Herzlich willkommen im</h2>
<h2>Gallus Pub!</h2>
<p>
Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung
@ -51,7 +52,7 @@ const { id } = Astro.props;
<div class="welcome-image">
<img src="/images/Welcome.png" alt="Welcome backgrount image" />
<img src="/images/Welcome.png" alt="Welcome background image" />
</div>
</section>

443
src/pages/admin.astro Normal file
View File

@ -0,0 +1,443 @@
---
const title = 'Admin';
---
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<style>
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; }
img.thumb { max-width: 100%; height: auto; display: block; }
.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>
<label>Alt-Text<input id="ev-alt" placeholder="Bildbeschreibung" /></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>Liste</h3>
<div class="toolbar">
<button id="btn-toggle-gal-reorder">Reihenfolge bearbeiten</button>
<button id="btn-save-gal-order" style="display:none">Reihenfolge speichern</button>
<span id="gal-order-msg" class="muted"></span>
</div>
<div id="gallery-list" class="grid"></div>
</div>
</div>
</section>
<section id="sec-publish" style="display:none">
<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();
};
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-publish').style.display = '';
// Direkt Events laden und auf Sektion fokussieren
await loadEvents();
await loadGallery();
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-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 ==========
async function uploadImage(file, altText) {
const fd = new FormData();
fd.append('file', file);
if (altText) fd.append('altText', altText);
fd.append('displayOrder', '0');
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>${ev.title}</strong></div>
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
</div>
<div class="muted">${ev.date}</div>
<div>${ev.description || ''}</div>
<div class="muted">Bild: ${ev.imageUrl || ''}</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 alt = (document.getElementById('ev-alt')).value.trim();
const msg = document.getElementById('ev-create-msg');
msg.textContent = 'Lade Bild hoch...';
try {
let imageUrl = '';
if (file) {
const up = await uploadImage(file, alt || title);
imageUrl = up?.image?.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 = '';
(document.getElementById('ev-alt')).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 }) });
s.textContent = res?.message || 'Veröffentlicht';
} 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 Management ==========
let galReorderMode = false;
let lastGallery = [];
async function loadGallery() {
const listEl = document.getElementById('gallery-list');
listEl.innerHTML = '<div class="muted">Lade...</div>';
try {
const data = await api('/api/gallery');
listEl.innerHTML = '';
lastGallery = (data.images || []).slice();
let renderList = lastGallery.slice();
if (galReorderMode) {
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
}
renderList.forEach((img, idx) => {
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('draggable', String(galReorderMode));
card.dataset.id = img.id;
card.dataset.displayOrder = String(img.displayOrder ?? idx);
card.innerHTML = `
<div class="row" style="justify-content:space-between;align-items:center">
<div><strong>${img.altText}</strong></div>
${galReorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
</div>
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" style="max-width:100%;height:auto;margin:0.5rem 0;" />
<div class="muted">URL: ${img.imageUrl}</div>
<div class="row-buttons">
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
</div>`;
listEl.appendChild(card);
});
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (!confirm('Bild wirklich löschen?')) return;
try {
await api(`/api/gallery/${id}`, { method: 'DELETE' });
await loadGallery();
} catch(e){ alert('Fehler: '+e.message); }
})
})
if (galReorderMode) {
enableGalleryDragAndDrop(listEl);
}
} catch (e) {
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
console.error(e);
}
}
function enableGalleryDragAndDrop(container){
let draggingEl = null;
container.querySelectorAll('.card').forEach(card => {
card.addEventListener('dragstart', (e) => {
draggingEl = card; card.classList.add('dragging');
e.dataTransfer.setData('text/plain', card.dataset.id || '');
});
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
card.addEventListener('dragover', (e) => { e.preventDefault(); });
card.addEventListener('drop', (e) => {
e.preventDefault();
const target = card;
if (!draggingEl || draggingEl === target) return;
const cards = Array.from(container.querySelectorAll('.card'));
const draggingIdx = cards.indexOf(draggingEl);
const targetIdx = cards.indexOf(target);
if (draggingIdx < targetIdx) {
target.after(draggingEl);
} else {
target.before(draggingEl);
}
});
});
}
document.getElementById('btn-create-gal').addEventListener('click', async () => {
const file = document.getElementById('gal-file').files[0];
const alt = document.getElementById('gal-alt').value.trim();
const msg = document.getElementById('gal-create-msg');
if (!file) {
msg.textContent = 'Bitte wähle ein Bild aus';
return;
}
msg.textContent = 'Lade Bild hoch...';
try {
const up = await uploadImage(file, alt || 'Gallery Image');
msg.textContent = 'Bild hochgeladen';
document.getElementById('gal-file').value = '';
document.getElementById('gal-alt').value = '';
await loadGallery();
} catch(e){
msg.textContent = 'Fehler: '+e.message
}
});
document.getElementById('btn-toggle-gal-reorder').addEventListener('click', async () => {
galReorderMode = !galReorderMode;
document.getElementById('btn-save-gal-order').style.display = galReorderMode ? '' : 'none';
await loadGallery();
});
document.getElementById('btn-save-gal-order').addEventListener('click', async () => {
const container = document.getElementById('gallery-list');
const cards = Array.from(container.querySelectorAll('.card'));
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
const msg = document.getElementById('gal-order-msg');
msg.textContent = 'Speichere Reihenfolge...';
try {
await api('/api/gallery/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
msg.textContent = 'Reihenfolge gespeichert';
galReorderMode = false;
document.getElementById('btn-save-gal-order').style.display = 'none';
await loadGallery();
} catch(e){ msg.textContent = 'Fehler: '+e.message }
});
refreshAuth();
</script>
</body>
</html>

View File

@ -0,0 +1,29 @@
---
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>

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