diff --git a/backend/Dockerfile b/backend/Dockerfile index 5a58a83..c44d951 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,56 +5,67 @@ FROM node:20-alpine AS builder WORKDIR /app +# Install build dependencies for native modules (better-sqlite3, sharp) RUN apk add --no-cache python3 make g++ vips-dev +# Install dependencies COPY package*.json ./ +# Use npm ci when lockfile exists, fallback to npm install for local/dev RUN npm ci || npm install +# Copy source COPY . . -RUN npm run db:generate || true - +# Build TypeScript RUN npm run build - # Stage 2: Production FROM node:20-alpine WORKDIR /app +# Install runtime dependencies (git for simple-git, sqlite3 CLI tool, vips for sharp) +# Note: python3, make, g++ are needed for native module compilation RUN apk add --no-cache git sqlite vips vips-dev python3 make g++ +# Copy package files first COPY --from=builder /app/package*.json ./ +# Install all production dependencies and rebuild sharp for linuxmusl-x64 RUN npm ci --omit=dev || npm install --production && \ npm rebuild sharp +# Clean up build dependencies after installation to reduce image size RUN apk del python3 make g++ vips-dev - -# Copy built files +# Copy built files from builder COPY --from=builder /app/dist ./dist COPY --from=builder /app/src/db/migrations ./dist/db/migrations +# Copy migration script and migrated images COPY --from=builder /app/migrate-production.js ./migrate-production.js COPY --from=builder /app/data/images ./data/images +# Create directories RUN mkdir -p /app/workspace /app/data +# Ensure proper permissions RUN chown -R node:node /app + +# Switch to non-root user USER node +# Expose port EXPOSE 8080 +# Set environment ENV NODE_ENV=production ENV PORT=8080 ENV DATABASE_PATH=/app/data/gallus_cms.db +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" -CMD ["/bin/sh", "-lc", "\ -mkdir -p /app/data/images/events /app/data/images/gallery; \ -echo 'Running Drizzle migrations...'; \ -npx drizzle-kit generate:sqlite || true; \ -npx drizzle-kit migrate || true; \ -node dist/index.js \ -"] +# Run DB migrations if present, then start application +CMD ["/bin/sh", "-lc", "mkdir -p /app/data/images/events /app/data/images/gallery && [ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"] diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index f9e5bea..a72ea2c 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -20,8 +20,7 @@ export const events = sqliteTable('events', { title: text('title').notNull(), date: text('date').notNull(), description: text('description').notNull(), - imageData: text('image_data').notNull(), // base64 encoded image - mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg' + imageUrl: text('image_url').notNull(), displayOrder: integer('display_order').notNull(), isPublished: integer('is_published', { mode: 'boolean' }).default(true), createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`), @@ -31,8 +30,7 @@ export const events = sqliteTable('events', { // Gallery images table export const galleryImages = sqliteTable('gallery_images', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), - imageData: text('image_data').notNull(), // base64 encoded image - mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg' + imageUrl: text('image_url').notNull(), altText: text('alt_text').notNull(), displayOrder: integer('display_order').notNull(), isPublished: integer('is_published', { mode: 'boolean' }).default(true), @@ -61,4 +59,4 @@ export const publishHistory = sqliteTable('publish_history', { commitHash: text('commit_hash'), commitMessage: text('commit_message'), publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`), -}); \ No newline at end of file +}); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 53829e3..6cdb352 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -2,18 +2,20 @@ import { FastifyPluginAsync } from 'fastify'; import { db } from '../config/database.js'; import { events } from '../db/schema.js'; import { eq } from 'drizzle-orm'; +import fs from 'fs'; +import path from 'path'; +// Fastify JSON schema for event body const eventBodyJsonSchema = { type: 'object', - required: ['title', 'date', 'description', 'displayOrder'], + required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'], properties: { title: { type: 'string', minLength: 1, maxLength: 200 }, date: { type: 'string', minLength: 1, maxLength: 100 }, description: { type: 'string', minLength: 1 }, + imageUrl: { type: 'string', minLength: 1 }, displayOrder: { type: 'integer', minimum: 0 }, isPublished: { type: 'boolean' }, - imageData: { type: 'string' }, - mimeType: { type: 'string' }, }, } as const; @@ -36,126 +38,50 @@ const reorderBodyJsonSchema = { } as const; const eventsRoute: FastifyPluginAsync = async (fastify) => { - // PUBLIC: List published events (no auth required) fastify.get('/events/public', async () => { - const all = await db.select({ - id: events.id, - title: events.title, - date: events.date, - description: events.description, - displayOrder: events.displayOrder, - isPublished: events.isPublished, - }).from(events) - .where(eq(events.isPublished, true)) - .orderBy(events.displayOrder); - - return { - events: all.map(ev => ({ - ...ev, - imageUrl: `/api/events/${ev.id}/image`, - })) - }; + const all = await db.select().from(events) + .where(eq(events.isPublished, true)) + .orderBy(events.displayOrder); + return { events: all }; }); - // Serve event image - fastify.get('/events/:id/image', async (request, reply) => { - const { id } = request.params as { id: string }; - const [event] = await db.select({ - imageData: events.imageData, - mimeType: events.mimeType, - }).from(events).where(eq(events.id, id)).limit(1); - - if (!event || !event.imageData) { - return reply.code(404).send({ error: 'Image not found' }); - } - - const buffer = Buffer.from(event.imageData, 'base64'); - return reply - .header('Content-Type', event.mimeType || 'image/webp') - .header('Cache-Control', 'public, max-age=31536000') - .send(buffer); - }); - - // List all events - admin only + // List all events (by displayOrder) - admin only fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => { - const all = await db.select({ - id: events.id, - title: events.title, - date: events.date, - description: events.description, - displayOrder: events.displayOrder, - isPublished: events.isPublished, - }).from(events).orderBy(events.displayOrder); - - return { - events: all.map(ev => ({ - ...ev, - imageUrl: `/api/events/${ev.id}/image`, - })) - }; + const all = await db.select().from(events).orderBy(events.displayOrder); + return { events: all }; }); // Get single event fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { const { id } = request.params as { id: string }; - const [event] = await db.select({ - id: events.id, - title: events.title, - date: events.date, - description: events.description, - displayOrder: events.displayOrder, - isPublished: events.isPublished, - }).from(events).where(eq(events.id, id)).limit(1); - - if (!event) { - return reply.code(404).send({ error: 'Event not found' }); - } - - return { - event: { - ...event, - imageUrl: `/api/events/${event.id}/image`, - } - }; + const rows = await db.select().from(events).where(eq(events.id, id)).limit(1); + if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' }); + return { event: rows[0] }; }); // Create event - fastify.post('/events', { - schema: { body: eventBodyJsonSchema }, - preHandler: [fastify.authenticate], - }, async (request, reply) => { + fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => { const data = request.body as any; - const [row] = await db.insert(events).values({ - title: data.title, - date: data.date, - description: data.description, - displayOrder: data.displayOrder, - isPublished: data.isPublished ?? true, - imageData: data.imageData || '', - mimeType: data.mimeType || '', - }).returning({ - id: events.id, - title: events.title, - date: events.date, - description: events.description, - displayOrder: events.displayOrder, - isPublished: events.isPublished, - }); - - return reply.code(201).send({ - event: { - ...row, - imageUrl: `/api/events/${row.id}/image`, - } - }); + const [row] = await db.insert(events).values(data).returning(); + return reply.code(201).send({ event: row }); }); - // Upload event image (returns base64 data) + // Update event + fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => { + const { id } = request.params as { id: string }; + const data = request.body as any; + const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning(); + if (!row) return reply.code(404).send({ error: 'Event not found' }); + return { event: row }; + }); + + // Upload event image file (multipart) fastify.post('/events/upload', { preHandler: [fastify.authenticate], }, async (request, reply) => { try { + // Expect a single file field named "file" const file = await (request as any).file(); if (!file) { return reply.code(400).send({ error: 'No file uploaded' }); @@ -166,6 +92,11 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'Only image uploads are allowed' }); } + // Prepare directories - use persistent volume for Fly.io + const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); + const uploadDir = path.join(dataDir, 'public', 'images', 'events'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + // Read uploaded stream into buffer const chunks: Buffer[] = []; for await (const chunk of file.file) { @@ -173,25 +104,38 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { } const inputBuffer = Buffer.concat(chunks); - // Process image and convert to base64 - let imageData: string; - let mimeType = 'image/webp'; + // Generate filename + const stamp = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 8); + const baseName = `${stamp}-${rand}`; + // Try to convert to webp and limit size; fallback to original + let outBuffer: Buffer | null = null; + let outExt = '.webp'; try { + // Lazy load sharp only when needed const sharp = (await import('sharp')).default; - const processedBuffer = await sharp(inputBuffer) - .rotate() - .resize({ width: 1600, withoutEnlargement: true }) - .webp({ quality: 82 }) - .toBuffer(); - imageData = processedBuffer.toString('base64'); + outBuffer = await sharp(inputBuffer) + .rotate() + .resize({ width: 1600, withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer(); } catch (err) { fastify.log.warn({ err }, 'Sharp processing failed, using original image'); - imageData = inputBuffer.toString('base64'); - mimeType = mime; + outBuffer = inputBuffer; + // naive extension from mimetype + const extFromMime = mime.split('/')[1] || 'bin'; + outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase(); } - return reply.code(201).send({ imageData, mimeType }); + const filename = baseName + outExt; + const destPath = path.join(uploadDir, filename); + fs.writeFileSync(destPath, outBuffer); + + // Public URL (served via /static) + const publicUrl = `/images/events/${filename}`; + + return reply.code(201).send({ imageUrl: publicUrl }); } catch (err) { fastify.log.error({ err }, 'Upload failed'); @@ -199,110 +143,6 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { } }); - // Update event - fastify.put('/events/:id', { - schema: { body: eventBodyJsonSchema }, - preHandler: [fastify.authenticate], - }, async (request, reply) => { - const { id } = request.params as { id: string }; - const data = request.body as any; - - const updateData: any = { - title: data.title, - date: data.date, - description: data.description, - displayOrder: data.displayOrder, - isPublished: data.isPublished, - updatedAt: new Date(), - }; - - // Only update image if provided - if (data.imageData) { - updateData.imageData = data.imageData; - updateData.mimeType = data.mimeType || 'image/webp'; - } - - const [row] = await db.update(events) - .set(updateData) - .where(eq(events.id, id)) - .returning({ - id: events.id, - title: events.title, - date: events.date, - description: events.description, - displayOrder: events.displayOrder, - isPublished: events.isPublished, - }); - - if (!row) { - return reply.code(404).send({ error: 'Event not found' }); - } - - return { - event: { - ...row, - imageUrl: `/api/events/${row.id}/image`, - } - }; - }); - - // Update event image only - fastify.put('/events/:id/image', { - preHandler: [fastify.authenticate], - }, async (request, reply) => { - const { id } = request.params as { id: string }; - - try { - const file = await (request as any).file(); - if (!file) { - return reply.code(400).send({ error: 'No file uploaded' }); - } - - const mime = file.mimetype as string | undefined; - if (!mime || !mime.startsWith('image/')) { - return reply.code(400).send({ error: 'Only image uploads are allowed' }); - } - - const chunks: Buffer[] = []; - for await (const chunk of file.file) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const inputBuffer = Buffer.concat(chunks); - - let imageData: string; - let mimeType = 'image/webp'; - - try { - const sharp = (await import('sharp')).default; - const processedBuffer = await sharp(inputBuffer) - .rotate() - .resize({ width: 1600, withoutEnlargement: true }) - .webp({ quality: 82 }) - .toBuffer(); - imageData = processedBuffer.toString('base64'); - } catch (err) { - fastify.log.warn({ err }, 'Sharp processing failed, using original image'); - imageData = inputBuffer.toString('base64'); - mimeType = mime; - } - - const [row] = await db.update(events) - .set({ imageData, mimeType, updatedAt: new Date() }) - .where(eq(events.id, id)) - .returning({ id: events.id }); - - if (!row) { - return reply.code(404).send({ error: 'Event not found' }); - } - - return { message: 'Image updated', imageUrl: `/api/events/${id}/image` }; - - } catch (err) { - fastify.log.error({ err }, 'Image update failed'); - return reply.code(500).send({ error: 'Failed to update image' }); - } - }); - // Delete event fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { const { id } = request.params as { id: string }; @@ -311,11 +151,8 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { return { message: 'Event deleted successfully' }; }); - // Reorder events - fastify.put('/events/reorder', { - schema: { body: reorderBodyJsonSchema }, - preHandler: [fastify.authenticate], - }, async (request) => { + // Reorder events (synchronous transaction for better-sqlite3) + fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => { const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> }; db.transaction((tx: any) => { for (const { id, displayOrder } of orders) { @@ -326,4 +163,4 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => { }); }; -export default eventsRoute; \ No newline at end of file +export default eventsRoute; diff --git a/backend/src/routes/gallery.ts b/backend/src/routes/gallery.ts index c7645b3..e6695af 100644 --- a/backend/src/routes/gallery.ts +++ b/backend/src/routes/gallery.ts @@ -1,12 +1,17 @@ import { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; import { db } from '../config/database.js'; import { galleryImages } from '../db/schema.js'; import { eq } from 'drizzle-orm'; +import fs from 'fs'; +import path from 'path'; +// Fastify JSON schema for gallery image body const galleryBodyJsonSchema = { type: 'object', - required: ['altText', 'displayOrder'], + required: ['imageUrl', 'altText', 'displayOrder'], properties: { + imageUrl: { type: 'string', minLength: 1 }, altText: { type: 'string', minLength: 1, maxLength: 200 }, displayOrder: { type: 'integer', minimum: 0 }, isPublished: { type: 'boolean' }, @@ -17,99 +22,46 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { // PUBLIC: List published gallery images (no auth required) fastify.get('/gallery/public', async () => { - const images = await db.select({ - id: galleryImages.id, - altText: galleryImages.altText, - displayOrder: galleryImages.displayOrder, - isPublished: galleryImages.isPublished, - }).from(galleryImages) - .where(eq(galleryImages.isPublished, true)) - .orderBy(galleryImages.displayOrder); - - // Return with generated URLs pointing to the image endpoint - return { - images: images.map(img => ({ - ...img, - imageUrl: `/api/gallery/${img.id}/image`, - })) - }; - }); - - // Serve image data - fastify.get('/gallery/:id/image', async (request, reply) => { - const { id } = request.params as { id: string }; - const [image] = await db.select({ - imageData: galleryImages.imageData, - mimeType: galleryImages.mimeType, - }).from(galleryImages).where(eq(galleryImages.id, id)).limit(1); - - if (!image || !image.imageData) { - return reply.code(404).send({ error: 'Image not found' }); - } - - const buffer = Buffer.from(image.imageData, 'base64'); - return reply - .header('Content-Type', image.mimeType || 'image/webp') - .header('Cache-Control', 'public, max-age=31536000') - .send(buffer); + const images = await db.select().from(galleryImages) + .where(eq(galleryImages.isPublished, true)) + .orderBy(galleryImages.displayOrder); + return { images }; }); // List all gallery images - admin only fastify.get('/gallery', { preHandler: [fastify.authenticate], - }, async () => { - const images = await db.select({ - id: galleryImages.id, - altText: galleryImages.altText, - displayOrder: galleryImages.displayOrder, - isPublished: galleryImages.isPublished, - }).from(galleryImages).orderBy(galleryImages.displayOrder); - - return { - images: images.map(img => ({ - ...img, - imageUrl: `/api/gallery/${img.id}/image`, - })) - }; + }, async (request, reply) => { + const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder); + return { images }; }); - // Get single gallery image metadata + // Get single gallery image fastify.get('/gallery/:id', { preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params as { id: string }; - const [image] = await db.select({ - id: galleryImages.id, - altText: galleryImages.altText, - displayOrder: galleryImages.displayOrder, - isPublished: galleryImages.isPublished, - }).from(galleryImages).where(eq(galleryImages.id, id)).limit(1); + const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1); - if (!image) { + if (image.length === 0) { return reply.code(404).send({ error: 'Image not found' }); } - return { - image: { - ...image, - imageUrl: `/api/gallery/${image.id}/image`, - } - }; + return { image: image[0] }; }); - // Create gallery image (JSON, no file) + // Create gallery image fastify.post('/gallery', { - schema: { body: galleryBodyJsonSchema }, + schema: { + body: galleryBodyJsonSchema, + }, preHandler: [fastify.authenticate], }, async (request, reply) => { const data = request.body as any; + const [newImage] = await db.insert(galleryImages).values(data).returning(); - return reply.code(201).send({ - image: { - ...newImage, - imageUrl: `/api/gallery/${newImage.id}/image`, - } - }); + + return reply.code(201).send({ image: newImage }); }); // Upload image file (multipart) @@ -117,6 +69,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { preHandler: [fastify.authenticate], }, async (request, reply) => { try { + // Expect a single file field named "file" const file = await (request as any).file(); if (!file) { return reply.code(400).send({ error: 'No file uploaded' }); @@ -131,6 +84,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { return reply.code(400).send({ error: 'Only image uploads are allowed' }); } + // Prepare directories - use persistent volume for Fly.io + const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); + const uploadDir = path.join(dataDir, 'public', 'images', 'gallery'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + // Read uploaded stream into buffer const chunks: Buffer[] = []; for await (const chunk of file.file) { @@ -138,44 +96,46 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { } const inputBuffer = Buffer.concat(chunks); - // Process image and convert to base64 - let imageData: string; - let mimeType = 'image/webp'; + // Generate filename + const stamp = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 8); + const baseName = `${stamp}-${rand}`; + // Try to convert to webp and limit size; fallback to original + let outBuffer: Buffer | null = null; + let outExt = '.webp'; try { + // Lazy load sharp only when needed const sharp = (await import('sharp')).default; - const processedBuffer = await sharp(inputBuffer) - .rotate() - .resize({ width: 1600, withoutEnlargement: true }) - .webp({ quality: 82 }) - .toBuffer(); - imageData = processedBuffer.toString('base64'); + outBuffer = await sharp(inputBuffer) + .rotate() + .resize({ width: 1600, withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer(); } catch (err) { fastify.log.warn({ err }, 'Sharp processing failed, using original image'); - imageData = inputBuffer.toString('base64'); - mimeType = mime; + outBuffer = inputBuffer; + // naive extension from mimetype + const extFromMime = mime.split('/')[1] || 'bin'; + outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase(); } - // Store in DB + const filename = baseName + outExt; + const destPath = path.join(uploadDir, filename); + fs.writeFileSync(destPath, outBuffer); + + // Public URL (served via /static) + const publicUrl = `/images/gallery/${filename}`; + + // Store in DB (optional but useful) const [row] = await db.insert(galleryImages).values({ - imageData, - mimeType, - altText: altText || 'Gallery image', + imageUrl: publicUrl, + altText: altText || filename, displayOrder, isPublished: true, - }).returning({ - id: galleryImages.id, - altText: galleryImages.altText, - displayOrder: galleryImages.displayOrder, - isPublished: galleryImages.isPublished, - }); + }).returning(); - return reply.code(201).send({ - image: { - ...row, - imageUrl: `/api/gallery/${row.id}/image`, - } - }); + return reply.code(201).send({ image: row }); } catch (err) { fastify.log.error({ err }, 'Upload failed'); @@ -185,33 +145,25 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { // Update gallery image fastify.put('/gallery/:id', { - schema: { body: galleryBodyJsonSchema }, + schema: { + body: galleryBodyJsonSchema, + }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params as { id: string }; const data = request.body as any; const [updated] = await db - .update(galleryImages) - .set(data) - .where(eq(galleryImages.id, id)) - .returning({ - id: galleryImages.id, - altText: galleryImages.altText, - displayOrder: galleryImages.displayOrder, - isPublished: galleryImages.isPublished, - }); + .update(galleryImages) + .set(data) + .where(eq(galleryImages.id, id)) + .returning(); if (!updated) { return reply.code(404).send({ error: 'Image not found' }); } - return { - image: { - ...updated, - imageUrl: `/api/gallery/${updated.id}/image`, - } - }; + return { image: updated }; }); // Delete gallery image @@ -221,9 +173,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { const { id } = request.params as { id: string }; const [deleted] = await db - .delete(galleryImages) - .where(eq(galleryImages.id, id)) - .returning(); + .delete(galleryImages) + .where(eq(galleryImages.id, id)) + .returning(); if (!deleted) { return reply.code(404).send({ error: 'Image not found' }); @@ -257,6 +209,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { }, async (request, reply) => { const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> }; + // Update all in synchronous transaction (better-sqlite3 requirement) db.transaction((tx: any) => { for (const { id, displayOrder } of orders) { tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.(); @@ -267,4 +220,4 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { }); }; -export default galleryRoute; \ No newline at end of file +export default galleryRoute; diff --git a/backend/src/scripts/migrate-old-data.ts b/backend/src/scripts/migrate-old-data.ts index 029a0ab..70f782b 100644 --- a/backend/src/scripts/migrate-old-data.ts +++ b/backend/src/scripts/migrate-old-data.ts @@ -9,7 +9,7 @@ const oldEvents = [ { image: "/images/events/event_karaoke.jpg", title: "Karaoke", - date: "2025-12-31", // Set as ongoing event + date: "2025-12-31", description: `Bei uns gibt es Karaoke Mi-Sa!! Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) Reserviere am besten gleich per Whatsapp 077 232 27 70`, @@ -18,7 +18,7 @@ Reserviere am besten gleich per Whatsapp 077 232 27 7 { image: "/images/events/event_pub-quiz.jpg", title: "Pub Quiz", - date: "2025-12-31", // Set as ongoing event + date: "2025-12-31", description: `Jeden Freitag findet unser Pub Quiz statt. Gespielt wird tischweise in 3-4 Runden. Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 Auch Einzelpersonen sind herzlich willkommen! @@ -75,62 +75,43 @@ const oldGalleryImages = [ { src: "/images/gallery/Gallery5.png", alt: "Gallery 5" }, ]; -async function copyAndConvertImage( - sourcePath: string, - destDir: string, - filename: string -): Promise { +async function processImageToBase64(sourcePath: string): Promise<{ imageData: string; mimeType: string }> { const projectRoot = path.join(process.cwd(), '..'); const fullSourcePath = path.join(projectRoot, 'public', sourcePath); - // Ensure destination directory exists - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } + console.log(`Processing: ${fullSourcePath}`); - const ext = path.extname(filename); - const baseName = path.basename(filename, ext); - const webpFilename = `${baseName}.webp`; - const destPath = path.join(destDir, webpFilename); - - console.log(`Processing: ${fullSourcePath} -> ${destPath}`); - - // Check if source exists if (!fs.existsSync(fullSourcePath)) { console.error(`Source file not found: ${fullSourcePath}`); throw new Error(`Source file not found: ${fullSourcePath}`); } - // Convert to webp and copy - await sharp(fullSourcePath) - .rotate() // Auto-rotate based on EXIF - .resize({ width: 1600, withoutEnlargement: true }) - .webp({ quality: 85 }) - .toFile(destPath); + // Convert to webp and get buffer + const buffer = await sharp(fullSourcePath) + .rotate() + .resize({ width: 1600, withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(); - return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`; + return { + imageData: buffer.toString('base64'), + mimeType: 'image/webp', + }; } async function migrateEvents() { console.log('\n=== Migrating Events ===\n'); - const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); - const eventsImageDir = path.join(dataDir, 'images', 'events'); - for (const event of oldEvents) { try { - const filename = path.basename(event.image); - const newImageUrl = await copyAndConvertImage( - event.image, - eventsImageDir, - filename - ); + const { imageData, mimeType } = await processImageToBase64(event.image); const [newEvent] = await db.insert(events).values({ title: event.title, date: event.date, description: event.description, - imageUrl: newImageUrl, + imageData, + mimeType, displayOrder: event.displayOrder, isPublished: true, }).returning(); @@ -145,21 +126,14 @@ async function migrateEvents() { async function migrateGallery() { console.log('\n=== Migrating Gallery Images ===\n'); - const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'); - const galleryImageDir = path.join(dataDir, 'images', 'gallery'); - for (let i = 0; i < oldGalleryImages.length; i++) { const img = oldGalleryImages[i]; try { - const filename = path.basename(img.src); - const newImageUrl = await copyAndConvertImage( - img.src, - galleryImageDir, - filename - ); + const { imageData, mimeType } = await processImageToBase64(img.src); const [newImage] = await db.insert(galleryImages).values({ - imageUrl: newImageUrl, + imageData, + mimeType, altText: img.alt, displayOrder: i, isPublished: true, @@ -175,7 +149,6 @@ async function migrateGallery() { async function main() { console.log('Starting migration of old data...\n'); console.log('Working directory:', process.cwd()); - console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data')); try { await migrateEvents(); @@ -187,4 +160,4 @@ async function main() { } } -main(); +main(); \ No newline at end of file diff --git a/src/pages/admin.astro b/src/pages/admin.astro index 254f3fa..463d3ac 100644 --- a/src/pages/admin.astro +++ b/src/pages/admin.astro @@ -3,391 +3,370 @@ const title = 'Admin'; --- - - - - {title} - - - -Admin - - Authentifizierung - Prüfe Anmeldestatus... - - Mit Gitea anmelden - Neu anmelden - Abmelden - - - - - Events verwalten - - - Neues Event - Titel - Datum - Beschreibung - Bild-Datei - Event anlegen - - - - Liste - - Anzeige: automatisch nach Datum (neueste zuerst) - Reihenfolge bearbeiten - Reihenfolge speichern - + + + + {title} + + + + Admin + + Authentifizierung + Prüfe Anmeldestatus... + + Mit Gitea anmelden + Neu anmelden + Abmelden - - - - + - - Gallery verwalten - - - Neues Gallery-Bild - Bild-Datei - Alt-Text - Bild hochladen - - - - Gallery-Liste - - - - + + Events verwalten + + + Neues Event + Titel + Datum + Beschreibung + Bild-Datei + Event anlegen + + + + Liste + + Anzeige: automatisch nach Datum (neueste zuerst) + Reihenfolge bearbeiten + Reihenfolge speichern + + + + + + - - Veröffentlichen - Commit-Message - Publish - - + + Gallery verwalten + + + Neues Gallery-Bild + Bild-Datei + Alt-Text + Bild hochladen + + + + Gallery-Liste + + + + - - - \ No newline at end of file + refreshAuth(); + + +