18 Commits
Author SHA1 Message Date
Gallus-maintanance c0d6a1c5de Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-15 09:51:18 +00:00
Gallus-maintanance d678d80116 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 11:54:15 +00:00
Gallus-maintanance bce973679b Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 11:39:35 +00:00
Gallus-maintanance 45f705a104 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 11:20:26 +00:00
Gallus-maintanance c4f17d5f78 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-31 10:51:05 +00:00
Gallus-maintanance 247f6ea272 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-28 19:34:40 +00:00
Gallus-maintanance ebcfec4ec7 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-16 20:13:03 +00:00
Kenzo e22ccfeda3 Update package-lock.json with latest dependency versions.
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 19:46:21 +02:00
Kenzo 3729639596 Update package-lock.json with latest dependency versions.
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-13 19:39:16 +02:00
Kenzo 4887f599fd Update package-lock.json with latest dependency versions. 2026-08-13 19:27:16 +02:00
Gallus-maintanance 69db8de6d7 Update events 2026-08-13 13:23:33 +00:00
Gallus-maintanance 7bb5f339c0 Update events 2026-08-13 13:15:54 +00:00
Gallus-maintanance 5982a1bfd1 Update events
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 13:15:58 +00:00
KenzoandClaude Opus 5 f46a24ee4e fix(upload): Groessengrenzen realistisch setzen, PDFs eigener Wert
ci/woodpecker/push/woodpecker Pipeline was successful
Der Upload lief gegen einen einzigen globalen Wert von 5 MB, der fuer alle
Dateitypen galt. Eine Getraenkekarte liegt aber schnell bei 15-20 MB, das
Hochladen scheiterte deshalb zuverlaessig.

- MAX_FILE_SIZE (Bilder) von 5 auf 20 MB. Bilder werden ohnehin auf 1600px
  heruntergerechnet, die Grenze muss nur ein unbearbeitetes Handyfoto
  durchlassen - 5 MB reichten dafuer schon nicht.
- MAX_PDF_SIZE neu, 40 MB. PDFs werden unveraendert abgelegt.
- Beides ueber Umgebungsvariablen uebersteuerbar.

Die Registrierung von multipart nimmt den groesseren der beiden Werte als
Obergrenze, die Routen setzen ihn per request.file({ limits }) auf ihren
eigenen herunter. Die Fehlermeldung im PDF-Zweig nannte bisher die
Bildgrenze.

Geprueft am laufenden Container, auch mit --memory=512m wie auf Fly: 18-MB-
PDF und 18-MB-Foto gehen durch, 45 MB bzw. 25 MB werden mit 413 und
passender Meldung abgelehnt, kein OOM.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 14:42:56 +02:00
Gallus-maintanance 76c4d5c9bf Update Menu
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-11 12:13:25 +00:00
KenzoandClaude Opus 5 a6fc557fad fix(admin): Styles greifen wieder fuer die dynamisch erzeugten Listen
ci/woodpecker/push/woodpecker Pipeline was successful
Astro scoped <style>-Bloecke und schraenkt jede Regel auf
[data-astro-cid-...] ein. Dieses Attribut setzt es nur auf Elemente, die zur
Bauzeit im Template stehen. Die Karten der Event-, Gallery- und
Banner-Listen entstehen aber erst zur Laufzeit per innerHTML und tragen es
nicht - fuer sie hat kein einziger Selektor gegriffen.

Betroffen waren .card, .thumb, .muted, .row-buttons, .pill, .drag-handle und
.highlight-row. Sichtbar wurde es an den Vorschaubildern: sie liefen in
voller Aufloesung ueber die Spalten hinaus. Auch die urspruengliche Regel
max-width:100% hatte deshalb nie gewirkt, nicht erst die neue feste Box.

Behoben mit is:global. Die Seite ist ein eigenstaendiges Dokument mit
eigenem <html>, es kann also nichts anderes davon betroffen sein.

Der erklaerende Kommentar steht als {/* */} im Template und landet damit
nicht in der ausgelieferten Seite.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:55:15 +02:00
KenzoandClaude Opus 5 a2bdbf9035 feat(backend): Skript zur Umwandlung des Altbestands nach AVIF
Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert im
Originalformat im Repo - damals war sharp im Container kaputt, es gab also
weder Verkleinerung noch Formatwandlung. Das Skript schickt sie durch
dieselbe Verarbeitung wie einen frischen Upload.

  node dist/scripts/convert-images-to-avif.js           # nur anzeigen
  node dist/scripts/convert-images-to-avif.js --apply   # wirklich tun

Ohne --apply wird nichts geschrieben.

Ablauf: Workspace auf den Repo-Stand bringen, jedes referenzierte Bild
umwandeln und nach seinem Inhalt benennen (Event-Titel, Alt-Text, Zweck),
die Verweise in Events, Gallery und Textbereichen nachziehen, die
Vorgaenger wegraeumen und committen.

Weggeraeumt wird nur, was das CMS selbst angelegt hat. Ein handgepflegtes
event_karaoke.jpg bekommt zwar eine AVIF-Fassung und der Verweis zeigt
darauf, das Original bleibt aber liegen und wird im Bericht genannt.

saveImageBuffer aus upload.service.ts herausgeloest, damit Upload und
Skript nachweislich dieselbe Verarbeitung benutzen statt zweier Kopien.
replaceImageUrls ersetzt Pfade in den Textbereichen, ohne die restliche
JSON-Struktur anzufassen.

Durchgespielt an vier Bildern zu je ~656 KB: 2.56 MB -> 0.16 MB. Die
Vorschau schreibt nichts, ein zweiter Lauf findet nichts mehr, die
Highlights-Liste bleibt unversehrt und alle Bilder werden danach als
image/avif ausgeliefert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:46:27 +02:00
KenzoandClaude Opus 5 f9193eebfd feat(cms): AVIF und sprechende Dateinamen fuer Uploads
Uploads werden nach AVIF gewandelt statt nach WebP und heissen jetzt nach
dem Inhalt statt nach Zeitstempel und Zufall:
- Events uebernehmen den Event-Titel, die Gallery den Alt-Text, Bilder in
  den Textbereichen ihren Zweck, PDFs den urspruenglichen Dateinamen.
