12 Commits

Author SHA1 Message Date
af4877300f Add public endpoints and refactor deployments
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Implemented public `/gallery/public` and `/events/public` endpoints for fetching published data without authentication.
- Updated persistent volume configuration for Fly.io across backend and static file serving.
- Adjusted frontend to dynamically fetch events and gallery images from backend API.
- Refined Woodpecker pipeline for clearer separation of backend and frontend deployments.
2025-12-09 15:53:39 +01:00
4a103cf7d6 Merge remote-tracking branch 'origin/main'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 15:17:25 +01:00
97e7f88906 Revert "ned soll endlich pushen"
This reverts commit 8ca30ae5f3.
2025-12-09 15:16:45 +01:00
807c56de5a Update events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:09:39 +00:00
8ca30ae5f3 ned soll endlich pushen
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-09 14:45:46 +01:00
8f1254840c anpassungen am woodpecker und fly .toml
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:36:31 +01:00
8b2d00385a test
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 14:03:36 +01:00
c55e274718 woodpecker soll nun auch das backend deployen
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-09 13:58:39 +01:00
e9a95ccf8d Implement cross-domain support for OAuth and API requests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Updated frontend to use `https://cms.gallus-pub.ch` as the API base URL.
- Configured cookies with `SameSite=None` and `Secure` for production in `auth.ts`.
- Enhanced `fly.toml` to include `FRONTEND_URL`, `CORS_ORIGIN`, and `GITEA_REDIRECT_URI`.
- Adjusted `.gitignore` to ignore `/ai/` directory.
2025-12-09 12:01:49 +01:00
b16ac76620 Update fly.toml to adjust [env] paths and simplify volume mounts configuration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-08 18:34:18 +01:00
0e03b9dea9 Fix typo in deployment guide section header
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:27:27 +01:00
da3a950a1a Update fly.toml to reference Dockerfile instead of Dockerfile.fly
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-08 18:19:01 +01:00
13 changed files with 279 additions and 104 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,6 +1,6 @@
# Deployment Guide # Deployment Guide
## Prerequisites ## Prerequisite
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh` 1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
2. Fly.io account: `flyctl auth login` 2. Fly.io account: `flyctl auth login`

View File

@ -3,6 +3,8 @@ app = "gallus-cms-backend"
primary_region = "ams" primary_region = "ams"
[build] [build]
# Ensure Fly uses the Dockerfile in this backend directory
dockerfile = "Dockerfile"
[env] [env]
PORT = "8080" PORT = "8080"
@ -10,6 +12,10 @@ primary_region = "ams"
GITEA_URL = "https://git.bookageek.ch" GITEA_URL = "https://git.bookageek.ch"
DATABASE_PATH = "/app/data/gallus_cms.db" DATABASE_PATH = "/app/data/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/data/workspace" GIT_WORKSPACE_DIR = "/app/data/workspace"
# Cross-site frontend and OAuth
FRONTEND_URL = "https://gallus-pub.ch"
CORS_ORIGIN = "https://gallus-pub.ch"
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
[http_service] [http_service]
internal_port = 8080 internal_port = 8080

View File

@ -15,6 +15,7 @@
"@fastify/cookie": "^9.3.1", "@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1", "@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.0", "@fastify/jwt": "^8.0.0",
"@fastify/static": "^6.12.0",
"@fastify/multipart": "^8.1.0", "@fastify/multipart": "^8.1.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"better-sqlite3": "^11.10.0", "better-sqlite3": "^11.10.0",

View File

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

View File

@ -5,6 +5,9 @@ import multipart from '@fastify/multipart';
import cookie from '@fastify/cookie'; import cookie from '@fastify/cookie';
import { authenticate } from './middleware/auth.middleware.js'; import { authenticate } from './middleware/auth.middleware.js';
import { env, validateEnv } from './config/env.js'; import { env, validateEnv } from './config/env.js';
import { db, initDatabase } from './config/database.js';
import fastifyStatic from '@fastify/static';
import path from 'path';
// Import routes // Import routes
import authRoute from './routes/auth.js'; import authRoute from './routes/auth.js';
@ -57,6 +60,14 @@ fastify.register(multipart, {
}, },
}); });
// Serve static files (uploaded images, etc.) from persistent volume
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
fastify.register(fastifyStatic, {
root: dataDir,
prefix: '/static/',
decorateReply: false
});
// Decorate fastify with authenticate method // Decorate fastify with authenticate method
fastify.decorate('authenticate', authenticate); fastify.decorate('authenticate', authenticate);
@ -99,6 +110,8 @@ fastify.setErrorHandler((error, request, reply) => {
// Start server // Start server
const start = async () => { const start = async () => {
try { try {
// Initialize database before starting server
initDatabase();
await fastify.listen({ port: env.PORT, host: '0.0.0.0' }); await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
console.log(`🚀 Server listening on port ${env.PORT}`); console.log(`🚀 Server listening on port ${env.PORT}`);
console.log(`📝 Environment: ${env.NODE_ENV}`); console.log(`📝 Environment: ${env.NODE_ENV}`);

View File

@ -31,9 +31,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
reply.setCookie('oauth_state', state, { reply.setCookie('oauth_state', state, {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'none',
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP secure: true,
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
maxAge: 10 * 60, // 10 minutes maxAge: 10 * 60, // 10 minutes
}); });
@ -122,11 +121,12 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
); );
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably // Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
reply.setCookie('token', token, { reply.setCookie('token', token, {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'), secure: (env.NODE_ENV === 'production') || (!!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https')),
maxAge: 60 * 60 * 24, // 24h maxAge: 60 * 60 * 24, // 24h
}); });

View File

@ -36,7 +36,15 @@ const reorderBodyJsonSchema = {
} as const; } as const;
const eventsRoute: FastifyPluginAsync = async (fastify) => { const eventsRoute: FastifyPluginAsync = async (fastify) => {
// List all events (by displayOrder) // PUBLIC: List published events (no auth required)
fastify.get('/events/public', async () => {
const all = await db.select().from(events)
.where(eq(events.isPublished, true))
.orderBy(events.displayOrder);
return { events: all };
});
// List all events (by displayOrder) - admin only
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => { fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
const all = await db.select().from(events).orderBy(events.displayOrder); const all = await db.select().from(events).orderBy(events.displayOrder);
return { events: all }; return { events: all };

View File

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

View File

@ -4,14 +4,14 @@ kill_signal = "SIGINT"
kill_timeout = 5 kill_timeout = 5
[build] [build]
dockerfile = "Dockerfile.fly" dockerfile = "Dockerfile"
[env] [env]
PORT = "3000" # Caddy (serves frontend + proxies /api/*) PORT = "3000"
NODE_ENV = "production" NODE_ENV = "production"
BACKEND_PORT = "8080" # Fastify backend will listen here BACKEND_PORT = "8080"
DATABASE_PATH = "/app/data/gallus_cms.db" DATABASE_PATH = "/app/data/db/gallus_cms.db"
GIT_WORKSPACE_DIR = "/app/workspace" GIT_WORKSPACE_DIR = "/app/data/workspace"
[http_service] [http_service]
internal_port = 3000 internal_port = 3000
@ -47,7 +47,3 @@ kill_timeout = 5
[[mounts]] [[mounts]]
source = "gallus_data" source = "gallus_data"
destination = "/app/data" destination = "/app/data"
[[mounts]]
source = "gallus_workspace"
destination = "/app/workspace"

View File

@ -36,7 +36,7 @@ const title = 'Admin';
<h2>Authentifizierung</h2> <h2>Authentifizierung</h2>
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div> <div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
<div class="row"> <div class="row">
<a id="login-link" class="btn" href="/api/auth/gitea">Mit Gitea anmelden</a> <a id="login-link" class="btn" href="https://cms.gallus-pub.ch/api/auth/gitea">Mit Gitea anmelden</a>
<button id="btn-relogin">Neu anmelden</button> <button id="btn-relogin">Neu anmelden</button>
<button id="btn-logout">Abmelden</button> <button id="btn-logout">Abmelden</button>
</div> </div>
@ -76,8 +76,11 @@ const title = 'Admin';
</section> </section>
<script> <script>
// Base-URL des Backends (separate Subdomain)
const API_BASE = 'https://cms.gallus-pub.ch';
const api = async (path, opts = {}) => { const api = async (path, opts = {}) => {
const res = await fetch(path, { credentials: 'include', ...opts }); const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get('content-type') || ''; const ct = res.headers.get('content-type') || '';
return ct.includes('application/json') ? res.json() : res.text(); return ct.includes('application/json') ? res.json() : res.text();
@ -107,13 +110,13 @@ const title = 'Admin';
loginLink.addEventListener('click', (e) => { loginLink.addEventListener('click', (e) => {
try { try {
// Stelle sicher, dass Navigieren erzwungen wird // Stelle sicher, dass Navigieren erzwungen wird
window.location.assign('/api/auth/gitea'); window.location.assign(API_BASE + '/api/auth/gitea');
} catch {} } catch {}
}); });
document.getElementById('btn-relogin').addEventListener('click', async () => { document.getElementById('btn-relogin').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {} try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
document.cookie = 'token=; Path=/; Max-Age=0;'; document.cookie = 'token=; Path=/; Max-Age=0;';
window.location.assign('/api/auth/gitea'); window.location.assign(API_BASE + '/api/auth/gitea');
}); });
document.getElementById('btn-logout').addEventListener('click', async () => { document.getElementById('btn-logout').addEventListener('click', async () => {
try { await api('/api/auth/logout', { method: 'POST' }); } catch {} try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
@ -127,7 +130,7 @@ const title = 'Admin';
fd.append('file', file); fd.append('file', file);
if (altText) fd.append('altText', altText); if (altText) fd.append('altText', altText);
fd.append('displayOrder', '0'); fd.append('displayOrder', '0');
const res = await fetch('/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' }); const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }

View File

@ -5,82 +5,42 @@ import Welcome from "../components/Welcome.astro";
import EventsGrid from "../components/EventsGrid.astro"; import EventsGrid from "../components/EventsGrid.astro";
import Drinks from "../components/Drinks.astro"; import Drinks from "../components/Drinks.astro";
import ImageCarousel from "../components/ImageCarousel.astro"; import ImageCarousel from "../components/ImageCarousel.astro";
import Contact from "../components/Contact.astro";
import About from "../components/About.astro";
const events = [ const API_BASE = 'https://cms.gallus-pub.ch';
{
image: "/images/events/event_karaoke.jpg",
title: "Karaoke",
date: "Mittwoch - Samstag",
description: `
Bei uns gibt es Karaoke Mi-Sa!! <br>
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/events/event_pub-quiz.jpg",
title: "Pub Quiz",
date: "Jeden Freitag",
description: `
Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
Auch Einzelpersonen sind herzlich willkommen! <br>
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
`,
},
{
image: "/images/events/event_schlager-karaoke.jpeg",
title: "Schlager Hüttenzauber Karaoke",
date: "27. November - 19:00 Uhr",
description: `
Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
`,
},
{
image: "/images/events/event_advents-kalender.jpeg",
title: "Adventskalender",
date: "03. Dezember - 20. Dezember 2025",
description: `
Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
`,
},
{
image: "/images/events/event_santa_karaoke.jpeg",
title: "Santa Karaoke-Party",
date: "06. Dezember 2025",
description: `
🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻 `,
},
{
image: "/images/events/event_ferien.jpeg",
title: "Weihnachtsferien",
date: "21. Dezember 2025 - 01. Januar 2026",
description: `
Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
`,
},
{
image: "/images/events/event_neujahrs-apero.jpeg",
title: "Neujahrs-Apero",
date: "02. Januar 2026 - 18:00-20:00 Uhr",
description: `
`, // Fetch events from backend API
}, let events = [];
try {
const eventsResponse = await fetch(`${API_BASE}/api/events/public`);
if (eventsResponse.ok) {
const eventsData = await eventsResponse.json();
events = (eventsData.events || []).map((ev: any) => ({
image: `${API_BASE}${ev.imageUrl}`,
title: ev.title,
date: ev.date,
description: ev.description
}));
}
} catch (error) {
console.error('Failed to fetch events:', error);
}
]; // Fetch gallery images from backend API
let images = [];
const images = [ try {
{ src: "/images/gallery/Gallery7.png", alt: "Siebtes Bild" }, const galleryResponse = await fetch(`${API_BASE}/api/gallery/public`);
{ src: "/images/gallery/Gallery8.png", alt: "Achtes Bild" }, if (galleryResponse.ok) {
{ src: "/images/gallery/Gallery9.png", alt: "Neuntes Bild" }, const galleryData = await galleryResponse.json();
{ src: "/images/gallery/Gallery6.png", alt: "Sechstes Bild" }, images = (galleryData.images || []).map((img: any) => ({
{ src: "/images/gallery/Gallery1.png", alt: "Erstes Bild" }, src: `${API_BASE}${img.imageUrl}`,
{ src: "/images/gallery/Gallery2.png", alt: "Zweites Bild" }, alt: img.altText
{ src: "/images/gallery/Gallery3.png", alt: "Drittes Bild" }, }));
{ src: "/images/gallery/Gallery4.png", alt: "Viertes Bild" }, }
{ src: "/images/gallery/Gallery5.png", alt: "Fünftes Bild" }, } catch (error) {
]; console.error('Failed to fetch gallery:', error);
}
--- ---
<Layout> <Layout>