Kenzo Signer
Back to Overview
Finished

Gallus Pub: Ein modernes Headless CMS für Gastronomie-Websites

Ein vollständiges Headless CMS mit Git-basiertem Publishing-Workflow, OAuth2-Authentifizierung und dynamischer Content-Generierung für eine Irish Pub Website.

TypeScriptNext.jsFastifyAstroSQLiteDrizzle ORMSharpGitDockerFly.io

Gallus Pub: Ein modernes Headless CMS für Gastronomie-Websites

Projektübersicht

Gallus Pub ist eine vollständige Web-Anwendung für einen Irish Pub, bestehend aus einer statisch generierten öffentlichen Website und einem leistungsstarken Admin-Portal zur Content-Verwaltung. Das Projekt demonstriert moderne Webentwicklungspraktiken mit einem Headless CMS-Ansatz, der Git-basierte Publishing-Workflows, OAuth2-Authentifizierung und dynamische Content-Generierung vereint.

Live-Demo:

  • Public Website: https://gallus-pub.ch
  • CMS Backend: https://cms.gallus-pub.ch

Projektart: Full-Stack Web-Anwendung mit Headless CMS


Inhaltsverzeichnis

  1. Technologie-Stack
  2. Architektur-Übersicht
  3. Das Admin-Portal
  4. Backend-Architektur
  5. Frontend-Architektur
  6. Besondere Features
  7. Deployment & Infrastructure
  8. Lessons Learned & Highlights

Technologie-Stack

Backend-Technologien

Runtime & Framework:

  • Node.js mit TypeScript für typsichere Server-Entwicklung
  • Fastify 4.26 als High-Performance Web-Framework (schneller als Express)
  • tsx für TypeScript-Entwicklung, tsc für Production Builds

Datenbank & ORM:

  • SQLite mit better-sqlite3 für eingebettete, dateibasierte Persistenz
  • Drizzle ORM für typsichere Datenbankoperationen
  • WAL-Modus für verbesserte Concurrent-Access-Performance

Authentifizierung & Sicherheit:

  • Gitea OAuth2 für sichere Benutzer-Authentifizierung
  • @fastify/jwt für JSON Web Token-basierte Sessions
  • HttpOnly Cookies mit SameSite-Protection gegen CSRF
  • Benutzer-Allowlist für eingeschränkten Admin-Zugriff
  • State-basierte CSRF-Protektion im OAuth-Flow

Image Processing:

  • Sharp für High-Performance-Bildoptimierung
    • Automatisches EXIF-basiertes Rotieren
    • Responsive Bildgrößen (max. 1600px Breite)
    • WebP-Konvertierung mit 82% Qualität
    • Fallback auf Originalformat bei Konvertierungsfehlern

Git-Integration:

  • simple-git für programmatische Git-Operationen
  • Automatisches Clone, Add, Commit, Push zu Gitea
  • Reset-on-Error für sichere Publish-Workflows

Weitere Backend-Dependencies:

  • @fastify/cors für Cross-Origin-Requests
  • @fastify/multipart für File-Uploads (5MB Limit)
  • Zod für Runtime-Validierung und Type-Safety
  • dotenv für Environment-Variable-Management

Frontend-Technologien

Framework & Build-Tools:

  • Astro 5.12 als Static Site Generator
    • Component-basierte Architektur
    • Zero-JS by Default (nur wo nötig)
    • TypeScript-Support

Styling:

  • Pure CSS mit CSS Custom Properties (CSS Variables)
  • Component-Scoped Styles
  • Responsive Design mit Mobile-First-Ansatz
  • Keine CSS-Framework-Abhängigkeiten (maßgeschneiderte Lösung)

JavaScript:

  • Vanilla JavaScript für interaktive Komponenten
  • Drag-and-Drop API für Content-Reordering
  • Fetch API für Backend-Kommunikation
  • Client-Side State-Management für Carousel und Admin-UI

Infrastructure & DevOps

Hosting & Deployment:

  • Fly.io für Backend und Frontend (separate Apps)
  • Docker & Docker Compose für lokale Entwicklung
  • Caddy als Reverse Proxy (Development)
  • Fly Volumes für persistente Datenbank- und Git-Workspace-Storage

CI/CD:

  • Woodpecker CI für automatisierte Build-Pipelines
  • Git-basierte Deployment-Trigger
  • Automatische Static-Site-Regeneration nach Publish

Version Control:

  • Git mit Gitea als Self-Hosted Git-Server
  • OAuth-Integration für Authentifizierung
  • Commit-basierte Publishing-History

Architektur-Übersicht

Headless CMS-Ansatz

Die Anwendung folgt einem modernen Headless CMS-Architekturmuster:

┌─────────────────────────────────────────────────────────────┐
│                     Benutzer-Workflow                         │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
         ┌────────────────────────────────────┐
         │    Admin Portal (admin.astro)      │
         │  - Events verwalten                │
         │  - Gallery bearbeiten              │
         │  - Banner erstellen                │
         │  - Content-Sections anpassen       │
         └────────────────────────────────────┘
                              │
                              ▼
              ┌───────────────────────────────┐
              │   Backend API (Fastify)       │
              │   cms.gallus-pub.ch/api       │
              │                               │
              │   - SQLite-Datenbank          │
              │   - Image Processing          │
              │   - OAuth2 Auth               │
              └───────────────────────────────┘
                              │
                    (Publish Button)
                              ▼
         ┌────────────────────────────────────┐
         │  File Generation Service           │
         │  - Generiere index.astro           │
         │  - Generiere Hero.astro            │
         │  - Generiere Welcome.astro         │
         │  - Generiere Drinks.astro          │
         └────────────────────────────────────┘
                              │
                              ▼
              ┌───────────────────────────────┐
              │   Git Service (simple-git)    │
              │   - Add files                 │
              │   - Commit mit Message        │
              │   - Push zu Gitea             │
              └───────────────────────────────┘
                              │
                              ▼
                ┌─────────────────────────────┐
                │   Woodpecker CI             │
                │   - Build Astro Site        │
                │   - Deploy zu Fly.io        │
                └─────────────────────────────┘
                              │
                              ▼
              ┌───────────────────────────────┐
              │   Public Website              │
              │   gallus-pub.ch               │
              │   - Static HTML               │
              │   - Optimierte Bilder         │
              │   - Schnelle Ladezeiten       │
              └───────────────────────────────┘

