Compare commits
94 Commits
15dedfabcf
...
feat/cms
| Author | SHA1 | Date | |
|---|---|---|---|
| fb7eaa6bb2 | |||
| daccc43677 | |||
| 3b6cb0a3fb | |||
| 6a3c77d7c5 | |||
| a28d43db45 | |||
| af930f345c | |||
| 22494084ce | |||
| bc6c1e95d3 | |||
| f2a0422f3b | |||
| 2cae2e86ed | |||
| 636c7fc03a | |||
| 5fdea37a90 | |||
| 11932d51ec | |||
| 803c7907f1 | |||
| 3d4bbf77bc | |||
| 71a586280e | |||
| 1f4cea0c35 | |||
| 9adec32839 | |||
| 688b4de945 | |||
| 193f3ff0bb | |||
| 292747d197 | |||
| 18f7ea5da5 | |||
| 1f94bbca15 | |||
| 5ef15f0b5c | |||
| 020bfca731 | |||
| ac864ba054 | |||
| e93ba5d29b | |||
| feb137471d | |||
| 0622d190d1 | |||
| 2867678223 | |||
| 096ac9f789 | |||
| 3006ccd5a0 | |||
| 8a8bcc304a | |||
| 54c6f205e0 | |||
| 48fddf7b15 | |||
| 2733c2e7f4 | |||
| 9502123b89 | |||
| ca2d724bd8 | |||
| 38229ac5e9 | |||
| a11c838d2a | |||
| f9fe914c32 | |||
| 21e09f7155 | |||
| 0b37f73634 | |||
| c764f892a1 | |||
| 78f367530a | |||
| b539329420 | |||
| 3e93e8ce3b | |||
| 2fab4bf70b | |||
| 1a6be67af1 | |||
| fea45fc4f8 | |||
| 761bd6be80 | |||
| 8e6bd12da5 | |||
| 548a2d6f53 | |||
| 01edb8d575 | |||
| c498b19afb | |||
| 74a8e7b393 | |||
| 9c4b6ec425 | |||
| dc3f0b53d7 | |||
| b215592292 | |||
| 9c7ecc97df | |||
| 0fd4fbe61f | |||
| 6e489ceac3 | |||
| 21d51732e5 | |||
| f1c94ed438 | |||
| 493c2a94f0 | |||
| 3a3a36e2ea | |||
| 535c82bd81 | |||
| 64aa08c699 | |||
| 6f3edc8977 | |||
| 9ac87b82e9 | |||
| 74e4799ea9 | |||
| 0a939975c3 | |||
| 7e0f052ce7 | |||
| 77c5d5df82 | |||
| f0afa677a0 | |||
| f356b37c9e | |||
| 096883b0ee | |||
| 749b3e5079 | |||
| 3c1a6fae2c | |||
| f3952e7e81 | |||
| 00213204c4 | |||
| 5247bd9816 | |||
| 50c06b3a8a | |||
| 5ab62f2b3b | |||
| 6120f04c95 | |||
| 179de67386 | |||
| 3da1b63a50 | |||
| 6b79e08684 | |||
| 7d5e77df76 | |||
| 23b47a7e85 | |||
| f4c75ea941 | |||
| 58522f2ae0 | |||
| 2a0aa7a6c8 | |||
| bcd86c9c68 |
@ -1,26 +1,16 @@
|
||||
pipeline:
|
||||
build:
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run build
|
||||
when:
|
||||
branch: main
|
||||
event: [push, pull_request]
|
||||
steps:
|
||||
deploy:
|
||||
depends_on: [build]
|
||||
image: flyio/flyctl:latest
|
||||
secrets: [fly_api_token]
|
||||
image: node:20
|
||||
environment:
|
||||
FLY_API_TOKEN:
|
||||
from_secret: FLY_API_TOKEN
|
||||
commands:
|
||||
- flyctl deploy --remote-only
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
- curl -L https://fly.io/install.sh | sh
|
||||
- export PATH="$HOME/.fly/bin:$PATH"
|
||||
- flyctl deploy --config fly.toml --app gallus-pub
|
||||
|
||||
branches:
|
||||
include: [main, dev]
|
||||
|
||||
cache:
|
||||
mount:
|
||||
- node_modules
|
||||
- .npm
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
|
||||
19
Dockerfile
@ -1,25 +1,24 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY ../backup/backup .
|
||||
|
||||
# Fallback to npm install if no lockfile is present
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
# Ensure CSS variables are present
|
||||
RUN mkdir -p public/styles
|
||||
RUN cp -r styles/* public/styles/ || true
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g serve
|
||||
|
||||
COPY --from=build /app/dist /app
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["serve", "-s", ".", "-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 \
|
||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||
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"]
|
||||
20
backend/.dockerignore
Normal file
@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.md
|
||||
!README.md
|
||||
tmp
|
||||
/tmp
|
||||
coverage
|
||||
.nyc_output
|
||||
29
backend/.env.example
Normal file
@ -0,0 +1,29 @@
|
||||
# Database (SQLite)
|
||||
DATABASE_PATH=./data/gallus_cms.db
|
||||
|
||||
# Gitea OAuth
|
||||
GITEA_URL=https://git.bookageek.ch
|
||||
GITEA_CLIENT_ID=your-oauth-client-id-here
|
||||
GITEA_CLIENT_SECRET=your-oauth-client-secret-here
|
||||
GITEA_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
||||
GITEA_ALLOWED_USERS=sabrina,raphael,admin
|
||||
|
||||
# Git Configuration (use Gitea repository)
|
||||
GIT_REPO_URL=https://git.bookageek.ch/yourusername/Gallus_Pub.git
|
||||
GIT_TOKEN=your-gitea-personal-access-token-here
|
||||
GIT_USER_NAME=Gallus CMS
|
||||
GIT_USER_EMAIL=cms@galluspub.ch
|
||||
GIT_WORKSPACE_DIR=./data/workspace
|
||||
|
||||
# JWT & Session
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||
SESSION_SECRET=your-session-secret-change-this
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Upload
|
||||
MAX_FILE_SIZE=5242880
|
||||
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
backend/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
/tmp
|
||||
/data
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
195
backend/DEPLOYMENT.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
|
||||
2. Fly.io account: `flyctl auth login`
|
||||
3. Gitea OAuth app configured at git.bookageek.ch
|
||||
4. Gitea Personal Access Token for git operations
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Create Fly.io App
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
flyctl apps create gallus-cms-backend
|
||||
```
|
||||
|
||||
### 2. Create Volume for Data (SQLite DB + Git Workspace)
|
||||
|
||||
```bash
|
||||
flyctl volumes create gallus_data --size 2 --region ams
|
||||
```
|
||||
|
||||
This volume will store:
|
||||
- SQLite database at `/app/data/gallus_cms.db`
|
||||
- Git workspace at `/app/data/workspace`
|
||||
|
||||
### 3. Set Secrets
|
||||
|
||||
```bash
|
||||
flyctl secrets set \
|
||||
GITEA_CLIENT_ID="<your-gitea-oauth-client-id>" \
|
||||
GITEA_CLIENT_SECRET="<your-gitea-oauth-client-secret>" \
|
||||
GIT_TOKEN="<your-gitea-personal-access-token>" \
|
||||
JWT_SECRET="$(openssl rand -base64 32)" \
|
||||
SESSION_SECRET="$(openssl rand -base64 32)" \
|
||||
GIT_REPO_URL="https://git.bookageek.ch/yourusername/Gallus_Pub.git" \
|
||||
GIT_USER_NAME="Gallus CMS" \
|
||||
GIT_USER_EMAIL="cms@galluspub.ch" \
|
||||
GITEA_REDIRECT_URI="https://gallus-cms-backend.fly.dev/api/auth/callback" \
|
||||
FRONTEND_URL="https://cms.galluspub.ch" \
|
||||
CORS_ORIGIN="https://cms.galluspub.ch" \
|
||||
GITEA_ALLOWED_USERS="sabrina,raphael"
|
||||
```
|
||||
|
||||
### 4. Deploy
|
||||
|
||||
```bash
|
||||
flyctl deploy
|
||||
```
|
||||
|
||||
### 5. Initialize Database
|
||||
|
||||
After first deployment, SSH into the container and run migrations:
|
||||
```bash
|
||||
flyctl ssh console
|
||||
cd /app
|
||||
node dist/index.js # Start once to create the database file
|
||||
# Then exit (Ctrl+C) and run migrations
|
||||
npm run db:migrate
|
||||
exit
|
||||
```
|
||||
|
||||
Or simply let the app run - the database will be created automatically on first start.
|
||||
|
||||
## Gitea OAuth Configuration
|
||||
|
||||
Update your Gitea OAuth application redirect URI to include:
|
||||
```
|
||||
https://gallus-cms-backend.fly.dev/api/auth/callback
|
||||
```
|
||||
|
||||
## Useful Commands
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
flyctl logs
|
||||
```
|
||||
|
||||
### Check Status
|
||||
```bash
|
||||
flyctl status
|
||||
```
|
||||
|
||||
### SSH into Container
|
||||
```bash
|
||||
flyctl ssh console
|
||||
```
|
||||
|
||||
### Scale App
|
||||
```bash
|
||||
flyctl scale count 2
|
||||
```
|
||||
|
||||
### View Secrets
|
||||
```bash
|
||||
flyctl secrets list
|
||||
```
|
||||
|
||||
### Update a Secret
|
||||
```bash
|
||||
flyctl secrets set KEY=VALUE
|
||||
```
|
||||
|
||||
### Restart App
|
||||
```bash
|
||||
flyctl apps restart
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl https://gallus-cms-backend.fly.dev/health
|
||||
```
|
||||
|
||||
### View Metrics
|
||||
```bash
|
||||
flyctl dashboard
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Deployment Fails
|
||||
- Check logs: `flyctl logs`
|
||||
- Verify all secrets are set: `flyctl secrets list`
|
||||
- Ensure Docker builds locally: `docker build -t test .`
|
||||
|
||||
### OAuth Not Working
|
||||
- Verify GITEA_REDIRECT_URI matches Gitea settings exactly
|
||||
- Check CORS_ORIGIN includes frontend domain
|
||||
- Review logs for authentication errors
|
||||
|
||||
### Git Push Fails
|
||||
- Verify GIT_TOKEN has correct permissions
|
||||
- Check GIT_REPO_URL is accessible
|
||||
- Ensure workspace volume is mounted
|
||||
|
||||
### Database Issues
|
||||
- Verify DATABASE_PATH is set correctly
|
||||
- Check volume is mounted: `flyctl ssh console` then `ls -la /app/data`
|
||||
- Verify database file permissions
|
||||
- Run migrations if needed: `flyctl ssh console` then `npm run db:migrate`
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
Current configuration uses:
|
||||
- `shared-cpu-1x` with 512MB RAM
|
||||
- Auto-suspend when idle
|
||||
- 2GB volume for SQLite database + git workspace
|
||||
|
||||
Estimated cost: ~$5-10/month (no separate database cost with SQLite!)
|
||||
|
||||
## Updating
|
||||
|
||||
To deploy updates:
|
||||
```bash
|
||||
git pull
|
||||
flyctl deploy
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
To rollback to previous version:
|
||||
```bash
|
||||
flyctl releases list
|
||||
flyctl releases rollback <version-number>
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All sensitive environment variables should be set as Fly.io secrets (not in fly.toml):
|
||||
|
||||
Note: DATABASE_PATH and GIT_WORKSPACE_DIR are set in fly.toml as they're not sensitive.
|
||||
- `GITEA_CLIENT_ID` - OAuth client ID
|
||||
- `GITEA_CLIENT_SECRET` - OAuth client secret
|
||||
- `GIT_TOKEN` - Gitea personal access token
|
||||
- `JWT_SECRET` - JWT signing secret
|
||||
- `SESSION_SECRET` - Session cookie secret
|
||||
- `GIT_REPO_URL` - Full git repository URL
|
||||
- `GITEA_REDIRECT_URI` - OAuth callback URL
|
||||
- `FRONTEND_URL` - Frontend application URL
|
||||
- `CORS_ORIGIN` - Allowed CORS origin
|
||||
- `GITEA_ALLOWED_USERS` - Comma-separated list of allowed usernames
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] All secrets set and not exposed in logs
|
||||
- [ ] HTTPS enforced (fly.toml: force_https = true)
|
||||
- [ ] CORS configured correctly
|
||||
- [ ] GITEA_ALLOWED_USERS whitelist configured
|
||||
- [ ] Database backups enabled
|
||||
- [ ] Health checks configured
|
||||
- [ ] Monitoring and alerts set up
|
||||
59
backend/Dockerfile
Normal file
@ -0,0 +1,59 @@
|
||||
# Multi-stage build for Gallus CMS Backend
|
||||
|
||||
# Stage 1: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules (better-sqlite3)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
||||
RUN npm ci || npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
||||
RUN apk add --no-cache git sqlite
|
||||
|
||||
# Copy production dependencies from builder (already compiled native modules)
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/workspace /app/data
|
||||
|
||||
# Ensure proper permissions
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8080
|
||||
ENV DATABASE_PATH=/app/data/gallus_cms.db
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Run DB migrations if present, then start application
|
||||
CMD ["/bin/sh", "-lc", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
||||
55
backend/README.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Gallus Pub CMS Backend
|
||||
|
||||
Headless CMS backend for managing Gallus Pub website content with Gitea OAuth authentication.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Create `.env` file from `.env.example`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Update environment variables in `.env`:
|
||||
- Set Gitea OAuth credentials
|
||||
- Set Git repository URL and token
|
||||
- JWT secrets are already generated
|
||||
|
||||
4. Create data directory and run migrations:
|
||||
```bash
|
||||
mkdir -p data
|
||||
```
|
||||
|
||||
5. Generate and run migrations:
|
||||
```bash
|
||||
npm run db:generate
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
6. Start development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Server will run at http://localhost:3000
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server with watch mode
|
||||
- `npm run build` - Build for production
|
||||
- `npm run start` - Start production server
|
||||
- `npm run db:generate` - Generate database migrations
|
||||
- `npm run db:migrate` - Run database migrations
|
||||
- `npm run db:studio` - Open Drizzle Studio
|
||||
|
||||
## Documentation
|
||||
|
||||
See parent directory for complete documentation:
|
||||
- `CMS_CONCEPT.md` - System architecture
|
||||
- `CMS_GITEA_AUTH.md` - Authentication details
|
||||
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
|
||||
- `CMS_SETUP_GUIDE.md` - Deployment guide
|
||||
216
backend/SETUP_QUICK_START.md
Normal file
@ -0,0 +1,216 @@
|
||||
# Quick Start Guide - SQLite Version
|
||||
|
||||
## ✅ Migration Complete: PostgreSQL → SQLite
|
||||
|
||||
The backend now uses **SQLite** instead of PostgreSQL for simplified deployment and lower costs.
|
||||
|
||||
## 🚀 Quick Start (3 Steps)
|
||||
|
||||
### 1. Configure Environment
|
||||
|
||||
Edit `.env` file (already created):
|
||||
```bash
|
||||
# Required: Update these values
|
||||
GITEA_CLIENT_ID=<your-gitea-oauth-client-id>
|
||||
GITEA_CLIENT_SECRET=<your-gitea-oauth-client-secret>
|
||||
GIT_REPO_URL=https://git.bookageek.ch/<yourusername>/Gallus_Pub.git
|
||||
GIT_TOKEN=<your-gitea-personal-access-token>
|
||||
GITEA_ALLOWED_USERS=sabrina,raphael
|
||||
|
||||
# Already set (JWT secrets generated)
|
||||
JWT_SECRET=dOrvUqifjBLvk68kkDOvWPQper/gjsNMlAbWlVBQIrc=
|
||||
SESSION_SECRET=SD0ZrvLkv9GrtI8+3GDkxZXA1UnCN4CE3c4+2vA/fIM=
|
||||
|
||||
# Database (SQLite - no changes needed)
|
||||
DATABASE_PATH=./data/gallus_cms.db
|
||||
```
|
||||
|
||||
### 2. Initialize Database
|
||||
|
||||
```bash
|
||||
# Generate migration files from schema
|
||||
pnpm run db:generate
|
||||
|
||||
# Run migrations to create tables
|
||||
pnpm run db:migrate
|
||||
```
|
||||
|
||||
### 3. Start Development Server
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Server will start at **http://localhost:3000**
|
||||
|
||||
## 📝 What Changed?
|
||||
|
||||
### Before (PostgreSQL)
|
||||
- Required PostgreSQL installation
|
||||
- Separate database service
|
||||
- Connection string configuration
|
||||
- ~$15/month hosting cost on Fly.io
|
||||
|
||||
### After (SQLite)
|
||||
- Single file database (`./data/gallus_cms.db`)
|
||||
- No separate database service needed
|
||||
- Works out of the box
|
||||
- **$0 database cost** (included in app volume)
|
||||
|
||||
## 🗂️ Database Location
|
||||
|
||||
- **Local:** `./data/gallus_cms.db`
|
||||
- **Production (Fly.io):** `/app/data/gallus_cms.db` (on persistent volume)
|
||||
- **Git Workspace:** Same `data/` directory
|
||||
|
||||
## 🧪 Test Authentication Flow
|
||||
|
||||
1. Make sure you have Gitea OAuth credentials configured
|
||||
2. Start dev server: `pnpm run dev`
|
||||
3. Visit: http://localhost:3000/api/auth/gitea
|
||||
4. Login with your Gitea credentials
|
||||
5. Should redirect back with JWT token
|
||||
|
||||
## 📚 Available Endpoints
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
### OAuth Flow
|
||||
```
|
||||
GET /api/auth/gitea - Initiate OAuth
|
||||
GET /api/auth/callback - OAuth callback
|
||||
GET /api/auth/me - Get current user (requires JWT)
|
||||
```
|
||||
|
||||
### Content Management (all require JWT)
|
||||
```
|
||||
GET/POST/PUT/DELETE /api/events
|
||||
GET/POST/PUT/DELETE /api/gallery
|
||||
GET/PUT /api/content/:section
|
||||
GET/PUT /api/settings/:key
|
||||
POST /api/publish
|
||||
```
|
||||
|
||||
## 🔐 Getting Gitea OAuth Credentials
|
||||
|
||||
1. Go to https://git.bookageek.ch/user/settings/applications
|
||||
2. Click "Manage OAuth2 Applications"
|
||||
3. Create new OAuth2 application:
|
||||
- **Name:** Gallus Pub CMS
|
||||
- **Redirect URI:** `http://localhost:3000/api/auth/callback`
|
||||
- **Confidential:** Yes
|
||||
4. Copy Client ID and Client Secret to `.env`
|
||||
|
||||
## 🎫 Getting Gitea Personal Access Token
|
||||
|
||||
1. Go to https://git.bookageek.ch/user/settings/applications
|
||||
2. Generate New Token
|
||||
3. **Name:** Gallus CMS Backend
|
||||
4. **Scopes:** Select `repo` (full repository access)
|
||||
5. Copy token to `.env` as `GIT_TOKEN`
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── data/ # SQLite database & git workspace (gitignored)
|
||||
│ ├── gallus_cms.db # Database file
|
||||
│ └── workspace/ # Git repository clone
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ ├── database.ts # SQLite connection (updated)
|
||||
│ │ └── env.ts # DATABASE_PATH instead of URL
|
||||
│ ├── db/
|
||||
│ │ └── schema.ts # SQLite schema (updated)
|
||||
│ ├── routes/ # API routes
|
||||
│ ├── services/ # Core services
|
||||
│ └── index.ts # Main server
|
||||
├── .env # Your configuration
|
||||
├── package.json # Updated with better-sqlite3
|
||||
└── drizzle.config.ts # SQLite dialect
|
||||
```
|
||||
|
||||
## ⚙️ Scripts
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies (done)
|
||||
pnpm run dev # Start dev server with watch
|
||||
pnpm run build # Build TypeScript
|
||||
pnpm run start # Start production server
|
||||
pnpm run db:generate # Generate migrations
|
||||
pnpm run db:migrate # Run migrations
|
||||
pnpm run db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## 🚀 Deploy to Fly.io
|
||||
|
||||
See `DEPLOYMENT.md` for full deployment guide.
|
||||
|
||||
**Quick version:**
|
||||
```bash
|
||||
# Create volume for database & git workspace
|
||||
flyctl volumes create gallus_data --size 2 --region ams
|
||||
|
||||
# Set secrets
|
||||
flyctl secrets set GITEA_CLIENT_ID=... GITEA_CLIENT_SECRET=... # etc
|
||||
|
||||
# Deploy
|
||||
flyctl deploy
|
||||
```
|
||||
|
||||
**Cost:** ~$5-10/month (no separate database!)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "tsx: command not found"
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### "DATABASE_PATH not set"
|
||||
Check `.env` file exists and has `DATABASE_PATH=./data/gallus_cms.db`
|
||||
|
||||
### "Database file not found"
|
||||
```bash
|
||||
mkdir -p data
|
||||
pnpm run db:migrate
|
||||
```
|
||||
|
||||
### "better-sqlite3" build errors
|
||||
Make sure you have build tools:
|
||||
- **Linux:** `apt-get install python3 make g++`
|
||||
- **macOS:** Install Xcode Command Line Tools
|
||||
- **Windows:** Install windows-build-tools
|
||||
|
||||
Then rebuild:
|
||||
```bash
|
||||
pnpm rebuild better-sqlite3
|
||||
```
|
||||
|
||||
## ✨ Benefits of SQLite
|
||||
|
||||
1. **Simpler** - No database server to manage
|
||||
2. **Faster** - No network overhead
|
||||
3. **Portable** - Single file, easy backups
|
||||
4. **Cost-effective** - No hosting fees
|
||||
5. **Perfect fit** - Low concurrency, simple queries
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- `SQLITE_MIGRATION.md` - Detailed migration notes
|
||||
- `DEPLOYMENT.md` - Fly.io deployment guide
|
||||
- `README.md` - General setup instructions
|
||||
- `CMS_GITEA_AUTH.md` - OAuth authentication details (parent dir)
|
||||
- `CMS_CONCEPT.md` - Full system architecture (parent dir)
|
||||
|
||||
## ✅ Ready to Go!
|
||||
|
||||
Your backend is now configured for SQLite. Just:
|
||||
1. Add your Gitea credentials to `.env`
|
||||
2. Run `pnpm run db:generate && pnpm run db:migrate`
|
||||
3. Start with `pnpm run dev`
|
||||
|
||||
Happy coding! 🎉
|
||||
217
backend/SQLITE_MIGRATION.md
Normal file
@ -0,0 +1,217 @@
|
||||
# SQLite Migration Summary
|
||||
|
||||
## Changes Made
|
||||
|
||||
The backend has been migrated from PostgreSQL to SQLite for both local development and production (Fly.io).
|
||||
|
||||
### Benefits of SQLite
|
||||
|
||||
1. **Simplified Deployment** - No separate database service needed
|
||||
2. **Lower Cost** - Save ~$15/month (no Postgres hosting)
|
||||
3. **Easier Development** - No need to install/run PostgreSQL locally
|
||||
4. **Single File Database** - Easy backups and migrations
|
||||
5. **Perfect for this use case** - Low concurrent writes, simple queries
|
||||
|
||||
## Modified Files
|
||||
|
||||
### Dependencies
|
||||
- **package.json**
|
||||
- Removed: `pg`, `@types/pg`
|
||||
- Added: `better-sqlite3`, `@types/better-sqlite3`
|
||||
|
||||
### Database Configuration
|
||||
- **src/config/database.ts**
|
||||
- Changed from `drizzle-orm/node-postgres` to `drizzle-orm/better-sqlite3`
|
||||
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
|
||||
- Enabled WAL mode for better concurrent access
|
||||
|
||||
- **src/config/env.ts**
|
||||
- Changed `DATABASE_URL` to `DATABASE_PATH`
|
||||
- Default: `./data/gallus_cms.db`
|
||||
|
||||
- **src/db/schema.ts**
|
||||
- Changed from `pgTable` to `sqliteTable`
|
||||
- Changed `uuid()` to `text()` with `crypto.randomUUID()`
|
||||
- Changed `jsonb()` to `text(..., { mode: 'json' })`
|
||||
- Changed `timestamp()` to `integer(..., { mode: 'timestamp' })`
|
||||
- Changed `boolean()` to `integer(..., { mode: 'boolean' })`
|
||||
- Uses `sql\`(unixepoch())\`` for default timestamps
|
||||
|
||||
- **drizzle.config.ts**
|
||||
- Changed dialect from `postgresql` to `sqlite`
|
||||
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
|
||||
|
||||
### Environment Files
|
||||
- **.env** and **.env.example**
|
||||
- Changed `DATABASE_URL=postgresql://...` to `DATABASE_PATH=./data/gallus_cms.db`
|
||||
- Changed `GIT_WORKSPACE_DIR=/tmp/gallus-repo` to `./data/workspace`
|
||||
|
||||
### Docker Configuration
|
||||
- **Dockerfile**
|
||||
- Added build tools for `better-sqlite3` native module (python3, make, g++)
|
||||
- Added `sqlite` CLI tool
|
||||
- Creates `/app/data` directory for database
|
||||
- Sets `DATABASE_PATH=/app/data/gallus_cms.db`
|
||||
- Proper permissions for non-root user
|
||||
|
||||
- **fly.toml**
|
||||
- Added `DATABASE_PATH` and `GIT_WORKSPACE_DIR` to [env]
|
||||
- Changed volume mount from `gallus_repo_workspace` to `gallus_data`
|
||||
- Mount destination: `/app/data` (contains both DB and git workspace)
|
||||
|
||||
### Documentation
|
||||
- **README.md** - Updated setup instructions
|
||||
- **DEPLOYMENT.md** - Removed Postgres setup, updated volume creation
|
||||
- **SQLITE_MIGRATION.md** - This file!
|
||||
|
||||
## Local Development
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Dependencies already installed
|
||||
pnpm install
|
||||
|
||||
# Create data directory (done)
|
||||
mkdir -p data
|
||||
|
||||
# Database will be created automatically at ./data/gallus_cms.db
|
||||
```
|
||||
|
||||
### Generate and Run Migrations
|
||||
```bash
|
||||
# Generate migration files from schema
|
||||
pnpm run db:generate
|
||||
|
||||
# Run migrations to create tables
|
||||
pnpm run db:migrate
|
||||
```
|
||||
|
||||
### Start Development Server
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
The database file will be created at `./data/gallus_cms.db` on first run.
|
||||
|
||||
## Production (Fly.io)
|
||||
|
||||
### Volume Setup
|
||||
```bash
|
||||
# Create single volume for both database and git workspace
|
||||
flyctl volumes create gallus_data --size 2 --region ams
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
Set in fly.toml (non-sensitive):
|
||||
- `DATABASE_PATH=/app/data/gallus_cms.db`
|
||||
- `GIT_WORKSPACE_DIR=/app/data/workspace`
|
||||
|
||||
Set as secrets (sensitive):
|
||||
- All other env vars (OAuth credentials, tokens, etc.)
|
||||
|
||||
### Deployment
|
||||
```bash
|
||||
flyctl deploy
|
||||
```
|
||||
|
||||
Database will be created automatically on first start. No need for separate database service!
|
||||
|
||||
## Database Location
|
||||
|
||||
### Local Development
|
||||
- **Database:** `./data/gallus_cms.db`
|
||||
- **WAL files:** `./data/gallus_cms.db-wal`, `./data/gallus_cms.db-shm`
|
||||
- **Git workspace:** `./data/workspace/`
|
||||
|
||||
### Production (Fly.io)
|
||||
- **Database:** `/app/data/gallus_cms.db` (on volume)
|
||||
- **Git workspace:** `/app/data/workspace/` (on volume)
|
||||
- **Volume name:** `gallus_data` (2GB)
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Manual Backup
|
||||
```bash
|
||||
# Local
|
||||
cp data/gallus_cms.db data/gallus_cms.backup.db
|
||||
|
||||
# Production (Fly.io)
|
||||
flyctl ssh console
|
||||
sqlite3 /app/data/gallus_cms.db ".backup /app/data/backup.db"
|
||||
# Then copy back: flyctl ssh sftp get /app/data/backup.db
|
||||
```
|
||||
|
||||
### Automated Backup (Optional)
|
||||
Consider setting up a cron job or Fly.io machine to periodically:
|
||||
1. Create SQLite backup
|
||||
2. Upload to S3/Backblaze/etc.
|
||||
|
||||
## Performance Notes
|
||||
|
||||
SQLite is perfect for this use case because:
|
||||
- **Low write concurrency** - Single admin user making changes
|
||||
- **Read-heavy** - Mostly reading content for publish operations
|
||||
- **Small dataset** - Events, gallery images, content sections
|
||||
- **Simple queries** - No complex joins or aggregations
|
||||
|
||||
WAL mode is enabled for:
|
||||
- Better concurrent read access
|
||||
- Safer writes (crash recovery)
|
||||
- Improved performance
|
||||
|
||||
## Migration from Existing Data
|
||||
|
||||
If you had PostgreSQL data to migrate:
|
||||
|
||||
1. Export from Postgres:
|
||||
```sql
|
||||
\copy events TO 'events.csv' CSV HEADER;
|
||||
\copy gallery_images TO 'gallery.csv' CSV HEADER;
|
||||
-- etc.
|
||||
```
|
||||
|
||||
2. Import to SQLite:
|
||||
```sql
|
||||
.mode csv
|
||||
.import events.csv events
|
||||
.import gallery.csv gallery_images
|
||||
-- etc.
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **No native UUID type** - Using TEXT with UUID format
|
||||
2. **No native JSON type** - Using TEXT with JSON serialization (Drizzle handles this)
|
||||
3. **No native TIMESTAMP** - Using INTEGER with Unix epoch (Drizzle handles this)
|
||||
4. **Single writer** - Only one write transaction at a time (not an issue for this use case)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Database is locked" error
|
||||
- WAL mode should prevent this
|
||||
- Check if multiple processes are accessing the database
|
||||
- Ensure proper file permissions
|
||||
|
||||
### Native module build errors
|
||||
- Make sure build tools are installed: `apt-get install python3 make g++` (Linux)
|
||||
- On Alpine: `apk add python3 make g++`
|
||||
- Try rebuilding: `pnpm rebuild better-sqlite3`
|
||||
|
||||
### Database file not found
|
||||
- Check `DATABASE_PATH` is set correctly
|
||||
- Ensure `data/` directory exists
|
||||
- Check file permissions
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Update dependencies
|
||||
2. ✅ Update database configuration
|
||||
3. ✅ Update schema
|
||||
4. ✅ Update Docker configuration
|
||||
5. ⏳ Generate migrations: `pnpm run db:generate`
|
||||
6. ⏳ Run migrations: `pnpm run db:migrate`
|
||||
7. ⏳ Test development server: `pnpm run dev`
|
||||
8. ⏳ Test publish flow
|
||||
9. ⏳ Deploy to Fly.io
|
||||
|
||||
The migration is complete! Just need to generate/run migrations and test.
|
||||
10
backend/drizzle.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema.ts',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_PATH || './data/gallus_cms.db',
|
||||
},
|
||||
} satisfies Config;
|
||||
35
backend/fly.toml
Normal file
@ -0,0 +1,35 @@
|
||||
# Fly.io configuration for Gallus CMS Backend
|
||||
app = "gallus-cms-backend"
|
||||
primary_region = "ams"
|
||||
|
||||
[build]
|
||||
|
||||
[env]
|
||||
PORT = "8080"
|
||||
NODE_ENV = "production"
|
||||
GITEA_URL = "https://git.bookageek.ch"
|
||||
DATABASE_PATH = "/app/data/gallus_cms.db"
|
||||
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8080
|
||||
force_https = true
|
||||
auto_stop_machines = "suspend"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
processes = ["app"]
|
||||
|
||||
[[http_service.checks]]
|
||||
grace_period = "10s"
|
||||
interval = "30s"
|
||||
method = "GET"
|
||||
timeout = "5s"
|
||||
path = "/health"
|
||||
|
||||
[[vm]]
|
||||
size = "shared-cpu-1x"
|
||||
memory = "512mb"
|
||||
|
||||
[mounts]
|
||||
source = "gallus_data"
|
||||
destination = "/app/data"
|
||||
35
backend/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "gallus-cms-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Headless CMS backend for Gallus Pub website",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/jwt": "^8.0.0",
|
||||
"@fastify/multipart": "^8.1.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"fastify": "^4.26.0",
|
||||
"sharp": "^0.33.2",
|
||||
"simple-git": "^3.22.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/better-sqlite3": "^7.6.9",
|
||||
"@types/node": "^20.11.16",
|
||||
"drizzle-kit": "^0.24.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
15
backend/src/config/database.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from '../db/schema.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
if (!env.DATABASE_PATH) {
|
||||
throw new Error('DATABASE_PATH environment variable is not set');
|
||||
}
|
||||
|
||||
const sqlite = new Database(env.DATABASE_PATH);
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
51
backend/src/config/env.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// Environment configuration with validation
|
||||
export const env = {
|
||||
// Database
|
||||
DATABASE_PATH: process.env.DATABASE_PATH || './data/gallus_cms.db',
|
||||
|
||||
// Gitea OAuth
|
||||
GITEA_URL: process.env.GITEA_URL || 'https://git.bookageek.ch',
|
||||
GITEA_CLIENT_ID: process.env.GITEA_CLIENT_ID || '',
|
||||
GITEA_CLIENT_SECRET: process.env.GITEA_CLIENT_SECRET || '',
|
||||
GITEA_REDIRECT_URI: process.env.GITEA_REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
|
||||
GITEA_ALLOWED_USERS: process.env.GITEA_ALLOWED_USERS || '',
|
||||
|
||||
// Git Configuration
|
||||
GIT_REPO_URL: process.env.GIT_REPO_URL || '',
|
||||
GIT_TOKEN: process.env.GIT_TOKEN || '',
|
||||
GIT_USER_NAME: process.env.GIT_USER_NAME || 'Gallus CMS',
|
||||
GIT_USER_EMAIL: process.env.GIT_USER_EMAIL || 'cms@galluspub.ch',
|
||||
GIT_WORKSPACE_DIR: process.env.GIT_WORKSPACE_DIR || '/tmp/gallus-repo',
|
||||
|
||||
// JWT & Session
|
||||
JWT_SECRET: process.env.JWT_SECRET || '',
|
||||
SESSION_SECRET: process.env.SESSION_SECRET || '',
|
||||
|
||||
// Server
|
||||
PORT: parseInt(process.env.PORT || '3000', 10),
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
|
||||
// Upload
|
||||
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
export function validateEnv() {
|
||||
const required = [
|
||||
'DATABASE_PATH',
|
||||
'GITEA_CLIENT_ID',
|
||||
'GITEA_CLIENT_SECRET',
|
||||
'GIT_REPO_URL',
|
||||
'GIT_TOKEN',
|
||||
'JWT_SECRET',
|
||||
'SESSION_SECRET',
|
||||
];
|
||||
|
||||
const missing = required.filter(key => !env[key as keyof typeof env]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
62
backend/src/db/schema.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
// Users table - stores Gitea user info for audit and access control
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
giteaId: text('gitea_id').notNull().unique(),
|
||||
giteaUsername: text('gitea_username').notNull(),
|
||||
giteaEmail: text('gitea_email'),
|
||||
displayName: text('display_name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
role: text('role').default('admin'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
lastLogin: integer('last_login', { mode: 'timestamp' }),
|
||||
});
|
||||
|
||||
// Events table
|
||||
export const events = sqliteTable('events', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
title: text('title').notNull(),
|
||||
date: text('date').notNull(),
|
||||
description: text('description').notNull(),
|
||||
imageUrl: text('image_url').notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Gallery images table
|
||||
export const galleryImages = sqliteTable('gallery_images', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
imageUrl: text('image_url').notNull(),
|
||||
altText: text('alt_text').notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Content sections table (for text-based sections)
|
||||
export const contentSections = sqliteTable('content_sections', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
sectionName: text('section_name').notNull().unique(),
|
||||
contentJson: text('content_json', { mode: 'json' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Site settings table (global config)
|
||||
export const siteSettings = sqliteTable('site_settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Publish history (audit log)
|
||||
export const publishHistory = sqliteTable('publish_history', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
commitHash: text('commit_hash'),
|
||||
commitMessage: text('commit_message'),
|
||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
112
backend/src/index.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import multipart from '@fastify/multipart';
|
||||
import cookie from '@fastify/cookie';
|
||||
import { authenticate } from './middleware/auth.middleware.js';
|
||||
import { env, validateEnv } from './config/env.js';
|
||||
|
||||
// Import routes
|
||||
import authRoute from './routes/auth.js';
|
||||
import eventsRoute from './routes/events.js';
|
||||
import galleryRoute from './routes/gallery.js';
|
||||
import contentRoute from './routes/content.js';
|
||||
import settingsRoute from './routes/settings.js';
|
||||
import publishRoute from './routes/publish.js';
|
||||
|
||||
// Validate environment variables
|
||||
try {
|
||||
validateEnv();
|
||||
} catch (error) {
|
||||
console.error('Environment validation failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: env.NODE_ENV === 'development' ? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
} : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Register plugins
|
||||
fastify.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
fastify.register(cookie);
|
||||
|
||||
fastify.register(jwt, {
|
||||
secret: env.JWT_SECRET,
|
||||
cookie: {
|
||||
cookieName: 'token',
|
||||
signed: false,
|
||||
},
|
||||
});
|
||||
|
||||
fastify.register(multipart, {
|
||||
limits: {
|
||||
fileSize: env.MAX_FILE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
// Decorate fastify with authenticate method
|
||||
fastify.decorate('authenticate', authenticate);
|
||||
|
||||
// Register routes
|
||||
fastify.register(authRoute, { prefix: '/api' });
|
||||
fastify.register(eventsRoute, { prefix: '/api' });
|
||||
fastify.register(galleryRoute, { prefix: '/api' });
|
||||
fastify.register(contentRoute, { prefix: '/api' });
|
||||
fastify.register(settingsRoute, { prefix: '/api' });
|
||||
fastify.register(publishRoute, { prefix: '/api' });
|
||||
|
||||
// Health check
|
||||
fastify.get('/health', async () => {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: env.NODE_ENV,
|
||||
};
|
||||
});
|
||||
|
||||
// Root endpoint
|
||||
fastify.get('/', async () => {
|
||||
return {
|
||||
name: 'Gallus Pub CMS Backend',
|
||||
version: '1.0.0',
|
||||
status: 'running',
|
||||
};
|
||||
});
|
||||
|
||||
// Error handler
|
||||
fastify.setErrorHandler((error, request, reply) => {
|
||||
fastify.log.error(error);
|
||||
|
||||
reply.status(error.statusCode || 500).send({
|
||||
error: error.message || 'Internal Server Error',
|
||||
statusCode: error.statusCode || 500,
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
const start = async () => {
|
||||
try {
|
||||
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||
console.log(`🚀 Server listening on port ${env.PORT}`);
|
||||
console.log(`📝 Environment: ${env.NODE_ENV}`);
|
||||
console.log(`🔐 CORS Origin: ${env.CORS_ORIGIN}`);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
12
backend/src/middleware/auth.middleware.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export async function authenticate(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
188
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { GiteaService } from '../services/gitea.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
// Use explicit JSON schema for Fastify route validation to avoid provider issues
|
||||
const callbackQueryJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['code', 'state'],
|
||||
properties: {
|
||||
code: { type: 'string' },
|
||||
state: { type: 'string' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||
const giteaService = new GiteaService();
|
||||
|
||||
/**
|
||||
* GET /auth/gitea
|
||||
* Initiate OAuth flow
|
||||
*/
|
||||
fastify.get('/auth/gitea', async (request, reply) => {
|
||||
// Generate CSRF state token
|
||||
const state = giteaService.generateState();
|
||||
|
||||
// Store state in a short-lived cookie
|
||||
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
|
||||
const authUrl = giteaService.getAuthorizationUrl(state);
|
||||
|
||||
// Redirect to Gitea
|
||||
return reply.redirect(authUrl);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/callback
|
||||
* OAuth callback endpoint
|
||||
*/
|
||||
fastify.get('/auth/callback', {
|
||||
schema: {
|
||||
querystring: callbackQueryJsonSchema,
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { code, state } = request.query as { code: string; state: string };
|
||||
|
||||
// Verify CSRF state from cookie
|
||||
const expectedState = request.cookies?.oauth_state as string | undefined;
|
||||
if (!expectedState || state !== expectedState) {
|
||||
return reply.code(400).send({ error: 'Invalid state parameter' });
|
||||
}
|
||||
|
||||
// Clear state cookie
|
||||
reply.clearCookie('oauth_state', { path: '/' });
|
||||
|
||||
// Exchange code for access token
|
||||
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
||||
|
||||
// Fetch user info from Gitea
|
||||
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
|
||||
|
||||
// Check if user is allowed
|
||||
if (!giteaService.isUserAllowed(giteaUser.login)) {
|
||||
return reply.code(403).send({
|
||||
error: 'Access denied. You are not authorized to access this CMS.'
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create user in database
|
||||
let [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.giteaId, giteaUser.id.toString()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
// Create new user
|
||||
[user] = await db.insert(users).values({
|
||||
giteaId: giteaUser.id.toString(),
|
||||
giteaUsername: giteaUser.login,
|
||||
giteaEmail: giteaUser.email,
|
||||
displayName: giteaUser.full_name,
|
||||
avatarUrl: giteaUser.avatar_url,
|
||||
lastLogin: new Date(),
|
||||
}).returning();
|
||||
} else {
|
||||
// Update existing user
|
||||
[user] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
giteaUsername: giteaUser.login,
|
||||
giteaEmail: giteaUser.email,
|
||||
displayName: giteaUser.full_name,
|
||||
avatarUrl: giteaUser.avatar_url,
|
||||
lastLogin: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Generate JWT for session management
|
||||
const token = fastify.jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
giteaId: user.giteaId,
|
||||
username: user.giteaUsername || '',
|
||||
role: user.role ?? 'admin',
|
||||
},
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// 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;
|
||||
return reply.redirect(`${frontendUrl}/admin`);
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error }, 'OAuth callback error');
|
||||
return reply.code(500).send({ error: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/me
|
||||
* Get current user info
|
||||
*/
|
||||
fastify.get('/auth/me', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
giteaUsername: user.giteaUsername,
|
||||
giteaEmail: user.giteaEmail,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Logout (client-side token deletion)
|
||||
*/
|
||||
fastify.post('/auth/logout', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
// For JWT, logout is primarily client-side (delete token)
|
||||
// You could maintain a token blacklist in Redis for production
|
||||
reply.clearCookie('token', { path: '/' });
|
||||
return { message: 'Logged out successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default authRoute;
|
||||
104
backend/src/routes/content.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { contentSections } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Fastify JSON schema for content section body
|
||||
const contentBodyJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['contentJson'],
|
||||
properties: {
|
||||
contentJson: {}, // allow any JSON
|
||||
},
|
||||
} as const;
|
||||
|
||||
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// Get content section
|
||||
fastify.get('/content/:section', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { section } = request.params as { section: string };
|
||||
|
||||
const [content] = await db
|
||||
.select()
|
||||
.from(contentSections)
|
||||
.where(eq(contentSections.sectionName, section))
|
||||
.limit(1);
|
||||
|
||||
if (!content) {
|
||||
return reply.code(404).send({ error: 'Content section not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
section: content.sectionName,
|
||||
content: content.contentJson,
|
||||
updatedAt: content.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Update content section
|
||||
fastify.put('/content/:section', {
|
||||
schema: {
|
||||
body: contentBodyJsonSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { section } = request.params as { section: string };
|
||||
const { contentJson } = request.body as any;
|
||||
|
||||
// Check if section exists
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(contentSections)
|
||||
.where(eq(contentSections.sectionName, section))
|
||||
.limit(1);
|
||||
|
||||
let result;
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
[result] = await db
|
||||
.update(contentSections)
|
||||
.set({
|
||||
contentJson,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(contentSections.sectionName, section))
|
||||
.returning();
|
||||
} else {
|
||||
// Create new
|
||||
[result] = await db
|
||||
.insert(contentSections)
|
||||
.values({
|
||||
sectionName: section,
|
||||
contentJson,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
return {
|
||||
section: result.sectionName,
|
||||
content: result.contentJson,
|
||||
updatedAt: result.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// List all content sections
|
||||
fastify.get('/content', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const sections = await db.select().from(contentSections);
|
||||
|
||||
return {
|
||||
sections: (sections as any[]).map((s: any) => ({
|
||||
section: s.sectionName,
|
||||
content: s.contentJson,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default contentRoute;
|
||||
89
backend/src/routes/events.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { db } from '../config/database.js';
|
||||
import { events } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Fastify JSON schema for event body
|
||||
const eventBodyJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
date: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
description: { type: 'string', minLength: 1 },
|
||||
imageUrl: { type: 'string', minLength: 1 },
|
||||
displayOrder: { type: 'integer', minimum: 0 },
|
||||
isPublished: { type: 'boolean' },
|
||||
},
|
||||
} 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) => {
|
||||
// List all events (by displayOrder)
|
||||
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
||||
const all = await db.select().from(events).orderBy(events.displayOrder);
|
||||
return { events: all };
|
||||
});
|
||||
|
||||
// Get single event
|
||||
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
|
||||
return { event: rows[0] };
|
||||
});
|
||||
|
||||
// Create event
|
||||
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const data = request.body as any;
|
||||
const [row] = await db.insert(events).values(data).returning();
|
||||
return reply.code(201).send({ event: row });
|
||||
});
|
||||
|
||||
// Update event
|
||||
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as any;
|
||||
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||
return { event: row };
|
||||
});
|
||||
|
||||
// Delete event
|
||||
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||
return { message: 'Event deleted successfully' };
|
||||
});
|
||||
|
||||
// Reorder events (synchronous transaction for better-sqlite3)
|
||||
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
|
||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||
db.transaction((tx: any) => {
|
||||
for (const { id, displayOrder } of orders) {
|
||||
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
|
||||
}
|
||||
});
|
||||
return { message: 'Events reordered successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default eventsRoute;
|
||||
134
backend/src/routes/gallery.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { galleryImages } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Fastify JSON schema for gallery image body
|
||||
const galleryBodyJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['imageUrl', 'altText', 'displayOrder'],
|
||||
properties: {
|
||||
imageUrl: { type: 'string', minLength: 1 },
|
||||
altText: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
displayOrder: { type: 'integer', minimum: 0 },
|
||||
isPublished: { type: 'boolean' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// List all gallery images
|
||||
fastify.get('/gallery', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
|
||||
return { images };
|
||||
});
|
||||
|
||||
// Get single gallery image
|
||||
fastify.get('/gallery/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||
|
||||
if (image.length === 0) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return { image: image[0] };
|
||||
});
|
||||
|
||||
// Create gallery image
|
||||
fastify.post('/gallery', {
|
||||
schema: {
|
||||
body: galleryBodyJsonSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const data = request.body as any;
|
||||
|
||||
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
||||
|
||||
return reply.code(201).send({ image: newImage });
|
||||
});
|
||||
|
||||
// Update gallery image
|
||||
fastify.put('/gallery/:id', {
|
||||
schema: {
|
||||
body: galleryBodyJsonSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as any;
|
||||
|
||||
const [updated] = await db
|
||||
.update(galleryImages)
|
||||
.set(data)
|
||||
.where(eq(galleryImages.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return { image: updated };
|
||||
});
|
||||
|
||||
// Delete gallery image
|
||||
fastify.delete('/gallery/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(galleryImages)
|
||||
.where(eq(galleryImages.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return { message: 'Image deleted successfully' };
|
||||
});
|
||||
|
||||
// Reorder gallery images
|
||||
fastify.put('/gallery/reorder', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['orders'],
|
||||
properties: {
|
||||
orders: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['id', 'displayOrder'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
displayOrder: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||
|
||||
// Update all in synchronous transaction (better-sqlite3 requirement)
|
||||
db.transaction((tx: any) => {
|
||||
for (const { id, displayOrder } of orders) {
|
||||
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Gallery images reordered successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default galleryRoute;
|
||||
127
backend/src/routes/publish.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { GitService } from '../services/git.service.js';
|
||||
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||
import { db } from '../config/database.js';
|
||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Fastify JSON schema for publish body
|
||||
const publishBodyJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['commitMessage'],
|
||||
properties: {
|
||||
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.post('/publish', {
|
||||
schema: {
|
||||
body: publishBodyJsonSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { commitMessage } = request.body as any;
|
||||
const userId = request.user.id;
|
||||
|
||||
fastify.log.info('Starting publish process...');
|
||||
|
||||
// Initialize git service
|
||||
const gitService = new GitService();
|
||||
await gitService.initialize();
|
||||
|
||||
fastify.log.info('Git repository initialized');
|
||||
|
||||
// Fetch all content from database
|
||||
const eventsData = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.isPublished, true))
|
||||
.orderBy(events.displayOrder);
|
||||
|
||||
const galleryData = await db
|
||||
.select()
|
||||
.from(galleryImages)
|
||||
.where(eq(galleryImages.isPublished, true))
|
||||
.orderBy(galleryImages.displayOrder);
|
||||
|
||||
const sectionsData = await db.select().from(contentSections);
|
||||
const sectionsMap = new Map<string, 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`);
|
||||
|
||||
// Generate and write files
|
||||
const fileGenerator = new FileGeneratorService();
|
||||
await fileGenerator.writeFiles(
|
||||
gitService.getWorkspacePath(''),
|
||||
(eventsData as any[]).map((e: any) => ({
|
||||
title: e.title,
|
||||
date: e.date,
|
||||
description: e.description,
|
||||
imageUrl: e.imageUrl,
|
||||
})),
|
||||
(galleryData as any[]).map((g: any) => ({
|
||||
imageUrl: g.imageUrl,
|
||||
altText: g.altText,
|
||||
})),
|
||||
sectionsMap
|
||||
);
|
||||
|
||||
fastify.log.info('Files generated successfully');
|
||||
|
||||
// Commit and push
|
||||
const commitHash = await gitService.commitAndPush(commitMessage);
|
||||
|
||||
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||
|
||||
// Record in history
|
||||
await db.insert(publishHistory).values({
|
||||
userId,
|
||||
commitHash,
|
||||
commitMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitHash,
|
||||
message: 'Changes published successfully',
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error }, 'Publish error');
|
||||
|
||||
// Attempt to reset git state on error
|
||||
try {
|
||||
const gitService = new GitService();
|
||||
await gitService.reset();
|
||||
} catch (resetError) {
|
||||
fastify.log.error({ err: resetError }, 'Failed to reset git state');
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: 'Failed to publish changes',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get publish history
|
||||
fastify.get('/publish/history', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const history = await db
|
||||
.select()
|
||||
.from(publishHistory)
|
||||
.orderBy(publishHistory.publishedAt)
|
||||
.limit(20);
|
||||
|
||||
return { history };
|
||||
});
|
||||
};
|
||||
|
||||
export default publishRoute;
|
||||
121
backend/src/routes/settings.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { siteSettings } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
// Fastify JSON schema for settings body
|
||||
const settingBodyJsonSchema = {
|
||||
type: 'object',
|
||||
required: ['value'],
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// Get all settings
|
||||
fastify.get('/settings', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const settings = await db.select().from(siteSettings);
|
||||
|
||||
return {
|
||||
settings: settings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
};
|
||||
});
|
||||
|
||||
// Get single setting
|
||||
fastify.get('/settings/:key', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
|
||||
const [setting] = await db
|
||||
.select()
|
||||
.from(siteSettings)
|
||||
.where(eq(siteSettings.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (!setting) {
|
||||
return reply.code(404).send({ error: 'Setting not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
updatedAt: setting.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Update setting
|
||||
fastify.put('/settings/:key', {
|
||||
schema: {
|
||||
body: settingBodyJsonSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const { value } = request.body as any;
|
||||
|
||||
// Check if setting exists
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(siteSettings)
|
||||
.where(eq(siteSettings.key, key))
|
||||
.limit(1);
|
||||
|
||||
let result;
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
[result] = await db
|
||||
.update(siteSettings)
|
||||
.set({
|
||||
value,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(siteSettings.key, key))
|
||||
.returning();
|
||||
} else {
|
||||
// Create new
|
||||
[result] = await db
|
||||
.insert(siteSettings)
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
return {
|
||||
key: result.key,
|
||||
value: result.value,
|
||||
updatedAt: result.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Delete setting
|
||||
fastify.delete('/settings/:key', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(siteSettings)
|
||||
.where(eq(siteSettings.key, key))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: 'Setting not found' });
|
||||
}
|
||||
|
||||
return { message: 'Setting deleted successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default settingsRoute;
|
||||
239
backend/src/services/file-generator.service.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
interface Event {
|
||||
title: string;
|
||||
date: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
interface GalleryImage {
|
||||
imageUrl: string;
|
||||
altText: string;
|
||||
}
|
||||
|
||||
interface ContentSection {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class FileGeneratorService {
|
||||
|
||||
escapeQuotes(str: string): string {
|
||||
return str.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
escapeBackticks(str: string): string {
|
||||
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||
}
|
||||
|
||||
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||
const eventsCode = events.map(e => `\t{
|
||||
\t\timage: "${e.imageUrl}",
|
||||
\t\ttitle: "${this.escapeQuotes(e.title)}",
|
||||
\t\tdate: "${e.date}",
|
||||
\t\tdescription: \`
|
||||
\t\t\t${this.escapeBackticks(e.description)}
|
||||
\t\t\`,
|
||||
\t}`).join(',\n');
|
||||
|
||||
const imagesCode = images.map(g =>
|
||||
`\t{ src: "${g.imageUrl}", alt: "${this.escapeQuotes(g.altText)}" }`
|
||||
).join(',\n');
|
||||
|
||||
return `---
|
||||
import Layout from "../components/Layout.astro";
|
||||
import Hero from "../components/Hero.astro";
|
||||
import Welcome from "../components/Welcome.astro";
|
||||
import EventsGrid from "../components/EventsGrid.astro";
|
||||
import Drinks from "../components/Drinks.astro";
|
||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||
import Contact from "../components/Contact.astro";
|
||||
import About from "../components/About.astro";
|
||||
|
||||
const events = [
|
||||
${eventsCode}
|
||||
];
|
||||
|
||||
const images = [
|
||||
${imagesCode}
|
||||
];
|
||||
---
|
||||
|
||||
<Layout>
|
||||
\t<Hero id="hero" />
|
||||
\t<Welcome id="welcome" />
|
||||
\t<EventsGrid id="events" events={events} />
|
||||
\t<ImageCarousel id="gallery" images={images} />
|
||||
\t<Drinks id="drinks" />
|
||||
</Layout>
|
||||
`;
|
||||
}
|
||||
|
||||
generateHeroComponent(content: ContentSection): string {
|
||||
return `---
|
||||
// src/components/Hero.astro
|
||||
import "../styles/components/Hero.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section id={id} class="hero container">
|
||||
|
||||
\t<div class="hero-overlay">
|
||||
|
||||
\t\t<div class="hero-content">
|
||||
|
||||
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
||||
|
||||
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
||||
|
||||
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||
\t\t</div>
|
||||
|
||||
\t</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
generateWelcomeComponent(content: ContentSection): string {
|
||||
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
||||
).join('\n\n');
|
||||
|
||||
return `---
|
||||
// src/components/Welcome.astro
|
||||
import "../styles/components/Welcome.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section id={id} class="welcome container">
|
||||
|
||||
\t<div class="welcome-text">
|
||||
|
||||
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
||||
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.introText || ''}
|
||||
\t\t</p>
|
||||
|
||||
\t\t<p><b>Unsere Highlights:</b></p>
|
||||
|
||||
\t\t<ul>
|
||||
${highlightsList}
|
||||
\t\t</ul>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.closingText || ''}
|
||||
\t\t</p>
|
||||
|
||||
\t</div>
|
||||
|
||||
|
||||
\t<div class="welcome-image">
|
||||
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
||||
\t</div>
|
||||
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
generateDrinksComponent(content: ContentSection): string {
|
||||
return `---
|
||||
import "../styles/components/Drinks.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
<section id={id} class="Drinks">
|
||||
<h2 class="title">Drinks</h2>
|
||||
|
||||
<p class="note">
|
||||
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
||||
</p>
|
||||
|
||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
|
||||
<h3 class="monats-hit">Monats Hit</h3>
|
||||
|
||||
<div class="mate-vodka">
|
||||
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
||||
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
||||
</p>
|
||||
|
||||
<div class="circle-row">
|
||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async writeFiles(
|
||||
workspaceDir: string,
|
||||
events: Event[],
|
||||
images: GalleryImage[],
|
||||
sections: Map<string, ContentSection>
|
||||
) {
|
||||
// Write index.astro
|
||||
const indexContent = this.generateIndexAstro(events, images);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/pages/index.astro'),
|
||||
indexContent,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Write Hero component
|
||||
if (sections.has('hero')) {
|
||||
const heroContent = this.generateHeroComponent(sections.get('hero')!);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/components/Hero.astro'),
|
||||
heroContent,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// Write Welcome component
|
||||
if (sections.has('welcome')) {
|
||||
const welcomeContent = this.generateWelcomeComponent(sections.get('welcome')!);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/components/Welcome.astro'),
|
||||
welcomeContent,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// Write Drinks component
|
||||
if (sections.has('drinks')) {
|
||||
const drinksContent = this.generateDrinksComponent(sections.get('drinks')!);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/components/Drinks.astro'),
|
||||
drinksContent,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
backend/src/services/git.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import simpleGit, { SimpleGit } from 'simple-git';
|
||||
import { mkdir, rm } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export class GitService {
|
||||
private git: SimpleGit;
|
||||
private workspaceDir: string;
|
||||
private repoUrl: string;
|
||||
private token: string;
|
||||
|
||||
constructor() {
|
||||
this.workspaceDir = env.GIT_WORKSPACE_DIR;
|
||||
this.repoUrl = env.GIT_REPO_URL;
|
||||
this.token = env.GIT_TOKEN;
|
||||
this.git = simpleGit();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Ensure workspace directory exists
|
||||
await mkdir(this.workspaceDir, { recursive: true });
|
||||
|
||||
// Add token to repo URL for authentication
|
||||
const authenticatedUrl = this.repoUrl.replace(
|
||||
'https://',
|
||||
`https://oauth2:${this.token}@`
|
||||
);
|
||||
|
||||
try {
|
||||
// Check if repo already exists
|
||||
await this.git.cwd(this.workspaceDir);
|
||||
await this.git.status();
|
||||
console.log('Repository already exists, pulling latest...');
|
||||
await this.git.pull();
|
||||
} catch {
|
||||
// Clone if doesn't exist
|
||||
console.log('Cloning repository...');
|
||||
await rm(this.workspaceDir, { recursive: true, force: true });
|
||||
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
||||
await this.git.cwd(this.workspaceDir);
|
||||
}
|
||||
|
||||
// Configure git user
|
||||
await this.git.addConfig('user.name', env.GIT_USER_NAME);
|
||||
await this.git.addConfig('user.email', env.GIT_USER_EMAIL);
|
||||
}
|
||||
|
||||
async commitAndPush(message: string): Promise<string> {
|
||||
await this.git.add('.');
|
||||
await this.git.commit(message);
|
||||
await this.git.push('origin', 'main');
|
||||
|
||||
const log = await this.git.log({ maxCount: 1 });
|
||||
return log.latest?.hash || '';
|
||||
}
|
||||
|
||||
getWorkspacePath(relativePath: string): string {
|
||||
return path.join(this.workspaceDir, relativePath);
|
||||
}
|
||||
|
||||
async reset() {
|
||||
await this.git.reset(['--hard', 'HEAD']);
|
||||
await this.git.clean('f', ['-d']);
|
||||
}
|
||||
}
|
||||
112
backend/src/services/gitea.service.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import crypto from 'crypto';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
interface GiteaUser {
|
||||
id: number;
|
||||
login: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export class GiteaService {
|
||||
private giteaUrl: string;
|
||||
private clientId: string;
|
||||
private clientSecret: string;
|
||||
private redirectUri: string;
|
||||
private allowedUsers: Set<string>;
|
||||
|
||||
constructor() {
|
||||
this.giteaUrl = env.GITEA_URL;
|
||||
this.clientId = env.GITEA_CLIENT_ID;
|
||||
this.clientSecret = env.GITEA_CLIENT_SECRET;
|
||||
this.redirectUri = env.GITEA_REDIRECT_URI;
|
||||
|
||||
const allowed = env.GITEA_ALLOWED_USERS;
|
||||
this.allowedUsers = new Set(allowed.split(',').map(u => u.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth authorization URL
|
||||
*/
|
||||
getAuthorizationUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
redirect_uri: this.redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
scope: 'read:user',
|
||||
});
|
||||
|
||||
return `${this.giteaUrl}/login/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
async exchangeCodeForToken(code: string): Promise<OAuthTokenResponse> {
|
||||
const response = await fetch(`${this.giteaUrl}/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
redirect_uri: this.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to exchange code: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info from Gitea using access token
|
||||
*/
|
||||
async getUserInfo(accessToken: string): Promise<GiteaUser> {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is allowed to access the CMS
|
||||
*/
|
||||
isUserAllowed(username: string): boolean {
|
||||
// If no allowed users specified, allow all
|
||||
if (this.allowedUsers.size === 0) {
|
||||
return true;
|
||||
}
|
||||
return this.allowedUsers.has(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
*/
|
||||
generateState(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
87
backend/src/services/media.service.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import sharp from 'sharp';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export class MediaService {
|
||||
private allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
private maxFileSize: number;
|
||||
|
||||
constructor() {
|
||||
this.maxFileSize = env.MAX_FILE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type and size
|
||||
*/
|
||||
async validateFile(file: any): Promise<void> {
|
||||
if (!this.allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new Error(`Invalid file type. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const buffer = await file.toBuffer();
|
||||
if (buffer.length > this.maxFileSize) {
|
||||
throw new Error(`File too large. Maximum size: ${this.maxFileSize / 1024 / 1024}MB`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate safe filename
|
||||
*/
|
||||
generateFilename(originalName: string): string {
|
||||
const ext = path.extname(originalName);
|
||||
const hash = crypto.randomBytes(8).toString('hex');
|
||||
const timestamp = Date.now();
|
||||
return `${timestamp}-${hash}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize and save image
|
||||
*/
|
||||
async processAndSaveImage(
|
||||
file: any,
|
||||
destinationDir: string
|
||||
): Promise<{ filename: string; url: string }> {
|
||||
await this.validateFile(file);
|
||||
|
||||
// Ensure destination directory exists
|
||||
await mkdir(destinationDir, { recursive: true });
|
||||
|
||||
// Generate filename
|
||||
const filename = this.generateFilename(file.filename);
|
||||
const filepath = path.join(destinationDir, filename);
|
||||
|
||||
// Get file buffer
|
||||
const buffer = await file.toBuffer();
|
||||
|
||||
// Process image with sharp (optimize and resize if needed)
|
||||
await sharp(buffer)
|
||||
.resize(2000, 2000, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 85 })
|
||||
.png({ quality: 85 })
|
||||
.webp({ quality: 85 })
|
||||
.toFile(filepath);
|
||||
|
||||
// Return filename and URL path
|
||||
return {
|
||||
filename,
|
||||
url: `/images/${filename}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save image to git workspace
|
||||
*/
|
||||
async saveToGitWorkspace(
|
||||
file: any,
|
||||
workspaceDir: string
|
||||
): Promise<{ filename: string; url: string }> {
|
||||
const imagesDir = path.join(workspaceDir, 'public', 'images');
|
||||
return this.processAndSaveImage(file, imagesDir);
|
||||
}
|
||||
}
|
||||
25
backend/src/types/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
export interface JWTPayload {
|
||||
id: string;
|
||||
giteaId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
user: JWTPayload;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: JWTPayload;
|
||||
user: JWTPayload;
|
||||
}
|
||||
}
|
||||
19
backend/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
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:
|
||||
19
fly.toml
@ -1,14 +1,17 @@
|
||||
app = "gallus-pub"
|
||||
primary_region = "fra" # Frankfurt region, change if needed
|
||||
primary_region = "fra"
|
||||
kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
dockerfile = "Dockerfile.fly"
|
||||
|
||||
[env]
|
||||
PORT = "3000"
|
||||
PORT = "3000" # Caddy (serves frontend + proxies /api/*)
|
||||
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]
|
||||
internal_port = 3000
|
||||
@ -26,7 +29,7 @@ kill_timeout = 5
|
||||
[[http_service.checks]]
|
||||
interval = "30s"
|
||||
timeout = "5s"
|
||||
grace_period = "10s"
|
||||
grace_period = "30s"
|
||||
method = "GET"
|
||||
path = "/"
|
||||
protocol = "http"
|
||||
@ -40,3 +43,11 @@ kill_timeout = 5
|
||||
memory = "512MB"
|
||||
cpu_kind = "shared"
|
||||
cpus = 1
|
||||
|
||||
[[mounts]]
|
||||
source = "gallus_data"
|
||||
destination = "/app/data"
|
||||
|
||||
[[mounts]]
|
||||
source = "gallus_workspace"
|
||||
destination = "/app/workspace"
|
||||
3
package-lock.json
generated
@ -3988,6 +3988,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",
|
||||
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@ -4625,6 +4626,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@ -4839,6 +4841,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "",
|
||||
"name": "Gallus Pub Site",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
|
||||
3114
pnpm-lock.yaml
generated
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 122 KiB |
BIN
public/images/MonthlyHit.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
public/images/events/event_advents-kalender.jpeg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/images/events/event_ferien.jpeg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/images/events/event_karaoke.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/images/events/event_neujahrs-apero.jpeg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/images/events/event_pub-quiz.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/images/events/event_santa_karaoke.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/images/events/event_schlager-karaoke.jpeg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/events/old/Event2.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/images/events/old/Event3.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/images/events/old/Event4.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/images/gallery/Gallery1.png
Normal file
|
After Width: | Height: | Size: 800 KiB |
BIN
public/images/gallery/Gallery2.png
Normal file
|
After Width: | Height: | Size: 747 KiB |
BIN
public/images/gallery/Gallery3.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/images/gallery/Gallery4.png
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
public/images/gallery/Gallery5.png
Normal file
|
After Width: | Height: | Size: 580 KiB |
BIN
public/images/gallery/Gallery6.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/images/gallery/Gallery7.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/images/gallery/Gallery8.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/images/gallery/Gallery9.png
Normal file
|
After Width: | Height: | Size: 567 KiB |
BIN
public/images/whiskey/Whiskey1.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
public/images/whiskey/Whiskey2.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
public/images/whiskey/Whiskey3.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
public/pdf/Getraenke_Gallus_2025.pdf
Normal file
42
src/components/Contact.astro
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
import Layout from "../components/Layout.astro";
|
||||
import "../styles/components/ContactForm.css";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="contact-container">
|
||||
<h1 class="contact-title">Kontakt</h1>
|
||||
|
||||
<form class="contact-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subject">Betreff</label>
|
||||
<input type="text" id="subject" name="subject" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Nachricht</label>
|
||||
<textarea id="message" name="message" rows="5" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button">Senden</button>
|
||||
</form>
|
||||
|
||||
<div class="whatsapp-container">
|
||||
<p>Oder kontaktiere uns direkt über WhatsApp:</p>
|
||||
<a href="https://wa.me/41772322770" class="whatsapp-link" target="_blank" rel="noopener noreferrer">
|
||||
<span class="whatsapp-icon">📱</span>
|
||||
<span>WhatsApp Chat starten</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
@ -1,33 +1,44 @@
|
||||
---
|
||||
import "../../styles/components/Drinks.css"
|
||||
import "../styles/components/Drinks.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
<section class="Drinks">
|
||||
<section id={id} class="Drinks">
|
||||
<h2 class="title">Drinks</h2>
|
||||
|
||||
<a href="/pdf/Menu.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
<p class="note">
|
||||
Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter
|
||||
Atmosphäre das Leben genießen. Natürlich dürfen auch Cocktails nicht fehlen. Vieles kreieren wir auch selber - Sláinte!
|
||||
</p>
|
||||
|
||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
|
||||
<h3 class="monats-hit">Monats Hit</h3>
|
||||
|
||||
<div class="mate-vodka">
|
||||
<div class="circle" title="Mate Vodka">
|
||||
<span class="circle-label">Mate Vodka</span>
|
||||
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div>Mate Vodka</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.
|
||||
</p>
|
||||
|
||||
<div class="circle-row">
|
||||
<div class="circle" title="Bier">
|
||||
<span class="circle-label">Bier</span>
|
||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle" title="Wein">
|
||||
<span class="circle-label">Wein</span>
|
||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle" title="Cocktails">
|
||||
<span class="circle-label">Cocktails</span>
|
||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
Wir bieten eine Auswahl an erlesenen Getränken für jeden Geschmack. Besuche uns und entdecke unsere saisonalen Spezialitäten und Klassiker.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@ -2,26 +2,30 @@
|
||||
// src/components/EventsGrid.astro
|
||||
|
||||
import HoverCard from "./HoverCard.astro";
|
||||
|
||||
interface Event {
|
||||
image: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
image: string;
|
||||
title: string;
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
const { events = [] }: { events?: Event[] } = Astro.props as { events?: Event[] };
|
||||
import '../../styles/components/EventsGrid.css';
|
||||
const { events = [], id }: { events?: Event[]; id?: string } = Astro.props as {
|
||||
events?: Event[];
|
||||
id?: string;
|
||||
};
|
||||
import "../styles/components/EventsGrid.css";
|
||||
---
|
||||
|
||||
<section class="events-gird container">
|
||||
|
||||
{events.map((event: Event) => (
|
||||
|
||||
<HoverCard
|
||||
title={event.title}
|
||||
date={event.date}
|
||||
description={event.description}
|
||||
image={event.image}
|
||||
/>
|
||||
))}
|
||||
|
||||
<h2 class="section-title">Events</h2>
|
||||
<section id={id} class="events-gird container">
|
||||
{
|
||||
events.map((event: Event) => (
|
||||
<HoverCard
|
||||
title={event.title}
|
||||
date={event.date}
|
||||
description={event.description}
|
||||
image={event.image}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
---
|
||||
// src/components/Footer.astro
|
||||
import "/styles/components/Footer.css"
|
||||
import "../styles/components/Footer.css"
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<footer class="footer" id="footer">
|
||||
<div class="footer-content">
|
||||
<div class="copyright">
|
||||
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="footer-sections">
|
||||
<div class="footer-section">
|
||||
@ -24,8 +22,8 @@ const currentYear = new Date().getFullYear();
|
||||
<p>Gallus Pub</p>
|
||||
<p>Metzgergasse 13</p>
|
||||
<p>9000 St. Gallen</p>
|
||||
<p>Reservierungen via Whatsapp</p>
|
||||
<p><a href="tel:0772322770">077 232 27 70</a></p>
|
||||
<p><a href="mailto:info@gallus-pub.ch">info@gallus-pub.ch</a></p>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
@ -33,12 +31,12 @@ const currentYear = new Date().getFullYear();
|
||||
<p>Du planst einen Event?</p>
|
||||
<p>Der "St.Gallerruum" im 2.OG</p>
|
||||
<p>kann gemietet werden.</p>
|
||||
<br/>
|
||||
<p>Gerne öffnen wir auf Anfrage</p>
|
||||
<p>auch ausserhalb unserer</p>
|
||||
<p>Betriebszeiten.</p>
|
||||
<p>Reservierungen via Whatsapp</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyright">
|
||||
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -1,39 +1,81 @@
|
||||
---
|
||||
// src/components/Header.astro
|
||||
const { url } = Astro;
|
||||
import "../../styles/components/Header.css"
|
||||
import "../styles/components/Header.css";
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
|
||||
<div class="logo-container">
|
||||
<a href="/">
|
||||
<img src="/images/Logo.png" alt="Logo" class="logo">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Hauptnavigation: immer Home, About, Contact -->
|
||||
<nav class="nav-main">
|
||||
|
||||
<div class="dropdown">
|
||||
|
||||
<a href="/" class="dropdbtn">Home</a>
|
||||
|
||||
<div class="dropdown-content">
|
||||
|
||||
<a href="/events">Events</a>
|
||||
<a href="/gallery">Gallery</a>
|
||||
<a href="/openings">Openings</a>
|
||||
<a href="/drinks">Drinks</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Desktop Layout -->
|
||||
<div class="desktop-layout">
|
||||
<div class="logo-container">
|
||||
<a href="/">
|
||||
<img src="/images/Logo.png" alt="Logo" class="logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="/about">About</a>
|
||||
<a href="/contact">Contact</a>
|
||||
<nav class="nav-main">
|
||||
<div class="desktop-menu">
|
||||
<a href="/#hero">Home</a>
|
||||
<a href="/#events">Events</a>
|
||||
<a href="/#gallery">Galerie</a>
|
||||
<a href="/#drinks">Drinks</a>
|
||||
<a href="/#footer">Contact</a>
|
||||
<!--<a href="/#about">About</a>
|
||||
<a href="/#contact">Contact</a>-->
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<!-- Mobile Layout -->
|
||||
<div class="mobile-layout">
|
||||
<!-- Centered Logo -->
|
||||
<div class="mobile-logo-container">
|
||||
<a href="/">
|
||||
<img src="/images/Logo.png" alt="Logo" class="logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Burger Menu Below Logo -->
|
||||
<div class="burger-menu">
|
||||
<div class="burger-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu (Dropdown) -->
|
||||
<div class="mobile-menu">
|
||||
<a href="/#hero">Home</a>
|
||||
<a href="/#events">Events</a>
|
||||
<a href="/#gallery">Galerie</a>
|
||||
<a href="/#drinks">Drinks</a>
|
||||
<a href="/#footer">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="header-spacer"></div>
|
||||
|
||||
<script>
|
||||
// Toggle mobile menu when burger icon is clicked
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const burgerIcon = document.querySelector('.burger-icon');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
const mobileMenuLinks = document.querySelectorAll('.mobile-menu a');
|
||||
|
||||
// Toggle menu when burger icon is clicked
|
||||
burgerIcon.addEventListener('click', () => {
|
||||
burgerIcon.classList.toggle('active');
|
||||
mobileMenu.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Close menu when a navigation link is clicked
|
||||
mobileMenuLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
burgerIcon.classList.remove('active');
|
||||
mobileMenu.classList.remove('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
---
|
||||
// src/components/Hero.astro
|
||||
import "../../styles/components/Hero.css"
|
||||
import "../styles/components/Hero.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="hero container">
|
||||
<section id={id} class="hero container">
|
||||
|
||||
<div class="hero-overlay">
|
||||
|
||||
@ -13,7 +15,7 @@ import "../../styles/components/Hero.css"
|
||||
|
||||
<p>Im Herzen von St.Gallen</p>
|
||||
|
||||
<a href="#" class="button">Aktuelles ↓</a>
|
||||
<a href="#welcome" class="button">Aktuelles ↓</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
---
|
||||
// src/components/HoverCard.astro
|
||||
import "../../styles/components/HoverCard.css"
|
||||
const { title, description, image = "", date} = Astro.props;
|
||||
import "../styles/components/HoverCard.css";
|
||||
const {title, description, image = "", date} = Astro.props;
|
||||
---
|
||||
|
||||
<article class="hover-card">
|
||||
@ -9,13 +8,41 @@ const { title, description, image = "", date} = Astro.props;
|
||||
<img class="card-image" src={image} alt={title} />
|
||||
</div>
|
||||
|
||||
<h3 class="card-title">{title}</h3>
|
||||
<h4 class="card_date">{date}</h4>
|
||||
|
||||
<div class="hover-text">
|
||||
<div>
|
||||
<p>{description}</p>
|
||||
<p set:html={description} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hoverCards = document.querySelectorAll('.hover-card');
|
||||
|
||||
hoverCards.forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
// Only toggle on mobile devices
|
||||
if (window.innerWidth <= 768) {
|
||||
e.preventDefault();
|
||||
|
||||
// Close all other active cards first
|
||||
hoverCards.forEach(otherCard => {
|
||||
if (otherCard !== card) {
|
||||
otherCard.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current card
|
||||
card.classList.toggle('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Close card when clicking outside (mobile only)
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.innerWidth <= 768 && !card.contains(e.target as Node)) {
|
||||
card.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
114
src/components/ImageCarousel.astro
Normal file
@ -0,0 +1,114 @@
|
||||
---
|
||||
// src/components/ImageCarousel.astro
|
||||
import "../styles/components/ImageCarousel.css";
|
||||
|
||||
interface Image {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const { images = [], id } = Astro.props as { images: Image[], id?: string };
|
||||
---
|
||||
|
||||
<section id={id} class="image-carousel-container">
|
||||
<h2 class="section-title">Galerie</h2>
|
||||
<div class="image-carousel">
|
||||
<button class="nav-button prev-button" aria-label="Previous image">
|
||||
<span class="arrow">❮</span>
|
||||
</button>
|
||||
|
||||
<div class="carousel-images">
|
||||
<div class="carousel-track">
|
||||
{images.map((image, index) => (
|
||||
<div class="carousel-slide" data-index={index}>
|
||||
<img src={image.src} alt={image.alt} class="carousel-image" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="nav-button next-button" aria-label="Next image">
|
||||
<span class="arrow">❯</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="carousel-indicators">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
class="indicator-dot"
|
||||
data-index={index}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Initialize carousel functionality
|
||||
function initCarousel() {
|
||||
const carousel = document.querySelector('.image-carousel');
|
||||
const track = document.querySelector('.carousel-track');
|
||||
const slides = document.querySelectorAll('.carousel-slide');
|
||||
const prevButton = document.querySelector('.prev-button');
|
||||
const nextButton = document.querySelector('.next-button');
|
||||
const indicators = document.querySelectorAll('.indicator-dot');
|
||||
|
||||
if (!carousel || !track || !slides.length || !prevButton || !nextButton) return;
|
||||
|
||||
let currentIndex = 0;
|
||||
const slideCount = slides.length;
|
||||
|
||||
// Set initial active state
|
||||
updateCarousel();
|
||||
|
||||
// Add event listeners
|
||||
prevButton.addEventListener('click', () => {
|
||||
currentIndex = (currentIndex - 1 + slideCount) % slideCount;
|
||||
updateCarousel();
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', () => {
|
||||
currentIndex = (currentIndex + 1) % slideCount;
|
||||
updateCarousel();
|
||||
});
|
||||
|
||||
// Add click events to indicators
|
||||
indicators.forEach((dot, index) => {
|
||||
dot.addEventListener('click', () => {
|
||||
currentIndex = index;
|
||||
updateCarousel();
|
||||
});
|
||||
});
|
||||
|
||||
// Function to update carousel display
|
||||
function updateCarousel() {
|
||||
// Update active class on slides
|
||||
slides.forEach((slide, index) => {
|
||||
const position = index - currentIndex;
|
||||
|
||||
// Remove all position classes
|
||||
slide.classList.remove('prev', 'current', 'next');
|
||||
|
||||
// Add appropriate position class
|
||||
if (position === -1 || (position === slideCount - 1 && currentIndex === 0)) {
|
||||
slide.classList.add('prev');
|
||||
} else if (position === 0) {
|
||||
slide.classList.add('current');
|
||||
} else if (position === 1 || (position === -(slideCount - 1) && currentIndex === slideCount - 1)) {
|
||||
slide.classList.add('next');
|
||||
}
|
||||
});
|
||||
|
||||
// Update indicators
|
||||
indicators.forEach((dot, index) => {
|
||||
dot.classList.toggle('active', index === currentIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run initialization when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', initCarousel);
|
||||
|
||||
// Re-initialize on astro:page-load for Astro View Transitions
|
||||
document.addEventListener('astro:page-load', initCarousel);
|
||||
</script>
|
||||
@ -2,6 +2,9 @@
|
||||
// src/components/Layout.astro
|
||||
import Header from "./Header.astro";
|
||||
import Footer from "./Footer.astro";
|
||||
import "../styles/components/Layout.css"
|
||||
import "../styles/variables.css"
|
||||
import "../styles/index.css"
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@ -13,8 +16,6 @@ import Footer from "./Footer.astro";
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gallus Pub</title>
|
||||
<link rel="stylesheet" href="/styles/variables.css" />
|
||||
<link rel="stylesheet" href="/styles/index.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
---
|
||||
// src/components/Welcome.astro
|
||||
import "../../styles/components/Welcome.css"
|
||||
import "../styles/components/Welcome.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="welcome container">
|
||||
<section id={id} class="welcome container">
|
||||
|
||||
<div class="welcome-text">
|
||||
|
||||
<h2>Herzlich willkommen im Gallus Pub!</h2>
|
||||
<h2>Herzlich willkommen im</h2>
|
||||
<h2>Gallus Pub!</h2>
|
||||
|
||||
<p>
|
||||
Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung
|
||||
@ -49,7 +52,7 @@ import "../../styles/components/Welcome.css"
|
||||
|
||||
|
||||
<div class="welcome-image">
|
||||
<img src="/images/Welcome.png" alt="Welcome backgrount image" />
|
||||
<img src="/images/Welcome.png" alt="Welcome background image" />
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
---
|
||||
import Layout from "../components/Layout.astro";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
|
||||
<h1>Contact</h1>
|
||||
|
||||
<p>Hier findest du alle aktuellen und kommenden Contact im Gallus Pub.</p>
|
||||
|
||||
</Layout>
|
||||
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
@ -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>
|
||||
@ -1,23 +1,92 @@
|
||||
---
|
||||
// src/pages/index.astro
|
||||
import Layout from "../components/Layout.astro";
|
||||
import Hero from "../components/Hero.astro";
|
||||
import Welcome from "../components/Welcome.astro";
|
||||
import EventsGrid from '../components/EventsGrid.astro';
|
||||
import EventsGrid from "../components/EventsGrid.astro";
|
||||
import Drinks from "../components/Drinks.astro";
|
||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||
|
||||
const events = [
|
||||
{image: '/images/Logo.png', title: 'Karaoke Night', date: 'Mi, 23. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua' },
|
||||
{image: '/images/Logo.png', title: 'Pub Quiz', date: 'Fr, 25. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptuaLorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua' },
|
||||
{image: '/images/Logo.png', title: 'Live-Musik', date: 'Sa, 26. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod' },
|
||||
{image: '/images/Logo.png', title: 'Cocktail-Abend', date: 'So, 27. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua' }
|
||||
{
|
||||
image: "/images/events/event_karaoke.jpg",
|
||||
title: "Karaoke",
|
||||
date: "Mittwoch - Samstag",
|
||||
description: `
|
||||
Bei uns gibt es Karaoke Mi-Sa!! <br>
|
||||
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
|
||||
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
|
||||
`,
|
||||
},
|
||||
{
|
||||
image: "/images/events/event_pub-quiz.jpg",
|
||||
title: "Pub Quiz",
|
||||
date: "Jeden Freitag",
|
||||
description: `
|
||||
Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
|
||||
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
|
||||
Auch Einzelpersonen sind herzlich willkommen! <br>
|
||||
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
|
||||
`,
|
||||
},
|
||||
{
|
||||
image: "/images/events/event_schlager-karaoke.jpeg",
|
||||
title: "Schlager Hüttenzauber Karaoke",
|
||||
date: "27. November - 19:00 Uhr",
|
||||
description: `
|
||||
Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
|
||||
`,
|
||||
},
|
||||
{
|
||||
image: "/images/events/event_advents-kalender.jpeg",
|
||||
title: "Adventskalender",
|
||||
date: "03. Dezember - 20. Dezember 2025",
|
||||
description: `
|
||||
Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
|
||||
`,
|
||||
},
|
||||
{
|
||||
image: "/images/events/event_santa_karaoke.jpeg",
|
||||
title: "Santa Karaoke-Party",
|
||||
date: "06. Dezember 2025",
|
||||
description: `
|
||||
🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻 `,
|
||||
},
|
||||
{
|
||||
image: "/images/events/event_ferien.jpeg",
|
||||
title: "Weihnachtsferien",
|
||||
date: "21. Dezember 2025 - 01. Januar 2026",
|
||||
description: `
|
||||
Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
|
||||
`,
|
||||
},
|
||||
{
|
||||
image: "/images/events/event_neujahrs-apero.jpeg",
|
||||
title: "Neujahrs-Apero",
|
||||
date: "02. Januar 2026 - 18:00-20:00 Uhr",
|
||||
description: `
|
||||
|
||||
`,
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
const images = [
|
||||
{ src: "/images/gallery/Gallery7.png", alt: "Siebtes Bild" },
|
||||
{ src: "/images/gallery/Gallery8.png", alt: "Achtes Bild" },
|
||||
{ src: "/images/gallery/Gallery9.png", alt: "Neuntes Bild" },
|
||||
{ src: "/images/gallery/Gallery6.png", alt: "Sechstes Bild" },
|
||||
{ src: "/images/gallery/Gallery1.png", alt: "Erstes Bild" },
|
||||
{ src: "/images/gallery/Gallery2.png", alt: "Zweites Bild" },
|
||||
{ src: "/images/gallery/Gallery3.png", alt: "Drittes Bild" },
|
||||
{ src: "/images/gallery/Gallery4.png", alt: "Viertes Bild" },
|
||||
{ src: "/images/gallery/Gallery5.png", alt: "Fünftes Bild" },
|
||||
];
|
||||
---
|
||||
|
||||
<Layout>
|
||||
|
||||
<Hero />
|
||||
<Welcome />
|
||||
<EventsGrid events={events} />
|
||||
<Drinks />
|
||||
<Hero id="hero" />
|
||||
<Welcome id="welcome" />
|
||||
<EventsGrid id="events" events={events} />
|
||||
<ImageCarousel id="gallery" images={images} />
|
||||
<Drinks id="drinks" />
|
||||
</Layout>
|
||||
|
||||
BIN
src/public/Logo.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
9
src/public/favicon.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
BIN
src/public/images/Background.png
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
src/public/images/Logo.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
src/public/images/Welcome.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/public/images/crepes_sucette.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
src/public/images/karaoke.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/public/images/kevin_mcflannigan.jpeg
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
src/public/images/pub_quiz.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
src/public/pdf/Menu.pdf
Normal file
125
src/styles/components/ContactForm.css
Normal file
@ -0,0 +1,125 @@
|
||||
.contact-container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.contact-title {
|
||||
margin-top: 70px;
|
||||
padding-top: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
background-color: rgba(33, 59, 40, 0.05);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #213b28;
|
||||
box-shadow: 0 0 0 2px rgba(33, 59, 40, 0.2);
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #213b28;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background-color: #2a4c35;
|
||||
}
|
||||
|
||||
.whatsapp-container {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.whatsapp-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #25D366;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.3s ease;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.whatsapp-link:hover {
|
||||
background-color: #128C7E;
|
||||
}
|
||||
|
||||
.whatsapp-icon {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.contact-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.contact-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.contact-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.contact-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.submit-button,
|
||||
.whatsapp-link {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-large);
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
text-transform: uppercase;
|
||||
@ -25,6 +25,7 @@
|
||||
.card-link {
|
||||
border: 2px solid var(--color-accent-beige);
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
@ -68,7 +69,6 @@
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: var(--border-radius);
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
@ -81,8 +81,8 @@
|
||||
}
|
||||
|
||||
.circle {
|
||||
height: 6em;
|
||||
width: 6em;
|
||||
height: 35vh;
|
||||
width: 35vh;
|
||||
border: 2px solid var(--color-accent-beige);
|
||||
border-radius: 50%;
|
||||
margin: 0.5rem;
|
||||
@ -94,6 +94,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.circle:hover {
|
||||
@ -109,12 +110,25 @@
|
||||
text-align: center;
|
||||
transition: opacity var(--transition-standard);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.circle:hover .circle-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.circle-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.circle-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -149,10 +163,6 @@
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.circle {
|
||||
height: 5em;
|
||||
width: 5em;
|
||||
}
|
||||
|
||||
.circle-label {
|
||||
font-size: 0.7rem;
|
||||
@ -1,3 +1,11 @@
|
||||
.section-title {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.events-gird {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -13,9 +13,9 @@
|
||||
|
||||
.copyright {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.footer-sections {
|
||||
190
src/styles/components/Header.css
Normal file
@ -0,0 +1,190 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #0e0c0c;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
height: 70px;
|
||||
/* Should match the header height */
|
||||
}
|
||||
|
||||
/* Desktop Layout */
|
||||
.desktop-layout {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-left: 2em;
|
||||
height: 4em;
|
||||
width: auto;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.nav-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-main a {
|
||||
margin: 0 1rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-main a:hover {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
/* Mobile Layout */
|
||||
.mobile-layout {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mobile-logo-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-logo-container .logo {
|
||||
margin: 0;
|
||||
height: 3.5em;
|
||||
}
|
||||
|
||||
/* Burger Menu Styles */
|
||||
.burger-menu {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.burger-icon {
|
||||
width: 30px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.burger-icon span {
|
||||
display: block;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.burger-icon.active span:nth-child(1) {
|
||||
transform: translateY(10.5px) rotate(45deg);
|
||||
}
|
||||
|
||||
.burger-icon.active span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.burger-icon.active span:nth-child(3) {
|
||||
transform: translateY(-10.5px) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* Mobile Menu Styles */
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
width: 100%;
|
||||
background-color: #0e0c0c;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.mobile-menu.active {
|
||||
max-height: 300px; /* Adjust based on content */
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.mobile-menu a {
|
||||
margin: 0.5rem 0;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.mobile-menu a:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-menu a:hover {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-spacer {
|
||||
height: 120px; /* Adjusted for the taller mobile header */
|
||||
}
|
||||
|
||||
/* Hide desktop layout, show mobile layout */
|
||||
.desktop-layout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-layout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Show mobile menu when active */
|
||||
.mobile-menu.active {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-spacer {
|
||||
height: 110px; /* Slightly smaller for very small screens */
|
||||
}
|
||||
|
||||
.mobile-logo-container .logo {
|
||||
height: 3em;
|
||||
}
|
||||
|
||||
.burger-icon {
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
65
src/styles/components/Hero.css
Normal file
@ -0,0 +1,65 @@
|
||||
.hero-overlay {
|
||||
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 1)), url('/images/Background.png');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* Background is set in the component */
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-content h1 {
|
||||
font-size: 4rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hero-content p {
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-top: 2rem;
|
||||
padding: 0.8rem 2rem;
|
||||
background: linear-gradient(45deg, #ffa500, #ff7f00);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
155
src/styles/components/HoverCard.css
Normal file
@ -0,0 +1,155 @@
|
||||
.hover-card {
|
||||
position: relative;
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-accent-green);
|
||||
box-shadow: var(--box-shadow);
|
||||
transition: transform var(--transition-standard);
|
||||
overflow: hidden;
|
||||
margin: var(--margin-standard);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hover effects only for devices that support hover */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.hover-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.hover-card:hover .hover-text {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hover-card:hover .card-image {
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
padding: 15px 15px 5px 15px;
|
||||
margin: 0;
|
||||
color: var(--color-accent-beige);
|
||||
font-size: var(--font-size-medium);
|
||||
text-align: center;
|
||||
order: -2;
|
||||
}
|
||||
|
||||
.card_date {
|
||||
padding: 0 15px 15px 15px;
|
||||
margin: 0;
|
||||
color: var(--color-accent-beige);
|
||||
font-size: var(--font-size-small);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-text {
|
||||
font-size: var(--font-size-small-medium);
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-accent-green-transparent);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-standard);
|
||||
}
|
||||
|
||||
.hover-text div {
|
||||
color: var(--color-accent-beige);
|
||||
text-align: center;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.hover-text div::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.hover-text div::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hover-text div::-webkit-scrollbar-thumb {
|
||||
background: var(--color-accent-beige);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hover-text div {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Active state for mobile tap functionality */
|
||||
.hover-card.active .hover-text {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hover-card.active .card-image {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.hover-text p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hover-card {
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
/* Maintain square aspect ratio */
|
||||
aspect-ratio: 1 / 1;
|
||||
height: auto;
|
||||
/* Add cursor pointer to indicate it's clickable */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Add visual feedback for tap */
|
||||
.hover-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.hover-card::after {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: var(--color-accent-beige);
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Hide the hint when card is active */
|
||||
.hover-card.active::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
176
src/styles/components/ImageCarousel.css
Normal file
@ -0,0 +1,176 @@
|
||||
/* styles/components/ImageCarousel.css */
|
||||
|
||||
.image-carousel-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.image-carousel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.carousel-images {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: all 0.5s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(0.8);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Current slide - center and fully visible */
|
||||
.carousel-slide.current {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Previous slide - left side, partially visible */
|
||||
.carousel-slide.prev {
|
||||
opacity: 0.7;
|
||||
transform: translateX(-30%) scale(0.85);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Next slide - right side, partially visible */
|
||||
.carousel-slide.next {
|
||||
opacity: 0.7;
|
||||
transform: translateX(30%) scale(0.85);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.carousel-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Navigation buttons */
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.prev-button {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.next-button {
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Indicators */
|
||||
.carousel-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #ccc;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.indicator-dot.active {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.image-carousel {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.carousel-slide.prev,
|
||||
.carousel-slide.next {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
|
||||
.carousel-slide.prev {
|
||||
transform: translateX(-20%) scale(0.7);
|
||||
}
|
||||
|
||||
.carousel-slide.next {
|
||||
transform: translateX(20%) scale(0.7);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.image-carousel {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.carousel-slide.prev,
|
||||
.carousel-slide.next {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
15
src/styles/components/Layout.css
Normal file
@ -0,0 +1,15 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 0 1rem;
|
||||
}
|
||||