1 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
21 changed files with 357 additions and 3010 deletions

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

View File

@ -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"]

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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, '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 = `/static/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 };

View File

@ -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 = {
@ -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';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

View File

@ -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,7 +79,12 @@ 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>
@ -119,7 +125,6 @@ const title = 'Admin';
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-publish').style.display = 'none'; document.getElementById('sec-publish').style.display = 'none';
} }
} }
@ -144,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);
@ -257,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', {
@ -275,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 }
}); });
@ -313,55 +312,127 @@ 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();
} 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(); await loadGallery();
} catch(e){ msg.textContent = 'Fehler: '+e.message } } catch(e){ msg.textContent = 'Fehler: '+e.message }
}); });

View File

@ -8,13 +8,39 @@ 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';
]; // Fetch events from backend API
let events = [];
try {
const eventsResponse = await fetch(`${API_BASE}/api/events/public`);
if (eventsResponse.ok) {
const eventsData = await eventsResponse.json();
events = (eventsData.events || []).map((ev: any) => ({
image: `${API_BASE}${ev.imageUrl}`,
title: ev.title,
date: ev.date,
description: ev.description
}));
}
} catch (error) {
console.error('Failed to fetch events:', error);
}
const images = [ // Fetch gallery images from backend API
{ src: "/static/images/gallery/miyvtrjn-zoq4j5.webp", alt: "miyvtrjn-zoq4j5.webp" } 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>