Warum Headless CMS?

Vorteile dieser Architektur:

  1. Sicherheit: Die öffentliche Website ist vollständig statisch - keine Datenbank-Angriffsfläche
  2. Performance: Pre-rendered HTML mit CDN-Auslieferung bedeutet extrem schnelle Ladezeiten
  3. Versionierung: Alle Änderungen sind Git-Commits - vollständige Audit-Trail und Rollback-Möglichkeit
  4. Skalierbarkeit: Static Assets können einfach gecacht und über CDN ausgeliefert werden
  5. Entwickler-Experience: Moderne TypeScript-basierte Tools für Frontend und Backend

Das Admin-Portal

Das Admin-Portal (src/pages/admin.astro) ist das Herzstück des CMS und bietet eine intuitive, single-page Benutzeroberfläche für alle Content-Management-Aufgaben.

1. Authentifizierung

OAuth2-Integration mit Gitea:

Das Portal verwendet einen sicheren OAuth2-Flow mit einem selbst gehosteten Gitea-Server:

Ablauf:
1. User klickt "Mit Gitea anmelden"
2. Redirect zu Gitea OAuth-Autorisierungsseite
3. User genehmigt Zugriff
4. Gitea leitet zurück mit Authorization Code
5. Backend tauscht Code gegen Access Token
6. Backend holt User-Info von Gitea API
7. Prüfung gegen GITEA_ALLOWED_USERS Allowlist
8. JWT-Token wird generiert und als HttpOnly Cookie gesetzt
9. User wird zu Admin-Portal weitergeleitet

Sicherheitsfeatures:

  • State-basierte CSRF-Protektion: Zufälliger State-Token verhindert Request-Forgery
  • HttpOnly Cookies: JWT nicht per JavaScript auslesbar (XSS-Schutz)
  • SameSite Cookie-Attribute: Cross-Site-Request-Schutz
  • 24-Stunden Token-Expiry: Automatisches Session-Timeout
  • User Allowlist: Nur explizit erlaubte Gitea-User erhalten Zugriff

UI-Features:

  • Login/Logout-Buttons mit Status-Anzeige
  • Anzeige des aktuellen Gitea-Benutzernamens
  • Re-Login-Funktion bei abgelaufenen Sessions

2. Event-Management

Kernfunktionalitäten:

Events sind das zentrale Content-Element der Pub-Website (Konzerte, Quiz-Abende, Sport-Events, etc.)

Create Event:

- Titel (z.B., "St. Patrick's Day Feier")
- Datum (YYYY-MM-DD Format)
- Beschreibung (Markdown-Support)
- Bild-Upload (Drag & Drop oder File Picker)
  → Automatische Optimierung zu WebP
  → Resize auf max. 1600px Breite
  → EXIF-basiertes Auto-Rotate

Event-Liste:

  • Grid-Ansicht mit Thumbnails
  • Standard-Sortierung: Neueste Events zuerst (nach Datum)
  • Jeder Event-Eintrag zeigt:
    • Thumbnail
    • Titel und Datum
    • Beschreibung (gekürzt)
    • Delete-Button

Reorder-Modus:

  • Toggle zwischen "Normal" und "Reorder" Mode
  • Im Reorder-Modus:
    • Drag-and-Drop zum Umsortieren
    • Visuelle Drag-Indicators
    • Batch-Update per API (PUT /api/events/reorder)
    • Display-Order wird in Datenbank persistiert

Technische Details:

// Drag-and-Drop Implementation (Vanilla JS)
element.addEventListener('dragstart', (e) => {
  e.dataTransfer.effectAllowed = 'move';
  draggedElement = element;
});

element.addEventListener('dragover', (e) => {
  e.preventDefault();
  // Visuelles Feedback während Drag
});

element.addEventListener('drop', async (e) => {
  e.preventDefault();
  // Neuordnung der DOM-Elemente
  // API-Call mit neuer Order
  await fetch('/api/events/reorder', {
    method: 'PUT',
    body: JSON.stringify(newOrder),
    credentials: 'include'
  });
});

3. Gallery-Management

Ähnliche Features wie Event-Management:

Die Gallery verwaltet Bilder für einen Carousel auf der Homepage.

Upload-Workflow:

  1. Bild auswählen (Drag & Drop oder File Picker)
  2. Alt-Text eingeben (Accessibility)
  3. Upload zu /api/gallery/upload
  4. Automatische Optimierung mit Sharp
  5. Display in Gallery-Grid

Reordering:

  • Identisches Drag-and-Drop-System wie bei Events
  • Batch-Update über /api/gallery/reorder
  • Display-Order bestimmt Carousel-Reihenfolge

Delete-Funktion:

  • Löscht sowohl DB-Eintrag als auch physische Datei
  • Confirmation-Dialog (Browser-native)

4. Banner-Management

Banners sind zeitlich begrenzte Ankündigungen (z.B., "Happy Hour heute 17-19 Uhr" oder "Geschlossen am 25. Dezember").

Banner erstellen:

- Text (z.B., "🎉 50% auf alle Cocktails von 17-19 Uhr!")
- Start-Datum (YYYY-MM-DD)
- End-Datum (YYYY-MM-DD)
- Active-Toggle (manuelles Ein-/Ausschalten)

Smart Display Logic:

  • Backend prüft automatisch, ob aktuelles Datum innerhalb Start/End liegt
  • Public Endpoint: /api/banners/active
  • Nur aktive Banner innerhalb des Datumsfensters werden angezeigt
  • Frontend-Komponente Banner.astro fetcht aktiven Banner client-side

Banner-Liste im Admin:

  • Alle Banner mit Status-Anzeige
  • Edit und Delete für jedes Banner
  • Sortiert nach Creation-Date

5. Content-Sections (Hero, Welcome, Drinks)

Dynamische Content-Generierung:

Diese Sections werden nicht direkt bearbeitet, sondern sind im Backend als JSON-Objekte in der contentSections-Tabelle gespeichert. Das Admin-Portal könnte künftig einen visuellen Editor dafür bieten.

Aktuell im Datenbank-Schema:

// Hero Section
{
  "sectionName": "hero",
  "contentJson": {
    "heading1": "Willkommen im",
    "heading2": "Gallus Pub",
    "backgroundImage": "/images/hero-bg.jpg"
  }
}

