Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d6a1c5de | ||
|
|
d678d80116 | ||
|
|
bce973679b | ||
|
|
45f705a104 | ||
|
|
c4f17d5f78 | ||
|
|
247f6ea272 | ||
|
|
ebcfec4ec7 | ||
|
|
e22ccfeda3 | ||
|
|
3729639596 | ||
|
|
4887f599fd | ||
|
|
69db8de6d7 | ||
|
|
7bb5f339c0 | ||
|
|
5982a1bfd1 | ||
|
|
f46a24ee4e | ||
|
|
76c4d5c9bf | ||
|
|
a6fc557fad | ||
|
|
a2bdbf9035 | ||
|
|
f9193eebfd |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.12.0"
|
||||
"astro": "^7.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 728 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 1021 KiB |
|
Before Width: | Height: | Size: 1021 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 voller Songs, Stimmen & Live Music.
|
||||
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 geht’s 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" }
|
||||
];
|
||||
---
|
||||
|
||||
|
||||