- Ohne solche Angabe wird der Dateiname vor dem Upload verwendet.
- Umlaute werden ausgeschrieben (Getraenkekarte, nicht Getrnkekarte),
  Sonderzeichen und Pfadanteile fallen weg.
- Gleiche Namen werden durchnummeriert, damit zwei Events namens
  "Karaoke" sich nicht gegenseitig ueberschreiben.

Besitz statt Namensmuster:
Die Loeschsicherheit haing bisher am Namensmuster <zeitstempel>-<zufall>.
Mit sprechenden Namen traegt der Name diese Information nicht mehr - ein
hochgeladenes karaoke-abend.avif ist von einem handgepflegten
event_karaoke.jpg nicht zu unterscheiden. Deshalb fuehrt das CMS jetzt in
managed_assets Buch darueber, welche Dateien es selbst angelegt hat, und
loescht ausschliesslich diese. Uploads von vor der Umstellung werden
weiterhin am alten Muster erkannt, damit sie aufraeumbar bleiben.

initDatabase() legte Tabellen nur an, wenn users noch fehlte. Auf einer
bestehenden Datenbank waere managed_assets damit nie entstanden. Der
Block laeuft jetzt bei jedem Start; alle Anweisungen sind IF NOT EXISTS.

Nebenbei repariert: die Formulare schickten den Alt-Text NACH der Datei.
Zu dem Zeitpunkt hat der Server ihn noch nicht geparst, in der Gallery
landete deshalb immer der Dateiname als Alt-Text. Textfelder gehen jetzt
vor der Datei raus.