// Welcome Section
{
  "sectionName": "welcome",
  "contentJson": {
    "heading": "Dein Irish Pub im Herzen von St. Gallen",
    "paragraph": "Erlebe authentische irische Gastfreundschaft...",
    "highlights": [
      "Live-Musik jeden Freitag",
      "Über 30 Whiskey-Sorten",
      "Guinness vom Fass"
    ],
    "backgroundImage": "/images/welcome-bg.jpg"
  }
}

// Drinks Section
{
  "sectionName": "drinks",
  "contentJson": {
    "heading": "Unsere Getränke",
    "monthlySpecial": "Jameson Caskmates",
    "featuredWhiskeys": [
      {
        "name": "Tullamore Dew",
        "image": "/images/tullamore.jpg"
      },
      // ...
    ],
    "menuPdfUrl": "/pdf/drinks-menu.pdf"
  }
}

File-Generation-Process:

Beim Publish generiert der FileGeneratorService Astro-Komponenten aus diesem JSON:

// Beispiel: Hero.astro wird generiert
const heroContent = await contentService.getSection('hero');
const heroTemplate = `
---
const { heading1, heading2, backgroundImage } = ${JSON.stringify(heroContent)};
---
<section class="hero" style="background-image: url({backgroundImage})">
  <h1>{heading1}</h1>
  <h2>{heading2}</h2>
</section>
`;
await fs.writeFile('src/components/Hero.astro', heroTemplate);

6. Publishing-System

Der Publish-Workflow:

Dies ist das innovativste Feature des CMS - ein Ein-Klick-Deployment mit vollständiger Git-Integration.

UI:

  • Commit-Message-Eingabefeld
  • "Publish All Changes"-Button
  • Letzte 20 Publishes mit Details (User, Zeit, Commit-Hash, Message)

Backend-Workflow bei Publish:

// Pseudo-Code des Publish-Prozesses
async function publishChanges(commitMessage: string, userId: string) {
  try {
    // 1. Repository Pull (neueste Änderungen)
    await gitService.pullRepository();

    // 2. Generiere index.astro mit Events & Gallery
    const events = await eventsService.getAllPublished();
    const gallery = await galleryService.getAllPublished();
    await fileGenerator.generateIndexAstro(events, gallery);

    // 3. Generiere Component-Files
    const heroContent = await contentService.getSection('hero');
    await fileGenerator.generateHeroComponent(heroContent);

    const welcomeContent = await contentService.getSection('welcome');
    await fileGenerator.generateWelcomeComponent(welcomeContent);

    const drinksContent = await contentService.getSection('drinks');
    await fileGenerator.generateDrinksComponent(drinksContent);

    // 4. Git Add
    await gitService.addAll();

    // 5. Git Commit
    const commitHash = await gitService.commit(commitMessage);

    // 6. Git Push zu Gitea
    await gitService.push();

    // 7. Record in Publish-History
    await publishHistoryService.create({
      userId,
      commitHash,
      commitMessage,
      publishedAt: new Date()
    });

    return { success: true, commitHash };

  } catch (error) {
    // Error Recovery: Git Reset
    await gitService.reset();
    throw error;
  }
}

Vorteile dieses Ansatzes:

  1. Audit Trail: Jede Änderung ist ein Git-Commit mit User-Attribution
  2. Rollback-Möglichkeit: Bei Problemen kann zu früheren Commits zurückgekehrt werden
  3. CI/CD-Trigger: Woodpecker CI erkennt Push und deployt automatisch
  4. Version Control: Alle Content-Änderungen sind versioniert
  5. Collaboration-Ready: Mehrere Admins können gleichzeitig arbeiten (Git Merge)

Admin-Portal UI/UX-Details

Design-Prinzipien:

  • Single-Page-Anwendung ohne Framework (Vanilla JS)
  • Section-basiertes Layout (Auth, Events, Gallery, Banners, Publish)
  • Responsive Design für Mobile und Desktop
  • Real-Time Feedback bei API-Calls
  • Error-Handling mit User-Friendly Messages

Performance-Optimierungen:

  • Lazy-Loading von Event-/Gallery-Bildern
  • Debouncing bei Drag-and-Drop-Updates
  • Optimistic UI-Updates (sofortige visuelle Änderung, dann API-Call)

Backend-Architektur

API-Design

RESTful API mit Fastify:

Das Backend folgt REST-Konventionen mit klarer Ressourcen-Struktur:

/api
├── /auth
│   ├── GET  /gitea              → OAuth Flow Start
│   ├── GET  /callback           → OAuth Callback Handler
│   ├── GET  /me                 → Current User Info (protected)
│   └── POST /logout             → Logout & Clear Cookie (protected)
├── /events
│   ├── GET    /public           → Published Events (public)
│   ├── GET    /                 → All Events (protected)
│   ├── GET    /:id              → Single Event (protected)
│   ├── POST   /                 → Create Event (protected)
│   ├── PUT    /:id              → Update Event (protected)
│   ├── DELETE /:id              → Delete Event (protected)
│   ├── POST   /upload           → Upload Event Image (protected)
│   └── PUT    /reorder          → Batch Reorder (protected)
├── /gallery
│   ├── GET    /public           → Published Images (public)
│   ├── GET    /                 → All Images (protected)
│   ├── POST   /                 → Create Entry (protected)
│   ├── PUT    /:id              → Update Entry (protected)
│   ├── DELETE /:id              → Delete Entry (protected)
│   ├── POST   /upload           → Upload Image (protected)
│   └── PUT    /reorder          → Batch Reorder (protected)
├── /banners
│   ├── GET    /active           → Current Active Banner (public)
│   ├── GET    /                 → All Banners (protected)
│   ├── POST   /                 → Create Banner (protected)
│   ├── PUT    /:id              → Update Banner (protected)
│   └── DELETE /:id              → Delete Banner (protected)
├── /content
│   ├── GET /                    → List All Sections (protected)
│   ├── GET /:section            → Get Section Content (protected)
│   └── PUT /:section            → Update Section (protected)
├── /settings
│   ├── GET    /                 → All Settings (protected)
│   ├── GET    /:key             → Single Setting (protected)
│   ├── PUT    /:key             → Update Setting (protected)
│   └── DELETE /:key             → Delete Setting (protected)
├── /publish
│   ├── POST /                   → Publish Changes (protected)
│   └── GET  /history            → Last 20 Publishes (protected)
└── /health                       → Service Health Check (public)

Authentication Middleware:

