images fixing with database saves
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@ -5,67 +5,56 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies for native modules (better-sqlite3, sharp)
|
|
||||||
RUN apk add --no-cache python3 make g++ vips-dev
|
RUN apk add --no-cache python3 make g++ vips-dev
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
|
||||||
RUN npm ci || npm install
|
RUN npm ci || npm install
|
||||||
|
|
||||||
# Copy source
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build TypeScript
|
RUN npm run db:generate || true
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
# Stage 2: Production
|
# Stage 2: Production
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
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++
|
RUN apk add --no-cache git sqlite vips vips-dev python3 make g++
|
||||||
|
|
||||||
# Copy package files first
|
|
||||||
COPY --from=builder /app/package*.json ./
|
COPY --from=builder /app/package*.json ./
|
||||||
|
|
||||||
# Install all production dependencies and rebuild sharp for linuxmusl-x64
|
|
||||||
RUN npm ci --omit=dev || npm install --production && \
|
RUN npm ci --omit=dev || npm install --production && \
|
||||||
npm rebuild sharp
|
npm rebuild sharp
|
||||||
|
|
||||||
# Clean up build dependencies after installation to reduce image size
|
|
||||||
RUN apk del python3 make g++ vips-dev
|
RUN apk del python3 make g++ vips-dev
|
||||||
|
|
||||||
# Copy built files from builder
|
|
||||||
|
# Copy built files
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
|
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/migrate-production.js ./migrate-production.js
|
||||||
COPY --from=builder /app/data/images ./data/images
|
COPY --from=builder /app/data/images ./data/images
|
||||||
|
|
||||||
# Create directories
|
|
||||||
RUN mkdir -p /app/workspace /app/data
|
RUN mkdir -p /app/workspace /app/data
|
||||||
|
|
||||||
# Ensure proper permissions
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Set environment
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
ENV DATABASE_PATH=/app/data/gallus_cms.db
|
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)})"
|
|
||||||
|
|
||||||
# Run DB migrations if present, then start application
|
CMD ["/bin/sh", "-lc", "\
|
||||||
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"]
|
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 \
|
||||||
|
"]
|
||||||
|
|||||||
@ -20,7 +20,8 @@ export const events = sqliteTable('events', {
|
|||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
date: text('date').notNull(),
|
date: text('date').notNull(),
|
||||||
description: text('description').notNull(),
|
description: text('description').notNull(),
|
||||||
imageUrl: text('image_url').notNull(),
|
imageData: text('image_data').notNull(), // base64 encoded image
|
||||||
|
mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg'
|
||||||
displayOrder: integer('display_order').notNull(),
|
displayOrder: integer('display_order').notNull(),
|
||||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
@ -30,7 +31,8 @@ export const events = sqliteTable('events', {
|
|||||||
// Gallery images table
|
// Gallery images table
|
||||||
export const galleryImages = sqliteTable('gallery_images', {
|
export const galleryImages = sqliteTable('gallery_images', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
imageUrl: text('image_url').notNull(),
|
imageData: text('image_data').notNull(), // base64 encoded image
|
||||||
|
mimeType: text('mime_type').notNull(), // e.g. 'image/webp', 'image/jpeg'
|
||||||
altText: text('alt_text').notNull(),
|
altText: text('alt_text').notNull(),
|
||||||
displayOrder: integer('display_order').notNull(),
|
displayOrder: integer('display_order').notNull(),
|
||||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||||
@ -59,4 +61,4 @@ export const publishHistory = sqliteTable('publish_history', {
|
|||||||
commitHash: text('commit_hash'),
|
commitHash: text('commit_hash'),
|
||||||
commitMessage: text('commit_message'),
|
commitMessage: text('commit_message'),
|
||||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
@ -2,20 +2,18 @@ import { FastifyPluginAsync } from 'fastify';
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events } from '../db/schema.js';
|
import { events } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
// Fastify JSON schema for event body
|
|
||||||
const eventBodyJsonSchema = {
|
const eventBodyJsonSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
|
required: ['title', 'date', 'description', 'displayOrder'],
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
date: { type: 'string', minLength: 1, maxLength: 100 },
|
date: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
description: { type: 'string', minLength: 1 },
|
description: { type: 'string', minLength: 1 },
|
||||||
imageUrl: { type: 'string', minLength: 1 },
|
|
||||||
displayOrder: { type: 'integer', minimum: 0 },
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
isPublished: { type: 'boolean' },
|
isPublished: { type: 'boolean' },
|
||||||
|
imageData: { type: 'string' },
|
||||||
|
mimeType: { type: 'string' },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -38,50 +36,126 @@ const reorderBodyJsonSchema = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
// PUBLIC: List published events (no auth required)
|
// PUBLIC: List published events (no auth required)
|
||||||
fastify.get('/events/public', async () => {
|
fastify.get('/events/public', async () => {
|
||||||
const all = await db.select().from(events)
|
const all = await db.select({
|
||||||
.where(eq(events.isPublished, true))
|
id: events.id,
|
||||||
.orderBy(events.displayOrder);
|
title: events.title,
|
||||||
return { events: all };
|
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`,
|
||||||
|
}))
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// List all events (by displayOrder) - admin only
|
// 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
|
||||||
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
||||||
const all = await db.select().from(events).orderBy(events.displayOrder);
|
const all = await db.select({
|
||||||
return { events: all };
|
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`,
|
||||||
|
}))
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single event
|
// Get single event
|
||||||
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
const [event] = await db.select({
|
||||||
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
|
id: events.id,
|
||||||
return { event: rows[0] };
|
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`,
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create event
|
// 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 data = request.body as any;
|
||||||
const [row] = await db.insert(events).values(data).returning();
|
const [row] = await db.insert(events).values({
|
||||||
return reply.code(201).send({ event: row });
|
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`,
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update event
|
// Upload event image (returns base64 data)
|
||||||
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', {
|
fastify.post('/events/upload', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
// Expect a single file field named "file"
|
|
||||||
const file = await (request as any).file();
|
const file = await (request as any).file();
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return reply.code(400).send({ error: 'No file uploaded' });
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
@ -92,11 +166,6 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
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
|
// Read uploaded stream into buffer
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of file.file) {
|
for await (const chunk of file.file) {
|
||||||
@ -104,38 +173,25 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
const inputBuffer = Buffer.concat(chunks);
|
const inputBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
// Generate filename
|
// Process image and convert to base64
|
||||||
const stamp = Date.now().toString(36);
|
let imageData: string;
|
||||||
const rand = Math.random().toString(36).slice(2, 8);
|
let mimeType = 'image/webp';
|
||||||
const baseName = `${stamp}-${rand}`;
|
|
||||||
|
|
||||||
// Try to convert to webp and limit size; fallback to original
|
|
||||||
let outBuffer: Buffer | null = null;
|
|
||||||
let outExt = '.webp';
|
|
||||||
try {
|
try {
|
||||||
// Lazy load sharp only when needed
|
|
||||||
const sharp = (await import('sharp')).default;
|
const sharp = (await import('sharp')).default;
|
||||||
outBuffer = await sharp(inputBuffer)
|
const processedBuffer = await sharp(inputBuffer)
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
.webp({ quality: 82 })
|
.webp({ quality: 82 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
imageData = processedBuffer.toString('base64');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
||||||
outBuffer = inputBuffer;
|
imageData = inputBuffer.toString('base64');
|
||||||
// naive extension from mimetype
|
mimeType = mime;
|
||||||
const extFromMime = mime.split('/')[1] || 'bin';
|
|
||||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = baseName + outExt;
|
return reply.code(201).send({ imageData, mimeType });
|
||||||
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) {
|
} catch (err) {
|
||||||
fastify.log.error({ err }, 'Upload failed');
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
@ -143,6 +199,110 @@ 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
|
// Delete event
|
||||||
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
@ -151,8 +311,11 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return { message: 'Event deleted successfully' };
|
return { message: 'Event deleted successfully' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reorder events (synchronous transaction for better-sqlite3)
|
// Reorder events
|
||||||
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
|
fastify.put('/events/reorder', {
|
||||||
|
schema: { body: reorderBodyJsonSchema },
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request) => {
|
||||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
db.transaction((tx: any) => {
|
db.transaction((tx: any) => {
|
||||||
for (const { id, displayOrder } of orders) {
|
for (const { id, displayOrder } of orders) {
|
||||||
@ -163,4 +326,4 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default eventsRoute;
|
export default eventsRoute;
|
||||||
@ -1,17 +1,12 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
import { z } from 'zod';
|
|
||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { galleryImages } from '../db/schema.js';
|
import { galleryImages } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
// Fastify JSON schema for gallery image body
|
|
||||||
const galleryBodyJsonSchema = {
|
const galleryBodyJsonSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['imageUrl', 'altText', 'displayOrder'],
|
required: ['altText', 'displayOrder'],
|
||||||
properties: {
|
properties: {
|
||||||
imageUrl: { type: 'string', minLength: 1 },
|
|
||||||
altText: { type: 'string', minLength: 1, maxLength: 200 },
|
altText: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
displayOrder: { type: 'integer', minimum: 0 },
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
isPublished: { type: 'boolean' },
|
isPublished: { type: 'boolean' },
|
||||||
@ -22,46 +17,99 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// PUBLIC: List published gallery images (no auth required)
|
// PUBLIC: List published gallery images (no auth required)
|
||||||
fastify.get('/gallery/public', async () => {
|
fastify.get('/gallery/public', async () => {
|
||||||
const images = await db.select().from(galleryImages)
|
const images = await db.select({
|
||||||
.where(eq(galleryImages.isPublished, true))
|
id: galleryImages.id,
|
||||||
.orderBy(galleryImages.displayOrder);
|
altText: galleryImages.altText,
|
||||||
return { images };
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// List all gallery images - admin only
|
// List all gallery images - admin only
|
||||||
fastify.get('/gallery', {
|
fastify.get('/gallery', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async () => {
|
||||||
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
|
const images = await db.select({
|
||||||
return { images };
|
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`,
|
||||||
|
}))
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single gallery image
|
// Get single gallery image metadata
|
||||||
fastify.get('/gallery/:id', {
|
fastify.get('/gallery/:id', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
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);
|
||||||
|
|
||||||
if (image.length === 0) {
|
if (!image) {
|
||||||
return reply.code(404).send({ error: 'Image not found' });
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { image: image[0] };
|
return {
|
||||||
|
image: {
|
||||||
|
...image,
|
||||||
|
imageUrl: `/api/gallery/${image.id}/image`,
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create gallery image
|
// Create gallery image (JSON, no file)
|
||||||
fastify.post('/gallery', {
|
fastify.post('/gallery', {
|
||||||
schema: {
|
schema: { body: galleryBodyJsonSchema },
|
||||||
body: galleryBodyJsonSchema,
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const data = request.body as any;
|
const data = request.body as any;
|
||||||
|
|
||||||
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
||||||
|
return reply.code(201).send({
|
||||||
return reply.code(201).send({ image: newImage });
|
image: {
|
||||||
|
...newImage,
|
||||||
|
imageUrl: `/api/gallery/${newImage.id}/image`,
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload image file (multipart)
|
// Upload image file (multipart)
|
||||||
@ -69,7 +117,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
// Expect a single file field named "file"
|
|
||||||
const file = await (request as any).file();
|
const file = await (request as any).file();
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return reply.code(400).send({ error: 'No file uploaded' });
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
@ -84,11 +131,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
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
|
// Read uploaded stream into buffer
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of file.file) {
|
for await (const chunk of file.file) {
|
||||||
@ -96,46 +138,44 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
const inputBuffer = Buffer.concat(chunks);
|
const inputBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
// Generate filename
|
// Process image and convert to base64
|
||||||
const stamp = Date.now().toString(36);
|
let imageData: string;
|
||||||
const rand = Math.random().toString(36).slice(2, 8);
|
let mimeType = 'image/webp';
|
||||||
const baseName = `${stamp}-${rand}`;
|
|
||||||
|
|
||||||
// Try to convert to webp and limit size; fallback to original
|
|
||||||
let outBuffer: Buffer | null = null;
|
|
||||||
let outExt = '.webp';
|
|
||||||
try {
|
try {
|
||||||
// Lazy load sharp only when needed
|
|
||||||
const sharp = (await import('sharp')).default;
|
const sharp = (await import('sharp')).default;
|
||||||
outBuffer = await sharp(inputBuffer)
|
const processedBuffer = await sharp(inputBuffer)
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
.webp({ quality: 82 })
|
.webp({ quality: 82 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
imageData = processedBuffer.toString('base64');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
||||||
outBuffer = inputBuffer;
|
imageData = inputBuffer.toString('base64');
|
||||||
// naive extension from mimetype
|
mimeType = mime;
|
||||||
const extFromMime = mime.split('/')[1] || 'bin';
|
|
||||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = baseName + outExt;
|
// Store in DB
|
||||||
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({
|
const [row] = await db.insert(galleryImages).values({
|
||||||
imageUrl: publicUrl,
|
imageData,
|
||||||
altText: altText || filename,
|
mimeType,
|
||||||
|
altText: altText || 'Gallery image',
|
||||||
displayOrder,
|
displayOrder,
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
}).returning();
|
}).returning({
|
||||||
|
id: galleryImages.id,
|
||||||
|
altText: galleryImages.altText,
|
||||||
|
displayOrder: galleryImages.displayOrder,
|
||||||
|
isPublished: galleryImages.isPublished,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(201).send({ image: row });
|
return reply.code(201).send({
|
||||||
|
image: {
|
||||||
|
...row,
|
||||||
|
imageUrl: `/api/gallery/${row.id}/image`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error({ err }, 'Upload failed');
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
@ -145,25 +185,33 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// Update gallery image
|
// Update gallery image
|
||||||
fastify.put('/gallery/:id', {
|
fastify.put('/gallery/:id', {
|
||||||
schema: {
|
schema: { body: galleryBodyJsonSchema },
|
||||||
body: galleryBodyJsonSchema,
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const data = request.body as any;
|
const data = request.body as any;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(galleryImages)
|
.update(galleryImages)
|
||||||
.set(data)
|
.set(data)
|
||||||
.where(eq(galleryImages.id, id))
|
.where(eq(galleryImages.id, id))
|
||||||
.returning();
|
.returning({
|
||||||
|
id: galleryImages.id,
|
||||||
|
altText: galleryImages.altText,
|
||||||
|
displayOrder: galleryImages.displayOrder,
|
||||||
|
isPublished: galleryImages.isPublished,
|
||||||
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return reply.code(404).send({ error: 'Image not found' });
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { image: updated };
|
return {
|
||||||
|
image: {
|
||||||
|
...updated,
|
||||||
|
imageUrl: `/api/gallery/${updated.id}/image`,
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete gallery image
|
// Delete gallery image
|
||||||
@ -173,9 +221,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
|
||||||
const [deleted] = await db
|
const [deleted] = await db
|
||||||
.delete(galleryImages)
|
.delete(galleryImages)
|
||||||
.where(eq(galleryImages.id, id))
|
.where(eq(galleryImages.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
return reply.code(404).send({ error: 'Image not found' });
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
@ -209,7 +257,6 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
|
|
||||||
// Update all in synchronous transaction (better-sqlite3 requirement)
|
|
||||||
db.transaction((tx: any) => {
|
db.transaction((tx: any) => {
|
||||||
for (const { id, displayOrder } of orders) {
|
for (const { id, displayOrder } of orders) {
|
||||||
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
|
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
|
||||||
@ -220,4 +267,4 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default galleryRoute;
|
export default galleryRoute;
|
||||||
@ -3,370 +3,391 @@ const title = 'Admin';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
|
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; }
|
h1, h2 { margin: 0.5rem 0; }
|
||||||
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
||||||
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
|
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
|
||||||
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
|
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
|
||||||
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
|
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
|
||||||
.muted { color: #666; }
|
.muted { color: #666; }
|
||||||
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
|
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
|
||||||
.btn:hover { background: #444; }
|
.btn:hover { background: #444; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 0.75rem; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 0.75rem; }
|
||||||
.card { border: 1px solid #eee; padding: 0.75rem; border-radius: 6px; }
|
.card { border: 1px solid #eee; padding: 0.75rem; border-radius: 6px; }
|
||||||
label { display:block; margin-top: 0.5rem; }
|
label { display:block; margin-top: 0.5rem; }
|
||||||
input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; }
|
input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; }
|
||||||
img.thumb { max-width: 100%; height: auto; display: block; }
|
img.thumb { max-width: 100%; height: auto; display: block; }
|
||||||
.toolbar { display:flex; gap:.5rem; align-items:center; margin:.5rem 0; }
|
.toolbar { display:flex; gap:.5rem; align-items:center; margin:.5rem 0; }
|
||||||
.pill { font-size:.85rem; padding:.25rem .5rem; border:1px solid #ddd; border-radius:999px; background:#f7f7f7; }
|
.pill { font-size:.85rem; padding:.25rem .5rem; border:1px solid #ddd; border-radius:999px; background:#f7f7f7; }
|
||||||
.dragging { opacity:.5; }
|
.dragging { opacity:.5; }
|
||||||
.drag-handle { cursor:grab; padding:.25rem .5rem; border:1px dashed #bbb; border-radius:6px; font-size:.85rem; }
|
.drag-handle { cursor:grab; padding:.25rem .5rem; border:1px dashed #bbb; border-radius:6px; font-size:.85rem; }
|
||||||
.row-buttons { display:flex; gap:.5rem; margin-top:.5rem; }
|
.row-buttons { display:flex; gap:.5rem; margin-top:.5rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<section>
|
<section>
|
||||||
<h2>Authentifizierung</h2>
|
<h2>Authentifizierung</h2>
|
||||||
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
|
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a id="login-link" class="btn" href="https://cms.gallus-pub.ch/api/auth/gitea">Mit Gitea anmelden</a>
|
<a id="login-link" class="btn" href="https://cms.gallus-pub.ch/api/auth/gitea">Mit Gitea anmelden</a>
|
||||||
<button id="btn-relogin">Neu anmelden</button>
|
<button id="btn-relogin">Neu anmelden</button>
|
||||||
<button id="btn-logout">Abmelden</button>
|
<button id="btn-logout">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sec-events" style="display:none">
|
||||||
|
<h2>Events verwalten</h2>
|
||||||
|
<div class="events-row">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Neues Event</h3>
|
||||||
|
<label>Titel<input id="ev-title" /></label>
|
||||||
|
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
|
||||||
|
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
|
||||||
|
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
|
||||||
|
<button id="btn-create-ev">Event anlegen</button>
|
||||||
|
<div id="ev-create-msg" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="min-width:380px;">
|
||||||
|
<h3>Liste</h3>
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="pill" id="lbl-order">Anzeige: automatisch nach Datum (neueste zuerst)</span>
|
||||||
|
<button id="btn-toggle-reorder">Reihenfolge bearbeiten</button>
|
||||||
|
<button id="btn-save-order" style="display:none">Reihenfolge speichern</button>
|
||||||
|
<span id="order-msg" class="muted"></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div id="events-list" class="grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="sec-events" style="display:none">
|
<section id="sec-gallery" style="display:none">
|
||||||
<h2>Events verwalten</h2>
|
<h2>Gallery verwalten</h2>
|
||||||
<div class="events-row">
|
<div class="events-row">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Neues Event</h3>
|
<h3>Neues Gallery-Bild</h3>
|
||||||
<label>Titel<input id="ev-title" /></label>
|
<label>Bild-Datei<input id="gal-file" type="file" accept="image/*" /></label>
|
||||||
<label>Datum<input id="ev-date" type="date" placeholder="z. B. 2025-12-31" /></label>
|
<label>Alt-Text<input id="gal-alt" placeholder="Bildbeschreibung" /></label>
|
||||||
<label>Beschreibung<textarea id="ev-desc" rows="4"></textarea></label>
|
<button id="btn-create-gal">Bild hochladen</button>
|
||||||
<label>Bild-Datei<input id="ev-file" type="file" accept="image/*" /></label>
|
<div id="gal-create-msg" class="muted"></div>
|
||||||
<button id="btn-create-ev">Event anlegen</button>
|
</div>
|
||||||
<div id="ev-create-msg" class="muted"></div>
|
<div class="card" style="min-width:380px;">
|
||||||
</div>
|
<h3>Gallery-Liste</h3>
|
||||||
<div class="card" style="min-width:380px;">
|
<div id="gallery-list" class="grid"></div>
|
||||||
<h3>Liste</h3>
|
</div>
|
||||||
<div class="toolbar">
|
</div>
|
||||||
<span class="pill" id="lbl-order">Anzeige: automatisch nach Datum (neueste zuerst)</span>
|
</section>
|
||||||
<button id="btn-toggle-reorder">Reihenfolge bearbeiten</button>
|
|
||||||
<button id="btn-save-order" style="display:none">Reihenfolge speichern</button>
|
|
||||||
<span id="order-msg" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
<div id="events-list" class="grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sec-gallery" style="display:none">
|
<section id="sec-publish" style="display:none">
|
||||||
<h2>Gallery verwalten</h2>
|
<h2>Veröffentlichen</h2>
|
||||||
<div class="events-row">
|
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
||||||
<div class="card">
|
<button id="btn-publish">Publish</button>
|
||||||
<h3>Neues Gallery-Bild</h3>
|
<div id="pub-status" class="muted"></div>
|
||||||
<label>Bild-Datei<input id="gal-file" type="file" accept="image/*" /></label>
|
</section>
|
||||||
<label>Alt-Text<input id="gal-alt" placeholder="Bildbeschreibung" /></label>
|
|
||||||
<button id="btn-create-gal">Bild hochladen</button>
|
|
||||||
<div id="gal-create-msg" class="muted"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="min-width:380px;">
|
|
||||||
<h3>Gallery-Liste</h3>
|
|
||||||
<div id="gallery-list" class="grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sec-publish" style="display:none">
|
<script>
|
||||||
<h2>Veröffentlichen</h2>
|
const API_BASE = 'https://cms.gallus-pub.ch';
|
||||||
<label>Commit-Message<input id="pub-msg" placeholder="Änderungen beschreiben" value="Update events" /></label>
|
|
||||||
<button id="btn-publish">Publish</button>
|
|
||||||
<div id="pub-status" class="muted"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script>
|
const api = async (path, opts = {}) => {
|
||||||
// Base-URL des Backends (separate Subdomain)
|
const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
|
||||||
const API_BASE = 'https://cms.gallus-pub.ch';
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
return ct.includes('application/json') ? res.json() : res.text();
|
||||||
|
};
|
||||||
|
|
||||||
const api = async (path, opts = {}) => {
|
// Helper to get full image URL
|
||||||
const res = await fetch(API_BASE + path, { credentials: 'include', ...opts });
|
function getImageUrl(imageUrl) {
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!imageUrl) return '';
|
||||||
const ct = res.headers.get('content-type') || '';
|
// API image endpoints need the base URL prefix
|
||||||
return ct.includes('application/json') ? res.json() : res.text();
|
if (imageUrl.startsWith('/api/')) {
|
||||||
};
|
return API_BASE + imageUrl;
|
||||||
|
}
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAuth() {
|
async function refreshAuth() {
|
||||||
try {
|
try {
|
||||||
const me = await api('/api/auth/me');
|
const me = await api('/api/auth/me');
|
||||||
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
|
document.getElementById('auth-status').textContent = `Angemeldet als ${me.user?.giteaUsername || 'Admin'}`;
|
||||||
// UI-Bereiche für eingeloggte Nutzer einblenden
|
document.getElementById('sec-events').style.display = '';
|
||||||
document.getElementById('sec-events').style.display = '';
|
document.getElementById('sec-gallery').style.display = '';
|
||||||
document.getElementById('sec-gallery').style.display = '';
|
document.getElementById('sec-publish').style.display = '';
|
||||||
document.getElementById('sec-publish').style.display = '';
|
await loadEvents();
|
||||||
// Direkt Events laden und auf Sektion fokussieren
|
await loadGallery();
|
||||||
await loadEvents();
|
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
||||||
await loadGallery();
|
} catch (e) {
|
||||||
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
const el = document.getElementById('auth-status');
|
||||||
} catch (e) {
|
el.textContent = 'Nicht angemeldet';
|
||||||
const el = document.getElementById('auth-status');
|
document.getElementById('sec-events').style.display = 'none';
|
||||||
el.textContent = 'Nicht angemeldet';
|
document.getElementById('sec-gallery').style.display = 'none';
|
||||||
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
|
document.getElementById('sec-publish').style.display = 'none';
|
||||||
document.getElementById('sec-events').style.display = 'none';
|
}
|
||||||
document.getElementById('sec-gallery').style.display = 'none';
|
}
|
||||||
document.getElementById('sec-publish').style.display = 'none';
|
|
||||||
}
|
const loginLink = document.getElementById('login-link');
|
||||||
|
loginLink.addEventListener('click', (e) => {
|
||||||
|
try {
|
||||||
|
window.location.assign(API_BASE + '/api/auth/gitea');
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
document.getElementById('btn-relogin').addEventListener('click', async () => {
|
||||||
|
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
||||||
|
document.cookie = 'token=; Path=/; Max-Age=0;';
|
||||||
|
window.location.assign(API_BASE + '/api/auth/gitea');
|
||||||
|
});
|
||||||
|
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||||
|
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
||||||
|
document.cookie = 'token=; Path=/; Max-Age=0;';
|
||||||
|
await refreshAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload image and get base64 data back
|
||||||
|
async function uploadEventImage(file) {
|
||||||
|
const fd = new FormData();
|
||||||
|
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());
|
||||||
|
return res.json(); // Returns { imageData, mimeType }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadGalleryImage(file, altText) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (altText) fd.append('altText', altText);
|
||||||
|
fd.append('displayOrder', '0');
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
let reorderMode = false;
|
||||||
|
let lastEvents = [];
|
||||||
|
|
||||||
|
function parseDateSafe(s){
|
||||||
|
const d = new Date(s);
|
||||||
|
return isNaN(+d) ? new Date(0) : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const listEl = document.getElementById('events-list');
|
||||||
|
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
||||||
|
try {
|
||||||
|
const data = await api('/api/events');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
lastEvents = (data.events || []).slice();
|
||||||
|
let renderList = lastEvents.slice();
|
||||||
|
if (!reorderMode) {
|
||||||
|
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
|
||||||
|
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
|
||||||
|
} else {
|
||||||
|
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
||||||
|
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: falls der Link von Browser/Extensions blockiert wäre
|
renderList.forEach((ev, idx) => {
|
||||||
const loginLink = document.getElementById('login-link');
|
const card = document.createElement('div');
|
||||||
loginLink.addEventListener('click', (e) => {
|
card.className = 'card';
|
||||||
try {
|
card.setAttribute('draggable', String(reorderMode));
|
||||||
// Stelle sicher, dass Navigieren erzwungen wird
|
card.dataset.id = ev.id;
|
||||||
window.location.assign(API_BASE + '/api/auth/gitea');
|
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
|
||||||
} catch {}
|
const imgUrl = getImageUrl(ev.imageUrl);
|
||||||
});
|
card.innerHTML = `
|
||||||
document.getElementById('btn-relogin').addEventListener('click', async () => {
|
|
||||||
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
|
||||||
document.cookie = 'token=; Path=/; Max-Age=0;';
|
|
||||||
window.location.assign(API_BASE + '/api/auth/gitea');
|
|
||||||
});
|
|
||||||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
|
||||||
try { await api('/api/auth/logout', { method: 'POST' }); } catch {}
|
|
||||||
document.cookie = 'token=; Path=/; Max-Age=0;';
|
|
||||||
await refreshAuth();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== Events & Publish ==========
|
|
||||||
async function uploadEventImage(file) {
|
|
||||||
const fd = new FormData();
|
|
||||||
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());
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadGalleryImage(file, altText) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
if (altText) fd.append('altText', altText);
|
|
||||||
fd.append('displayOrder', '0');
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
let reorderMode = false;
|
|
||||||
let lastEvents = [];
|
|
||||||
|
|
||||||
function parseDateSafe(s){
|
|
||||||
const d = new Date(s);
|
|
||||||
return isNaN(+d) ? new Date(0) : d;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEvents() {
|
|
||||||
const listEl = document.getElementById('events-list');
|
|
||||||
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
|
||||||
try {
|
|
||||||
const data = await api('/api/events');
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
// Merken, globale Liste aktualisieren
|
|
||||||
lastEvents = (data.events || []).slice();
|
|
||||||
let renderList = lastEvents.slice();
|
|
||||||
if (!reorderMode) {
|
|
||||||
// Automatisch nach Datum sortieren (neueste zuerst)
|
|
||||||
renderList.sort((a,b) => +parseDateSafe(b.date) - +parseDateSafe(a.date));
|
|
||||||
document.getElementById('lbl-order').textContent = 'Anzeige: automatisch nach Datum (neueste zuerst)';
|
|
||||||
} else {
|
|
||||||
// Nach displayOrder aufsteigend
|
|
||||||
renderList.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
|
||||||
document.getElementById('lbl-order').textContent = 'Anzeige: manuelle Reihenfolge (drag & drop)';
|
|
||||||
}
|
|
||||||
|
|
||||||
renderList.forEach((ev, idx) => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card';
|
|
||||||
card.setAttribute('draggable', String(reorderMode));
|
|
||||||
card.dataset.id = ev.id;
|
|
||||||
card.dataset.displayOrder = String(ev.displayOrder ?? idx);
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="row" style="justify-content:space-between;align-items:center">
|
<div class="row" style="justify-content:space-between;align-items:center">
|
||||||
<div><strong>${ev.title}</strong></div>
|
<div><strong>${ev.title}</strong></div>
|
||||||
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
|
${reorderMode ? '<span class="drag-handle">↕ Ziehen</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="muted">${ev.date}</div>
|
<div class="muted">${ev.date}</div>
|
||||||
<div>${ev.description || ''}</div>
|
<div>${ev.description || ''}</div>
|
||||||
<div class="muted">Bild: ${ev.imageUrl || ''}</div>
|
${imgUrl ? `<img src="${imgUrl}" alt="${ev.title}" class="thumb" style="margin-top:0.5rem;max-height:120px;object-fit:cover;" />` : '<div class="muted">Kein Bild</div>'}
|
||||||
<div class="row-buttons">
|
<div class="row-buttons">
|
||||||
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
|
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
listEl.appendChild(card);
|
listEl.appendChild(card);
|
||||||
});
|
});
|
||||||
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
|
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const id = btn.getAttribute('data-id');
|
const id = btn.getAttribute('data-id');
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!confirm('Event wirklich löschen?')) return;
|
if (!confirm('Event wirklich löschen?')) return;
|
||||||
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
|
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (reorderMode) {
|
if (reorderMode) {
|
||||||
enableDragAndDrop(listEl);
|
enableDragAndDrop(listEl);
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drag & Drop Reorder
|
function enableDragAndDrop(container){
|
||||||
function enableDragAndDrop(container){
|
let draggingEl = null;
|
||||||
let draggingEl = null;
|
container.querySelectorAll('.card').forEach(card => {
|
||||||
container.querySelectorAll('.card').forEach(card => {
|
card.addEventListener('dragstart', (e) => {
|
||||||
card.addEventListener('dragstart', (e) => {
|
draggingEl = card; card.classList.add('dragging');
|
||||||
draggingEl = card; card.classList.add('dragging');
|
e.dataTransfer.setData('text/plain', card.dataset.id || '');
|
||||||
e.dataTransfer.setData('text/plain', card.dataset.id || '');
|
|
||||||
});
|
|
||||||
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
|
|
||||||
card.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
||||||
card.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const target = card;
|
|
||||||
if (!draggingEl || draggingEl === target) return;
|
|
||||||
const cards = Array.from(container.querySelectorAll('.card'));
|
|
||||||
const draggingIdx = cards.indexOf(draggingEl);
|
|
||||||
const targetIdx = cards.indexOf(target);
|
|
||||||
if (draggingIdx < targetIdx) {
|
|
||||||
target.after(draggingEl);
|
|
||||||
} else {
|
|
||||||
target.before(draggingEl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-create-ev').addEventListener('click', async () => {
|
|
||||||
const title = (document.getElementById('ev-title')).value.trim();
|
|
||||||
const date = (document.getElementById('ev-date')).value.trim();
|
|
||||||
const desc = (document.getElementById('ev-desc')).value.trim();
|
|
||||||
const file = /** @type {HTMLInputElement} */ (document.getElementById('ev-file')).files[0];
|
|
||||||
const msg = document.getElementById('ev-create-msg');
|
|
||||||
msg.textContent = 'Lade Bild hoch...';
|
|
||||||
try {
|
|
||||||
let imageUrl = '';
|
|
||||||
if (file) {
|
|
||||||
const up = await uploadEventImage(file);
|
|
||||||
imageUrl = up?.imageUrl || '';
|
|
||||||
}
|
|
||||||
msg.textContent = 'Lege Event an...';
|
|
||||||
await api('/api/events', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title, date, description: desc, imageUrl, displayOrder: 0, isPublished: true })
|
|
||||||
});
|
|
||||||
msg.textContent = 'Event erstellt';
|
|
||||||
(document.getElementById('ev-title')).value = '';
|
|
||||||
(document.getElementById('ev-date')).value = '';
|
|
||||||
(document.getElementById('ev-desc')).value = '';
|
|
||||||
(document.getElementById('ev-file')).value = '';
|
|
||||||
await loadEvents();
|
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
});
|
||||||
|
card.addEventListener('dragend', () => { draggingEl = null; card.classList.remove('dragging'); });
|
||||||
document.getElementById('btn-publish').addEventListener('click', async () => {
|
card.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||||
const s = document.getElementById('pub-status');
|
card.addEventListener('drop', (e) => {
|
||||||
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
|
e.preventDefault();
|
||||||
s.textContent = 'Veröffentliche...';
|
const target = card;
|
||||||
try {
|
if (!draggingEl || draggingEl === target) return;
|
||||||
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
|
|
||||||
s.textContent = res?.message || 'Veröffentlicht';
|
|
||||||
} catch(e){ s.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle Reorder
|
|
||||||
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
|
|
||||||
reorderMode = !reorderMode;
|
|
||||||
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
|
|
||||||
await loadEvents();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save Order
|
|
||||||
document.getElementById('btn-save-order').addEventListener('click', async () => {
|
|
||||||
const container = document.getElementById('events-list');
|
|
||||||
const cards = Array.from(container.querySelectorAll('.card'));
|
const cards = Array.from(container.querySelectorAll('.card'));
|
||||||
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
|
const draggingIdx = cards.indexOf(draggingEl);
|
||||||
const msg = document.getElementById('order-msg');
|
const targetIdx = cards.indexOf(target);
|
||||||
msg.textContent = 'Speichere Reihenfolge...';
|
if (draggingIdx < targetIdx) {
|
||||||
try {
|
target.after(draggingEl);
|
||||||
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
|
} else {
|
||||||
msg.textContent = 'Reihenfolge gespeichert';
|
target.before(draggingEl);
|
||||||
// Bleibe in Reorder-Mode oder verlasse ihn? Hier: verlassen
|
}
|
||||||
reorderMode = false;
|
});
|
||||||
document.getElementById('btn-save-order').style.display = 'none';
|
});
|
||||||
await loadEvents();
|
}
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
|
||||||
|
document.getElementById('btn-create-ev').addEventListener('click', async () => {
|
||||||
|
const title = (document.getElementById('ev-title')).value.trim();
|
||||||
|
const date = (document.getElementById('ev-date')).value.trim();
|
||||||
|
const desc = (document.getElementById('ev-desc')).value.trim();
|
||||||
|
const file = (document.getElementById('ev-file')).files[0];
|
||||||
|
const msg = document.getElementById('ev-create-msg');
|
||||||
|
|
||||||
|
if (!title || !date) {
|
||||||
|
msg.textContent = 'Titel und Datum sind erforderlich';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
|
try {
|
||||||
|
let imageData = '';
|
||||||
|
let mimeType = '';
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const uploadResult = await uploadEventImage(file);
|
||||||
|
imageData = uploadResult.imageData || '';
|
||||||
|
mimeType = uploadResult.mimeType || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.textContent = 'Lege Event an...';
|
||||||
|
await api('/api/events', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
description: desc,
|
||||||
|
imageData,
|
||||||
|
mimeType,
|
||||||
|
displayOrder: 0,
|
||||||
|
isPublished: true
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Gallery ==========
|
msg.textContent = 'Event erstellt';
|
||||||
async function loadGallery() {
|
document.getElementById('ev-title').value = '';
|
||||||
const listEl = document.getElementById('gallery-list');
|
document.getElementById('ev-date').value = '';
|
||||||
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
document.getElementById('ev-desc').value = '';
|
||||||
try {
|
document.getElementById('ev-file').value = '';
|
||||||
const data = await api('/api/gallery');
|
await loadEvents();
|
||||||
listEl.innerHTML = '';
|
} catch(e) {
|
||||||
const galleryImages = (data.images || []).slice();
|
msg.textContent = 'Fehler: ' + e.message;
|
||||||
galleryImages.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
}
|
||||||
|
});
|
||||||
|
|
||||||
galleryImages.forEach((img) => {
|
document.getElementById('btn-publish').addEventListener('click', async () => {
|
||||||
const card = document.createElement('div');
|
const s = document.getElementById('pub-status');
|
||||||
card.className = 'card';
|
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
|
||||||
card.innerHTML = `
|
s.textContent = 'Veröffentliche...';
|
||||||
<img src="${API_BASE}${img.imageUrl}" alt="${img.altText}" class="thumb" />
|
try {
|
||||||
|
const res = await api('/api/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ commitMessage: m }) });
|
||||||
|
s.textContent = res?.message || 'Veröffentlicht';
|
||||||
|
} catch(e){ s.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-toggle-reorder').addEventListener('click', async () => {
|
||||||
|
reorderMode = !reorderMode;
|
||||||
|
document.getElementById('btn-save-order').style.display = reorderMode ? '' : 'none';
|
||||||
|
await loadEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-save-order').addEventListener('click', async () => {
|
||||||
|
const container = document.getElementById('events-list');
|
||||||
|
const cards = Array.from(container.querySelectorAll('.card'));
|
||||||
|
const orders = cards.map((c, idx) => ({ id: c.dataset.id, displayOrder: idx }));
|
||||||
|
const msg = document.getElementById('order-msg');
|
||||||
|
msg.textContent = 'Speichere Reihenfolge...';
|
||||||
|
try {
|
||||||
|
await api('/api/events/reorder', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orders }) });
|
||||||
|
msg.textContent = 'Reihenfolge gespeichert';
|
||||||
|
reorderMode = false;
|
||||||
|
document.getElementById('btn-save-order').style.display = 'none';
|
||||||
|
await loadEvents();
|
||||||
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gallery
|
||||||
|
async function loadGallery() {
|
||||||
|
const listEl = document.getElementById('gallery-list');
|
||||||
|
listEl.innerHTML = '<div class="muted">Lade...</div>';
|
||||||
|
try {
|
||||||
|
const data = await api('/api/gallery');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
const galleryImages = (data.images || []).slice();
|
||||||
|
galleryImages.sort((a,b) => (a.displayOrder??0) - (b.displayOrder??0));
|
||||||
|
|
||||||
|
galleryImages.forEach((img) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
const imgUrl = getImageUrl(img.imageUrl);
|
||||||
|
card.innerHTML = `
|
||||||
|
<img src="${imgUrl}" alt="${img.altText}" class="thumb" />
|
||||||
<div class="muted">${img.altText || ''}</div>
|
<div class="muted">${img.altText || ''}</div>
|
||||||
<div class="row-buttons">
|
<div class="row-buttons">
|
||||||
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
|
<button data-id="${img.id}" class="btn-del-gal">Löschen</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
listEl.appendChild(card);
|
listEl.appendChild(card);
|
||||||
});
|
|
||||||
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const id = btn.getAttribute('data-id');
|
|
||||||
if (!id) return;
|
|
||||||
if (!confirm('Bild wirklich löschen?')) return;
|
|
||||||
try { await api(`/api/gallery/${id}`, { method: 'DELETE' }); await loadGallery(); } catch(e){ alert('Fehler: '+e.message); }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-create-gal').addEventListener('click', async () => {
|
|
||||||
const file = /** @type {HTMLInputElement} */ (document.getElementById('gal-file')).files[0];
|
|
||||||
const alt = (document.getElementById('gal-alt')).value.trim();
|
|
||||||
const msg = document.getElementById('gal-create-msg');
|
|
||||||
if (!file) {
|
|
||||||
msg.textContent = 'Bitte Datei auswählen';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
msg.textContent = 'Lade Bild hoch...';
|
|
||||||
try {
|
|
||||||
await uploadGalleryImage(file, alt);
|
|
||||||
msg.textContent = 'Bild hochgeladen';
|
|
||||||
(document.getElementById('gal-file')).value = '';
|
|
||||||
(document.getElementById('gal-alt')).value = '';
|
|
||||||
await loadGallery();
|
|
||||||
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
|
||||||
});
|
});
|
||||||
|
listEl.querySelectorAll('.btn-del-gal').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
if (!id) return;
|
||||||
|
if (!confirm('Bild wirklich löschen?')) return;
|
||||||
|
try { await api(`/api/gallery/${id}`, { method: 'DELETE' }); await loadGallery(); } catch(e){ alert('Fehler: '+e.message); }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
refreshAuth();
|
document.getElementById('btn-create-gal').addEventListener('click', async () => {
|
||||||
</script>
|
const file = (document.getElementById('gal-file')).files[0];
|
||||||
</body>
|
const alt = (document.getElementById('gal-alt')).value.trim();
|
||||||
</html>
|
const msg = document.getElementById('gal-create-msg');
|
||||||
|
if (!file) {
|
||||||
|
msg.textContent = 'Bitte Datei auswählen';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
|
try {
|
||||||
|
await uploadGalleryImage(file, alt);
|
||||||
|
msg.textContent = 'Bild hochgeladen';
|
||||||
|
document.getElementById('gal-file').value = '';
|
||||||
|
document.getElementById('gal-alt').value = '';
|
||||||
|
await loadGallery();
|
||||||
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user