Gemessen an einem 4032x3024-Bild: 0.90 MB rein, 36 KB AVIF bei 1600x1200
raus, 579 ms. Das Testbild ist synthetisch und komprimiert besser als ein
echtes Foto - die Verkleinerung auf 1600px greift aber immer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 13:39:42 +02:00
97 changed files with 2309 additions and 2671 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
@@ -7,7 +7,7 @@ RUN npm ci || npm install
COPY . .
RUN npm run build
FROM node:20-alpine AS production
FROM node:22-alpine AS production
WORKDIR /app
RUN npm install -g serve
+11 -6
View File
@@ -27,14 +27,16 @@ export function initDatabase() {
console.log('🔧 Initializing database...');
try {
// Check if users table exists (acts as a sentinel for initial setup)
// Nur zur Protokollierung - angelegt wird immer, siehe unten
const tableCheck = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get();
if (!tableCheck) {
console.log('📝 Creating database schema...');
console.log(tableCheck ? '📝 Checking database schema...' : '📝 Creating database schema...');
// Laeuft bei JEDEM Start. Alle Anweisungen sind IF NOT EXISTS, und nur so
// bekommen bestehende Datenbanken spaeter ergaenzte Tabellen ueberhaupt.
{
sqlite.exec(`
PRAGMA foreign_keys = ON;
@@ -84,6 +86,11 @@ export function initDatabase() {
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS managed_assets (
path TEXT PRIMARY KEY,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS publish_history (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
@@ -93,9 +100,7 @@ export function initDatabase() {
);
`);
console.log('✅ Database schema created successfully!');
} else {
console.log('✅ Database already initialized.');
console.log('✅ Database schema is up to date.');
}
} catch (error) {
console.error('❌ Error initializing database:', error);
+6 -1
View File
@@ -28,7 +28,12 @@ export const env = {
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
// Upload
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
// Bilder werden ohnehin auf 1600px heruntergerechnet, die Grenze muss nur
// gross genug fuer ein unbearbeitetes Handyfoto sein.
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || String(20 * 1024 * 1024), 10),
// PDFs werden unveraendert abgelegt. Eine Getraenkekarte mit Bildern liegt
// schnell bei 15-20 MB, deshalb ein eigener, groesserer Wert.
MAX_PDF_SIZE: parseInt(process.env.MAX_PDF_SIZE || String(40 * 1024 * 1024), 10),
};
// Validate required environment variables
+12
View File
@@ -60,6 +60,18 @@ export const publishHistory = sqliteTable('publish_history', {
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Vom CMS selbst angelegte Dateien.
//
// Frueher wurde am Dateinamen erkannt, ob das CMS eine Datei loeschen darf.
// Seit die Namen aus Alt-Text bzw. Original-Dateiname abgeleitet werden,
// traegt der Name diese Information nicht mehr - ein hochgeladenes
// karaoke-abend.avif sieht aus wie ein handgepflegtes event_karaoke.jpg.
// Deshalb wird der Besitz hier festgehalten.
export const managedAssets = sqliteTable('managed_assets', {
path: text('path').primaryKey(), // z.B. /images/events/karaoke-abend.avif
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
// Banner table (for announcements like holidays, special info)
export const banners = sqliteTable('banners', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
+3 -1
View File
@@ -73,9 +73,11 @@ fastify.register(jwt, {
},
});
// Der globale Wert ist die Obergrenze fuer alles. Die Routen setzen ihn per
// request.file({ limits }) auf ihren eigenen, engeren Wert herunter.
fastify.register(multipart, {
limits: {
fileSize: env.MAX_FILE_SIZE,
fileSize: Math.max(env.MAX_FILE_SIZE, env.MAX_PDF_SIZE),
},
});
+8 -2
View File
@@ -5,6 +5,7 @@ import { contentSections } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { saveUploadedImage } from '../services/upload.service.js';
import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js';
import { env } from '../config/env.js';
// Fastify JSON schema for content section body
const contentBodyJsonSchema = {
@@ -106,7 +107,7 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const file = await (request as any).file();
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
@@ -116,7 +117,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'content', fastify.log);
const preferredName = (file.fields?.name?.value as string | undefined) || '';
const saved = await saveUploadedImage(file, 'content', {
preferredName,
log: fastify.log,
});
return reply.code(201).send({ imageUrl: saved.imageUrl });
+10 -2
View File
@@ -4,6 +4,7 @@ import { events } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { dropImageIfUnused } from '../services/image-refs.service.js';
import { saveUploadedImage } from '../services/upload.service.js';
import { env } from '../config/env.js';
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
@@ -102,7 +103,7 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
}, async (request, reply) => {
try {
// Expect a single file field named "file"
const file = await (request as any).file();
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
@@ -112,7 +113,14 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'events', fastify.log);
// Der Titel kommt als Formularfeld VOR der Datei, sonst ist er hier
// noch nicht geparst
const preferredName = (file.fields?.title?.value as string | undefined) || '';
const saved = await saveUploadedImage(file, 'events', {
preferredName,
log: fastify.log,
});
return reply.code(201).send({ imageUrl: saved.imageUrl });
+6 -2
View File
@@ -5,6 +5,7 @@ import { galleryImages } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { dropImageIfUnused } from '../services/image-refs.service.js';
import { saveUploadedImage } from '../services/upload.service.js';
import { env } from '../config/env.js';
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
@@ -81,7 +82,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
}, async (request, reply) => {
try {
// Expect a single file field named "file"
const file = await (request as any).file();
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
@@ -95,7 +96,10 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only image uploads are allowed' });
}
const saved = await saveUploadedImage(file, 'gallery', fastify.log);
const saved = await saveUploadedImage(file, 'gallery', {
preferredName: altText,
log: fastify.log,
});
// Store in DB (optional but useful)
const [row] = await db.insert(galleryImages).values({
+8 -15
View File
@@ -1,10 +1,10 @@
import { FastifyPluginAsync } from 'fastify';
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { db } from '../config/database.js';
import { contentSections } from '../db/schema.js';
import { AssetService } from '../services/asset.service.js';
import { isManagedAsset, forgetManagedAsset } from '../services/managed-assets.service.js';
import { saveUploadedPdf } from '../services/upload.service.js';
import { env } from '../config/env.js';
const assets = new AssetService();
@@ -45,7 +45,7 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
}
try {
const file = await (request as any).file();
const file = await (request as any).file({ limits: { fileSize: env.MAX_PDF_SIZE } });
if (!file) {
return reply.code(400).send({ error: 'No file uploaded' });
}
@@ -63,7 +63,7 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
const buffer = Buffer.concat(chunks);
if ((file.file as any)?.truncated) {
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
}
@@ -72,15 +72,7 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'File is not a valid PDF' });
}
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
fs.mkdirSync(uploadDir, { recursive: true });
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
const filename = `${stamp}-${rand}.pdf`;
fs.writeFileSync(path.join(uploadDir, filename), buffer);
const pdfUrl = `/pdf/${filename}`;
const pdfUrl = await saveUploadedPdf(file, buffer);
// URL in der Content-Section hinterlegen
const [existing] = await db
@@ -106,11 +98,12 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
if (previousUrl && previousUrl !== pdfUrl) {
if (previousUrl && previousUrl !== pdfUrl && (await isManagedAsset(previousUrl))) {
try {
if (assets.deletePdf(previousUrl)) {
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
}
await forgetManagedAsset(previousUrl);
} catch (err) {
fastify.log.warn({ err }, 'Could not remove replaced PDF');
}
@@ -120,7 +113,7 @@ const pdfRoute: FastifyPluginAsync = async (fastify) => {
} catch (err: any) {
if (err?.code === 'FST_REQ_FILE_TOO_LARGE') {
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
}
fastify.log.error({ err }, 'PDF upload failed');
+3 -8
View File
@@ -2,8 +2,7 @@ import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { GitService } from '../services/git.service.js';
import { FileGeneratorService } from '../services/file-generator.service.js';
import { AssetService } from '../services/asset.service.js';
import { collectReferencedImageUrls } from '../services/image-refs.service.js';
import { sweepOrphanedImages } from '../services/image-refs.service.js';
import { db } from '../config/database.js';
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
import { eq } from 'drizzle-orm';
@@ -36,12 +35,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
fastify.log.info('Git repository initialized');
// Verwaiste Uploads entfernen, bevor committet wird. Die Referenzliste
// deckt bewusst alle Zeilen ab, auch unveroeffentlichte, und zusaetzlich
// die Bildpfade aus den Textbereichen.
const assetService = new AssetService();
const referencedImages = await collectReferencedImageUrls();
const removedImages = assetService.sweepOrphanedImages([...referencedImages]);
// Verwaiste Uploads entfernen, bevor committet wird
const removedImages = await sweepOrphanedImages();
if (removedImages.length > 0) {
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
@@ -0,0 +1,218 @@
/**
* Einmalige Umwandlung des Altbestands nach AVIF.
*
* Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert
* und im Originalformat im Repo - damals war sharp im Container kaputt, es
* gab also weder Verkleinerung noch Formatwandlung. Dieses Skript schickt
* sie durch dieselbe Verarbeitung wie einen frischen Upload, benennt sie
* nach ihrem Inhalt und zieht die Verweise in der Datenbank mit.
*
* Aufruf im Container:
* node dist/scripts/convert-images-to-avif.js # nur anzeigen
* node dist/scripts/convert-images-to-avif.js --apply # wirklich tun
*
* Ohne --apply wird nichts geschrieben.
*/
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { db, initDatabase } from '../config/database.js';
import { events, galleryImages, contentSections } from '../db/schema.js';
import { GitService } from '../services/git.service.js';
import { AssetService, MANAGED_IMAGE_DIRS } from '../services/asset.service.js';
import { saveImageBuffer, UploadSubdir } from '../services/upload.service.js';
import { replaceImageUrls, dropImageIfUnused } from '../services/image-refs.service.js';
import { env } from '../config/env.js';
const APPLY = process.argv.includes('--apply');
const assets = new AssetService();
interface Candidate {
url: string;
preferredName: string;
}
/** /images/events/foo.jpg -> events */
function subdirOf(url: string): UploadSubdir | null {
const match = /^\/images\/(events|gallery|content)\//.exec(url);
return match ? (match[1] as UploadSubdir) : null;
}
function absolutePathOf(url: string): string {
return path.join(env.GIT_WORKSPACE_DIR, 'public', url.replace(/^\/+/, ''));
}
/** Sprechender Name fuer ein Bild aus einem Textbereich. */
function contentImageName(section: string, key: string, content: any): string {
if (section === 'welcome' && key === 'imageUrl') return 'willkommen';
if (section === 'drinks' && key === 'monthlySpecialImage') {
return content?.monthlySpecialName || 'monats-hit';
}
const whiskey = /^whiskeyImage(\d)$/.exec(key);
if (whiskey) return `whiskey-${whiskey[1]}`;
return `${section}-bild`;
}
function asObject(value: any): Record<string, any> {
if (!value) return {};
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
return typeof value === 'object' ? value : {};
}
async function collectCandidates(): Promise<Candidate[]> {
const byUrl = new Map<string, string>();
const remember = (url: any, name: string) => {
if (typeof url !== 'string' || !url.startsWith('/images/')) return;
if (!byUrl.has(url)) byUrl.set(url, name);
};
for (const row of (await db.select().from(events)) as any[]) {
remember(row.imageUrl, row.title);
}
for (const row of (await db.select().from(galleryImages)) as any[]) {
remember(row.imageUrl, row.altText);
}
for (const row of (await db.select().from(contentSections)) as any[]) {
const content = asObject(row.contentJson);
for (const [key, value] of Object.entries(content)) {
remember(value, contentImageName(row.sectionName, key, content));
}
}
return [...byUrl.entries()].map(([url, preferredName]) => ({ url, preferredName }));
}
async function main() {
console.log(APPLY ? '=== Umwandlung nach AVIF ===' : '=== Vorschau (nichts wird geschrieben) ===\n');
initDatabase();
// Workspace auf den Stand des Repos bringen
const git = new GitService();
await git.initialize();
console.log(`Workspace: ${env.GIT_WORKSPACE_DIR}\n`);
const candidates = await collectCandidates();
const mapping = new Map<string, string>();
let bytesBefore = 0;
let bytesAfter = 0;
let skipped = 0;
let missing = 0;
for (const { url, preferredName } of candidates) {
const subdir = subdirOf(url);
if (!subdir) {
console.log(` uebersprungen (ausserhalb der Upload-Ordner): ${url}`);
skipped++;
continue;
}
if (url.toLowerCase().endsWith('.avif')) {
skipped++;
continue;
}
const source = absolutePathOf(url);
if (!assets.resolveInDirs(url, MANAGED_IMAGE_DIRS) || !fs.existsSync(source)) {
console.log(` FEHLT auf der Platte, Verweis bleibt: ${url}`);
missing++;
continue;
}
const input = fs.readFileSync(source);
bytesBefore += input.length;
if (!APPLY) {
console.log(` ${url} (${(input.length / 1024).toFixed(0)} KB) -> benannt nach "${preferredName}"`);
continue;
}
const saved = await saveImageBuffer(input, subdir, { preferredName });
const outSize = fs.statSync(absolutePathOf(saved.imageUrl)).size;
bytesAfter += outSize;
mapping.set(url, saved.imageUrl);
console.log(
` ${url} ${(input.length / 1024).toFixed(0)} KB -> ${saved.imageUrl} ${(outSize / 1024).toFixed(0)} KB`
);
}
if (!APPLY) {
console.log(`\n${candidates.length - skipped - missing} Bild(er) wuerden umgewandelt.`);
console.log(`Gesamtgroesse aktuell: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB`);
console.log('\nZum Ausfuehren: node dist/scripts/convert-images-to-avif.js --apply');
return;
}
if (mapping.size === 0) {
console.log('\nNichts umzuwandeln.');
return;
}
// Verweise in der Datenbank nachziehen
for (const row of (await db.select().from(events)) as any[]) {
const next = mapping.get(row.imageUrl);
if (next) await db.update(events).set({ imageUrl: next }).where(eq(events.id, row.id));
}
for (const row of (await db.select().from(galleryImages)) as any[]) {
const next = mapping.get(row.imageUrl);
if (next) await db.update(galleryImages).set({ imageUrl: next }).where(eq(galleryImages.id, row.id));
}
for (const row of (await db.select().from(contentSections)) as any[]) {
const updated = replaceImageUrls(asObject(row.contentJson), mapping);
await db
.update(contentSections)
.set({ contentJson: updated, updatedAt: new Date() })
.where(eq(contentSections.sectionName, row.sectionName));
}
console.log('\nVerweise in der Datenbank aktualisiert.');
// Vorgaenger wegraeumen - dropImageIfUnused loescht nur, was das CMS
// selbst angelegt hat. Handgepflegte Dateien wie event_karaoke.jpg
// bleiben liegen, obwohl jetzt eine AVIF-Fassung existiert.
let removed = 0;
let kept = 0;
for (const oldUrl of mapping.keys()) {
if (await dropImageIfUnused(oldUrl)) {
removed++;
} else if (fs.existsSync(absolutePathOf(oldUrl))) {
console.log(` Original behalten (nicht vom CMS angelegt): ${oldUrl}`);
kept++;
}
}
console.log(`\n${mapping.size} Bild(er) umgewandelt, ${removed} Original(e) entfernt, ${kept} behalten.`);
console.log(
`Groesse: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB -> ${(bytesAfter / 1024 / 1024).toFixed(2)} MB` +
` (${(100 - (bytesAfter / bytesBefore) * 100).toFixed(1)} % gespart)`
);
const hash = await git.commitAndPush('Bestandsbilder nach AVIF umgewandelt');
console.log(`\nCommit: ${hash}`);
console.log('Fertig. Woodpecker baut die Seite jetzt neu.');
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error('\nFehlgeschlagen:', err);
process.exit(1);
});
+26 -46
View File
@@ -3,21 +3,16 @@ import path from 'path';
import { env } from '../config/env.js';
/**
* Verwaltet die Dateien, die das CMS in den Git-Workspace schreibt.
* Kennt die Verzeichnisse, in die das CMS schreibt, und sorgt dafuer, dass
* kein Pfad ausserhalb davon angefasst wird.
*
* Grundregel: geloescht wird ausschliesslich, was das CMS selbst angelegt hat.
* Uploads bekommen den Namen <base36-zeitstempel>-<6 zufaellige zeichen>.<ext>
* (siehe events.ts / gallery.ts). Handgepflegte Assets aus dem Repo heissen
* anders - event_karaoke.jpg, Welcome.png, Gallery1.webp - und werden dadurch
* nie angefasst, auch wenn sie in keinem Datensatz mehr vorkommen.
* Ob eine konkrete Datei geloescht werden DARF, entscheidet diese Klasse
* bewusst nicht - das steht in managed-assets.service.ts.
*/
// Unterordner unterhalb von public/, in denen das CMS aufraeumen darf
const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
const MANAGED_PDF_DIR = 'pdf';
// Namensmuster der CMS-Uploads
const GENERATED_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
// Unterordner unterhalb von public/
export const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
export const MANAGED_PDF_DIR = 'pdf';
export class AssetService {
private publicDir: string;
@@ -26,7 +21,7 @@ export class AssetService {
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
}
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb */
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb. */
private resolveInPublic(url: string): string | null {
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
@@ -41,31 +36,31 @@ export class AssetService {
return absolute;
}
/**
* Pfad einer Datei, die das CMS loeschen darf.
* Liefert null fuer fremde Pfade und fuer handgepflegte Dateien.
*/
resolveDeletable(url: string, dirs: string[]): string | null {
/** Absoluter Pfad, sofern die Datei in einem der erlaubten Ordner liegt. */
resolveInDirs(url: string, dirs: string[]): string | null {
const absolute = this.resolveInPublic(url);
if (!absolute) return null;
const relative = path.relative(this.publicDir, absolute);
const dir = path.dirname(relative).split(path.sep).join('/');
if (!dirs.includes(dir)) return null;
if (!GENERATED_NAME.test(path.basename(absolute))) return null;
return absolute;
return dirs.includes(dir) ? absolute : null;
}
/** Loescht ein hochgeladenes Bild. Gibt zurueck, ob wirklich etwas weg ist. */
/** Existiert die Datei bereits? Fuer die Namensvergabe. */
exists(url: string, dirs: string[]): boolean {
const absolute = this.resolveInDirs(url, dirs);
return absolute ? fs.existsSync(absolute) : false;
}
/** Loescht eine Bilddatei. Die Besitzfrage muss vorher geklaert sein. */
deleteImage(url: string): boolean {
return this.unlink(this.resolveDeletable(url, MANAGED_IMAGE_DIRS));
return this.unlink(this.resolveInDirs(url, MANAGED_IMAGE_DIRS));
}
/** Loescht ein hochgeladenes PDF. */
/** Loescht ein PDF. Die Besitzfrage muss vorher geklaert sein. */
deletePdf(url: string): boolean {
return this.unlink(this.resolveDeletable(url, [MANAGED_PDF_DIR]));
return this.unlink(this.resolveInDirs(url, [MANAGED_PDF_DIR]));
}
private unlink(absolute: string | null): boolean {
@@ -79,19 +74,9 @@ export class AssetService {
}
}
/**
* Entfernt alle hochgeladenen Bilder, die in keiner der uebergebenen URLs
* mehr vorkommen. Die Liste muss ALLE Datensaetze abdecken, auch
* unveroeffentlichte - sonst verlieren die ihr Bild.
*/
sweepOrphanedImages(referencedUrls: string[]): string[] {
const keep = new Set<string>();
for (const url of referencedUrls) {
const absolute = this.resolveInPublic(url);
if (absolute) keep.add(absolute);
}
const removed: string[] = [];
/** Alle Bilddateien, die in den verwalteten Ordnern liegen, als URL-Pfade. */
listImageFiles(): string[] {
const found: string[] = [];
for (const dir of MANAGED_IMAGE_DIRS) {
const absoluteDir = path.join(this.publicDir, dir);
@@ -99,16 +84,11 @@ export class AssetService {
for (const name of fs.readdirSync(absoluteDir)) {
const absolute = path.join(absoluteDir, name);
if (!GENERATED_NAME.test(name)) continue;
if (keep.has(absolute)) continue;
if (!fs.statSync(absolute).isFile()) continue;
fs.unlinkSync(absolute);
removed.push('/' + dir + '/' + name);
found.push(`/${dir}/${name}`);
}
}
return removed;
return found;
}
}
+54 -3
View File
@@ -1,18 +1,45 @@
import { db } from '../config/database.js';
import { events, galleryImages, contentSections } from '../db/schema.js';
import { AssetService } from './asset.service.js';
import { isManagedAsset, forgetManagedAsset } from './managed-assets.service.js';
const assets = new AssetService();
/**
* Loescht eine Bilddatei, sofern sie von keinem Datensatz mehr benutzt wird.
* Muss NACH dem Loeschen bzw. Aktualisieren der Zeile aufgerufen werden.
* Loescht eine Bilddatei, sofern das CMS sie selbst angelegt hat und kein
* Datensatz sie mehr benutzt. Muss NACH dem Loeschen bzw. Aktualisieren der
* Zeile aufgerufen werden.
*/
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
if (!url) return false;
if (!(await isManagedAsset(url))) return false;
const referenced = await collectReferencedImageUrls();
if (referenced.has(url)) return false;
return assets.deleteImage(url);
const deleted = assets.deleteImage(url);
await forgetManagedAsset(url);
return deleted;
}
/**
* Entfernt alle vom CMS angelegten Bilder, die nirgends mehr referenziert
* werden. Die Referenzliste deckt bewusst ALLE Zeilen ab, auch
* unveroeffentlichte - sonst verlieren die ihr Bild.
*/
export async function sweepOrphanedImages(): Promise<string[]> {
const referenced = await collectReferencedImageUrls();
const removed: string[] = [];
for (const url of assets.listImageFiles()) {
if (referenced.has(url)) continue;
if (!(await isManagedAsset(url))) continue;
if (assets.deleteImage(url)) removed.push(url);
await forgetManagedAsset(url);
}
return removed;
}
/**
@@ -42,6 +69,30 @@ export async function collectReferencedImageUrls(): Promise<Set<string>> {
return urls;
}
/**
* Ersetzt Bildpfade in einem beliebigen Content-JSON anhand einer Zuordnung
* alt -> neu. Die Struktur bleibt dabei unveraendert.
*/
export function replaceImageUrls(value: any, mapping: Map<string, string>): any {
if (typeof value === 'string') {
return mapping.get(value) ?? value;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceImageUrls(entry, mapping));
}
if (value && typeof value === 'object') {
const out: Record<string, any> = {};
for (const [key, entry] of Object.entries(value)) {
out[key] = replaceImageUrls(entry, mapping);
}
return out;
}
return value;
}
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
export function extractImageUrls(value: any): Set<string> {
const out = new Set<string>();
@@ -0,0 +1,54 @@
import { eq } from 'drizzle-orm';
import { db } from '../config/database.js';
import { managedAssets } from '../db/schema.js';
/**
* Fuehrt Buch darueber, welche Dateien das CMS selbst angelegt hat.
* Nur diese darf es spaeter wieder loeschen.
*/
// Altbestand: vor der Umstellung auf sprechende Namen hiessen Uploads
// <base36-zeitstempel>-<6 zeichen>.<ext>. Diese Dateien stehen nicht in der
// Tabelle, sollen aber weiterhin aufgeraeumt werden koennen. Handgepflegte
// Assets wie event_karaoke.jpg oder Gallery1.webp passen nicht auf das Muster.
const LEGACY_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
function basename(urlPath: string): string {
return urlPath.split('/').pop() || '';
}
/** Merkt sich eine neu angelegte Datei. */
export async function registerManagedAsset(urlPath: string): Promise<void> {
await db.insert(managedAssets).values({ path: urlPath }).onConflictDoNothing();
}
/** Vergisst eine Datei wieder (nach dem Loeschen). */
export async function forgetManagedAsset(urlPath: string): Promise<void> {
await db.delete(managedAssets).where(eq(managedAssets.path, urlPath));
}
/** Darf das CMS diese Datei loeschen? */
export async function isManagedAsset(urlPath: string): Promise<boolean> {
if (!urlPath) return false;
const [row] = await db
.select()
.from(managedAssets)
.where(eq(managedAssets.path, urlPath))
.limit(1);
if (row) return true;
return LEGACY_NAME.test(basename(urlPath));
}
/** Alle vom CMS angelegten Pfade aus der Tabelle. */
export async function listManagedAssets(): Promise<string[]> {
const rows = (await db.select().from(managedAssets)) as any[];
return rows.map((row) => row.path as string);
}
/** Trifft der Altbestands-Namensstil zu? */
export function hasLegacyName(urlPath: string): boolean {
return LEGACY_NAME.test(basename(urlPath));
}
+109 -15
View File
@@ -1,6 +1,8 @@
import fs from 'fs';
import path from 'path';
import { env } from '../config/env.js';
import { AssetService, MANAGED_IMAGE_DIRS, MANAGED_PDF_DIR } from './asset.service.js';
import { registerManagedAsset } from './managed-assets.service.js';
export type UploadSubdir = 'events' | 'gallery' | 'content';
@@ -9,20 +11,63 @@ export interface SavedImage {
imageUrl: string;
}
const assets = new AssetService();
/**
* Nimmt einen Multipart-Upload entgegen, rechnet ihn nach WebP herunter und
* legt ihn unter public/images/<subdir> im Git-Workspace ab.
* Macht aus "Karaoke-Abend im Gallus Pub!" -> "karaoke-abend-im-gallus-pub".
* Umlaute werden ausgeschrieben, nicht entfernt, damit aus "Getränke" nicht
* "getrnke" wird.
*/
export function slugify(value: string): string {
const slug = String(value || '')
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60)
.replace(/-+$/, '');
return slug;
}
/** Dateiname ohne Endung, wie er vor dem Upload hiess. */
function originalBaseName(file: any): string {
const name = (file?.filename as string | undefined) || '';
return name.replace(/\.[^.]+$/, '');
}
/**
* Sucht einen freien Namen. Gibt es <basis>.avif schon, wird -2, -3, ...
* angehaengt, damit zwei Events namens "Karaoke" sich nicht ueberschreiben.
*/
function findFreeName(base: string, ext: string, urlDir: string, dirs: string[]): string {
const safeBase = base || `datei-${Date.now().toString(36)}`;
for (let attempt = 1; attempt <= 500; attempt++) {
const candidate = attempt === 1 ? `${safeBase}${ext}` : `${safeBase}-${attempt}${ext}`;
if (!assets.exists(`${urlDir}/${candidate}`, dirs)) return candidate;
}
// Sollte nie eintreten - lieber ein haesslicher Name als eine Endlosschleife
return `${safeBase}-${Date.now().toString(36)}${ext}`;
}
/**
* Nimmt einen Multipart-Upload entgegen, rechnet ihn auf 1600px herunter,
* wandelt nach AVIF und legt ihn unter public/images/<subdir> ab.
*
* Der Dateiname folgt dem Muster <zeitstempel>-<zufall>.<ext>. Daran erkennt
* der AssetService spaeter, dass er die Datei wieder loeschen darf.
* Der Name kommt aus preferredName (Event-Titel bzw. Alt-Text) und faellt
* sonst auf den urspruenglichen Dateinamen zurueck.
*/
export async function saveUploadedImage(
file: any,
subdir: UploadSubdir,
log?: { warn: (obj: any, msg: string) => void }
options: { preferredName?: string; log?: { warn: (obj: any, msg: string) => void } } = {}
): Promise<SavedImage> {
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
fs.mkdirSync(uploadDir, { recursive: true });
const { preferredName, log } = options;
const chunks: Buffer[] = [];
for await (const chunk of file.file) {
@@ -39,11 +84,37 @@ export async function saveUploadedImage(
throw error;
}
const stamp = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
return saveImageBuffer(inputBuffer, subdir, {
preferredName: preferredName || originalBaseName(file),
fallbackExtension: '.' + ((file.mimetype || '').split('/')[1] || 'bin')
.replace(/[^a-z0-9]/gi, '').toLowerCase(),
log,
});
}
/**
* Kern der Bildverarbeitung: verkleinern, nach AVIF wandeln, unter einem
* sprechenden Namen ablegen und als CMS-eigene Datei vermerken.
*
* Wird sowohl vom Upload als auch vom Umwandlungsskript fuer den Altbestand
* benutzt, damit beide Wege identisch arbeiten.
*/
export async function saveImageBuffer(
inputBuffer: Buffer,
subdir: UploadSubdir,
options: {
preferredName?: string;
fallbackExtension?: string;
log?: { warn: (obj: any, msg: string) => void };
} = {}
): Promise<SavedImage> {
const { preferredName, fallbackExtension = '.bin', log } = options;
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
fs.mkdirSync(uploadDir, { recursive: true });
let outBuffer: Buffer;
let outExt = '.webp';
let outExt = '.avif';
try {
// Sharp erst laden wenn wirklich gebraucht
@@ -51,17 +122,40 @@ export async function saveUploadedImage(
outBuffer = await sharp(inputBuffer)
.rotate()
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 82 })
.avif({ quality: 55 })
.toBuffer();
} catch (err) {
log?.warn({ err }, 'Sharp processing failed, using original image');
outBuffer = inputBuffer;
const extFromMime = (file.mimetype || '').split('/')[1] || 'bin';
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
outExt = fallbackExtension;
}
const filename = `${stamp}-${rand}${outExt}`;
const urlDir = `/images/${subdir}`;
const filename = findFreeName(slugify(preferredName || ''), outExt, urlDir, MANAGED_IMAGE_DIRS);
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
return { filename, imageUrl: `/images/${subdir}/${filename}` };
const imageUrl = `${urlDir}/${filename}`;
await registerManagedAsset(imageUrl);
return { filename, imageUrl };
}
/**
* Legt ein hochgeladenes PDF unter public/pdf ab, benannt nach dem
* urspruenglichen Dateinamen.
*/
export async function saveUploadedPdf(file: any, buffer: Buffer, preferredName?: string): Promise<string> {
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
fs.mkdirSync(uploadDir, { recursive: true });
const base = slugify(preferredName || '') || slugify(originalBaseName(file)) || 'dokument';
const filename = findFreeName(base, '.pdf', '/pdf', [MANAGED_PDF_DIR]);
fs.writeFileSync(path.join(uploadDir, filename), buffer);
const pdfUrl = `/pdf/${filename}`;
await registerManagedAsset(pdfUrl);
return pdfUrl;
}
+1704 -2530
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,6 +9,6 @@
"astro": "astro"
},
"dependencies": {
"astro": "^5.12.0"
"astro": "^7.2.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 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: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 604 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: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.
+8 -8
View File
@@ -7,19 +7,19 @@ const { id } = Astro.props;
<h2 class="title">Drinks</h2>
<p class="note">
Ob frisch gezapftes Pint, edler Whisky oder ein gemütliches Glas Wein bei uns genießt du das Leben in entspannter Atmosphäre. Auch Cocktails dürfen nicht fehlen: Vieles kreieren wir selbst. Sláinte!
Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein hier kannst du in entspannter Atmosphäre das Leben genießen.
</p>
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<a href="/pdf/gallus-getraenkekarte-2026.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
<h3 class="monats-hit">Monats Hit</h3>
<div class="mate-vodka">
<div class="circle" title="Mate Vodka">
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
<div class="circle" title="mit oder ohne Schuss ;)">
<img src="/images/content/mit-oder-ohne-schuss.avif" alt="Monats Hit" class="circle-image" />
<span class="circle-label"></span>
</div>
<div>Mate Vodka</div>
<div>mit oder ohne Schuss ;)</div>
</div>
<p class="note">
@@ -28,15 +28,15 @@ const { id } = Astro.props;
<div class="circle-row">
<div class="circle whiskey-circle" title="Whiskey 1">
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
<img src="/images/content/whiskey-1.avif" alt="Whiskey 1" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 2">
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
<img src="/images/content/whiskey-2.avif" alt="Whiskey 2" class="circle-image" />
<span class="circle-label"></span>
</div>
<div class="circle whiskey-circle" title="Whiskey 3">
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
<img src="/images/content/whiskey-3.avif" alt="Whiskey 3" class="circle-image" />
<span class="circle-label"></span>
</div>
</div>
+25 -12
View File
@@ -7,7 +7,14 @@ const title = 'Admin';
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<style>
{/*
is:global ist hier zwingend. Astro wuerde die Regeln sonst auf
[data-astro-cid-...] einschraenken, und dieses Attribut setzt es nur auf
Elemente, die zur Bauzeit im Template stehen. Die Karten und Bilder der
Listen entstehen aber erst zur Laufzeit per innerHTML - fuer die haette
kein einziger Selektor gegriffen.
*/}
<style is:global>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
h1, h2 { margin: 0.5rem 0; }
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
@@ -254,8 +261,11 @@ const title = 'Admin';
});
// ========== Events & Publish ==========
async function uploadEventImage(file) {
// Wichtig: Textfelder IMMER vor der Datei anhaengen. Der Server liest
// sie beim Empfang der Datei aus; was danach kommt, ist zu spaet.
async function uploadEventImage(file, title) {
const fd = new FormData();
fd.append('title', title || '');
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());
@@ -264,9 +274,9 @@ const title = 'Admin';
async function uploadGalleryImage(file, altText) {
const fd = new FormData();
fd.append('file', file);
if (altText) fd.append('altText', altText);
fd.append('displayOrder', '0');
fd.append('file', file);
const res = await fetch(API_BASE + '/api/gallery/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
return res.json();
@@ -372,7 +382,8 @@ const title = 'Admin';
try {
let imageUrl = '';
if (file) {
const up = await uploadEventImage(file);
// Titel mitgeben, damit die Datei danach benannt wird
const up = await uploadEventImage(file, title);
imageUrl = up?.imageUrl || '';
}
msg.textContent = 'Lege Event an...';
@@ -655,8 +666,9 @@ const title = 'Admin';
}
}
async function uploadContentImage(file) {
async function uploadContentImage(file, name) {
const fd = new FormData();
fd.append('name', name || '');
fd.append('file', file);
const res = await fetch(API_BASE + '/api/content/upload', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
@@ -664,10 +676,10 @@ const title = 'Admin';
}
/** Lädt die Datei aus einem Input hoch, sofern eine gewählt wurde. */
async function maybeUpload(inputId, current) {
async function maybeUpload(inputId, current, name) {
const file = byId(inputId).files[0];
if (!file) return current;
const up = await uploadContentImage(file);
const up = await uploadContentImage(file, name);
byId(inputId).value = '';
return up?.imageUrl || current;
}
@@ -710,7 +722,7 @@ const title = 'Admin';
msg.textContent = 'Lade Bild hoch...';
msg.className = 'muted';
try {
const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl);
const imageUrl = await maybeUpload('wel-file', contentState.welcome.imageUrl, 'willkommen');
await saveSection('welcome', {
...contentState.welcome,
heading1: byId('wel-heading1').value.trim(),
@@ -731,10 +743,11 @@ const title = 'Admin';
msg.textContent = 'Lade Dateien hoch...';
msg.className = 'muted';
try {
const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage);
const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1);
const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2);
const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3);
const specialName = byId('dr-special-name').value.trim();
const special = await maybeUpload('dr-special-file', contentState.drinks.monthlySpecialImage, specialName || 'monats-hit');
const w1 = await maybeUpload('dr-whiskey-file1', contentState.drinks.whiskeyImage1, 'whiskey-1');
const w2 = await maybeUpload('dr-whiskey-file2', contentState.drinks.whiskeyImage2, 'whiskey-2');
const w3 = await maybeUpload('dr-whiskey-file3', contentState.drinks.whiskeyImage3, 'whiskey-3');
// PDF geht über einen eigenen Endpunkt, der die URL selbst hinterlegt
const pdfFile = byId('dr-pdf-file').files[0];
+48 -24
View File
@@ -28,27 +28,20 @@ Plätze sind begrenzt! Jetzt reservieren unter 🍀WA 077 232 27 70
`,
},
{
image: "/images/events/msogsj4l-bmlcxc.jpeg",
title: "SG Fest",
date: "2026-08-14",
image: "/images/events/noeg.avif",
title: "NOeg",
date: "2026-09-16",
description: `
Das Stadt-Fest steht vor der Tür! Wir haben OFFEN ab 17:00Uhr mit Karaoke! Wir freuen uns auf euch!
🎤 Singe die größten Hits auf Spanisch, Italienisch, Französisch, Türkisch oder vielleicht sogar Japanisch?!
✨ Lass das Gallus zum Leben erwachen bei unserer multilingualen Karaoke-Nacht!
`,
},
{
image: "/images/events/mr82c7eh-yp87hy.jpeg",
title: "Richie Live",
date: "2026-08-27",
image: "/images/events/yutaro.avif",
title: "Yutaro",
date: "2026-09-17",
description: `
SOLD OUT! - Richie hat das Image eines Weltenbummlers und Musikkünstlers. In der Ostschweiz ist er bereits bestens bekannt.🍀Warteliste: WA 077 232 27 70
`,
},
{
image: "/images/events/mrnpux59-b3075p.jpeg",
title: "Hannes LIVE",
date: "2026-09-10",
description: `
Er ist zurück!🥰🍀Johannes ist wieder bei uns! Eine weitere unvergessliche Irish-Night!!🍀Sei dabei! Reservationen🍀via WA 077 232 27 70
Mit seiner Gitarre und seiner markanten Stimme verbindet er Singer-Songwriter-Sound mit Einflüssen aus Rock, Blues und Progressive Rock. 🍀Reservationen🍀via WA 077 232 27 70
`,
},
{
@@ -59,21 +52,52 @@ Plätze sind begrenzt! Jetzt reservieren unter 🍀WA 077 232 27 70
SOLD OUT! - First time on stage und das direkt im Gallus Pub!🎤✨
Danielle & Nina bringen Irish Folk & liebevolle Cover Songs.🍀Warteliste: WA 077 232 27 70
`,
},
{
image: "/images/events/dirty-karaoke.avif",
title: "Dirty Karaoke",
date: "2026-10-07",
description: `
*Nicht für alle Ohren geeignet! Ob eindeutig zweideutig, hemmungslos daneben oder einfach nur zum Fremdschämen an diesem Abend gilt: Was gesungen wird, bleibt auf der Bühne.😉
`,
},
{
image: "/images/events/voices.avif",
title: "Voices",
date: "2026-10-22",
description: `
Ein Abend vol­ler Songs, Stim­men & Live Mu­sic.
Mit besonderen Stimmen, guter Stimmung und ganz viel musikalischer Energie. 🍀Reservationen🍀via WA 077 232 27 70
`,
},
{
image: "/images/events/disney-karaoke.avif",
title: "Disney Karaoke",
date: "2026-10-28",
description: `
Die nächste Disney Karaoke Night steht an und passend zur Gruselweek: wer mit Grusel-Kostüm kommt, kriegt einen Glitzer-Shot auf's Haus.🎤✨
`,
},
{
image: "/images/events/katy-elias.avif",
title: "Katy & Elias",
date: "2026-11-21",
description: `
ACHTUNG Kurz-Konzert 19:30-20:30! Danach gehts für die beiden direkt weiter zu einem zweiten Konzert in der Lukas Bar in St. Georgen.
🍀Reservationen🍀via WA 077 232 27 70
`,
}
];
const images = [
{ src: "/images/gallery/mndhrrtl-s4o8dn.png", alt: "mndhrrtl-s4o8dn.png" },
{ src: "/images/gallery/mndhtjvz-965yra.jpeg", alt: "mndhtjvz-965yra.jpeg" },
{ src: "/images/gallery/mndi3625-lnnggc.jpeg", alt: "mndi3625-lnnggc.jpeg" },
{ src: "/images/gallery/mndi7upa-qxukqg.jpeg", alt: "mndi7upa-qxukqg.jpeg" },
{ src: "/images/gallery/mndi8kzg-oazjp8.jpeg", alt: "mndi8kzg-oazjp8.jpeg" },
{ src: "/images/gallery/mndiabuo-m29xj0.jpeg", alt: "mndiabuo-m29xj0.jpeg" },
{ src: "/images/gallery/mndicr59-n8suoy.jpeg", alt: "mndicr59-n8suoy.jpeg" },
{ src: "/images/gallery/mndieqns-mjs7xe.jpeg", alt: "mndieqns-mjs7xe.jpeg" },
{ src: "/images/gallery/mndif8nv-aptjf6.jpeg", alt: "mndif8nv-aptjf6.jpeg" },
{ src: "/images/gallery/mndiggta-hnldkv.jpeg", alt: "mndiggta-hnldkv.jpeg" },
{ src: "/images/gallery/mndih8lp-woprt4.webp", alt: "mndih8lp-woprt4.webp" }
{ src: "/images/gallery/halloween.avif", alt: "Halloween" },
{ src: "/images/gallery/sangallerruum.avif", alt: "Sangallerruum" },
{ src: "/images/gallery/welovegalluspub.avif", alt: "WeLoveGallusPub" },
{ src: "/images/gallery/raphi-sabrina.jpeg", alt: "Raphi&Sabrina" },
{ src: "/images/gallery/pubquiz.avif", alt: "PubQuiz" }
];
---