// auth.middleware.ts
export async function authenticateHandler(
  request: FastifyRequest,
  reply: FastifyReply
) {
  try {
    // Verify JWT from cookie
    await request.jwtVerify();

    // Attach user to request
    const payload = request.user as JWTPayload;
    request.userId = payload.userId;

  } catch (error) {
    reply.code(401).send({
      error: 'Unauthorized',
      message: 'Invalid or expired token'
    });
  }
}

// Usage in routes
fastify.get('/events', {
  preHandler: [fastify.authenticate]
}, async (request, reply) => {
  // Protected route logic
});

Datenbank-Schema

SQLite mit Drizzle ORM:

// schema.ts - Vollständiges Schema

// Users Table
export const users = sqliteTable('users', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  giteaId: text('gitea_id').notNull().unique(),
  giteaUsername: text('gitea_username').notNull(),
  giteaEmail: text('gitea_email'),
  displayName: text('display_name'),
  avatarUrl: text('avatar_url'),
  role: text('role').default('admin'),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date()),
  lastLogin: integer('last_login', { mode: 'timestamp' })
});

// Events Table
export const events = sqliteTable('events', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  title: text('title').notNull(),
  date: text('date').notNull(), // ISO 8601 format
  description: text('description').notNull(),
  imageUrl: text('image_url').notNull(),
  displayOrder: integer('display_order').default(0),
  isPublished: integer('is_published', { mode: 'boolean' }).default(true),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
});

// Gallery Images Table
export const galleryImages = sqliteTable('gallery_images', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  imageUrl: text('image_url').notNull(),
  altText: text('alt_text').notNull(),
  displayOrder: integer('display_order').default(0),
  isPublished: integer('is_published', { mode: 'boolean' }).default(true),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
});

// Content Sections (Hero, Welcome, Drinks)
export const contentSections = sqliteTable('content_sections', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  sectionName: text('section_name').notNull().unique(), // 'hero', 'welcome', 'drinks'
  contentJson: text('content_json', { mode: 'json' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
});

// Site Settings (Key-Value)
export const siteSettings = sqliteTable('site_settings', {
  key: text('key').primaryKey(),
  value: text('value').notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
});

// Publish History (Audit Log)
export const publishHistory = sqliteTable('publish_history', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId: text('user_id').notNull().references(() => users.id),
  commitHash: text('commit_hash').notNull(),
  commitMessage: text('commit_message').notNull(),
  publishedAt: integer('published_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
});

// Banners Table
export const banners = sqliteTable('banners', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  text: text('text').notNull(),
  startDate: text('start_date').notNull(), // YYYY-MM-DD
  endDate: text('end_date').notNull(),     // YYYY-MM-DD
  isActive: integer('is_active', { mode: 'boolean' }).default(true),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' })
    .$defaultFn(() => new Date())
});

Warum SQLite?

Für dieses Projekt ist SQLite die ideale Wahl:

  1. Einfachheit: Keine separate Datenbank-Server-Installation
  2. Portabilität: Eine einzige Datei, einfach zu backupen
  3. Performance: Ausreichend schnell für CMS-Operationen (nicht high-traffic)
  4. Deployment: Fly.io Volumes für Persistenz, keine Managed Database nötig
  5. Cost-Efficiency: Keine zusätzlichen Datenbank-Kosten

WAL-Modus für Concurrency:

// database.ts
const db = new Database(DATABASE_PATH);
db.pragma('journal_mode = WAL'); // Write-Ahead Logging

WAL-Modus ermöglicht gleichzeitige Reads während Writes, wichtig für Admin-Zugriffe.

Services-Schicht

Service-Oriented Architecture:

Das Backend ist in wiederverwendbare Services aufgeteilt:

  1. GitService (git.service.ts)

    • Repository-Cloning mit OAuth Token
    • Pull, Add, Commit, Push-Operationen
    • Reset-on-Error für sichere Rollbacks
  2. GiteaService (gitea.service.ts)

    • OAuth2-Token-Exchange
    • User-Info-Fetching von Gitea API
    • User-Allowlist-Validation
  3. MediaService (media.service.ts)

    • Image-Upload-Handling
    • Sharp-basierte Optimierung
    • File-System-Management (Save/Delete)
  4. FileGeneratorService (file-generator.service.ts)

    • Dynamische Astro-Component-Generierung
    • Template-String-Escaping
    • index.astro-Regeneration mit Events/Gallery

Beispiel: FileGeneratorService

// Vereinfachter Code
class FileGeneratorService {
  async generateIndexAstro(events: Event[], gallery: GalleryImage[]) {
    const template = `
---
import Layout from '../components/Layout.astro';
import Hero from '../components/Hero.astro';
import Banner from '../components/Banner.astro';
import Welcome from '../components/Welcome.astro';
import EventsGrid from '../components/EventsGrid.astro';
import ImageCarousel from '../components/ImageCarousel.astro';
import Drinks from '../components/Drinks.astro';
import Contact from '../components/Contact.astro';

const events = ${JSON.stringify(events)};
const galleryImages = ${JSON.stringify(gallery)};
---

<Layout title="Gallus Pub - Irish Pub St. Gallen">
  <Hero />
  <Banner />
  <Welcome />
  <EventsGrid events={events} />
  <ImageCarousel images={galleryImages} />
  <Drinks />
  <Contact />
</Layout>
    `;

    await fs.writeFile(
      path.join(GIT_WORKSPACE_DIR, 'src/pages/index.astro'),
      template
    );
  }

  async generateHeroComponent(content: HeroContent) {
    // Escape quotes in content
    const escaped = this.escapeTemplateString(JSON.stringify(content));

    const template = `
---
const content = ${escaped};
---
<section class="hero" style={\`background-image: url(\${content.backgroundImage})\`}>
  <div class="hero-content">
    <h1>{content.heading1}</h1>
    <h2>{content.heading2}</h2>
  </div>
</section>
    `;

    await fs.writeFile(
      path.join(GIT_WORKSPACE_DIR, 'src/components/Hero.astro'),
      template
    );
  }

