Compare commits
1 Commits
47743e9239
...
dev_2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eea814fad |
220
SYSTEM_ERKLAERUNG.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
# 🎯 Gallus Pub CMS - System-Erklärung
|
||||||
|
|
||||||
|
## 📐 Architektur-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ Backend CMS │ │ Fly.io Volume │
|
||||||
|
│ (Astro SSG) │◄────────┤ (Fastify API) │◄────────┤ /app/data/ │
|
||||||
|
│ gallus-pub.ch │ fetch │ cms.gallus-pub.ch│ mount │ - SQLite DB │
|
||||||
|
│ │ │ │ │ - images/ │
|
||||||
|
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Wie funktioniert der Upload-Flow?
|
||||||
|
|
||||||
|
### 1. **Admin lädt Bild hoch** (admin.astro)
|
||||||
|
```
|
||||||
|
User wählt Bild → uploadImage() → POST /api/gallery/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Backend verarbeitet Upload** (backend/src/routes/gallery.ts)
|
||||||
|
```javascript
|
||||||
|
// Line 60-134 in gallery.ts
|
||||||
|
1. Empfange Multipart-File
|
||||||
|
2. Validiere Mimetype (nur images/*)
|
||||||
|
3. Lese Stream in Buffer
|
||||||
|
4. sharp() konvertiert zu WebP:
|
||||||
|
- Auto-rotate (EXIF)
|
||||||
|
- Resize auf max 1600px
|
||||||
|
- WebP quality 82%
|
||||||
|
5. Speichere in /app/data/images/gallery/
|
||||||
|
6. Erstelle DB-Eintrag mit imageUrl
|
||||||
|
7. Return imageUrl an Frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Bilder werden serviert** (backend/src/index.ts)
|
||||||
|
```javascript
|
||||||
|
// Line 63-69
|
||||||
|
fastifyStatic → /static/ → /app/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
- Bild liegt in: `/app/data/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
- URL ist: `/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
- Wird serviert als: `https://cms.gallus-pub.ch/static/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
|
||||||
|
### 4. **Frontend zeigt Bilder** (src/pages/index.astro)
|
||||||
|
```javascript
|
||||||
|
// Line 30-43
|
||||||
|
1. Fetch von /api/gallery/public
|
||||||
|
2. Map imageUrl: `${API_BASE}${img.imageUrl}`
|
||||||
|
3. Result: https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💾 Warum funktioniert SQLite mit Fly.io?
|
||||||
|
|
||||||
|
**Fly.io Volumes** sind persistente Speicher:
|
||||||
|
- Gemountet als: `/app/data/`
|
||||||
|
- Konfiguration in: `backend/fly.toml` (Line 40-42)
|
||||||
|
- Bleibt bei Restarts/Deploys erhalten
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mounts]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Was liegt wo?
|
||||||
|
|
||||||
|
```
|
||||||
|
/app/data/
|
||||||
|
├── gallus_cms.db # SQLite Datenbank
|
||||||
|
├── gallus_cms.db-wal # Write-Ahead Log
|
||||||
|
├── gallus_cms.db-shm # Shared Memory
|
||||||
|
└── images/
|
||||||
|
├── events/
|
||||||
|
│ ├── event_karaoke.webp
|
||||||
|
│ └── ...
|
||||||
|
└── gallery/
|
||||||
|
├── Gallery1.webp
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Datenfluss im Detail
|
||||||
|
|
||||||
|
### Event erstellen:
|
||||||
|
```
|
||||||
|
1. Admin: Bild auswählen + Formular ausfüllen
|
||||||
|
2. uploadImage(file) → /api/gallery/upload
|
||||||
|
↓
|
||||||
|
3. Backend:
|
||||||
|
- Sharp konvertiert → WebP
|
||||||
|
- Speichert in /app/data/images/gallery/
|
||||||
|
- Returnt: { image: { imageUrl: "/images/gallery/xyz.webp" } }
|
||||||
|
↓
|
||||||
|
4. Admin: POST /api/events
|
||||||
|
Body: { title, date, description, imageUrl: "/images/gallery/xyz.webp" }
|
||||||
|
↓
|
||||||
|
5. Backend:
|
||||||
|
- INSERT INTO events (imageUrl = "/images/gallery/xyz.webp")
|
||||||
|
↓
|
||||||
|
6. Frontend: GET /api/events/public
|
||||||
|
- Fetcht Events aus DB
|
||||||
|
- Mapped imageUrl zu voller URL
|
||||||
|
- Zeigt an: <img src="https://cms.gallus-pub.ch/static/images/gallery/xyz.webp">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warum separate Uploads für Gallery?
|
||||||
|
|
||||||
|
Events nutzen den Gallery-Upload, weil:
|
||||||
|
- Beide brauchen WebP-Konvertierung
|
||||||
|
- Beide nutzen gleichen Storage
|
||||||
|
- Vermeidet Code-Duplikation
|
||||||
|
- Gallery kann eigenständige Bildergalerie haben
|
||||||
|
|
||||||
|
## 🎨 Admin-Panel Features
|
||||||
|
|
||||||
|
### Events-Verwaltung:
|
||||||
|
- ✅ Event erstellen (mit Bild-Upload)
|
||||||
|
- ✅ Events auflisten
|
||||||
|
- ✅ Events löschen
|
||||||
|
- ✅ Reihenfolge ändern (Drag & Drop)
|
||||||
|
- ✅ Events veröffentlichen/verstecken (isPublished)
|
||||||
|
|
||||||
|
### Gallery-Verwaltung: (NEU!)
|
||||||
|
- ✅ Bild hochladen
|
||||||
|
- ✅ Gallery auflisten
|
||||||
|
- ✅ Bilder löschen
|
||||||
|
- ✅ Reihenfolge ändern (Drag & Drop)
|
||||||
|
- ✅ Bilder veröffentlichen/verstecken (isPublished)
|
||||||
|
|
||||||
|
### Publish:
|
||||||
|
- Git-Integration (für statische Seiten-Updates)
|
||||||
|
- Commit & Push zu Repository
|
||||||
|
|
||||||
|
## 🚀 Deployment-Prozess
|
||||||
|
|
||||||
|
### Was passiert beim Deploy?
|
||||||
|
|
||||||
|
1. **Woodpecker CI** triggered bei Push zu `main`
|
||||||
|
2. **Frontend Deploy** (gallus-pub):
|
||||||
|
- Build Astro SSG
|
||||||
|
- Deploy zu Fly.io
|
||||||
|
|
||||||
|
3. **Backend Deploy** (gallus-cms-backend):
|
||||||
|
- Docker Build:
|
||||||
|
```dockerfile
|
||||||
|
# Backend-Code kompilieren
|
||||||
|
npm run build → dist/
|
||||||
|
|
||||||
|
# Migrierte Bilder einpacken
|
||||||
|
COPY backend/data/images → /app/migration-images
|
||||||
|
|
||||||
|
# Migration-Script kopieren
|
||||||
|
COPY migrate-production.js → /app/
|
||||||
|
```
|
||||||
|
- Deploy zu Fly.io
|
||||||
|
- **Volume bleibt erhalten** (SQLite DB + hochgeladene Bilder)
|
||||||
|
|
||||||
|
4. **Nach erstem Deploy**: Migration ausführen
|
||||||
|
```bash
|
||||||
|
fly ssh console -a gallus-cms-backend
|
||||||
|
node migrate-production.js
|
||||||
|
```
|
||||||
|
- Kopiert Bilder: `/app/migration-images/` → `/app/data/images/`
|
||||||
|
- Befüllt DB mit Event/Gallery-Einträgen
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### "Bilder werden nicht angezeigt"
|
||||||
|
|
||||||
|
**Prüfe:**
|
||||||
|
1. Backend logs: `fly logs -a gallus-cms-backend`
|
||||||
|
2. Bild existiert: `fly ssh console -a gallus-cms-backend`
|
||||||
|
```bash
|
||||||
|
ls -la /app/data/images/gallery/
|
||||||
|
```
|
||||||
|
3. DB-Eintrag korrekt:
|
||||||
|
```bash
|
||||||
|
sqlite3 /app/data/gallus_cms.db
|
||||||
|
SELECT * FROM gallery_images;
|
||||||
|
```
|
||||||
|
4. Static-Route funktioniert:
|
||||||
|
```bash
|
||||||
|
curl https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
### "SQLite locked" Fehler
|
||||||
|
|
||||||
|
- Nur ein Writer zur Zeit erlaubt
|
||||||
|
- Bei hohem Traffic: Wechsel zu PostgreSQL empfohlen
|
||||||
|
- Für Gallus Pub: ausreichend (wenig Writes)
|
||||||
|
|
||||||
|
### "Volume voll"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fly volumes list -a gallus-cms-backend
|
||||||
|
fly volumes extend <volume-id> -s <new-size>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Wichtige Dateien
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|-------|
|
||||||
|
| `backend/src/routes/gallery.ts` | Gallery-Upload & CRUD |
|
||||||
|
| `backend/src/routes/events.ts` | Events CRUD + Public API |
|
||||||
|
| `backend/src/index.ts` | Static File Serving |
|
||||||
|
| `backend/migrate-production.js` | Initiale Daten-Migration |
|
||||||
|
| `src/pages/admin.astro` | Admin-Interface |
|
||||||
|
| `src/pages/index.astro` | Frontend (fetched von API) |
|
||||||
|
| `backend/fly.toml` | Backend Fly.io Config |
|
||||||
|
| `fly.toml` | Frontend Fly.io Config |
|
||||||
|
|
||||||
|
## 🎯 Nächste Schritte
|
||||||
|
|
||||||
|
1. ✅ Gallery-Verwaltung implementiert
|
||||||
|
2. ⏳ Migration ausführen (nach Deploy)
|
||||||
|
3. ⏳ Testen: Bild hochladen → Frontend anzeigen
|
||||||
|
4. 📋 Optional: Image-Editing Features
|
||||||
|
5. 📋 Optional: Bulk-Upload für Gallery
|
||||||
@ -5,8 +5,8 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies for native modules (better-sqlite3, sharp)
|
# Install build dependencies for native modules (better-sqlite3)
|
||||||
RUN apk add --no-cache python3 make g++ vips-dev
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@ -24,28 +24,16 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool, vips for sharp)
|
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
||||||
# Note: python3, make, g++ are needed for native module compilation
|
RUN apk add --no-cache git sqlite
|
||||||
RUN apk add --no-cache git sqlite vips vips-dev python3 make g++
|
|
||||||
|
|
||||||
# Copy package files first
|
# Copy production dependencies from builder (already compiled native modules)
|
||||||
COPY --from=builder /app/package*.json ./
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Install all production dependencies and rebuild sharp for linuxmusl-x64
|
|
||||||
RUN npm ci --omit=dev || npm install --production && \
|
|
||||||
npm rebuild sharp
|
|
||||||
|
|
||||||
# Clean up build dependencies after installation to reduce image size
|
|
||||||
RUN apk del python3 make g++ vips-dev
|
|
||||||
|
|
||||||
# Copy built files from builder
|
# Copy built files from builder
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
|
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
|
||||||
|
|
||||||
# Copy migration script and migrated images
|
|
||||||
COPY --from=builder /app/migrate-production.js ./migrate-production.js
|
|
||||||
COPY --from=builder /app/data/images ./data/images
|
|
||||||
|
|
||||||
# Create directories
|
# Create directories
|
||||||
RUN mkdir -p /app/workspace /app/data
|
RUN mkdir -p /app/workspace /app/data
|
||||||
|
|
||||||
@ -68,4 +56,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
# Run DB migrations if present, then start application
|
# Run DB migrations if present, then start application
|
||||||
CMD ["/bin/sh", "-lc", "mkdir -p /app/data/images/events /app/data/images/gallery && [ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
CMD ["/bin/sh", "-lc", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
||||||
|
|||||||
@ -14,7 +14,7 @@ primary_region = "ams"
|
|||||||
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||||
# Cross-site frontend and OAuth
|
# Cross-site frontend and OAuth
|
||||||
FRONTEND_URL = "https://gallus-pub.ch"
|
FRONTEND_URL = "https://gallus-pub.ch"
|
||||||
CORS_ORIGIN = "https://gallus-pub.ch,https://www.gallus-pub.ch"
|
CORS_ORIGIN = "https://gallus-pub.ch"
|
||||||
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
|
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
// Production migration script - can be run directly with node
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
||||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
// Database schema
|
|
||||||
const events = sqliteTable('events', {
|
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
||||||
title: text('title').notNull(),
|
|
||||||
date: text('date').notNull(),
|
|
||||||
description: text('description').notNull(),
|
|
||||||
imageUrl: text('image_url').notNull(),
|
|
||||||
displayOrder: integer('display_order').notNull(),
|
|
||||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const galleryImages = sqliteTable('gallery_images', {
|
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
||||||
imageUrl: text('image_url').notNull(),
|
|
||||||
altText: text('alt_text').notNull(),
|
|
||||||
displayOrder: integer('display_order').notNull(),
|
|
||||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Old events data
|
|
||||||
const oldEvents = [
|
|
||||||
{
|
|
||||||
title: "Karaoke",
|
|
||||||
date: "2025-12-31",
|
|
||||||
description: `Bei uns gibt es Karaoke Mi-Sa!! <br>Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>`,
|
|
||||||
imageUrl: "/images/events/event_karaoke.webp",
|
|
||||||
displayOrder: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Pub Quiz",
|
|
||||||
date: "2025-12-31",
|
|
||||||
description: `Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>Auch Einzelpersonen sind herzlich willkommen! <br>*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF`,
|
|
||||||
displayOrder: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Schlager Hüttenzauber Karaoke",
|
|
||||||
date: "2025-11-27",
|
|
||||||
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
|
|
||||||
imageUrl: "/images/events/event_schlager-karaoke.webp",
|
|
||||||
displayOrder: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Adventskalender",
|
|
||||||
date: "2025-12-20",
|
|
||||||
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
|
|
||||||
imageUrl: "/images/events/event_advents-kalender.webp",
|
|
||||||
displayOrder: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Santa Karaoke-Party",
|
|
||||||
date: "2025-12-06",
|
|
||||||
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
|
|
||||||
imageUrl: "/images/events/event_santa_karaoke.webp",
|
|
||||||
displayOrder: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Weihnachtsferien",
|
|
||||||
date: "2025-12-21",
|
|
||||||
description: `Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h`,
|
|
||||||
imageUrl: "/images/events/event_ferien.webp",
|
|
||||||
displayOrder: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Neujahrs-Apero",
|
|
||||||
date: "2026-01-02",
|
|
||||||
description: `18:00-20:00 Uhr`,
|
|
||||||
imageUrl: "/images/events/event_neujahrs-apero.webp",
|
|
||||||
displayOrder: 6,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Old gallery images
|
|
||||||
const oldGalleryImages = [
|
|
||||||
{ imageUrl: "/images/gallery/Gallery7.webp", alt: "Gallery 7", order: 0 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery8.webp", alt: "Gallery 8", order: 1 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery9.webp", alt: "Gallery 9", order: 2 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery6.webp", alt: "Gallery 6", order: 3 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery1.webp", alt: "Gallery 1", order: 4 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery2.webp", alt: "Gallery 2", order: 5 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery3.webp", alt: "Gallery 3", order: 6 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery4.webp", alt: "Gallery 4", order: 7 },
|
|
||||||
{ imageUrl: "/images/gallery/Gallery5.webp", alt: "Gallery 5", order: 8 },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('=== Production Migration Script ===\n');
|
|
||||||
|
|
||||||
const dbPath = process.env.DATABASE_PATH || '/app/data/gallus_cms.db';
|
|
||||||
console.log('Database path:', dbPath);
|
|
||||||
|
|
||||||
// Check if database exists
|
|
||||||
if (!fs.existsSync(dbPath)) {
|
|
||||||
console.error('ERROR: Database not found at:', dbPath);
|
|
||||||
console.error('Please ensure the backend has been started at least once to create the database.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if images exist
|
|
||||||
const dataDir = process.env.GIT_WORKSPACE_DIR || '/app/data';
|
|
||||||
const eventsDir = path.join(dataDir, 'images', 'events');
|
|
||||||
const galleryDir = path.join(dataDir, 'images', 'gallery');
|
|
||||||
|
|
||||||
console.log('Events images directory:', eventsDir);
|
|
||||||
console.log('Gallery images directory:', galleryDir);
|
|
||||||
|
|
||||||
if (!fs.existsSync(eventsDir)) {
|
|
||||||
console.error('ERROR: Events images directory not found:', eventsDir);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(galleryDir)) {
|
|
||||||
console.error('ERROR: Gallery images directory not found:', galleryDir);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// List available images
|
|
||||||
console.log('\nAvailable event images:', fs.readdirSync(eventsDir));
|
|
||||||
console.log('Available gallery images:', fs.readdirSync(galleryDir));
|
|
||||||
|
|
||||||
// Connect to database
|
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
const db = drizzle(sqlite);
|
|
||||||
|
|
||||||
console.log('\n=== Migrating Events ===\n');
|
|
||||||
|
|
||||||
for (const event of oldEvents) {
|
|
||||||
try {
|
|
||||||
const [newEvent] = await db.insert(events).values({
|
|
||||||
title: event.title,
|
|
||||||
date: event.date,
|
|
||||||
description: event.description,
|
|
||||||
imageUrl: event.imageUrl,
|
|
||||||
displayOrder: event.displayOrder,
|
|
||||||
isPublished: true,
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
console.log(`✓ Migrated event: ${newEvent.title}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`✗ Failed to migrate event "${event.title}":`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n=== Migrating Gallery Images ===\n');
|
|
||||||
|
|
||||||
for (const img of oldGalleryImages) {
|
|
||||||
try {
|
|
||||||
const [newImage] = await db.insert(galleryImages).values({
|
|
||||||
imageUrl: img.imageUrl,
|
|
||||||
altText: img.alt,
|
|
||||||
displayOrder: img.order,
|
|
||||||
isPublished: true,
|
|
||||||
}).returning();
|
|
||||||
|
|
||||||
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlite.close();
|
|
||||||
|
|
||||||
console.log('\n✓ Migration completed successfully!');
|
|
||||||
console.log('\nYou can verify the migration by visiting:');
|
|
||||||
console.log('- Frontend: https://gallus-pub.ch/');
|
|
||||||
console.log('- Admin: https://gallus-pub.ch/admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(error => {
|
|
||||||
console.error('\n✗ Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
2704
backend/package-lock.json
generated
@ -60,14 +60,3 @@ export const publishHistory = sqliteTable('publish_history', {
|
|||||||
commitMessage: text('commit_message'),
|
commitMessage: text('commit_message'),
|
||||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Banner table (for announcements like holidays, special info)
|
|
||||||
export const banners = sqliteTable('banners', {
|
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
||||||
text: text('text').notNull(),
|
|
||||||
startDate: text('start_date').notNull(), // ISO date string
|
|
||||||
endDate: text('end_date').notNull(), // ISO date string
|
|
||||||
isActive: integer('is_active', { mode: 'boolean' }).default(true),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import galleryRoute from './routes/gallery.js';
|
|||||||
import contentRoute from './routes/content.js';
|
import contentRoute from './routes/content.js';
|
||||||
import settingsRoute from './routes/settings.js';
|
import settingsRoute from './routes/settings.js';
|
||||||
import publishRoute from './routes/publish.js';
|
import publishRoute from './routes/publish.js';
|
||||||
import bannersRoute from './routes/banners.js';
|
|
||||||
|
|
||||||
// Validate environment variables
|
// Validate environment variables
|
||||||
try {
|
try {
|
||||||
@ -40,24 +39,8 @@ const fastify = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register plugins
|
// Register plugins
|
||||||
// Support multiple origins for CORS
|
|
||||||
const allowedOrigins = env.CORS_ORIGIN.split(',').map(o => o.trim());
|
|
||||||
fastify.register(cors, {
|
fastify.register(cors, {
|
||||||
origin: (origin, cb) => {
|
origin: env.CORS_ORIGIN,
|
||||||
// Allow requests with no origin (like mobile apps or curl)
|
|
||||||
if (!origin) {
|
|
||||||
return cb(null, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if origin is in allowed list
|
|
||||||
const isAllowed = allowedOrigins.includes(origin);
|
|
||||||
|
|
||||||
if (isAllowed) {
|
|
||||||
return cb(null, true);
|
|
||||||
} else {
|
|
||||||
return cb(null, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -95,7 +78,6 @@ fastify.register(galleryRoute, { prefix: '/api' });
|
|||||||
fastify.register(contentRoute, { prefix: '/api' });
|
fastify.register(contentRoute, { prefix: '/api' });
|
||||||
fastify.register(settingsRoute, { prefix: '/api' });
|
fastify.register(settingsRoute, { prefix: '/api' });
|
||||||
fastify.register(publishRoute, { prefix: '/api' });
|
fastify.register(publishRoute, { prefix: '/api' });
|
||||||
fastify.register(bannersRoute, { prefix: '/api' });
|
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { GiteaService } from '../services/gitea.service.js';
|
import { GiteaService } from '../services/gitea.service.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
// Use explicit JSON schema for Fastify route validation to avoid provider issues
|
||||||
const callbackQueryJsonSchema = {
|
const callbackQueryJsonSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['code', 'state'],
|
required: ['code', 'state'],
|
||||||
|
|||||||
@ -1,152 +0,0 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
|
||||||
import { db } from '../config/database.js';
|
|
||||||
import { banners } from '../db/schema.js';
|
|
||||||
import { eq, and, lte, gte, desc } from 'drizzle-orm';
|
|
||||||
|
|
||||||
const bannerBodyJsonSchema = {
|
|
||||||
type: 'object',
|
|
||||||
required: ['text', 'startDate', 'endDate'],
|
|
||||||
properties: {
|
|
||||||
text: { type: 'string' },
|
|
||||||
startDate: { type: 'string' },
|
|
||||||
endDate: { type: 'string' },
|
|
||||||
isActive: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const bannersRoute: FastifyPluginAsync = async (fastify) => {
|
|
||||||
|
|
||||||
// Get active banner (public endpoint)
|
|
||||||
fastify.get('/banners/active', async (request, reply) => {
|
|
||||||
// Use local date to avoid timezone issues
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getTime() - (now.getTimezoneOffset() * 60000))
|
|
||||||
.toISOString()
|
|
||||||
.split('T')[0]; // YYYY-MM-DD
|
|
||||||
|
|
||||||
const [activeBanner] = await db
|
|
||||||
.select()
|
|
||||||
.from(banners)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(banners.isActive, true),
|
|
||||||
lte(banners.startDate, today),
|
|
||||||
gte(banners.endDate, today)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(banners.createdAt))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!activeBanner) {
|
|
||||||
return { banner: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
banner: {
|
|
||||||
id: activeBanner.id,
|
|
||||||
text: activeBanner.text,
|
|
||||||
startDate: activeBanner.startDate,
|
|
||||||
endDate: activeBanner.endDate,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all banners (admin only)
|
|
||||||
fastify.get('/banners', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const allBanners = await db.select().from(banners);
|
|
||||||
|
|
||||||
return {
|
|
||||||
banners: allBanners.map((b: any) => ({
|
|
||||||
id: b.id,
|
|
||||||
text: b.text,
|
|
||||||
startDate: b.startDate,
|
|
||||||
endDate: b.endDate,
|
|
||||||
isActive: b.isActive,
|
|
||||||
createdAt: b.createdAt,
|
|
||||||
updatedAt: b.updatedAt,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create banner (admin only)
|
|
||||||
fastify.post('/banners', {
|
|
||||||
schema: {
|
|
||||||
body: bannerBodyJsonSchema,
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { text, startDate, endDate, isActive = true } = request.body as any;
|
|
||||||
|
|
||||||
const [newBanner] = await db
|
|
||||||
.insert(banners)
|
|
||||||
.values({
|
|
||||||
text,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
isActive,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return {
|
|
||||||
banner: {
|
|
||||||
id: newBanner.id,
|
|
||||||
text: newBanner.text,
|
|
||||||
startDate: newBanner.startDate,
|
|
||||||
endDate: newBanner.endDate,
|
|
||||||
isActive: newBanner.isActive,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update banner (admin only)
|
|
||||||
fastify.put('/banners/:id', {
|
|
||||||
schema: {
|
|
||||||
body: bannerBodyJsonSchema,
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const { text, startDate, endDate, isActive } = request.body as any;
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(banners)
|
|
||||||
.set({
|
|
||||||
text,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
isActive,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(banners.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return reply.code(404).send({ error: 'Banner not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
banner: {
|
|
||||||
id: updated.id,
|
|
||||||
text: updated.text,
|
|
||||||
startDate: updated.startDate,
|
|
||||||
endDate: updated.endDate,
|
|
||||||
isActive: updated.isActive,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete banner (admin only)
|
|
||||||
fastify.delete('/banners/:id', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
|
|
||||||
await db.delete(banners).where(eq(banners.id, id));
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default bannersRoute;
|
|
||||||
@ -2,8 +2,6 @@ import { FastifyPluginAsync } from 'fastify';
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events } from '../db/schema.js';
|
import { events } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
// Fastify JSON schema for event body
|
// Fastify JSON schema for event body
|
||||||
const eventBodyJsonSchema = {
|
const eventBodyJsonSchema = {
|
||||||
@ -76,73 +74,6 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return { event: row };
|
return { event: row };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload event image file (multipart)
|
|
||||||
fastify.post('/events/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 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, 'public', 'images', 'events');
|
|
||||||
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 {
|
|
||||||
// Lazy load sharp only when needed
|
|
||||||
const sharp = (await import('sharp')).default;
|
|
||||||
outBuffer = await sharp(inputBuffer)
|
|
||||||
.rotate()
|
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
|
||||||
.webp({ quality: 82 })
|
|
||||||
.toBuffer();
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
|
||||||
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 = `/images/events/${filename}`;
|
|
||||||
|
|
||||||
return reply.code(201).send({ imageUrl: publicUrl });
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error({ err }, 'Upload failed');
|
|
||||||
return reply.code(500).send({ error: 'Failed to upload image' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete event
|
// Delete event
|
||||||
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { galleryImages } from '../db/schema.js';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
// Fastify JSON schema for gallery image body
|
// Fastify JSON schema for gallery image body
|
||||||
const galleryBodyJsonSchema = {
|
const galleryBodyJsonSchema = {
|
||||||
@ -86,7 +87,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// Prepare directories - use persistent volume for Fly.io
|
// Prepare directories - use persistent volume for Fly.io
|
||||||
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
const uploadDir = path.join(dataDir, 'public', 'images', 'gallery');
|
const uploadDir = path.join(dataDir, 'images', 'gallery');
|
||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
// Read uploaded stream into buffer
|
// Read uploaded stream into buffer
|
||||||
@ -105,15 +106,12 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
let outBuffer: Buffer | null = null;
|
let outBuffer: Buffer | null = null;
|
||||||
let outExt = '.webp';
|
let outExt = '.webp';
|
||||||
try {
|
try {
|
||||||
// Lazy load sharp only when needed
|
|
||||||
const sharp = (await import('sharp')).default;
|
|
||||||
outBuffer = await sharp(inputBuffer)
|
outBuffer = await sharp(inputBuffer)
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
.webp({ quality: 82 })
|
.webp({ quality: 82 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch (err) {
|
} catch {
|
||||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
|
||||||
outBuffer = inputBuffer;
|
outBuffer = inputBuffer;
|
||||||
// naive extension from mimetype
|
// naive extension from mimetype
|
||||||
const extFromMime = mime.split('/')[1] || 'bin';
|
const extFromMime = mime.split('/')[1] || 'bin';
|
||||||
@ -125,7 +123,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
fs.writeFileSync(destPath, outBuffer);
|
fs.writeFileSync(destPath, outBuffer);
|
||||||
|
|
||||||
// Public URL (served via /static)
|
// Public URL (served via /static)
|
||||||
const publicUrl = `/images/gallery/${filename}`;
|
const publicUrl = `/static/images/gallery/${filename}`;
|
||||||
|
|
||||||
// Store in DB (optional but useful)
|
// Store in DB (optional but useful)
|
||||||
const [row] = await db.insert(galleryImages).values({
|
const [row] = await db.insert(galleryImages).values({
|
||||||
|
|||||||
@ -43,7 +43,6 @@ export class FileGeneratorService {
|
|||||||
|
|
||||||
return `---
|
return `---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
import Hero from "../components/Hero.astro";
|
import Hero from "../components/Hero.astro";
|
||||||
import Welcome from "../components/Welcome.astro";
|
import Welcome from "../components/Welcome.astro";
|
||||||
import EventsGrid from "../components/EventsGrid.astro";
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
@ -62,7 +61,6 @@ ${imagesCode}
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
\t<Banner />
|
|
||||||
\t<Hero id="hero" />
|
\t<Hero id="hero" />
|
||||||
\t<Welcome id="welcome" />
|
\t<Welcome id="welcome" />
|
||||||
\t<EventsGrid id="events" events={events} />
|
\t<EventsGrid id="events" events={events} />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 728 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 1021 KiB |
@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
// src/components/Banner.astro
|
|
||||||
import "../styles/components/Banner.css"
|
|
||||||
---
|
|
||||||
|
|
||||||
<div id="banner-container"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_BASE = 'https://cms.gallus-pub.ch';
|
|
||||||
|
|
||||||
async function loadBanner() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/banners/active`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.banner) {
|
|
||||||
const container = document.getElementById('banner-container');
|
|
||||||
if (container) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="banner-wrapper">
|
|
||||||
<div class="banner container">
|
|
||||||
<p>${data.banner.text}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch banner:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load banner when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', loadBanner);
|
|
||||||
} else {
|
|
||||||
loadBanner();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -1,10 +1,8 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Banner />
|
|
||||||
|
|
||||||
<h1>Gallery</h1>
|
<h1>Gallery</h1>
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Banner />
|
|
||||||
|
|
||||||
<h1>Openings</h1>
|
<h1>Openings</h1>
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,7 @@ const title = 'Admin';
|
|||||||
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></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>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
|
||||||
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></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>
|
<button id="btn-create-ev">Event anlegen</button>
|
||||||
<div id="ev-create-msg" class="muted"></div>
|
<div id="ev-create-msg" class="muted"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -78,30 +79,17 @@ const title = 'Admin';
|
|||||||
<div id="gal-create-msg" class="muted"></div>
|
<div id="gal-create-msg" class="muted"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="min-width:380px;">
|
<div class="card" style="min-width:380px;">
|
||||||
<h3>Gallery-Liste</h3>
|
<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 id="gallery-list" class="grid"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="sec-banner" style="display:none">
|
|
||||||
<h2>Banner verwalten</h2>
|
|
||||||
<div class="events-row">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Neuen Banner erstellen</h3>
|
|
||||||
<label>Text<textarea id="banner-text" rows="3" placeholder="z.B. Wir sind vom 24.12. bis 02.01. geschlossen"></textarea></label>
|
|
||||||
<label>Von (Datum)<input id="banner-start" type="date" /></label>
|
|
||||||
<label>Bis (Datum)<input id="banner-end" type="date" /></label>
|
|
||||||
<button id="btn-create-banner">Banner erstellen</button>
|
|
||||||
<div id="banner-create-msg" class="muted"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="min-width:380px;">
|
|
||||||
<h3>Banner-Liste</h3>
|
|
||||||
<div id="banner-list" class="grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sec-publish" style="display:none">
|
<section id="sec-publish" style="display:none">
|
||||||
<h2>Veröffentlichen</h2>
|
<h2>Veröffentlichen</h2>
|
||||||
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
||||||
@ -127,20 +115,16 @@ const title = 'Admin';
|
|||||||
// UI-Bereiche für eingeloggte Nutzer einblenden
|
// UI-Bereiche für eingeloggte Nutzer einblenden
|
||||||
document.getElementById('sec-events').style.display = '';
|
document.getElementById('sec-events').style.display = '';
|
||||||
document.getElementById('sec-gallery').style.display = '';
|
document.getElementById('sec-gallery').style.display = '';
|
||||||
document.getElementById('sec-banner').style.display = '';
|
|
||||||
document.getElementById('sec-publish').style.display = '';
|
document.getElementById('sec-publish').style.display = '';
|
||||||
// Direkt Events laden und auf Sektion fokussieren
|
// Direkt Events laden und auf Sektion fokussieren
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
await loadGallery();
|
await loadGallery();
|
||||||
await loadBanners();
|
|
||||||
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const el = document.getElementById('auth-status');
|
const el = document.getElementById('auth-status');
|
||||||
el.textContent = 'Nicht angemeldet';
|
el.textContent = 'Nicht angemeldet';
|
||||||
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
|
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
|
||||||
document.getElementById('sec-events').style.display = 'none';
|
document.getElementById('sec-events').style.display = 'none';
|
||||||
document.getElementById('sec-gallery').style.display = 'none';
|
|
||||||
document.getElementById('sec-banner').style.display = 'none';
|
|
||||||
document.getElementById('sec-publish').style.display = 'none';
|
document.getElementById('sec-publish').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,15 +149,7 @@ const title = 'Admin';
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ========== Events & Publish ==========
|
// ========== Events & Publish ==========
|
||||||
async function uploadEventImage(file) {
|
async function uploadImage(file, altText) {
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
const res = await fetch(API_BASE + '/api/events/upload', { method: 'POST', body: fd, credentials: 'include' });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadGalleryImage(file, altText) {
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
if (altText) fd.append('altText', altText);
|
if (altText) fd.append('altText', altText);
|
||||||
@ -223,7 +199,7 @@ const title = 'Admin';
|
|||||||
</div>
|
</div>
|
||||||
<div class="muted">${ev.date}</div>
|
<div class="muted">${ev.date}</div>
|
||||||
<div>${ev.description || ''}</div>
|
<div>${ev.description || ''}</div>
|
||||||
${ev.imageUrl ? `<img src="${API_BASE}${ev.imageUrl}" alt="${ev.title}" class="thumb" style="margin-top:0.5rem;" />` : '<div class="muted">Kein Bild</div>'}
|
<div class="muted">Bild: ${ev.imageUrl || ''}</div>
|
||||||
<div class="row-buttons">
|
<div class="row-buttons">
|
||||||
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
|
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@ -278,13 +254,14 @@ const title = 'Admin';
|
|||||||
const date = (document.getElementById('ev-date')).value.trim();
|
const date = (document.getElementById('ev-date')).value.trim();
|
||||||
const desc = (document.getElementById('ev-desc')).value.trim();
|
const desc = (document.getElementById('ev-desc')).value.trim();
|
||||||
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
|
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');
|
const msg = document.getElementById('ev-create-msg');
|
||||||
msg.textContent = 'Lade Bild hoch...';
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
try {
|
try {
|
||||||
let imageUrl = '';
|
let imageUrl = '';
|
||||||
if (file) {
|
if (file) {
|
||||||
const up = await uploadEventImage(file);
|
const up = await uploadImage(file, alt || title);
|
||||||
imageUrl = up?.imageUrl || '';
|
imageUrl = up?.image?.imageUrl || '';
|
||||||
}
|
}
|
||||||
msg.textContent = 'Lege Event an...';
|
msg.textContent = 'Lege Event an...';
|
||||||
await api('/api/events', {
|
await api('/api/events', {
|
||||||
@ -296,6 +273,7 @@ const title = 'Admin';
|
|||||||
(document.getElementById('ev-date')).value = '';
|
(document.getElementById('ev-date')).value = '';
|
||||||
(document.getElementById('ev-desc')).value = '';
|
(document.getElementById('ev-desc')).value = '';
|
||||||
(document.getElementById('ev-file')).value = '';
|
(document.getElementById('ev-file')).value = '';
|
||||||
|
(document.getElementById('ev-alt')).value = '';
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
});
|
});
|
||||||
@ -334,144 +312,128 @@ const title = 'Admin';
|
|||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Gallery ==========
|
// ========== Gallery Management ==========
|
||||||
|
let galReorderMode = false;
|
||||||
|
let lastGallery = [];
|
||||||
|
|
||||||
async function loadGallery() {
|
async function loadGallery() {
|
||||||
const listEl = document.getElementById('gallery-list');
|
const listEl = document.getElementById('gallery-list');
|
||||||
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/gallery');
|
const data = await api('/api/gallery');
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
const galleryImages = (data.images || []).slice();
|
lastGallery = (data.images || []).slice();
|
||||||
galleryImages.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
let renderList = lastGallery.slice();
|
||||||
|
|
||||||
galleryImages.forEach((img) => {
|
if (galReorderMode) {
|
||||||
|
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList.forEach((img, idx) => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card';
|
card.className = 'card';
|
||||||
|
card.setAttribute('draggable', String(galReorderMode));
|
||||||
|
card.dataset.id = img.id;
|
||||||
|
card.dataset.displayOrder = String(img.displayOrder ?? idx);
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" class="thumb" />
|
<div class="row" style="justify-content:space-between;align-items:center">
|
||||||
<div class="muted">${img.altText || ''}</div>
|
<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">
|
<div class="row-buttons">
|
||||||
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
|
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
listEl.appendChild(card);
|
listEl.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
|
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const id = btn.getAttribute('data-id');
|
const id = btn.getAttribute('data-id');
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!confirm('Bild wirklich löschen?')) return;
|
if (!confirm('Bild wirklich löschen?')) return;
|
||||||
try { await api(`/api/gallery/${id}`, { method: 'DELETE' }); await loadGallery(); } catch(e){ alert('Fehler: '+e.message); }
|
try {
|
||||||
|
await api(`/api/gallery/${id}`, { method: 'DELETE' });
|
||||||
|
await loadGallery();
|
||||||
|
} catch(e){ alert('Fehler: '+e.message); }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (galReorderMode) {
|
||||||
|
enableGalleryDragAndDrop(listEl);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
||||||
console.error(e);
|
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 () => {
|
document.getElementById('btn-create-gal').addEventListener('click', async () => {
|
||||||
const file = /** @type {HTMLInputElement} */ (document.getElementById('gal-file')).files[0];
|
const file = document.getElementById('gal-file').files[0];
|
||||||
const alt = (document.getElementById('gal-alt')).value.trim();
|
const alt = document.getElementById('gal-alt').value.trim();
|
||||||
const msg = document.getElementById('gal-create-msg');
|
const msg = document.getElementById('gal-create-msg');
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
msg.textContent = 'Bitte Datei auswählen';
|
msg.textContent = 'Bitte wähle ein Bild aus';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
msg.textContent = 'Lade Bild hoch...';
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
try {
|
try {
|
||||||
await uploadGalleryImage(file, alt);
|
const up = await uploadImage(file, alt || 'Gallery Image');
|
||||||
msg.textContent = 'Bild hochgeladen';
|
msg.textContent = 'Bild hochgeladen';
|
||||||
(document.getElementById('gal-file')).value = '';
|
document.getElementById('gal-file').value = '';
|
||||||
(document.getElementById('gal-alt')).value = '';
|
document.getElementById('gal-alt').value = '';
|
||||||
await loadGallery();
|
await loadGallery();
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
} catch(e){
|
||||||
});
|
msg.textContent = 'Fehler: '+e.message
|
||||||
|
|
||||||
// ========== Banner ==========
|
|
||||||
async function loadBanners() {
|
|
||||||
const listEl = document.getElementById('banner-list');
|
|
||||||
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
|
||||||
try {
|
|
||||||
const data = await api('/api/banners');
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
const bannersList = (data.banners || []).slice();
|
|
||||||
bannersList.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
||||||
|
|
||||||
bannersList.forEach((banner) => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card';
|
|
||||||
const statusText = banner.isActive ? '✓ Aktiv' : '✗ Inaktiv';
|
|
||||||
card.innerHTML = `
|
|
||||||
<div><strong>${banner.text.substring(0, 60)}${banner.text.length > 60 ? '...' : ''}</strong></div>
|
|
||||||
<div class="muted">Von: ${banner.startDate}</div>
|
|
||||||
<div class="muted">Bis: ${banner.endDate}</div>
|
|
||||||
<div class="pill">${statusText}</div>
|
|
||||||
<div class="row-buttons">
|
|
||||||
<button data-id="${banner.id}" class="btn-toggle-banner">${banner.isActive ? 'Deaktivieren' : 'Aktivieren'}</button>
|
|
||||||
<button data-id="${banner.id}" class="btn-del-banner">Löschen</button>
|
|
||||||
</div>`;
|
|
||||||
listEl.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.btn-toggle-banner').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const id = btn.getAttribute('data-id');
|
|
||||||
if (!id) return;
|
|
||||||
const banner = bannersList.find(b => b.id === id);
|
|
||||||
if (!banner) return;
|
|
||||||
try {
|
|
||||||
await api(`/api/banners/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: banner.text,
|
|
||||||
startDate: banner.startDate,
|
|
||||||
endDate: banner.endDate,
|
|
||||||
isActive: !banner.isActive
|
|
||||||
})
|
|
||||||
});
|
|
||||||
await loadBanners();
|
|
||||||
} catch(e){ alert('Fehler: '+e.message); }
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.btn-del-banner').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const id = btn.getAttribute('data-id');
|
|
||||||
if (!id) return;
|
|
||||||
if (!confirm('Banner wirklich löschen?')) return;
|
|
||||||
try { await api(`/api/banners/${id}`, { method: 'DELETE' }); await loadBanners(); } catch(e){ alert('Fehler: '+e.message); }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-create-banner').addEventListener('click', async () => {
|
|
||||||
const text = (document.getElementById('banner-text')).value.trim();
|
|
||||||
const startDate = (document.getElementById('banner-start')).value.trim();
|
|
||||||
const endDate = (document.getElementById('banner-end')).value.trim();
|
|
||||||
const msg = document.getElementById('banner-create-msg');
|
|
||||||
|
|
||||||
if (!text || !startDate || !endDate) {
|
|
||||||
msg.textContent = 'Bitte alle Felder ausfüllen';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.textContent = 'Erstelle Banner...';
|
|
||||||
try {
|
|
||||||
await api('/api/banners', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ text, startDate, endDate, isActive: true })
|
|
||||||
});
|
});
|
||||||
msg.textContent = 'Banner erstellt';
|
|
||||||
(document.getElementById('banner-text')).value = '';
|
document.getElementById('btn-toggle-gal-reorder').addEventListener('click', async () => {
|
||||||
(document.getElementById('banner-start')).value = '';
|
galReorderMode = !galReorderMode;
|
||||||
(document.getElementById('banner-end')).value = '';
|
document.getElementById('btn-save-gal-order').style.display = galReorderMode ? '' : 'none';
|
||||||
await loadBanners();
|
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 }
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../components/Layout.astro";
|
import Layout from "../components/Layout.astro";
|
||||||
import Banner from "../components/Banner.astro";
|
|
||||||
import Hero from "../components/Hero.astro";
|
import Hero from "../components/Hero.astro";
|
||||||
import Welcome from "../components/Welcome.astro";
|
import Welcome from "../components/Welcome.astro";
|
||||||
import EventsGrid from "../components/EventsGrid.astro";
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
@ -9,76 +8,42 @@ import ImageCarousel from "../components/ImageCarousel.astro";
|
|||||||
import Contact from "../components/Contact.astro";
|
import Contact from "../components/Contact.astro";
|
||||||
import About from "../components/About.astro";
|
import About from "../components/About.astro";
|
||||||
|
|
||||||
const events = [
|
const API_BASE = 'https://cms.gallus-pub.ch';
|
||||||
{
|
|
||||||
image: "/images/events/mj7dj1ko-mtnbg6.jpeg",
|
|
||||||
title: "Karaoke",
|
|
||||||
date: "2025-12-01",
|
|
||||||
description: `
|
|
||||||
Von Mittwoch bis Samstag kannst du deine Stimme zum Besten geben. Du singst gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat mieten. 🍀 WA 077 232 27 70
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/mj67800i-6ng82x.jpeg",
|
|
||||||
title: "Pub Quiz",
|
|
||||||
date: "2025-12-02",
|
|
||||||
description: `
|
|
||||||
Jeden Freitag 20:00Uhr-ca 21:30Uhr.
|
|
||||||
Plätze sind begrenzt! Jetzt reservieren unter 🍀WA 077 232 27 70
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/miyxej2c-7l8end.jpeg",
|
|
||||||
title: "Ferien Flyer",
|
|
||||||
date: "2026-01-02",
|
|
||||||
description: `
|
|
||||||
Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀Für Anfragen WA 077 232 27 70 Antwort innerhalb 48h
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/mjbgwbzv-n60vrw.jpeg",
|
|
||||||
title: "New Year Apero",
|
|
||||||
date: "2026-01-02",
|
|
||||||
description: `
|
|
||||||
Wir stossen mit euch an!
|
|
||||||
Freitag 02.01. bereits ab 18:00 OFFEN!
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/mjbgxwyk-ygcymt.jpeg",
|
|
||||||
title: "Schlager Flyer",
|
|
||||||
date: "2026-01-15",
|
|
||||||
description: `
|
|
||||||
Schalger- HüttenzauberKARAOKE geht in die 2.Runde!
|
|
||||||
Eintritt ist frei!
|
|
||||||
Plätze reservieren unter WA 077 232 27 70
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: "/images/events/mj7donky-md8jp5.jpeg",
|
|
||||||
title: "Celtik Folk Night",
|
|
||||||
date: "2026-01-29",
|
|
||||||
description: `
|
|
||||||
Celtic Folk Night im Gallus Pub!✨🌿20:30Uhr Eintritt ist Frei/Hutkollekte. Reservation via WA 077 232 27 70
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const images = [
|
// Fetch events from backend API
|
||||||
{ src: "/images/gallery/miywxkwh-m4xaww.webp", alt: "miywxkwh-m4xaww.webp" },
|
let events = [];
|
||||||
{ src: "/images/gallery/miyxgbqr-n3zzrg.png", alt: "miyxgbqr-n3zzrg.png" },
|
try {
|
||||||
{ src: "/images/gallery/miyxgfh1-c7zawh.png", alt: "miyxgfh1-c7zawh.png" },
|
const eventsResponse = await fetch(`${API_BASE}/api/events/public`);
|
||||||
{ src: "/images/gallery/miyxgjff-wjtyim.png", alt: "miyxgjff-wjtyim.png" },
|
if (eventsResponse.ok) {
|
||||||
{ src: "/images/gallery/miyxgn6h-jsaltu.png", alt: "miyxgn6h-jsaltu.png" },
|
const eventsData = await eventsResponse.json();
|
||||||
{ src: "/images/gallery/mj67l5x3-pdasw8.jpeg", alt: "mj67l5x3-pdasw8.jpeg" },
|
events = (eventsData.events || []).map((ev: any) => ({
|
||||||
{ src: "/images/gallery/mj67mw2z-3pd81q.jpeg", alt: "mj67mw2z-3pd81q.jpeg" },
|
image: `${API_BASE}${ev.imageUrl}`,
|
||||||
{ src: "/images/gallery/mj67nwjs-6oaijj.jpeg", alt: "mj67nwjs-6oaijj.jpeg" },
|
title: ev.title,
|
||||||
{ src: "/images/gallery/mj67ove6-el3pf7.png", alt: "mj67ove6-el3pf7.png" }
|
date: ev.date,
|
||||||
];
|
description: ev.description
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch events:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch gallery images from backend API
|
||||||
|
let images = [];
|
||||||
|
try {
|
||||||
|
const galleryResponse = await fetch(`${API_BASE}/api/gallery/public`);
|
||||||
|
if (galleryResponse.ok) {
|
||||||
|
const galleryData = await galleryResponse.json();
|
||||||
|
images = (galleryData.images || []).map((img: any) => ({
|
||||||
|
src: `${API_BASE}${img.imageUrl}`,
|
||||||
|
alt: img.altText
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch gallery:', error);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Banner />
|
|
||||||
<Hero id="hero" />
|
<Hero id="hero" />
|
||||||
<Welcome id="welcome" />
|
<Welcome id="welcome" />
|
||||||
<EventsGrid id="events" events={events} />
|
<EventsGrid id="events" events={events} />
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
.banner-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--color-orange1);
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
max-width: var(--container-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 var(--padding-horizontal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner p {
|
|
||||||
color: #000;
|
|
||||||
font-size: var(--font-size-small-medium);
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.banner p {
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
}
|
|
||||||
}
|
|
||||||