Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend. - Implemented admin dashboard for creating, editing, and managing events. - Replaced session-based authentication with token-based OAuth using Gitea. - Added support for drag-and-drop event reordering in the admin interface. - Standardized Fastify route validation with JSON schemas. - Enhanced authentication flow with cookie-based state and secure token storage. - Reworked backend routes to handle publishing, event management, and content updates. - Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
This commit is contained in:
@ -2,7 +2,8 @@ FROM node:20-alpine AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
# Fallback to npm install if no lockfile is present
|
||||||
|
RUN npm ci || npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
# Ensure CSS variables are present
|
# Ensure CSS variables are present
|
||||||
RUN mkdir -p public/styles
|
RUN mkdir -p public/styles
|
||||||
@ -16,7 +17,8 @@ RUN npm install -g serve
|
|||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["serve", "-s", "dist", "-l", "3000"]
|
# Serve static files (no SPA fallback), so /admin serves dist/admin/index.html
|
||||||
|
CMD ["serve", "-l", "3000", "dist"]
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||||
9
Dockerfile.caddy
Normal file
9
Dockerfile.caddy
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
|
# Embed Caddyfile directly to avoid host path issues on Windows
|
||||||
|
RUN mkdir -p /etc/caddy \
|
||||||
|
&& printf ":80\nlog\nroute {\n handle /api/* {\n reverse_proxy backend:8080\n }\n handle {\n reverse_proxy frontend:3000\n }\n}\n" > /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
34
backend/.env.local
Normal file
34
backend/.env.local
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Local development environment for Gallus CMS Backend
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_CLIENT=sqlite
|
||||||
|
DATABASE_URL=
|
||||||
|
DATABASE_PATH=./data/gallus_cms.db
|
||||||
|
|
||||||
|
# Gitea OAuth
|
||||||
|
GITEA_URL=https://git.bookageek.ch
|
||||||
|
GITEA_CLIENT_ID=bcddfe24-3099-41bc-bb7a-6c6d80bd9048
|
||||||
|
GITEA_CLIENT_SECRET=gto_me7hsswrsdq2ygey65edlc6qa2xxr3i5nrq7q4jvjwx654ytrh7q
|
||||||
|
# Frontend proxy callback in local dev
|
||||||
|
GITEA_REDIRECT_URI=http://localhost:4321/api/auth/callback
|
||||||
|
GITEA_ALLOWED_USERS=Gallus-maintanance
|
||||||
|
|
||||||
|
# Git repository for content versioning
|
||||||
|
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub
|
||||||
|
GIT_TOKEN=1482ae7bcdbd7610bf0cfd468b6757722d16a2a2
|
||||||
|
GIT_USER_NAME=Gallus-maintanance
|
||||||
|
GIT_USER_EMAIL=Admin@gallus-pub.ch
|
||||||
|
GIT_WORKSPACE_DIR=./data/workspace
|
||||||
|
|
||||||
|
# JWT & Session secrets (use strong random strings in real deployments)
|
||||||
|
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
|
||||||
|
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
|
||||||
|
|
||||||
|
# Server & CORS
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
FRONTEND_URL=http://localhost:4321
|
||||||
|
CORS_ORIGIN=http://localhost:4321
|
||||||
|
|
||||||
|
# Upload limits
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
@ -10,7 +10,8 @@ RUN apk add --no-cache python3 make g++
|
|||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
||||||
|
RUN npm ci || npm install
|
||||||
|
|
||||||
# Copy source
|
# Copy source
|
||||||
COPY . .
|
COPY . .
|
||||||
@ -26,17 +27,8 @@ WORKDIR /app
|
|||||||
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
||||||
RUN apk add --no-cache git sqlite
|
RUN apk add --no-cache git sqlite
|
||||||
|
|
||||||
# Install build dependencies for better-sqlite3 (needed for npm ci)
|
# Copy production dependencies from builder (already compiled native modules)
|
||||||
RUN apk add --no-cache python3 make g++
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install production dependencies only
|
|
||||||
RUN npm ci --production
|
|
||||||
|
|
||||||
# Remove build dependencies after install
|
|
||||||
RUN apk del python3 make g++
|
|
||||||
|
|
||||||
# Copy built files from builder
|
# Copy built files from builder
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
@ -63,5 +55,5 @@ ENV DATABASE_PATH=/app/data/gallus_cms.db
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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 node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
# Start application
|
# Run DB migrations if present, then start application
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["/bin/sh", "-lc", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
||||||
|
|||||||
@ -16,7 +16,6 @@
|
|||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/jwt": "^8.0.0",
|
"@fastify/jwt": "^8.0.0",
|
||||||
"@fastify/multipart": "^8.1.0",
|
"@fastify/multipart": "^8.1.0",
|
||||||
"@fastify/session": "^10.8.0",
|
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import cors from '@fastify/cors';
|
|||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import multipart from '@fastify/multipart';
|
import multipart from '@fastify/multipart';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
import session from '@fastify/session';
|
|
||||||
import { authenticate } from './middleware/auth.middleware.js';
|
import { authenticate } from './middleware/auth.middleware.js';
|
||||||
import { env, validateEnv } from './config/env.js';
|
import { env, validateEnv } from './config/env.js';
|
||||||
|
|
||||||
@ -44,17 +43,12 @@ fastify.register(cors, {
|
|||||||
|
|
||||||
fastify.register(cookie);
|
fastify.register(cookie);
|
||||||
|
|
||||||
fastify.register(session, {
|
|
||||||
secret: env.SESSION_SECRET,
|
|
||||||
cookie: {
|
|
||||||
secure: env.NODE_ENV === 'production',
|
|
||||||
httpOnly: true,
|
|
||||||
maxAge: 600000, // 10 minutes (only needed for OAuth flow)
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.register(jwt, {
|
fastify.register(jwt, {
|
||||||
secret: env.JWT_SECRET,
|
secret: env.JWT_SECRET,
|
||||||
|
cookie: {
|
||||||
|
cookieName: 'token',
|
||||||
|
signed: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.register(multipart, {
|
fastify.register(multipart, {
|
||||||
|
|||||||
@ -6,10 +6,15 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { GiteaService } from '../services/gitea.service.js';
|
import { GiteaService } from '../services/gitea.service.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
const callbackSchema = z.object({
|
// Use explicit JSON schema for Fastify route validation to avoid provider issues
|
||||||
code: z.string(),
|
const callbackQueryJsonSchema = {
|
||||||
state: z.string(),
|
type: 'object',
|
||||||
});
|
required: ['code', 'state'],
|
||||||
|
properties: {
|
||||||
|
code: { type: 'string' },
|
||||||
|
state: { type: 'string' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const authRoute: FastifyPluginAsync = async (fastify) => {
|
const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
const giteaService = new GiteaService();
|
const giteaService = new GiteaService();
|
||||||
@ -22,8 +27,15 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Generate CSRF state token
|
// Generate CSRF state token
|
||||||
const state = giteaService.generateState();
|
const state = giteaService.generateState();
|
||||||
|
|
||||||
// Store state in session
|
// Store state in a short-lived cookie
|
||||||
request.session.set('oauth_state', state);
|
reply.setCookie('oauth_state', state, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
|
||||||
|
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
||||||
|
maxAge: 10 * 60, // 10 minutes
|
||||||
|
});
|
||||||
|
|
||||||
// Generate authorization URL
|
// Generate authorization URL
|
||||||
const authUrl = giteaService.getAuthorizationUrl(state);
|
const authUrl = giteaService.getAuthorizationUrl(state);
|
||||||
@ -38,20 +50,20 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
*/
|
*/
|
||||||
fastify.get('/auth/callback', {
|
fastify.get('/auth/callback', {
|
||||||
schema: {
|
schema: {
|
||||||
querystring: callbackSchema,
|
querystring: callbackQueryJsonSchema,
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { code, state } = request.query as z.infer<typeof callbackSchema>;
|
const { code, state } = request.query as { code: string; state: string };
|
||||||
|
|
||||||
// Verify CSRF state
|
// Verify CSRF state from cookie
|
||||||
const expectedState = request.session.get('oauth_state');
|
const expectedState = request.cookies?.oauth_state as string | undefined;
|
||||||
if (!expectedState || state !== expectedState) {
|
if (!expectedState || state !== expectedState) {
|
||||||
return reply.code(400).send({ error: 'Invalid state parameter' });
|
return reply.code(400).send({ error: 'Invalid state parameter' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear state from session
|
// Clear state cookie
|
||||||
request.session.delete('oauth_state');
|
reply.clearCookie('oauth_state', { path: '/' });
|
||||||
|
|
||||||
// Exchange code for access token
|
// Exchange code for access token
|
||||||
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
||||||
@ -103,18 +115,27 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
{
|
{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
giteaId: user.giteaId,
|
giteaId: user.giteaId,
|
||||||
username: user.giteaUsername,
|
username: user.giteaUsername || '',
|
||||||
role: user.role,
|
role: user.role ?? 'admin',
|
||||||
},
|
},
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Redirect to frontend with token
|
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
|
||||||
|
reply.setCookie('token', token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
||||||
|
maxAge: 60 * 60 * 24, // 24h
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to admin dashboard
|
||||||
const frontendUrl = env.FRONTEND_URL;
|
const frontendUrl = env.FRONTEND_URL;
|
||||||
return reply.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
return reply.redirect(`${frontendUrl}/admin`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error('OAuth callback error:', error);
|
fastify.log.error({ err: error }, 'OAuth callback error');
|
||||||
return reply.code(500).send({ error: 'Authentication failed' });
|
return reply.code(500).send({ error: 'Authentication failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -139,12 +160,14 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
user: {
|
||||||
username: user.giteaUsername,
|
id: user.id,
|
||||||
email: user.giteaEmail,
|
giteaUsername: user.giteaUsername,
|
||||||
displayName: user.displayName,
|
giteaEmail: user.giteaEmail,
|
||||||
avatarUrl: user.avatarUrl,
|
displayName: user.displayName,
|
||||||
role: user.role,
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -157,6 +180,7 @@ const authRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
// For JWT, logout is primarily client-side (delete token)
|
// For JWT, logout is primarily client-side (delete token)
|
||||||
// You could maintain a token blacklist in Redis for production
|
// You could maintain a token blacklist in Redis for production
|
||||||
|
reply.clearCookie('token', { path: '/' });
|
||||||
return { message: 'Logged out successfully' };
|
return { message: 'Logged out successfully' };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,9 +4,14 @@ import { db } from '../config/database.js';
|
|||||||
import { contentSections } from '../db/schema.js';
|
import { contentSections } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
const contentSectionSchema = z.object({
|
// Fastify JSON schema for content section body
|
||||||
contentJson: z.record(z.any()),
|
const contentBodyJsonSchema = {
|
||||||
});
|
type: 'object',
|
||||||
|
required: ['contentJson'],
|
||||||
|
properties: {
|
||||||
|
contentJson: {}, // allow any JSON
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
@ -36,12 +41,12 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Update content section
|
// Update content section
|
||||||
fastify.put('/content/:section', {
|
fastify.put('/content/:section', {
|
||||||
schema: {
|
schema: {
|
||||||
body: contentSectionSchema,
|
body: contentBodyJsonSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { section } = request.params as { section: string };
|
const { section } = request.params as { section: string };
|
||||||
const { contentJson } = request.body as z.infer<typeof contentSectionSchema>;
|
const { contentJson } = request.body as any;
|
||||||
|
|
||||||
// Check if section exists
|
// Check if section exists
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@ -87,7 +92,7 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
const sections = await db.select().from(contentSections);
|
const sections = await db.select().from(contentSections);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sections: sections.map(s => ({
|
sections: (sections as any[]).map((s: any) => ({
|
||||||
section: s.sectionName,
|
section: s.sectionName,
|
||||||
content: s.contentJson,
|
content: s.contentJson,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
|
|||||||
@ -1,121 +1,87 @@
|
|||||||
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 { events } from '../db/schema.js';
|
import { events } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
const eventSchema = z.object({
|
// Fastify JSON schema for event body
|
||||||
title: z.string().min(1).max(200),
|
const eventBodyJsonSchema = {
|
||||||
date: z.string().min(1).max(100),
|
type: 'object',
|
||||||
description: z.string().min(1),
|
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
|
||||||
imageUrl: z.string().url(),
|
properties: {
|
||||||
displayOrder: z.number().int().min(0),
|
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
isPublished: z.boolean().optional().default(true),
|
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' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const reorderBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['orders'],
|
||||||
|
properties: {
|
||||||
|
orders: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
// List all events (by displayOrder)
|
||||||
// List all events
|
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
||||||
fastify.get('/events', {
|
const all = await db.select().from(events).orderBy(events.displayOrder);
|
||||||
preHandler: [fastify.authenticate],
|
return { events: all };
|
||||||
}, async (request, reply) => {
|
|
||||||
const allEvents = await db.select().from(events).orderBy(events.displayOrder);
|
|
||||||
return { events: allEvents };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single event
|
// Get single event
|
||||||
fastify.get('/events/:id', {
|
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const event = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
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' });
|
||||||
if (event.length === 0) {
|
return { event: rows[0] };
|
||||||
return reply.code(404).send({ error: 'Event not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { event: event[0] };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create event
|
// Create event
|
||||||
fastify.post('/events', {
|
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
schema: {
|
const data = request.body as any;
|
||||||
body: eventSchema,
|
const [row] = await db.insert(events).values(data).returning();
|
||||||
},
|
return reply.code(201).send({ event: row });
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const data = request.body as z.infer<typeof eventSchema>;
|
|
||||||
|
|
||||||
const [newEvent] = await db.insert(events).values(data).returning();
|
|
||||||
|
|
||||||
return reply.code(201).send({ event: newEvent });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update event
|
// Update event
|
||||||
fastify.put('/events/:id', {
|
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
schema: {
|
|
||||||
body: eventSchema,
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const data = request.body as z.infer<typeof eventSchema>;
|
const data = request.body as any;
|
||||||
|
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
||||||
const [updated] = await db
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
.update(events)
|
return { event: row };
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where(eq(events.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return reply.code(404).send({ error: 'Event not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { event: updated };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete event
|
// Delete event
|
||||||
fastify.delete('/events/:id', {
|
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
||||||
const [deleted] = await db
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
.delete(events)
|
|
||||||
.where(eq(events.id, id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!deleted) {
|
|
||||||
return reply.code(404).send({ error: 'Event not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: 'Event deleted successfully' };
|
return { message: 'Event deleted successfully' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reorder events
|
// Reorder events (synchronous transaction for better-sqlite3)
|
||||||
fastify.put('/events/reorder', {
|
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
schema: {
|
|
||||||
body: z.object({
|
|
||||||
orders: z.array(z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
displayOrder: z.number().int().min(0),
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
}, 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 }> };
|
||||||
|
db.transaction((tx: any) => {
|
||||||
// Update all in transaction
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
for (const { id, displayOrder } of orders) {
|
for (const { id, displayOrder } of orders) {
|
||||||
await tx
|
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
|
||||||
.update(events)
|
|
||||||
.set({ displayOrder })
|
|
||||||
.where(eq(events.id, id));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { message: 'Events reordered successfully' };
|
return { message: 'Events reordered successfully' };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,12 +4,17 @@ 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';
|
||||||
|
|
||||||
const galleryImageSchema = z.object({
|
// Fastify JSON schema for gallery image body
|
||||||
imageUrl: z.string().url(),
|
const galleryBodyJsonSchema = {
|
||||||
altText: z.string().min(1).max(200),
|
type: 'object',
|
||||||
displayOrder: z.number().int().min(0),
|
required: ['imageUrl', 'altText', 'displayOrder'],
|
||||||
isPublished: z.boolean().optional().default(true),
|
properties: {
|
||||||
});
|
imageUrl: { type: 'string', minLength: 1 },
|
||||||
|
altText: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
isPublished: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
@ -38,11 +43,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Create gallery image
|
// Create gallery image
|
||||||
fastify.post('/gallery', {
|
fastify.post('/gallery', {
|
||||||
schema: {
|
schema: {
|
||||||
body: galleryImageSchema,
|
body: galleryBodyJsonSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const data = request.body as z.infer<typeof galleryImageSchema>;
|
const data = request.body as any;
|
||||||
|
|
||||||
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
||||||
|
|
||||||
@ -52,12 +57,12 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Update gallery image
|
// Update gallery image
|
||||||
fastify.put('/gallery/:id', {
|
fastify.put('/gallery/:id', {
|
||||||
schema: {
|
schema: {
|
||||||
body: galleryImageSchema,
|
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 z.infer<typeof galleryImageSchema>;
|
const data = request.body as any;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(galleryImages)
|
.update(galleryImages)
|
||||||
@ -93,24 +98,32 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Reorder gallery images
|
// Reorder gallery images
|
||||||
fastify.put('/gallery/reorder', {
|
fastify.put('/gallery/reorder', {
|
||||||
schema: {
|
schema: {
|
||||||
body: z.object({
|
body: {
|
||||||
orders: z.array(z.object({
|
type: 'object',
|
||||||
id: z.string().uuid(),
|
required: ['orders'],
|
||||||
displayOrder: z.number().int().min(0),
|
properties: {
|
||||||
})),
|
orders: {
|
||||||
}),
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, 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 transaction
|
// Update all in synchronous transaction (better-sqlite3 requirement)
|
||||||
await db.transaction(async (tx) => {
|
db.transaction((tx: any) => {
|
||||||
for (const { id, displayOrder } of orders) {
|
for (const { id, displayOrder } of orders) {
|
||||||
await tx
|
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
|
||||||
.update(galleryImages)
|
|
||||||
.set({ displayOrder })
|
|
||||||
.where(eq(galleryImages.id, id));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,19 +6,24 @@ import { db } from '../config/database.js';
|
|||||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
const publishSchema = z.object({
|
// Fastify JSON schema for publish body
|
||||||
commitMessage: z.string().min(1).max(200),
|
const publishBodyJsonSchema = {
|
||||||
});
|
type: 'object',
|
||||||
|
required: ['commitMessage'],
|
||||||
|
properties: {
|
||||||
|
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.post('/publish', {
|
fastify.post('/publish', {
|
||||||
schema: {
|
schema: {
|
||||||
body: publishSchema,
|
body: publishBodyJsonSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { commitMessage } = request.body as z.infer<typeof publishSchema>;
|
const { commitMessage } = request.body as any;
|
||||||
const userId = request.user.id;
|
const userId = request.user.id;
|
||||||
|
|
||||||
fastify.log.info('Starting publish process...');
|
fastify.log.info('Starting publish process...');
|
||||||
@ -43,8 +48,8 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
.orderBy(galleryImages.displayOrder);
|
.orderBy(galleryImages.displayOrder);
|
||||||
|
|
||||||
const sectionsData = await db.select().from(contentSections);
|
const sectionsData = await db.select().from(contentSections);
|
||||||
const sectionsMap = new Map(
|
const sectionsMap = new Map<string, any>(
|
||||||
sectionsData.map(s => [s.sectionName, s.contentJson as any])
|
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
|
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
|
||||||
@ -53,13 +58,13 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
const fileGenerator = new FileGeneratorService();
|
const fileGenerator = new FileGeneratorService();
|
||||||
await fileGenerator.writeFiles(
|
await fileGenerator.writeFiles(
|
||||||
gitService.getWorkspacePath(''),
|
gitService.getWorkspacePath(''),
|
||||||
eventsData.map(e => ({
|
(eventsData as any[]).map((e: any) => ({
|
||||||
title: e.title,
|
title: e.title,
|
||||||
date: e.date,
|
date: e.date,
|
||||||
description: e.description,
|
description: e.description,
|
||||||
imageUrl: e.imageUrl,
|
imageUrl: e.imageUrl,
|
||||||
})),
|
})),
|
||||||
galleryData.map(g => ({
|
(galleryData as any[]).map((g: any) => ({
|
||||||
imageUrl: g.imageUrl,
|
imageUrl: g.imageUrl,
|
||||||
altText: g.altText,
|
altText: g.altText,
|
||||||
})),
|
})),
|
||||||
@ -87,14 +92,14 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fastify.log.error('Publish error:', error);
|
fastify.log.error({ err: error }, 'Publish error');
|
||||||
|
|
||||||
// Attempt to reset git state on error
|
// Attempt to reset git state on error
|
||||||
try {
|
try {
|
||||||
const gitService = new GitService();
|
const gitService = new GitService();
|
||||||
await gitService.reset();
|
await gitService.reset();
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
fastify.log.error('Failed to reset git state:', resetError);
|
fastify.log.error({ err: resetError }, 'Failed to reset git state');
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
|
|||||||
@ -4,9 +4,14 @@ import { db } from '../config/database.js';
|
|||||||
import { siteSettings } from '../db/schema.js';
|
import { siteSettings } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
const settingSchema = z.object({
|
// Fastify JSON schema for settings body
|
||||||
value: z.string(),
|
const settingBodyJsonSchema = {
|
||||||
});
|
type: 'object',
|
||||||
|
required: ['value'],
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
@ -50,12 +55,12 @@ const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Update setting
|
// Update setting
|
||||||
fastify.put('/settings/:key', {
|
fastify.put('/settings/:key', {
|
||||||
schema: {
|
schema: {
|
||||||
body: settingSchema,
|
body: settingBodyJsonSchema,
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { key } = request.params as { key: string };
|
const { key } = request.params as { key: string };
|
||||||
const { value } = request.body as z.infer<typeof settingSchema>;
|
const { value } = request.body as any;
|
||||||
|
|
||||||
// Check if setting exists
|
// Check if setting exists
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
|
|||||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=http://proxy:4321
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env.local
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=8080
|
||||||
|
- DATABASE_PATH=/app/data/gallus_cms.db
|
||||||
|
- GIT_WORKSPACE_DIR=/app/workspace
|
||||||
|
volumes:
|
||||||
|
- backend_data:/app/data
|
||||||
|
- backend_workspace:/app/workspace
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.caddy
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "4321:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_data:
|
||||||
|
backend_workspace:
|
||||||
17
fly.toml
17
fly.toml
@ -4,11 +4,14 @@ kill_signal = "SIGINT"
|
|||||||
kill_timeout = 5
|
kill_timeout = 5
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
dockerfile = "Dockerfile"
|
dockerfile = "Dockerfile.fly"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "3000"
|
PORT = "3000" # Caddy (serves frontend + proxies /api/*)
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
|
BACKEND_PORT = "8080" # Fastify backend will listen here
|
||||||
|
DATABASE_PATH = "/app/data/gallus_cms.db"
|
||||||
|
GIT_WORKSPACE_DIR = "/app/workspace"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3000
|
internal_port = 3000
|
||||||
@ -39,4 +42,12 @@ kill_timeout = 5
|
|||||||
[[vm]]
|
[[vm]]
|
||||||
memory = "512MB"
|
memory = "512MB"
|
||||||
cpu_kind = "shared"
|
cpu_kind = "shared"
|
||||||
cpus = 1
|
cpus = 1
|
||||||
|
|
||||||
|
[[mounts]]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
|
|
||||||
|
[[mounts]]
|
||||||
|
source = "gallus_workspace"
|
||||||
|
destination = "/app/workspace"
|
||||||
291
src/pages/admin.astro
Normal file
291
src/pages/admin.astro
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
---
|
||||||
|
const title = 'Admin';
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 1rem; }
|
||||||
|
h1, h2 { margin: 0.5rem 0; }
|
||||||
|
section { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
||||||
|
.row { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.events-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start; }
|
||||||
|
@media (max-width: 900px){ .events-row { grid-template-columns: 1fr; } }
|
||||||
|
button { margin-top: 0.5rem; padding: 0.5rem 1rem; cursor: pointer; }
|
||||||
|
.muted { color: #666; }
|
||||||
|
.btn { display: inline-block; padding: 0.5rem 1rem; background: #222; color: #fff; border-radius: 6px; text-decoration: none; }
|
||||||
|
.btn:hover { background: #444; }
|
||||||
|
.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; }
|
||||||
|
label { display:block; margin-top: 0.5rem; }
|
||||||
|
input, textarea { width: 100%; max-width: 600px; padding: 0.5rem; margin-top: 0.25rem; }
|
||||||
|
img.thumb { max-width: 100%; height: auto; display: block; }
|
||||||
|
.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; }
|
||||||
|
.dragging { opacity:.5; }
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<section>
|
||||||
|
<h2>Authentifizierung</h2>
|
||||||
|
<div id="auth-status" class="muted">Prüfe Anmeldestatus...</div>
|
||||||
|
<div class="row">
|
||||||
|
<a id="login-link" class="btn" href="/api/auth/gitea">Mit Gitea anmelden</a>
|
||||||
|
<button id="btn-relogin">Neu anmelden</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>
|
||||||
|
<label>Alt-Text<input id="ev-alt" placeholder="Bildbeschreibung" /></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 id="events-list" class="grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sec-publish" style="display:none">
|
||||||
|
<h2>Veröffentlichen</h2>
|
||||||
|
<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 = {}) => {
|
||||||
|
const res = await fetch(path, { credentials: 'include', ...opts });
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function refreshAuth() {
|
||||||
|
try {
|
||||||
|
const me = await api('/api/auth/me');
|
||||||
|
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-publish').style.display = '';
|
||||||
|
// Direkt Events laden und auf Sektion fokussieren
|
||||||
|
await loadEvents();
|
||||||
|
document.getElementById('sec-events').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
} catch (e) {
|
||||||
|
const el = document.getElementById('auth-status');
|
||||||
|
el.textContent = 'Nicht angemeldet';
|
||||||
|
// Kein Auto-Redirect, damit keine Schleife entsteht. Login-Button verwenden.
|
||||||
|
document.getElementById('sec-events').style.display = 'none';
|
||||||
|
document.getElementById('sec-publish').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: falls der Link von Browser/Extensions blockiert wäre
|
||||||
|
const loginLink = document.getElementById('login-link');
|
||||||
|
loginLink.addEventListener('click', (e) => {
|
||||||
|
try {
|
||||||
|
// Stelle sicher, dass Navigieren erzwungen wird
|
||||||
|
window.location.assign('/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/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 uploadImage(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/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><strong>${ev.title}</strong></div>
|
||||||
|
${reorderMode ? '<span class="drag-handle">⇅ Ziehen</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="muted">${ev.date}</div>
|
||||||
|
<div>${ev.description || ''}</div>
|
||||||
|
<div class="muted">Bild: ${ev.imageUrl || ''}</div>
|
||||||
|
<div class="row-buttons">
|
||||||
|
<button data-id="${ev.id}" class="btn-del-ev">Löschen</button>
|
||||||
|
</div>`;
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('.btn-del-ev').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
if (!id) return;
|
||||||
|
if (!confirm('Event wirklich löschen?')) return;
|
||||||
|
try { await api(`/api/events/${id}`, { method: 'DELETE' }); await loadEvents(); } catch(e){ alert('Fehler: '+e.message); }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (reorderMode) {
|
||||||
|
enableDragAndDrop(listEl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = '<div class="muted">Fehler beim Laden</div>';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag & Drop Reorder
|
||||||
|
function enableDragAndDrop(container){
|
||||||
|
let draggingEl = null;
|
||||||
|
container.querySelectorAll('.card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', (e) => {
|
||||||
|
draggingEl = card; card.classList.add('dragging');
|
||||||
|
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 alt = (document.getElementById('ev-alt')).value.trim();
|
||||||
|
const msg = document.getElementById('ev-create-msg');
|
||||||
|
msg.textContent = 'Lade Bild hoch...';
|
||||||
|
try {
|
||||||
|
let imageUrl = '';
|
||||||
|
if (file) {
|
||||||
|
const up = await uploadImage(file, alt || title);
|
||||||
|
imageUrl = up?.image?.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 = '';
|
||||||
|
(document.getElementById('ev-alt')).value = '';
|
||||||
|
await loadEvents();
|
||||||
|
} catch(e){ msg.textContent = 'Fehler: '+e.message }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-publish').addEventListener('click', async () => {
|
||||||
|
const s = document.getElementById('pub-status');
|
||||||
|
const m = (document.getElementById('pub-msg')).value.trim() || 'Update events';
|
||||||
|
s.textContent = 'Veröffentliche...';
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 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';
|
||||||
|
// 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 }
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
src/pages/auth/callback.astro
Normal file
29
src/pages/auth/callback.astro
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
const title = 'Anmeldung wird abgeschlossen...';
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>{title}</p>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = params.get('token');
|
||||||
|
if (token) {
|
||||||
|
const secure = window.location.protocol === 'https:';
|
||||||
|
document.cookie = `token=${encodeURIComponent(token)}; Path=/; Max-Age=${60*60*24}; SameSite=Lax; ${secure ? 'Secure' : ''}`.trim();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to process OAuth token', e);
|
||||||
|
}
|
||||||
|
window.location.replace('/admin');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user