  private escapeTemplateString(str: string): string {
    return str
      .replace(/\\/g, '\\\\')
      .replace(/`/g, '\\`')
      .replace(/\$/g, '\\$');
  }
}

Image Processing Pipeline

Sharp-basierte Optimierung:

// media.service.ts
async function optimizeImage(buffer: Buffer): Promise<Buffer> {
  try {
    return await sharp(buffer)
      .rotate() // Auto-rotate basierend auf EXIF
      .resize(1600, null, {
        withoutEnlargement: true, // Kein Upscaling
        fit: 'inside' // Aspect Ratio beibehalten
      })
      .webp({ quality: 82 }) // WebP Konvertierung
      .toBuffer();
  } catch (error) {
    // Fallback: Original zurückgeben
    console.warn('WebP conversion failed, using original', error);
    return buffer;
  }
}

Warum WebP?

  • 25-35% kleinere Dateigrößen vs. JPEG
  • Transparenz-Support (vs. JPEG)
  • Breite Browser-Unterstützung (95%+)
  • Fallback auf Original bei Conversion-Failure

Frontend-Architektur

Astro Static Site Generator

Warum Astro?

Astro ist perfekt für Content-fokussierte Websites:

  1. Zero-JS by Default: Sendet kein JavaScript, es sei denn explizit benötigt
  2. Component Islands: Nur interaktive Komponenten sind hydratiert
  3. Framework-Agnostic: Kann React, Vue, Svelte-Komponenten mixen (hier: Pure Astro)
  4. Performance-First: Automatische Image-Optimierung, Lazy-Loading
  5. TypeScript-Native: Type-Safety auch im Frontend

Component-Architektur

Haupt-Layout:

---
// Layout.astro
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{title}</title>
  <link rel="stylesheet" href="/styles/index.css">
</head>
<body>
  <Header />
  <main>
    <slot />
  </main>
  <Footer />
</body>
</html>

Event-Grid Komponente:

---
// EventsGrid.astro
import HoverCard from './HoverCard.astro';

const { events } = Astro.props;
---

<section class="events-section">
  <h2>Kommende Events</h2>
  <div class="events-grid">
    {events.map(event => (
      <HoverCard
        title={event.title}
        date={event.date}
        description={event.description}
        imageUrl={event.imageUrl}
      />
    ))}
  </div>
</section>

<style>
  .events-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 2rem;
    padding: 2rem;
  }
</style>

Image Carousel mit Client-Side JavaScript:

---
// ImageCarousel.astro
const { images } = Astro.props;
---

<div class="carousel">
  <div class="carousel-slides">
    {images.map((img, index) => (
      <img
        src={img.imageUrl}
        alt={img.altText}
        class={index === 0 ? 'active' : ''}
        loading="lazy"
      />
    ))}
  </div>
  <button class="carousel-prev">‹</button>
  <button class="carousel-next">›</button>
  <div class="carousel-dots"></div>
</div>

<script>
  // Vanilla JS für Carousel-Logik
  let currentSlide = 0;
  const slides = document.querySelectorAll('.carousel-slides img');
  const dots = document.querySelector('.carousel-dots');

  // Initialize dots
  slides.forEach((_, i) => {
    const dot = document.createElement('span');
    dot.className = i === 0 ? 'dot active' : 'dot';
    dot.addEventListener('click', () => goToSlide(i));
    dots.appendChild(dot);
  });

  function goToSlide(n) {
    slides[currentSlide].classList.remove('active');
    dots.children[currentSlide].classList.remove('active');
    currentSlide = n;
    slides[currentSlide].classList.add('active');
    dots.children[currentSlide].classList.add('active');
  }

  document.querySelector('.carousel-prev').addEventListener('click', () => {
    goToSlide((currentSlide - 1 + slides.length) % slides.length);
  });

  document.querySelector('.carousel-next').addEventListener('click', () => {
    goToSlide((currentSlide + 1) % slides.length);
  });
</script>

<style>
  .carousel { position: relative; }
  .carousel-slides img {
    display: none;
    width: 100%;
    height: auto;
  }
  .carousel-slides img.active { display: block; }
  /* ... weitere Styles ... */
</style>

CSS-Architektur

Design-System mit CSS Custom Properties:

/* variables.css */
:root {
  /* Colors */
  --color-primary: #1a472a; /* Irish Green */
  --color-secondary: #ff7f00; /* Whiskey Orange */
  --color-dark: #1a1a1a;
  --color-light: #f5f5f5;
  --color-accent: #8b4513; /* Leather Brown */

  /* Typography */
  --font-primary: 'Georgia', serif;
  --font-secondary: 'Helvetica Neue', sans-serif;
  --font-size-base: 16px;
  --font-size-large: 24px;
  --font-size-xlarge: 36px;

  /* Spacing */
  --spacing-xs: 0.5rem;
  --spacing-sm: 1rem;
  --spacing-md: 2rem;
  --spacing-lg: 4rem;

  /* Layout */
  --max-width: 1200px;
  --border-radius: 8px;
  --transition: all 0.3s ease;
}

Component-Scoped Styles:

Astro's <style> Tags sind automatisch component-scoped (ähnlich CSS Modules):

<style>
  .hero {
    /* Diese Styles gelten nur für Hero.astro */
    background-size: cover;
    background-position: center;
    min-height: 60vh;
  }
</style>

Responsive Design:

/* Mobile-First Approach */
.events-grid {
  grid-template-columns: 1fr; /* Mobile: 1 Spalte */
}

@media (min-width: 768px) {
  .events-grid {
    grid-template-columns: repeat(2, 1fr); /* Tablet: 2 Spalten */
  }
}

@media (min-width: 1024px) {
  .events-grid {
    grid-template-columns: repeat(3, 1fr); /* Desktop: 3 Spalten */
  }
}

Besondere Features

1. Git-basiertes Publishing

Innovative Lösung für Deployment:

Anstatt nur die Datenbank zu aktualisieren, committed das CMS alle Änderungen direkt in Git. Das hat mehrere Vorteile:

Vorteile:

  • Version Control: Jede Content-Änderung ist nachvollziehbar
  • Rollback: Kann zu jedem früheren Zustand zurückkehren
  • Collaboration: Mehrere Admins können gleichzeitig arbeiten
  • CI/CD Integration: Woodpecker erkennt Commits automatisch
  • Audit Trail: Wer hat was wann geändert?

Implementation:

// git.service.ts
class GitService {
  async pullRepository(): Promise<void> {
    const git = simpleGit(GIT_WORKSPACE_DIR);
    await git.pull('origin', 'main');
  }

