Compare commits
115 Commits
0f13e8eb66
...
dev_2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eea814fad | |||
| 0597c73690 | |||
| 0a2aa84a8c | |||
| 1120472af8 | |||
| db3a38ed45 | |||
| 4f8feb8652 | |||
| 0c291079ff | |||
| fe2f61cdc2 | |||
| af4877300f | |||
| 4a103cf7d6 | |||
| 97e7f88906 | |||
| 807c56de5a | |||
| 8ca30ae5f3 | |||
| 8f1254840c | |||
| 8b2d00385a | |||
| c55e274718 | |||
| e9a95ccf8d | |||
| b16ac76620 | |||
| 0e03b9dea9 | |||
| da3a950a1a | |||
| 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 | |||
| 15dedfabcf | |||
| 5247bd9816 | |||
| 50c06b3a8a | |||
| 5ab62f2b3b | |||
| 6120f04c95 | |||
| 179de67386 | |||
| 3da1b63a50 | |||
| 6b79e08684 | |||
| 7d5e77df76 | |||
| 23b47a7e85 | |||
| f4c75ea941 | |||
| 58522f2ae0 | |||
| 2a0aa7a6c8 | |||
| bcd86c9c68 |
3
Gallus_Pub_v1/.gitignore → .gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
@ -12,7 +13,6 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
@ -22,3 +22,4 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
/ai/
|
||||||
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
13
.idea/Gallus_Pub.iml
generated
@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="WEB_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
<orderEntry type="library" name="font-awesome" level="application" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
8
.idea/modules.xml
generated
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/Gallus_Pub.iml" filepath="$PROJECT_DIR$/.idea/Gallus_Pub.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.formatOnSave": false
|
|
||||||
}
|
|
||||||
13
.woodpecker.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
steps:
|
||||||
|
deploy_frontend:
|
||||||
|
image: node:20
|
||||||
|
environment:
|
||||||
|
FLY_API_TOKEN:
|
||||||
|
from_secret: FLY_API_TOKEN
|
||||||
|
commands:
|
||||||
|
- curl -L https://fly.io/install.sh | sh
|
||||||
|
- export PATH="$HOME/.fly/bin:$PATH"
|
||||||
|
- flyctl deploy --config fly.toml --app gallus-pub --remote-only
|
||||||
|
when:
|
||||||
|
branch: main
|
||||||
|
event: push
|
||||||
24
Dockerfile
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
# 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 ./dist
|
||||||
|
|
||||||
|
EXPOSE 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"]
|
||||||
37
Gallus_Pub/.gitignore
vendored
@ -1,37 +0,0 @@
|
|||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Expo
|
|
||||||
.expo/
|
|
||||||
dist/
|
|
||||||
web-build/
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
# Native
|
|
||||||
.kotlin/
|
|
||||||
*.orig.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.*
|
|
||||||
yarn-debug.*
|
|
||||||
yarn-error.*
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
||||||
import { View } from 'react-native';
|
|
||||||
import LoginScreen from './src/pages/LoginPage';
|
|
||||||
import AdminScreen from './src/pages/AdminPage';
|
|
||||||
import HomeScreen from './src/pages/HomePage';
|
|
||||||
import ProtectedRoute from './src/components/ProtectedRoute';
|
|
||||||
import { AuthProvider } from './src/AuthContext';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<BrowserRouter>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<HomeScreen />} />
|
|
||||||
<Route path="/admin/login" element={<LoginScreen />} />
|
|
||||||
<Route path="/admin" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AdminScreen />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
</Routes>
|
|
||||||
</View>
|
|
||||||
</BrowserRouter>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "Gallus_Pub",
|
|
||||||
"slug": "Gallus_Pub",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"icon": "./assets/icon.png",
|
|
||||||
"userInterfaceStyle": "light",
|
|
||||||
"newArchEnabled": true,
|
|
||||||
"splash": {
|
|
||||||
"image": "./assets/splash-icon.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
},
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
},
|
|
||||||
"edgeToEdgeEnabled": true
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"favicon": "./assets/favicon.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -1,8 +0,0 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
|
||||||
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
|
||||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
|
||||||
// the environment is set up appropriately
|
|
||||||
registerRootComponent(App);
|
|
||||||
8417
Gallus_Pub/package-lock.json
generated
@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "gallus_pub",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"start": "expo start",
|
|
||||||
"android": "expo start --android",
|
|
||||||
"ios": "expo start --ios",
|
|
||||||
"web": "expo start --web"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/metro-runtime": "~5.0.4",
|
|
||||||
"expo": "~53.0.9",
|
|
||||||
"expo-status-bar": "~2.2.3",
|
|
||||||
"react": "19.0.0",
|
|
||||||
"react-native": "0.79.2",
|
|
||||||
"react-native-web": "^0.20.0",
|
|
||||||
"react-router-dom": "^7.6.0",
|
|
||||||
"react-dom": "19.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.25.2",
|
|
||||||
"@types/react": "~19.0.10",
|
|
||||||
"typescript": "~5.8.3"
|
|
||||||
},
|
|
||||||
"private": true
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import React, { createContext, useState, useContext, ReactNode } from 'react';
|
|
||||||
|
|
||||||
type AuthContextType = {
|
|
||||||
isAdmin: boolean;
|
|
||||||
login: (username: string, password: string) => Promise<void>;
|
|
||||||
logout: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
|
|
||||||
const login = async (username: string, password: string) => {
|
|
||||||
// Hier würden Sie normalerweise API-Anfragen machen, um die Anmeldedaten zu überprüfen
|
|
||||||
// Einfache Validierung für das Beispiel:
|
|
||||||
if (username === 'admin' && password === 'password') {
|
|
||||||
setIsAdmin(true);
|
|
||||||
// In einer echten Anwendung würden Sie hier einen Token im localStorage speichern
|
|
||||||
localStorage.setItem('isAdmin', 'true');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error('Falsche Anmeldedaten');
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
setIsAdmin(false);
|
|
||||||
localStorage.removeItem('isAdmin');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ isAdmin, login, logout }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAuth = () => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth muss innerhalb eines AuthProviders verwendet werden');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../AuthContext';
|
|
||||||
|
|
||||||
type ProtectedRouteProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
|
||||||
const { isAdmin } = useAuth();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
// Wenn nicht angemeldet, zur Login-Seite umleiten
|
|
||||||
return <Navigate to="/admin/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProtectedRoute;
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useAuth } from '../AuthContext';
|
|
||||||
|
|
||||||
const AdminPage: React.FC = () => {
|
|
||||||
const { logout } = useAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.container}>
|
|
||||||
<h1>Admin</h1>
|
|
||||||
<p>Willkommen im Admin-Bereich! Hier können Sie Ihr Gallus Pub verwalten.</p>
|
|
||||||
<button onClick={logout} style={styles.button}>Abmelden</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
container: {
|
|
||||||
maxWidth: '800px',
|
|
||||||
margin: '50px auto',
|
|
||||||
padding: '20px',
|
|
||||||
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
|
|
||||||
borderRadius: '5px',
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
padding: '10px',
|
|
||||||
backgroundColor: '#f44336',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '3px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginTop: '20px',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminPage;
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.title}>Willkommen bei Gallus Pub</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../AuthContext';
|
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const { login } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await login(username, password);
|
|
||||||
navigate('/admin');
|
|
||||||
} catch (err) {
|
|
||||||
setError('Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.container}>
|
|
||||||
<h2>Admin-Login</h2>
|
|
||||||
{error && <p style={styles.error}>{error}</p>}
|
|
||||||
<form onSubmit={handleSubmit} style={styles.form}>
|
|
||||||
<div style={styles.inputGroup}>
|
|
||||||
<label htmlFor="username">Benutzername:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
style={styles.input}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={styles.inputGroup}>
|
|
||||||
<label htmlFor="password">Passwort:</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
style={styles.input}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" style={styles.button}>Anmelden</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
container: {
|
|
||||||
maxWidth: '400px',
|
|
||||||
margin: '100px auto',
|
|
||||||
padding: '20px',
|
|
||||||
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
|
|
||||||
borderRadius: '5px',
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column' as 'column',
|
|
||||||
},
|
|
||||||
inputGroup: {
|
|
||||||
marginBottom: '15px',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px',
|
|
||||||
borderRadius: '3px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
marginTop: '5px',
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
padding: '10px',
|
|
||||||
backgroundColor: '#4CAF50',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '3px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
color: 'red',
|
|
||||||
marginBottom: '15px',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginPage;
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "expo/tsconfig.base",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
pipeline:
|
|
||||||
build:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npm run build
|
|
||||||
when:
|
|
||||||
branch: main
|
|
||||||
event: [push, pull_request]
|
|
||||||
deploy:
|
|
||||||
depends_on: [build]
|
|
||||||
image: flyio/flyctl:latest
|
|
||||||
secrets: [fly_api_token]
|
|
||||||
commands:
|
|
||||||
- flyctl deploy --remote-only
|
|
||||||
when:
|
|
||||||
branch: main
|
|
||||||
event: push
|
|
||||||
|
|
||||||
branches:
|
|
||||||
include: [main, dev]
|
|
||||||
|
|
||||||
cache:
|
|
||||||
mount:
|
|
||||||
- node_modules
|
|
||||||
- .npm
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
FROM node:20-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN npm install -g serve
|
|
||||||
|
|
||||||
COPY --from=build /app/dist /app
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["serve", "-s", ".", "-l", "3000"]
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
# Astro Starter Kit: Minimal
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm create astro@latest -- --template minimal
|
|
||||||
```
|
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
|
||||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
|
||||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
|
||||||
|
|
||||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
|
||||||
|
|
||||||
## 🚀 Project Structure
|
|
||||||
|
|
||||||
Inside of your Astro project, you'll see the following folders and files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/
|
|
||||||
├── public/
|
|
||||||
├── src/
|
|
||||||
│ └── pages/
|
|
||||||
│ └── index.astro
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
|
||||||
|
|
||||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
|
||||||
|
|
||||||
Any static assets, like images, can be placed in the `public/` directory.
|
|
||||||
|
|
||||||
## 🧞 Commands
|
|
||||||
|
|
||||||
All commands are run from the root of the project, from a terminal:
|
|
||||||
|
|
||||||
| Command | Action |
|
|
||||||
| :------------------------ | :----------------------------------------------- |
|
|
||||||
| `npm install` | Installs dependencies |
|
|
||||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
|
||||||
| `npm run build` | Build your production site to `./dist/` |
|
|
||||||
| `npm run preview` | Preview your build locally, before deploying |
|
|
||||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
|
||||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
|
||||||
|
|
||||||
## 👀 Want to learn more?
|
|
||||||
|
|
||||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
|
||||||
4876
Gallus_Pub_v1/package-lock.json
generated
@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
import "../../styles/components/Drinks.css"
|
|
||||||
---
|
|
||||||
<section class="Drinks">
|
|
||||||
<h2 class="title">Drinks</h2>
|
|
||||||
|
|
||||||
<a href="/pdf/Menu.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>
|
|
||||||
</div>
|
|
||||||
<div>Mate Vodka</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="circle-row">
|
|
||||||
<div class="circle" title="Bier">
|
|
||||||
<span class="circle-label">Bier</span>
|
|
||||||
</div>
|
|
||||||
<div class="circle" title="Wein">
|
|
||||||
<span class="circle-label">Wein</span>
|
|
||||||
</div>
|
|
||||||
<div class="circle" title="Cocktails">
|
|
||||||
<span class="circle-label">Cocktails</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>
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
// src/components/EventsGrid.astro
|
|
||||||
|
|
||||||
import HoverCard from "./HoverCard.astro";
|
|
||||||
interface Event {
|
|
||||||
image: string;
|
|
||||||
title: string;
|
|
||||||
date: Date;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
const { events = [] }: { events?: Event[] } = Astro.props as { events?: Event[] };
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
</section>
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
// src/components/Header.astro
|
|
||||||
const { url } = Astro;
|
|
||||||
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>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/about">About</a>
|
|
||||||
<a href="/contact">Contact</a>
|
|
||||||
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
</header>
|
|
||||||
<div class="header-spacer"></div>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
// src/components/HoverCard.astro
|
|
||||||
import "../../styles/components/HoverCard.css"
|
|
||||||
const { title, description, image = "", date} = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<article class="hover-card">
|
|
||||||
<div class="image-container">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
@ -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>
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
// 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 Drinks from "../components/Drinks.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' }
|
|
||||||
];
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
|
|
||||||
<Hero />
|
|
||||||
<Welcome />
|
|
||||||
<EventsGrid events={events} />
|
|
||||||
<Drinks />
|
|
||||||
</Layout>
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
.header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background-color: rgba(26, 26, 26, 0.95);
|
|
||||||
z-index: 1000;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
|
||||||
backdrop-filter: blur(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.header-spacer {
|
|
||||||
height: 70px; /* Should match the header height */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.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;
|
|
||||||
margin-right: 377px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-main a,
|
|
||||||
.dropdbtn {
|
|
||||||
margin: 0 1rem;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-main a:hover,
|
|
||||||
.dropdbtn:hover {
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown für Home */
|
|
||||||
.dropdown {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-content {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
background-color: #0e0c0c;
|
|
||||||
min-width: 160px;
|
|
||||||
z-index: 1;
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-content a {
|
|
||||||
color: #f5f5f5;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-content a:hover {
|
|
||||||
background-color: #1f1a1a;
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown:hover .dropdown-content {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.nav-container {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
/* === Header & Nav === */
|
|
||||||
.header {
|
|
||||||
background-color: #0e0c0c;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
border-bottom: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 377px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-main a,
|
|
||||||
.dropdbtn {
|
|
||||||
margin: 0 1rem;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-main a:hover,
|
|
||||||
.dropdbtn:hover {
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown für Home */
|
|
||||||
.dropdown {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-content {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
background-color: #0e0c0c;
|
|
||||||
min-width: 160px;
|
|
||||||
z-index: 1;
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-content a {
|
|
||||||
color: #f5f5f5;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-content a:hover {
|
|
||||||
background-color: #1f1a1a;
|
|
||||||
color: #ffa500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown:hover .dropdown-content {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
.hover-card {
|
|
||||||
position: relative;
|
|
||||||
width: 350px;
|
|
||||||
height: 400px;
|
|
||||||
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-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-card:hover .hover-text {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-card:hover .card-image {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-text p {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hover-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
:root {
|
|
||||||
/* Colors */
|
|
||||||
--color-background: #000;
|
|
||||||
--color-text: #f5f5f5;
|
|
||||||
--color-accent-green: #213b28;
|
|
||||||
--color-accent-beige: #ceb39b;
|
|
||||||
--color-accent-green-transparent: rgba(33, 59, 40, 0.95);
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.2);
|
|
||||||
|
|
||||||
--font-family-primary: 'Georgia', serif;
|
|
||||||
--font-size-small: 1rem;
|
|
||||||
--font-size-medium: 1.5rem;
|
|
||||||
--font-size-large: 2rem;
|
|
||||||
--line-height: 1.6;
|
|
||||||
|
|
||||||
--container-width: 100%;
|
|
||||||
--container-max-width: 1600px;
|
|
||||||
--padding-vertical: 2rem;
|
|
||||||
--padding-horizontal: 0;
|
|
||||||
--margin-standard: 1rem;
|
|
||||||
--gap-standard: 30px;
|
|
||||||
|
|
||||||
--border-radius: 8px;
|
|
||||||
--box-shadow: 0 4px 8px var(--color-shadow);
|
|
||||||
--transition-standard: 0.3s ease;
|
|
||||||
|
|
||||||
|
|
||||||
--breakpoint-mobile: 768px;
|
|
||||||
--breakpoint-desktop: 1600px;
|
|
||||||
}
|
|
||||||
142
MIGRATION_README.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# Migration der alten Events und Gallery-Bilder
|
||||||
|
|
||||||
|
## ✅ Was wurde migriert?
|
||||||
|
|
||||||
|
### Events (7 Stück):
|
||||||
|
- Karaoke (wiederkehrend)
|
||||||
|
- Pub Quiz (wiederkehrend)
|
||||||
|
- Schlager Hüttenzauber Karaoke
|
||||||
|
- Adventskalender
|
||||||
|
- Santa Karaoke-Party
|
||||||
|
- Weihnachtsferien
|
||||||
|
- Neujahrs-Apero
|
||||||
|
|
||||||
|
### Gallery-Bilder (9 Stück):
|
||||||
|
- Gallery1.webp bis Gallery9.webp
|
||||||
|
|
||||||
|
## 📁 Wo liegen die Bilder?
|
||||||
|
|
||||||
|
Alle Bilder wurden konvertiert und liegen jetzt in:
|
||||||
|
- **Events:** `backend/data/images/events/`
|
||||||
|
- **Gallery:** `backend/data/images/gallery/`
|
||||||
|
|
||||||
|
Die Bilder wurden automatisch:
|
||||||
|
- Von PNG/JPG/JPEG zu WebP konvertiert
|
||||||
|
- Auf max. 1600px Breite skaliert
|
||||||
|
- Mit 85% Qualität optimiert
|
||||||
|
|
||||||
|
## 🚀 Deployment-Schritte
|
||||||
|
|
||||||
|
### 1. Lokale Vorbereitung (bereits erledigt ✓)
|
||||||
|
- ✓ Migrations-Script erstellt
|
||||||
|
- ✓ Bilder konvertiert und in `backend/data/images/` kopiert
|
||||||
|
- ✓ Public API-Endpunkte erstellt (`/api/events/public`, `/api/gallery/public`)
|
||||||
|
- ✓ Frontend aktualisiert, um Events und Gallery dynamisch zu laden
|
||||||
|
|
||||||
|
### 2. Auf Fly.io deployen
|
||||||
|
|
||||||
|
Alle Änderungen committen und pushen:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: Migrate old events and gallery images to CMS"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Woodpecker CI wird automatisch beide Services deployen.
|
||||||
|
|
||||||
|
### 3. Nach dem ersten Deploy - Datenbank initialisieren
|
||||||
|
|
||||||
|
**Wichtig:** Die Bilder sind bereits im Repository in `backend/data/images/`, aber die Datenbank muss noch mit den Event- und Gallery-Einträgen befüllt werden.
|
||||||
|
|
||||||
|
#### Via fly ssh (Empfohlen):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In das Backend einloggen
|
||||||
|
fly ssh console -a gallus-cms-backend
|
||||||
|
|
||||||
|
# Prüfen ob Bilder da sind
|
||||||
|
ls -la /app/data/images/events/
|
||||||
|
ls -la /app/data/images/gallery/
|
||||||
|
|
||||||
|
# Migrations-Script ausführen
|
||||||
|
cd /app
|
||||||
|
npm run migrate:old-data
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Alternative: Manuell via Admin-Panel
|
||||||
|
|
||||||
|
1. Gehe zu https://gallus-pub.ch/admin
|
||||||
|
2. Melde dich an
|
||||||
|
3. Für jedes Event:
|
||||||
|
- Klicke auf "Neues Event"
|
||||||
|
- Gib Titel, Datum und Beschreibung ein
|
||||||
|
- Statt Bild hochzuladen, trage manuell die imageUrl ein:
|
||||||
|
- z.B. `/images/events/event_karaoke.webp`
|
||||||
|
- Speichere das Event
|
||||||
|
|
||||||
|
## 🔍 Verifikation
|
||||||
|
|
||||||
|
Nach dem Deployment prüfen:
|
||||||
|
|
||||||
|
1. **Frontend:** https://gallus-pub.ch/
|
||||||
|
- Events sollten angezeigt werden
|
||||||
|
- Gallery sollte Bilder zeigen
|
||||||
|
|
||||||
|
2. **Admin:** https://gallus-pub.ch/admin
|
||||||
|
- Events können bearbeitet werden
|
||||||
|
- Neue Events können hinzugefügt werden
|
||||||
|
|
||||||
|
3. **Backend Health:** https://cms.gallus-pub.ch/health
|
||||||
|
- Status sollte "ok" sein
|
||||||
|
|
||||||
|
## 📝 Event-Daten für manuelles Einfügen
|
||||||
|
|
||||||
|
Falls du die Events manuell via Admin-Panel einfügen möchtest:
|
||||||
|
|
||||||
|
### Karaoke
|
||||||
|
- **Titel:** Karaoke
|
||||||
|
- **Datum:** 2025-12-31
|
||||||
|
- **Beschreibung:** 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>
|
||||||
|
- **Bild-URL:** `/images/events/event_karaoke.webp`
|
||||||
|
|
||||||
|
### Pub Quiz
|
||||||
|
- **Titel:** Pub Quiz
|
||||||
|
- **Datum:** 2025-12-31
|
||||||
|
- **Beschreibung:** 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
|
||||||
|
- **Bild-URL:** `/images/events/event_pub-quiz.webp`
|
||||||
|
|
||||||
|
### Schlager Hüttenzauber Karaoke
|
||||||
|
- **Titel:** Schlager Hüttenzauber Karaoke
|
||||||
|
- **Datum:** 2025-11-27
|
||||||
|
- **Beschreibung:** Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
|
||||||
|
- **Bild-URL:** `/images/events/event_schlager-karaoke.webp`
|
||||||
|
|
||||||
|
### Adventskalender
|
||||||
|
- **Titel:** Adventskalender
|
||||||
|
- **Datum:** 2025-12-20
|
||||||
|
- **Beschreibung:** Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
|
||||||
|
- **Bild-URL:** `/images/events/event_advents-kalender.webp`
|
||||||
|
|
||||||
|
### Santa Karaoke-Party
|
||||||
|
- **Titel:** Santa Karaoke-Party
|
||||||
|
- **Datum:** 2025-12-06
|
||||||
|
- **Beschreibung:** 🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻
|
||||||
|
- **Bild-URL:** `/images/events/event_santa_karaoke.webp`
|
||||||
|
|
||||||
|
### Weihnachtsferien
|
||||||
|
- **Titel:** Weihnachtsferien
|
||||||
|
- **Datum:** 2025-12-21
|
||||||
|
- **Beschreibung:** 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
|
||||||
|
- **Bild-URL:** `/images/events/event_ferien.webp`
|
||||||
|
|
||||||
|
### Neujahrs-Apero
|
||||||
|
- **Titel:** Neujahrs-Apero
|
||||||
|
- **Datum:** 2026-01-02
|
||||||
|
- **Beschreibung:** 18:00-20:00 Uhr
|
||||||
|
- **Bild-URL:** `/images/events/event_neujahrs-apero.webp`
|
||||||
|
|
||||||
|
## ⚠️ Wichtige Hinweise
|
||||||
|
|
||||||
|
1. **Bilder sind im Volume persistent:** Alle Bilder in `/app/data/` bleiben bei Restarts erhalten
|
||||||
|
2. **Datenbank ist persistent:** Die SQLite-DB in `/app/data/gallus_cms.db` bleibt erhalten
|
||||||
|
3. **Alte Bilder in `public/images/`:** Die alten Original-Bilder bleiben im Frontend-Repository, werden aber nicht mehr verwendet
|
||||||
50
README.md
@ -1,6 +1,48 @@
|
|||||||
# Gallus_Pub
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
Reposetory für die Website vom Gallus Pub
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
Auftragsgeber: Sabrina Signer
|
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
Geschäfft: Gallus Pub
|
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
|
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
|
# Test commit to trigger Woodpecker
|
||||||
|
|||||||
220
SYSTEM_ERKLAERUNG.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
# 🎯 Gallus Pub CMS - System-Erklärung
|
||||||
|
|
||||||
|
## 📐 Architektur-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ Backend CMS │ │ Fly.io Volume │
|
||||||
|
│ (Astro SSG) │◄────────┤ (Fastify API) │◄────────┤ /app/data/ │
|
||||||
|
│ gallus-pub.ch │ fetch │ cms.gallus-pub.ch│ mount │ - SQLite DB │
|
||||||
|
│ │ │ │ │ - images/ │
|
||||||
|
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Wie funktioniert der Upload-Flow?
|
||||||
|
|
||||||
|
### 1. **Admin lädt Bild hoch** (admin.astro)
|
||||||
|
```
|
||||||
|
User wählt Bild → uploadImage() → POST /api/gallery/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Backend verarbeitet Upload** (backend/src/routes/gallery.ts)
|
||||||
|
```javascript
|
||||||
|
// Line 60-134 in gallery.ts
|
||||||
|
1. Empfange Multipart-File
|
||||||
|
2. Validiere Mimetype (nur images/*)
|
||||||
|
3. Lese Stream in Buffer
|
||||||
|
4. sharp() konvertiert zu WebP:
|
||||||
|
- Auto-rotate (EXIF)
|
||||||
|
- Resize auf max 1600px
|
||||||
|
- WebP quality 82%
|
||||||
|
5. Speichere in /app/data/images/gallery/
|
||||||
|
6. Erstelle DB-Eintrag mit imageUrl
|
||||||
|
7. Return imageUrl an Frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Bilder werden serviert** (backend/src/index.ts)
|
||||||
|
```javascript
|
||||||
|
// Line 63-69
|
||||||
|
fastifyStatic → /static/ → /app/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
- Bild liegt in: `/app/data/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
- URL ist: `/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
- Wird serviert als: `https://cms.gallus-pub.ch/static/images/gallery/miyma9zc-8he1di.webp`
|
||||||
|
|
||||||
|
### 4. **Frontend zeigt Bilder** (src/pages/index.astro)
|
||||||
|
```javascript
|
||||||
|
// Line 30-43
|
||||||
|
1. Fetch von /api/gallery/public
|
||||||
|
2. Map imageUrl: `${API_BASE}${img.imageUrl}`
|
||||||
|
3. Result: https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💾 Warum funktioniert SQLite mit Fly.io?
|
||||||
|
|
||||||
|
**Fly.io Volumes** sind persistente Speicher:
|
||||||
|
- Gemountet als: `/app/data/`
|
||||||
|
- Konfiguration in: `backend/fly.toml` (Line 40-42)
|
||||||
|
- Bleibt bei Restarts/Deploys erhalten
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mounts]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Was liegt wo?
|
||||||
|
|
||||||
|
```
|
||||||
|
/app/data/
|
||||||
|
├── gallus_cms.db # SQLite Datenbank
|
||||||
|
├── gallus_cms.db-wal # Write-Ahead Log
|
||||||
|
├── gallus_cms.db-shm # Shared Memory
|
||||||
|
└── images/
|
||||||
|
├── events/
|
||||||
|
│ ├── event_karaoke.webp
|
||||||
|
│ └── ...
|
||||||
|
└── gallery/
|
||||||
|
├── Gallery1.webp
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Datenfluss im Detail
|
||||||
|
|
||||||
|
### Event erstellen:
|
||||||
|
```
|
||||||
|
1. Admin: Bild auswählen + Formular ausfüllen
|
||||||
|
2. uploadImage(file) → /api/gallery/upload
|
||||||
|
↓
|
||||||
|
3. Backend:
|
||||||
|
- Sharp konvertiert → WebP
|
||||||
|
- Speichert in /app/data/images/gallery/
|
||||||
|
- Returnt: { image: { imageUrl: "/images/gallery/xyz.webp" } }
|
||||||
|
↓
|
||||||
|
4. Admin: POST /api/events
|
||||||
|
Body: { title, date, description, imageUrl: "/images/gallery/xyz.webp" }
|
||||||
|
↓
|
||||||
|
5. Backend:
|
||||||
|
- INSERT INTO events (imageUrl = "/images/gallery/xyz.webp")
|
||||||
|
↓
|
||||||
|
6. Frontend: GET /api/events/public
|
||||||
|
- Fetcht Events aus DB
|
||||||
|
- Mapped imageUrl zu voller URL
|
||||||
|
- Zeigt an: <img src="https://cms.gallus-pub.ch/static/images/gallery/xyz.webp">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warum separate Uploads für Gallery?
|
||||||
|
|
||||||
|
Events nutzen den Gallery-Upload, weil:
|
||||||
|
- Beide brauchen WebP-Konvertierung
|
||||||
|
- Beide nutzen gleichen Storage
|
||||||
|
- Vermeidet Code-Duplikation
|
||||||
|
- Gallery kann eigenständige Bildergalerie haben
|
||||||
|
|
||||||
|
## 🎨 Admin-Panel Features
|
||||||
|
|
||||||
|
### Events-Verwaltung:
|
||||||
|
- ✅ Event erstellen (mit Bild-Upload)
|
||||||
|
- ✅ Events auflisten
|
||||||
|
- ✅ Events löschen
|
||||||
|
- ✅ Reihenfolge ändern (Drag & Drop)
|
||||||
|
- ✅ Events veröffentlichen/verstecken (isPublished)
|
||||||
|
|
||||||
|
### Gallery-Verwaltung: (NEU!)
|
||||||
|
- ✅ Bild hochladen
|
||||||
|
- ✅ Gallery auflisten
|
||||||
|
- ✅ Bilder löschen
|
||||||
|
- ✅ Reihenfolge ändern (Drag & Drop)
|
||||||
|
- ✅ Bilder veröffentlichen/verstecken (isPublished)
|
||||||
|
|
||||||
|
### Publish:
|
||||||
|
- Git-Integration (für statische Seiten-Updates)
|
||||||
|
- Commit & Push zu Repository
|
||||||
|
|
||||||
|
## 🚀 Deployment-Prozess
|
||||||
|
|
||||||
|
### Was passiert beim Deploy?
|
||||||
|
|
||||||
|
1. **Woodpecker CI** triggered bei Push zu `main`
|
||||||
|
2. **Frontend Deploy** (gallus-pub):
|
||||||
|
- Build Astro SSG
|
||||||
|
- Deploy zu Fly.io
|
||||||
|
|
||||||
|
3. **Backend Deploy** (gallus-cms-backend):
|
||||||
|
- Docker Build:
|
||||||
|
```dockerfile
|
||||||
|
# Backend-Code kompilieren
|
||||||
|
npm run build → dist/
|
||||||
|
|
||||||
|
# Migrierte Bilder einpacken
|
||||||
|
COPY backend/data/images → /app/migration-images
|
||||||
|
|
||||||
|
# Migration-Script kopieren
|
||||||
|
COPY migrate-production.js → /app/
|
||||||
|
```
|
||||||
|
- Deploy zu Fly.io
|
||||||
|
- **Volume bleibt erhalten** (SQLite DB + hochgeladene Bilder)
|
||||||
|
|
||||||
|
4. **Nach erstem Deploy**: Migration ausführen
|
||||||
|
```bash
|
||||||
|
fly ssh console -a gallus-cms-backend
|
||||||
|
node migrate-production.js
|
||||||
|
```
|
||||||
|
- Kopiert Bilder: `/app/migration-images/` → `/app/data/images/`
|
||||||
|
- Befüllt DB mit Event/Gallery-Einträgen
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### "Bilder werden nicht angezeigt"
|
||||||
|
|
||||||
|
**Prüfe:**
|
||||||
|
1. Backend logs: `fly logs -a gallus-cms-backend`
|
||||||
|
2. Bild existiert: `fly ssh console -a gallus-cms-backend`
|
||||||
|
```bash
|
||||||
|
ls -la /app/data/images/gallery/
|
||||||
|
```
|
||||||
|
3. DB-Eintrag korrekt:
|
||||||
|
```bash
|
||||||
|
sqlite3 /app/data/gallus_cms.db
|
||||||
|
SELECT * FROM gallery_images;
|
||||||
|
```
|
||||||
|
4. Static-Route funktioniert:
|
||||||
|
```bash
|
||||||
|
curl https://cms.gallus-pub.ch/static/images/gallery/xyz.webp
|
||||||
|
```
|
||||||
|
|
||||||
|
### "SQLite locked" Fehler
|
||||||
|
|
||||||
|
- Nur ein Writer zur Zeit erlaubt
|
||||||
|
- Bei hohem Traffic: Wechsel zu PostgreSQL empfohlen
|
||||||
|
- Für Gallus Pub: ausreichend (wenig Writes)
|
||||||
|
|
||||||
|
### "Volume voll"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fly volumes list -a gallus-cms-backend
|
||||||
|
fly volumes extend <volume-id> -s <new-size>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Wichtige Dateien
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|-------|
|
||||||
|
| `backend/src/routes/gallery.ts` | Gallery-Upload & CRUD |
|
||||||
|
| `backend/src/routes/events.ts` | Events CRUD + Public API |
|
||||||
|
| `backend/src/index.ts` | Static File Serving |
|
||||||
|
| `backend/migrate-production.js` | Initiale Daten-Migration |
|
||||||
|
| `src/pages/admin.astro` | Admin-Interface |
|
||||||
|
| `src/pages/index.astro` | Frontend (fetched von API) |
|
||||||
|
| `backend/fly.toml` | Backend Fly.io Config |
|
||||||
|
| `fly.toml` | Frontend Fly.io Config |
|
||||||
|
|
||||||
|
## 🎯 Nächste Schritte
|
||||||
|
|
||||||
|
1. ✅ Gallery-Verwaltung implementiert
|
||||||
|
2. ⏳ Migration ausführen (nach Deploy)
|
||||||
|
3. ⏳ Testen: Bild hochladen → Frontend anzeigen
|
||||||
|
4. 📋 Optional: Image-Editing Features
|
||||||
|
5. 📋 Optional: Bulk-Upload für Gallery
|
||||||
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
|
||||||
12
backend/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
/tmp
|
||||||
|
/data/*.db
|
||||||
|
/data/*.db-wal
|
||||||
|
/data/*.db-shm
|
||||||
|
/data/workspace
|
||||||
|
# Allow images to be committed
|
||||||
|
!/data/images
|
||||||
195
backend/DEPLOYMENT.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
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.
|
||||||
BIN
backend/data/images/events/event_advents-kalender.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/data/images/events/event_ferien.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
backend/data/images/events/event_karaoke.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
backend/data/images/events/event_neujahrs-apero.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
backend/data/images/events/event_pub-quiz.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
backend/data/images/events/event_santa_karaoke.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
backend/data/images/events/event_schlager-karaoke.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
backend/data/images/gallery/Gallery1.webp
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
backend/data/images/gallery/Gallery2.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
backend/data/images/gallery/Gallery3.webp
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
backend/data/images/gallery/Gallery4.webp
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
backend/data/images/gallery/Gallery5.webp
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
backend/data/images/gallery/Gallery6.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
backend/data/images/gallery/Gallery7.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
backend/data/images/gallery/Gallery8.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
backend/data/images/gallery/Gallery9.webp
Normal file
|
After Width: | Height: | Size: 162 KiB |
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;
|
||||||
41
backend/fly.toml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Fly.io configuration for Gallus CMS Backend
|
||||||
|
app = "gallus-cms-backend"
|
||||||
|
primary_region = "ams"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Ensure Fly uses the Dockerfile in this backend directory
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
|
[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"
|
||||||
|
# Cross-site frontend and OAuth
|
||||||
|
FRONTEND_URL = "https://gallus-pub.ch"
|
||||||
|
CORS_ORIGIN = "https://gallus-pub.ch"
|
||||||
|
GITEA_REDIRECT_URI = "https://cms.gallus-pub.ch/api/auth/callback"
|
||||||
|
|
||||||
|
[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"
|
||||||
37
backend/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"migrate:old-data": "tsx src/scripts/migrate-old-data.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^9.3.1",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@fastify/jwt": "^8.0.0",
|
||||||
|
"@fastify/static": "^6.12.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
104
backend/src/config/database.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
if (!env.DATABASE_PATH) {
|
||||||
|
throw new Error('DATABASE_PATH environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists BEFORE opening the database file
|
||||||
|
const dbDir = path.dirname(env.DATABASE_PATH);
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Auto-create tables if they don't exist
|
||||||
|
export function initDatabase() {
|
||||||
|
console.log('🔧 Initializing database...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if users table exists (acts as a sentinel for initial setup)
|
||||||
|
const tableCheck = sqlite
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!tableCheck) {
|
||||||
|
console.log('📝 Creating database schema...');
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
gitea_id TEXT UNIQUE NOT NULL,
|
||||||
|
gitea_username TEXT NOT NULL,
|
||||||
|
gitea_email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT DEFAULT 'admin',
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
last_login INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
is_published INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gallery_images (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
alt_text TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
is_published INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_sections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
section_name TEXT UNIQUE NOT NULL,
|
||||||
|
content_json TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS publish_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
commit_hash TEXT,
|
||||||
|
commit_message TEXT,
|
||||||
|
published_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ Database schema created successfully!');
|
||||||
|
} else {
|
||||||
|
console.log('✅ Database already initialized.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error initializing database:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
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())`),
|
||||||
|
});
|
||||||
125
backend/src/index.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
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 { db, initDatabase } from './config/database.js';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files (uploaded images, etc.) from persistent volume
|
||||||
|
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
fastify.register(fastifyStatic, {
|
||||||
|
root: dataDir,
|
||||||
|
prefix: '/static/',
|
||||||
|
decorateReply: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Initialize database before starting server
|
||||||
|
initDatabase();
|
||||||
|
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: 'none',
|
||||||
|
secure: true,
|
||||||
|
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
|
||||||
|
// Cross-site admin (gallus-pub.ch) -> backend (cms.gallus-pub.ch) requires SameSite=None & Secure in production
|
||||||
|
reply.setCookie('token', token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: (env.NODE_ENV === 'production' ? 'none' : 'lax'),
|
||||||
|
secure: (env.NODE_ENV === 'production') || (!!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;
|
||||||
97
backend/src/routes/events.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
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) => {
|
||||||
|
// PUBLIC: List published events (no auth required)
|
||||||
|
fastify.get('/events/public', async () => {
|
||||||
|
const all = await db.select().from(events)
|
||||||
|
.where(eq(events.isPublished, true))
|
||||||
|
.orderBy(events.displayOrder);
|
||||||
|
return { events: all };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all events (by displayOrder) - admin only
|
||||||
|
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;
|
||||||
221
backend/src/routes/gallery.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { galleryImages } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
|
||||||
|
// PUBLIC: List published gallery images (no auth required)
|
||||||
|
fastify.get('/gallery/public', async () => {
|
||||||
|
const images = await db.select().from(galleryImages)
|
||||||
|
.where(eq(galleryImages.isPublished, true))
|
||||||
|
.orderBy(galleryImages.displayOrder);
|
||||||
|
return { images };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all gallery images - admin only
|
||||||
|
fastify.get('/gallery', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload image file (multipart)
|
||||||
|
fastify.post('/gallery/upload', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
// Expect a single file field named "file"
|
||||||
|
const file = await (request as any).file();
|
||||||
|
if (!file) {
|
||||||
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const altText = (file.fields?.altText?.value as string | undefined) || '';
|
||||||
|
const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0';
|
||||||
|
const displayOrder = Number.parseInt(displayOrderRaw) || 0;
|
||||||
|
|
||||||
|
const mime = file.mimetype as string | undefined;
|
||||||
|
if (!mime || !mime.startsWith('image/')) {
|
||||||
|
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare directories - use persistent volume for Fly.io
|
||||||
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const uploadDir = path.join(dataDir, 'images', 'gallery');
|
||||||
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
// Read uploaded stream into buffer
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of file.file) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const inputBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const rand = Math.random().toString(36).slice(2, 8);
|
||||||
|
const baseName = `${stamp}-${rand}`;
|
||||||
|
|
||||||
|
// Try to convert to webp and limit size; fallback to original
|
||||||
|
let outBuffer: Buffer | null = null;
|
||||||
|
let outExt = '.webp';
|
||||||
|
try {
|
||||||
|
outBuffer = await sharp(inputBuffer)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch {
|
||||||
|
outBuffer = inputBuffer;
|
||||||
|
// naive extension from mimetype
|
||||||
|
const extFromMime = mime.split('/')[1] || 'bin';
|
||||||
|
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = baseName + outExt;
|
||||||
|
const destPath = path.join(uploadDir, filename);
|
||||||
|
fs.writeFileSync(destPath, outBuffer);
|
||||||
|
|
||||||
|
// Public URL (served via /static)
|
||||||
|
const publicUrl = `/static/images/gallery/${filename}`;
|
||||||
|
|
||||||
|
// Store in DB (optional but useful)
|
||||||
|
const [row] = await db.insert(galleryImages).values({
|
||||||
|
imageUrl: publicUrl,
|
||||||
|
altText: altText || filename,
|
||||||
|
displayOrder,
|
||||||
|
isPublished: true,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({ image: row });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
|
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
190
backend/src/scripts/migrate-old-data.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { events, galleryImages } from '../db/schema.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
// Old events data
|
||||||
|
const oldEvents = [
|
||||||
|
{
|
||||||
|
image: "/images/events/event_karaoke.jpg",
|
||||||
|
title: "Karaoke",
|
||||||
|
date: "2025-12-31", // Set as ongoing event
|
||||||
|
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>`,
|
||||||
|
displayOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_pub-quiz.jpg",
|
||||||
|
title: "Pub Quiz",
|
||||||
|
date: "2025-12-31", // Set as ongoing event
|
||||||
|
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`,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_schlager-karaoke.jpeg",
|
||||||
|
title: "Schlager Hüttenzauber Karaoke",
|
||||||
|
date: "2025-11-27",
|
||||||
|
description: `Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>`,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_advents-kalender.jpeg",
|
||||||
|
title: "Adventskalender",
|
||||||
|
date: "2025-12-20",
|
||||||
|
description: `Jeden Tag neue Überraschungen! Check unsere Social Media Stories!`,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_santa_karaoke.jpeg",
|
||||||
|
title: "Santa Karaoke-Party",
|
||||||
|
date: "2025-12-06",
|
||||||
|
description: `🤶🏻🎅🏻Komme als Weihnachts-Mann/-Frau und bekomme einen Shot auf's Haus!🤶🏻🎅🏻`,
|
||||||
|
displayOrder: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_ferien.jpeg",
|
||||||
|
title: "Weihnachtsferien",
|
||||||
|
date: "2025-12-21",
|
||||||
|
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`,
|
||||||
|
displayOrder: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/events/event_neujahrs-apero.jpeg",
|
||||||
|
title: "Neujahrs-Apero",
|
||||||
|
date: "2026-01-02",
|
||||||
|
description: `18:00-20:00 Uhr`,
|
||||||
|
displayOrder: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Old gallery images
|
||||||
|
const oldGalleryImages = [
|
||||||
|
{ src: "/images/gallery/Gallery7.png", alt: "Gallery 7" },
|
||||||
|
{ src: "/images/gallery/Gallery8.png", alt: "Gallery 8" },
|
||||||
|
{ src: "/images/gallery/Gallery9.png", alt: "Gallery 9" },
|
||||||
|
{ src: "/images/gallery/Gallery6.png", alt: "Gallery 6" },
|
||||||
|
{ src: "/images/gallery/Gallery1.png", alt: "Gallery 1" },
|
||||||
|
{ src: "/images/gallery/Gallery2.png", alt: "Gallery 2" },
|
||||||
|
{ src: "/images/gallery/Gallery3.png", alt: "Gallery 3" },
|
||||||
|
{ src: "/images/gallery/Gallery4.png", alt: "Gallery 4" },
|
||||||
|
{ src: "/images/gallery/Gallery5.png", alt: "Gallery 5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function copyAndConvertImage(
|
||||||
|
sourcePath: string,
|
||||||
|
destDir: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<string> {
|
||||||
|
const projectRoot = path.join(process.cwd(), '..');
|
||||||
|
const fullSourcePath = path.join(projectRoot, 'public', sourcePath);
|
||||||
|
|
||||||
|
// Ensure destination directory exists
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filename);
|
||||||
|
const baseName = path.basename(filename, ext);
|
||||||
|
const webpFilename = `${baseName}.webp`;
|
||||||
|
const destPath = path.join(destDir, webpFilename);
|
||||||
|
|
||||||
|
console.log(`Processing: ${fullSourcePath} -> ${destPath}`);
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
if (!fs.existsSync(fullSourcePath)) {
|
||||||
|
console.error(`Source file not found: ${fullSourcePath}`);
|
||||||
|
throw new Error(`Source file not found: ${fullSourcePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to webp and copy
|
||||||
|
await sharp(fullSourcePath)
|
||||||
|
.rotate() // Auto-rotate based on EXIF
|
||||||
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.toFile(destPath);
|
||||||
|
|
||||||
|
return `/images/${path.relative(destDir, destPath).replace(/\\/g, '/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateEvents() {
|
||||||
|
console.log('\n=== Migrating Events ===\n');
|
||||||
|
|
||||||
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const eventsImageDir = path.join(dataDir, 'images', 'events');
|
||||||
|
|
||||||
|
for (const event of oldEvents) {
|
||||||
|
try {
|
||||||
|
const filename = path.basename(event.image);
|
||||||
|
const newImageUrl = await copyAndConvertImage(
|
||||||
|
event.image,
|
||||||
|
eventsImageDir,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newEvent] = await db.insert(events).values({
|
||||||
|
title: event.title,
|
||||||
|
date: event.date,
|
||||||
|
description: event.description,
|
||||||
|
imageUrl: newImageUrl,
|
||||||
|
displayOrder: event.displayOrder,
|
||||||
|
isPublished: true,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
console.log(`✓ Migrated event: ${newEvent.title}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Failed to migrate event "${event.title}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateGallery() {
|
||||||
|
console.log('\n=== Migrating Gallery Images ===\n');
|
||||||
|
|
||||||
|
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
const galleryImageDir = path.join(dataDir, 'images', 'gallery');
|
||||||
|
|
||||||
|
for (let i = 0; i < oldGalleryImages.length; i++) {
|
||||||
|
const img = oldGalleryImages[i];
|
||||||
|
try {
|
||||||
|
const filename = path.basename(img.src);
|
||||||
|
const newImageUrl = await copyAndConvertImage(
|
||||||
|
img.src,
|
||||||
|
galleryImageDir,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newImage] = await db.insert(galleryImages).values({
|
||||||
|
imageUrl: newImageUrl,
|
||||||
|
altText: img.alt,
|
||||||
|
displayOrder: i,
|
||||||
|
isPublished: true,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
console.log(`✓ Migrated gallery image: ${newImage.altText}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Failed to migrate gallery image "${img.alt}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting migration of old data...\n');
|
||||||
|
console.log('Working directory:', process.cwd());
|
||||||
|
console.log('Data directory:', process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrateEvents();
|
||||||
|
await migrateGallery();
|
||||||
|
console.log('\n✓ Migration completed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n✗ Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
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:
|
||||||
@ -1,5 +1,5 @@
|
|||||||
app = "gallus-pub"
|
app = "gallus-pub"
|
||||||
primary_region = "fra" # Frankfurt region, change if needed
|
primary_region = "fra"
|
||||||
kill_signal = "SIGINT"
|
kill_signal = "SIGINT"
|
||||||
kill_timeout = 5
|
kill_timeout = 5
|
||||||
|
|
||||||
@ -9,6 +9,9 @@ kill_timeout = 5
|
|||||||
[env]
|
[env]
|
||||||
PORT = "3000"
|
PORT = "3000"
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
|
BACKEND_PORT = "8080"
|
||||||
|
DATABASE_PATH = "/app/data/db/gallus_cms.db"
|
||||||
|
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3000
|
internal_port = 3000
|
||||||
@ -26,7 +29,7 @@ kill_timeout = 5
|
|||||||
[[http_service.checks]]
|
[[http_service.checks]]
|
||||||
interval = "30s"
|
interval = "30s"
|
||||||
timeout = "5s"
|
timeout = "5s"
|
||||||
grace_period = "10s"
|
grace_period = "30s"
|
||||||
method = "GET"
|
method = "GET"
|
||||||
path = "/"
|
path = "/"
|
||||||
protocol = "http"
|
protocol = "http"
|
||||||
@ -40,3 +43,7 @@ kill_timeout = 5
|
|||||||
memory = "512MB"
|
memory = "512MB"
|
||||||
cpu_kind = "shared"
|
cpu_kind = "shared"
|
||||||
cpus = 1
|
cpus = 1
|
||||||
|
|
||||||
|
[[mounts]]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
4874
package-lock.json
generated
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "gallus-pub-v1",
|
"name": "Gallus Pub Site",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"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: 749 B After Width: | Height: | Size: 749 B |
BIN
public/images/Background.png
Normal file
|
After Width: | Height: | Size: 706 KiB |