  async commitAndPush(message: string): Promise<string> {
    const git = simpleGit(GIT_WORKSPACE_DIR);

    // Configure user
    await git.addConfig('user.name', GIT_USER_NAME);
    await git.addConfig('user.email', GIT_USER_EMAIL);

    // Stage all changes
    await git.add('.');

    // Commit
    const commit = await git.commit(message);

    // Push with OAuth token
    const remoteUrl = `https://oauth2:${GIT_TOKEN}@${GIT_REPO_URL.replace('https://', '')}`;
    await git.push(remoteUrl, 'main');

    return commit.commit; // Hash
  }

  async reset(): Promise<void> {
    const git = simpleGit(GIT_WORKSPACE_DIR);
    await git.reset(['--hard', 'HEAD']);
  }
}

2. Dynamische File-Generierung

Problem: Astro ist ein Static Site Generator - es gibt keine Datenbank zur Build-Zeit.

Lösung: Das Backend generiert Astro-Component-Dateien mit eingebetteten Daten.

Beispiel-Workflow:

1. Admin fügt Event hinzu → Speichert in SQLite
2. Admin klickt "Publish"
3. Backend liest Events aus Datenbank
4. Backend generiert index.astro:

   ---
   const events = [
     { title: "Quiz Night", date: "2026-02-15", ... },
     { title: "Live Music", date: "2026-02-20", ... }
   ];
   ---
   <EventsGrid events={events} />

5. Git Commit & Push
6. Woodpecker CI buildet Astro-Site
7. Events sind jetzt im statischen HTML

Template-String-Escaping:

Ein kritisches Detail - Content kann Quotes und Backticks enthalten:

function escapeForTemplate(content: any): string {
  const json = JSON.stringify(content);
  return json
    .replace(/\\/g, '\\\\')  // Escape backslashes
    .replace(/`/g, '\\`')    // Escape backticks
    .replace(/\$/g, '\\$');  // Escape template literals
}

// Verwendung
const template = `const events = ${escapeForTemplate(eventsArray)};`;

3. OAuth2-Integration mit Gitea

Self-Hosted Authentication:

Anstatt Drittanbieter wie Auth0 oder Firebase zu verwenden, nutzt das Projekt den eigenen Gitea-Server für OAuth.

Vorteile:

  • Kostenfrei: Keine externen Auth-Provider-Kosten
  • Privacy: User-Daten bleiben auf eigenen Servern
  • Single Sign-On: Admins sind bereits Gitea-User
  • Custom Allowlist: Einfache User-Kontrolle via ENV

OAuth2-Flow (vereinfacht):

// 1. Start OAuth Flow
fastify.get('/auth/gitea', async (request, reply) => {
  const state = crypto.randomUUID();

  reply.setCookie('oauth_state', state, {
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 600 // 10 Minuten
  });

  const authUrl = `${GITEA_URL}/login/oauth/authorize?` +
    `client_id=${GITEA_CLIENT_ID}` +
    `&redirect_uri=${GITEA_REDIRECT_URI}` +
    `&response_type=code` +
    `&state=${state}`;

  reply.redirect(authUrl);
});

// 2. Callback Handler
fastify.get('/auth/callback', async (request, reply) => {
  const { code, state } = request.query;
  const storedState = request.cookies.oauth_state;

  // CSRF Check
  if (state !== storedState) {
    throw new Error('State mismatch');
  }

  // Exchange code for token
  const tokenResponse = await fetch(`${GITEA_URL}/login/oauth/access_token`, {
    method: 'POST',
    body: JSON.stringify({
      client_id: GITEA_CLIENT_ID,
      client_secret: GITEA_CLIENT_SECRET,
      code,
      grant_type: 'authorization_code',
      redirect_uri: GITEA_REDIRECT_URI
    })
  });

  const { access_token } = await tokenResponse.json();

  // Get user info
  const userResponse = await fetch(`${GITEA_URL}/api/v1/user`, {
    headers: { Authorization: `token ${access_token}` }
  });

  const giteaUser = await userResponse.json();

  // Check allowlist
  if (GITEA_ALLOWED_USERS && !GITEA_ALLOWED_USERS.includes(giteaUser.login)) {
    throw new Error('User not allowed');
  }

  // Find or create local user
  let user = await db.query.users.findFirst({
    where: eq(users.giteaId, String(giteaUser.id))
  });

  if (!user) {
    user = await db.insert(users).values({
      giteaId: String(giteaUser.id),
      giteaUsername: giteaUser.login,
      giteaEmail: giteaUser.email,
      displayName: giteaUser.full_name,
      avatarUrl: giteaUser.avatar_url
    }).returning();
  }

  // Sign JWT
  const token = fastify.jwt.sign({
    userId: user.id,
    giteaUsername: user.giteaUsername
  });

  // Set HttpOnly cookie
  reply.setCookie('token', token, {
    httpOnly: true,
    secure: NODE_ENV === 'production',
    sameSite: NODE_ENV === 'production' ? 'none' : 'lax',
    maxAge: 86400 // 24 Stunden
  });

  reply.redirect(`${FRONTEND_URL}/admin`);
});

4. Image-Optimierung mit Sharp

High-Performance Image Processing:

Sharp ist eine der schnellsten Image-Processing-Libraries für Node.js (nutzt libvips).

Optimierungen:

  1. EXIF-basiertes Auto-Rotate:

    • Smartphones speichern Rotation in EXIF-Daten
    • .rotate() wendet diese automatisch an
  2. Responsive Sizing:

    • Max. 1600px Breite für Desktop-Displays
    • withoutEnlargement: true verhindert Upscaling (Qualitätsverlust)
  3. WebP-Konvertierung:

    • 82% Qualität ist Sweet Spot (gute Qualität, große Einsparung)
    • Fallback auf Original bei Conversion-Fehlern
  4. File-Size Reduktion:

    • Typisch: 1-3MB JPEGs → 200-500KB WebP
    • 60-80% Bandwidth-Einsparung

Implementation:

async function processUpload(file: MultipartFile): Promise<string> {
  // Read file buffer
  const buffer = await file.toBuffer();

  // Process with Sharp
  const optimized = await sharp(buffer)
    .rotate() // EXIF auto-rotate
    .resize(1600, null, {
      withoutEnlargement: true,
      fit: 'inside'
    })
    .webp({ quality: 82 })
    .toBuffer();

  // Generate unique filename
  const filename = `${crypto.randomUUID()}.webp`;
  const filepath = path.join(IMAGES_DIR, filename);

  // Save to disk
  await fs.writeFile(filepath, optimized);

  return `/static/images/${filename}`;
}

5. Drag-and-Drop Reordering

Native Drag-and-Drop API:

Anstatt einer Library wie react-beautiful-dnd, nutzt das Admin-Portal die native HTML Drag-and-Drop API.

Implementation (Vanilla JS):

// Simplified code from admin.astro

let draggedElement = null;

function makeDraggable(element) {
  element.draggable = true;

  element.addEventListener('dragstart', (e) => {
    draggedElement = element;
    element.classList.add('dragging');
    e.dataTransfer.effectAllowed = 'move';
  });

  element.addEventListener('dragend', (e) => {
    element.classList.remove('dragging');
  });

  element.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';

    // Visual feedback
    const afterElement = getDragAfterElement(e.clientY);
    if (afterElement == null) {
      element.parentElement.appendChild(draggedElement);
    } else {
      element.parentElement.insertBefore(draggedElement, afterElement);
    }
  });

  element.addEventListener('drop', async (e) => {
    e.preventDefault();

    // Collect new order
    const items = Array.from(element.parentElement.children);
    const newOrder = items.map((el, index) => ({
      id: el.dataset.id,
      displayOrder: index
    }));

    // Send to backend
    await fetch('/api/events/reorder', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify(newOrder)
    });
  });
}

function getDragAfterElement(y) {
  const draggableElements = [
    ...document.querySelectorAll('.draggable:not(.dragging)')
  ];

  return draggableElements.reduce((closest, child) => {
    const box = child.getBoundingClientRect();
    const offset = y - box.top - box.height / 2;

    if (offset < 0 && offset > closest.offset) {
      return { offset, element: child };
    } else {
      return closest;
    }
  }, { offset: Number.NEGATIVE_INFINITY }).element;
}

Backend-Endpoint:

// PUT /api/events/reorder
fastify.put('/reorder', {
  preHandler: [fastify.authenticate]
}, async (request, reply) => {
  const updates = request.body as Array<{ id: string, displayOrder: number }>;

  // Batch update in transaction
  await db.transaction(async (tx) => {
    for (const { id, displayOrder } of updates) {
      await tx.update(events)
        .set({ displayOrder })
        .where(eq(events.id, id));
    }
  });

  return { success: true };
});

Deployment & Infrastructure

Fly.io-Architektur

Zwei separate Apps:

  1. Backend (cms-gallus-pub):

    • Node.js/Fastify-App
    • Dockerfile-basiertes Build
    • Persistent Volume für SQLite & Git-Workspace
    • Environment Variables via Fly Secrets
  2. Frontend (gallus-pub):

    • Astro Static Build
    • Serve via Node.js HTTP-Server
    • Separate Dockerfile
    • No persistent storage needed

Backend fly.toml:

app = "cms-gallus-pub"
primary_region = "fra"

[build]
  dockerfile = "Dockerfile"

[env]
  NODE_ENV = "production"
  PORT = "8080"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0
  processes = ["app"]

[[http_service.checks]]
  interval = "10s"
  timeout = "2s"
  grace_period = "5s"
  method = "GET"
  path = "/api/health"

[mounts]
  source = "gallus_cms_data"
  destination = "/app/data"
  initial_size = "1GB"

Volume für Persistenz:

# Create volume
fly volumes create gallus_cms_data --size 1 --region fra

# Volume enthält:
# /app/data/gallus_cms.db        → SQLite Database
# /app/data/workspace/           → Git Repository Checkout
# /app/data/public/images/       → Uploaded Images

Docker Setup

Backend Dockerfile:

FROM node:20-alpine AS builder

WORKDIR /app

# Install dependencies
COPY backend/package*.json ./
RUN npm ci --only=production

# Copy source
COPY backend/ .

# Build TypeScript
RUN npm run build

# Production image
FROM node:20-alpine

WORKDIR /app

# Copy built files & dependencies
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

# Create data directory
RUN mkdir -p /app/data/public/images

# Expose port
EXPOSE 8080

# Start server
CMD ["node", "dist/index.js"]

Frontend Dockerfile:

FROM node:20-alpine AS builder

WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm ci

# Copy source
COPY . .

# Build Astro
RUN npm run build

# Production image
FROM node:20-alpine

WORKDIR /app

# Install serve
RUN npm install -g serve

# Copy built files
COPY --from=builder /app/dist ./dist

# Expose port
EXPOSE 3000

# Serve static files
CMD ["serve", "-s", "dist", "-l", "3000"]

Lokale Entwicklung mit Docker Compose

docker-compose.yml:

version: '3.8'

services:
  backend:
    build:
      context: .
      dockerfile: backend/Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_PATH=/app/data/gallus_cms.db
      - GIT_WORKSPACE_DIR=/app/data/workspace
      - CORS_ORIGIN=http://localhost:5173
      - FRONTEND_URL=http://localhost:5173
    env_file:
      - backend/.env.local
    volumes:
      - ./backend/src:/app/src
      - backend-data:/app/data
    command: npm run dev

  frontend:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "5173:5173"
    volumes:
      - ./src:/app/src
      - ./public:/app/public
    command: npm run dev

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config

volumes:
  backend-data:
  caddy-data:
  caddy-config:

Caddyfile (Reverse Proxy):

localhost {
  # Frontend
  route /* {
    reverse_proxy frontend:5173
  }

  # Backend API
  route /api/* {
    reverse_proxy backend:3000
  }

  # Static files (images)
  route /static/* {
    reverse_proxy backend:3000
  }
}

CI/CD mit Woodpecker

.woodpecker.yml:

pipeline:
  build-frontend:
    image: node:20-alpine
    commands:
      - npm ci
      - npm run build
    when:
      branch: main
      path: "src/**"

  deploy-frontend:
    image: flyio/flyctl
    secrets: [ fly_api_token ]
    commands:
      - flyctl deploy --config fly.toml
    when:
      branch: main
      event: push

  build-backend:
    image: node:20-alpine
    commands:
      - cd backend
      - npm ci
      - npm run build
    when:
      branch: main
      path: "backend/**"

  deploy-backend:
    image: flyio/flyctl
    secrets: [ fly_api_token ]
    commands:
      - flyctl deploy --config backend/fly.toml
    when:
      branch: main
      event: push

Environment Variables

Backend (.env.local):

# Gitea OAuth
GITEA_URL=https://git.bookageek.ch
GITEA_CLIENT_ID=abc123...
GITEA_CLIENT_SECRET=xyz789...
GITEA_REDIRECT_URI=https://cms.gallus-pub.ch/api/auth/callback
GITEA_ALLOWED_USERS=sabrina,raphael

# Git Integration
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub.git
GIT_TOKEN=gitea_personal_access_token
GIT_USER_NAME=Gallus CMS
GIT_USER_EMAIL=cms@galluspub.ch
GIT_WORKSPACE_DIR=/app/data/workspace

# Database
DATABASE_PATH=/app/data/gallus_cms.db

# JWT & Session
JWT_SECRET=your-random-base64-secret-here
SESSION_SECRET=another-random-base64-secret

# Server
PORT=8080
NODE_ENV=production
CORS_ORIGIN=https://gallus-pub.ch
FRONTEND_URL=https://gallus-pub.ch

# File Upload
MAX_FILE_SIZE=5242880

Fly Secrets setzen:

# Backend
fly secrets set \
  GITEA_CLIENT_SECRET=xyz789... \
  GIT_TOKEN=gitea_token... \
  JWT_SECRET=random_base64... \
  SESSION_SECRET=random_base64... \
  --app cms-gallus-pub

# Frontend (keine Secrets benötigt)

Lessons Learned & Highlights

Technische Highlights

  1. Headless CMS mit Git-Integration

    • Innovative Kombination von CMS und Version Control
    • Ermöglicht Content-Versionierung und Rollbacks
    • Audit Trail für alle Änderungen
  2. Typsichere Full-Stack-TypeScript-Architektur

    • Shared Types zwischen Frontend und Backend
    • Reduzierung von Runtime-Errors durch Compile-Time-Checks
    • Bessere Developer Experience mit Autocomplete
  3. Performance-Optimierung

    • Statische Site-Generierung für schnellste Ladezeiten
    • WebP-Konvertierung für 60-80% kleinere Bilder
    • Zero-JS-by-Default mit Astro
  4. Self-Hosted Infrastructure

    • Keine Abhängigkeit von Drittanbieter-Auth-Services
    • Kostenkontrolle durch SQLite statt Managed Database
    • Privacy-freundlich (alle Daten auf eigenen Servern)
  5. Developer-Friendly Workflows

    • Hot-Reload in Development
    • Docker Compose für einfaches lokales Setup
    • Klare Service-Separation

Herausforderungen & Lösungen

Challenge 1: Static Site Generator + Dynamic Content

Problem: Astro generiert HTML zur Build-Zeit, aber CMS-Content ändert sich dynamisch.

Lösung: Backend generiert Astro-Component-Files mit eingebetteten Daten, committed zu Git, CI/CD rebuildet automatisch.


Challenge 2: Cross-Domain Authentication

Problem: Frontend (gallus-pub.ch) muss mit Backend (cms.gallus-pub.ch) kommunizieren, Browser blockieren Third-Party Cookies.

Lösung:

  • SameSite=None mit Secure=true für Production
  • credentials: 'include' in Fetch-Requests
  • CORS-Konfiguration mit credentials: true

Challenge 3: Image Upload & Optimization

Problem: User-Uploads können mehrere MB groß sein, langsame Ladezeiten.

Lösung:

  • Sharp-Pipeline: Resize + WebP-Konvertierung
  • Automatisches EXIF-Rotate für korrekte Orientierung
  • Fallback auf Original bei Conversion-Fehlern

Challenge 4: Drag-and-Drop ohne Framework

Problem: React Beautiful DnD wäre Overkill für ein Astro-Projekt.

Lösung: Native HTML Drag-and-Drop API mit Vanilla JavaScript, Batch-Update per Single API-Call.


Challenge 5: Secure OAuth ohne External Provider

Problem: Auth0/Firebase sind teuer oder mit Privacy-Problemen behaftet.

Lösung: Self-Hosted Gitea OAuth2, User-Allowlist, State-basierte CSRF-Protection.

Mögliche Erweiterungen

Kurzfristig:

  1. Visual Editor für Content Sections (Hero, Welcome, Drinks)
  2. Image-Cropping im Upload-Dialog
  3. Bulk-Delete für Events/Gallery
  4. Search & Filter im Admin-Portal
  5. Publish-Preview (vor Git-Commit)

Mittelfristig:

  1. Multi-Language Support (DE/EN)
  2. Role-Based Access Control (Editor vs. Admin)
  3. Email-Benachrichtigungen bei Publishes
  4. Analytics-Integration (Privacy-friendly)
  5. PDF-Menü-Generator aus CMS

Langfristig:

  1. Mobile App für Admin-Portal
  2. Reservierungs-System für Events
  3. Newsletter-Integration
  4. Social Media Auto-Posting
  5. A/B-Testing für Content

Fazit

Das Gallus Pub CMS demonstriert moderne Full-Stack-Entwicklung mit einem einzigartigen Ansatz: Die Kombination von Git-basiertem Publishing, Headless CMS-Architektur und statischer Site-Generierung schafft eine Lösung, die gleichzeitig performant, sicher und wartbar ist.

Kernkompetenzen gezeigt:

  • Full-Stack TypeScript (Fastify + Astro)
  • OAuth2-Authentifizierung & JWT-Sessions
  • SQLite-Datenbank-Design mit Drizzle ORM
  • Git-Integration & CI/CD-Workflows
  • Image Processing mit Sharp
  • Responsive UI/UX ohne Frameworks
  • Docker & Fly.io Deployment
  • RESTful API-Design

Besonderheit: Dieses Projekt zeigt ein tiefes Verständnis für Web-Architektur - die Entscheidung für statische Site-Generierung + Headless CMS ist nicht die "einfache" Lösung, aber die richtige für Performance und Sicherheit. Die Git-basierte Publishing-Pipeline ist eine innovative Lösung, die in kommerziellen CMS selten zu finden ist.

Produktionsreif: Die Anwendung ist live auf gallus-pub.ch und wird aktiv genutzt, mit echten Events, Gallery-Bildern und regelmäßigen Content-Updates.


Links & Ressourcen

  • Live Website: https://gallus-pub.ch
  • CMS Backend: https://cms.gallus-pub.ch
  • Git Repository: https://git.bookageek.ch/Kenzo/Gallus_Pub

Technologie-Dokumentation:


Entwickelt mit ❤️ für authentische irische Pub-Atmosphäre in St. Gallen.