Compare commits
95 Commits
0f13e8eb66
...
feat/cms
| Author | SHA1 | Date | |
|---|---|---|---|
| fb7eaa6bb2 | |||
| daccc43677 | |||
| 3b6cb0a3fb | |||
| 6a3c77d7c5 | |||
| a28d43db45 | |||
| af930f345c | |||
| 22494084ce | |||
| bc6c1e95d3 | |||
| f2a0422f3b | |||
| 2cae2e86ed | |||
| 636c7fc03a | |||
| 5fdea37a90 | |||
| 11932d51ec | |||
| 803c7907f1 | |||
| 3d4bbf77bc | |||
| 71a586280e | |||
| 1f4cea0c35 | |||
| 9adec32839 | |||
| 688b4de945 | |||
| 193f3ff0bb | |||
| 292747d197 | |||
| 18f7ea5da5 | |||
| 1f94bbca15 | |||
| 5ef15f0b5c | |||
| 020bfca731 | |||
| ac864ba054 | |||
| e93ba5d29b | |||
| feb137471d | |||
| 0622d190d1 | |||
| 2867678223 | |||
| 096ac9f789 | |||
| 3006ccd5a0 | |||
| 8a8bcc304a | |||
| 54c6f205e0 | |||
| 48fddf7b15 | |||
| 2733c2e7f4 | |||
| 9502123b89 | |||
| ca2d724bd8 | |||
| 38229ac5e9 | |||
| a11c838d2a | |||
| f9fe914c32 | |||
| 21e09f7155 | |||
| 0b37f73634 | |||
| c764f892a1 | |||
| 78f367530a | |||
| b539329420 | |||
| 3e93e8ce3b | |||
| 2fab4bf70b | |||
| 1a6be67af1 | |||
| fea45fc4f8 | |||
| 761bd6be80 | |||
| 8e6bd12da5 | |||
| 548a2d6f53 | |||
| 01edb8d575 | |||
| c498b19afb | |||
| 74a8e7b393 | |||
| 9c4b6ec425 | |||
| dc3f0b53d7 | |||
| b215592292 | |||
| 9c7ecc97df | |||
| 0fd4fbe61f | |||
| 6e489ceac3 | |||
| 21d51732e5 | |||
| f1c94ed438 | |||
| 493c2a94f0 | |||
| 3a3a36e2ea | |||
| 535c82bd81 | |||
| 64aa08c699 | |||
| 6f3edc8977 | |||
| 9ac87b82e9 | |||
| 74e4799ea9 | |||
| 0a939975c3 | |||
| 7e0f052ce7 | |||
| 77c5d5df82 | |||
| f0afa677a0 | |||
| f356b37c9e | |||
| 096883b0ee | |||
| 749b3e5079 | |||
| 3c1a6fae2c | |||
| f3952e7e81 | |||
| 00213204c4 | |||
| 15dedfabcf | |||
| 5247bd9816 | |||
| 50c06b3a8a | |||
| 5ab62f2b3b | |||
| 6120f04c95 | |||
| 179de67386 | |||
| 3da1b63a50 | |||
| 6b79e08684 | |||
| 7d5e77df76 | |||
| 23b47a7e85 | |||
| f4c75ea941 | |||
| 58522f2ae0 | |||
| 2a0aa7a6c8 | |||
| bcd86c9c68 |
2
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
|
||||||
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
|
|
||||||
}
|
|
||||||
16
.woodpecker.yml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
steps:
|
||||||
|
deploy:
|
||||||
|
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
|
||||||
|
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
51
README.md
@ -1,6 +1,47 @@
|
|||||||
# Gallus_Pub
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
Reposetory für die Website vom Gallus Pub
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
Auftragsgeber: Sabrina Signer
|
```
|
||||||
Geschäfft: Gallus Pub
|
|
||||||
|
[](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).
|
||||||
|
|||||||
20
backend/.dockerignore
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
tmp
|
||||||
|
/tmp
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
29
backend/.env.example
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Database (SQLite)
|
||||||
|
DATABASE_PATH=./data/gallus_cms.db
|
||||||
|
|
||||||
|
# Gitea OAuth
|
||||||
|
GITEA_URL=https://git.bookageek.ch
|
||||||
|
GITEA_CLIENT_ID=your-oauth-client-id-here
|
||||||
|
GITEA_CLIENT_SECRET=your-oauth-client-secret-here
|
||||||
|
GITEA_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
||||||
|
GITEA_ALLOWED_USERS=sabrina,raphael,admin
|
||||||
|
|
||||||
|
# Git Configuration (use Gitea repository)
|
||||||
|
GIT_REPO_URL=https://git.bookageek.ch/yourusername/Gallus_Pub.git
|
||||||
|
GIT_TOKEN=your-gitea-personal-access-token-here
|
||||||
|
GIT_USER_NAME=Gallus CMS
|
||||||
|
GIT_USER_EMAIL=cms@galluspub.ch
|
||||||
|
GIT_WORKSPACE_DIR=./data/workspace
|
||||||
|
|
||||||
|
# JWT & Session
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||||
|
SESSION_SECRET=your-session-secret-change-this
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
34
backend/.env.local
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Local development environment for Gallus CMS Backend
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_CLIENT=sqlite
|
||||||
|
DATABASE_URL=
|
||||||
|
DATABASE_PATH=./data/gallus_cms.db
|
||||||
|
|
||||||
|
# Gitea OAuth
|
||||||
|
GITEA_URL=https://git.bookageek.ch
|
||||||
|
GITEA_CLIENT_ID=bcddfe24-3099-41bc-bb7a-6c6d80bd9048
|
||||||
|
GITEA_CLIENT_SECRET=gto_me7hsswrsdq2ygey65edlc6qa2xxr3i5nrq7q4jvjwx654ytrh7q
|
||||||
|
# Frontend proxy callback in local dev
|
||||||
|
GITEA_REDIRECT_URI=http://localhost:4321/api/auth/callback
|
||||||
|
GITEA_ALLOWED_USERS=Gallus-maintanance
|
||||||
|
|
||||||
|
# Git repository for content versioning
|
||||||
|
GIT_REPO_URL=https://git.bookageek.ch/Kenzo/Gallus_Pub
|
||||||
|
GIT_TOKEN=1482ae7bcdbd7610bf0cfd468b6757722d16a2a2
|
||||||
|
GIT_USER_NAME=Gallus-maintanance
|
||||||
|
GIT_USER_EMAIL=Admin@gallus-pub.ch
|
||||||
|
GIT_WORKSPACE_DIR=./data/workspace
|
||||||
|
|
||||||
|
# JWT & Session secrets (use strong random strings in real deployments)
|
||||||
|
JWT_SECRET=local-dev-jwt-secret-please-change-1234567890abcdef
|
||||||
|
SESSION_SECRET=local-dev-session-secret-please-change-abcdef1234567890
|
||||||
|
|
||||||
|
# Server & CORS
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
FRONTEND_URL=http://localhost:4321
|
||||||
|
CORS_ORIGIN=http://localhost:4321
|
||||||
|
|
||||||
|
# Upload limits
|
||||||
|
MAX_FILE_SIZE=5242880
|
||||||
10
backend/.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
/tmp
|
||||||
|
/data
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
195
backend/DEPLOYMENT.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Fly.io CLI installed: `curl -L https://fly.io/install.sh | sh`
|
||||||
|
2. Fly.io account: `flyctl auth login`
|
||||||
|
3. Gitea OAuth app configured at git.bookageek.ch
|
||||||
|
4. Gitea Personal Access Token for git operations
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
### 1. Create Fly.io App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
flyctl apps create gallus-cms-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Volume for Data (SQLite DB + Git Workspace)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flyctl volumes create gallus_data --size 2 --region ams
|
||||||
|
```
|
||||||
|
|
||||||
|
This volume will store:
|
||||||
|
- SQLite database at `/app/data/gallus_cms.db`
|
||||||
|
- Git workspace at `/app/data/workspace`
|
||||||
|
|
||||||
|
### 3. Set Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flyctl secrets set \
|
||||||
|
GITEA_CLIENT_ID="<your-gitea-oauth-client-id>" \
|
||||||
|
GITEA_CLIENT_SECRET="<your-gitea-oauth-client-secret>" \
|
||||||
|
GIT_TOKEN="<your-gitea-personal-access-token>" \
|
||||||
|
JWT_SECRET="$(openssl rand -base64 32)" \
|
||||||
|
SESSION_SECRET="$(openssl rand -base64 32)" \
|
||||||
|
GIT_REPO_URL="https://git.bookageek.ch/yourusername/Gallus_Pub.git" \
|
||||||
|
GIT_USER_NAME="Gallus CMS" \
|
||||||
|
GIT_USER_EMAIL="cms@galluspub.ch" \
|
||||||
|
GITEA_REDIRECT_URI="https://gallus-cms-backend.fly.dev/api/auth/callback" \
|
||||||
|
FRONTEND_URL="https://cms.galluspub.ch" \
|
||||||
|
CORS_ORIGIN="https://cms.galluspub.ch" \
|
||||||
|
GITEA_ALLOWED_USERS="sabrina,raphael"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Initialize Database
|
||||||
|
|
||||||
|
After first deployment, SSH into the container and run migrations:
|
||||||
|
```bash
|
||||||
|
flyctl ssh console
|
||||||
|
cd /app
|
||||||
|
node dist/index.js # Start once to create the database file
|
||||||
|
# Then exit (Ctrl+C) and run migrations
|
||||||
|
npm run db:migrate
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simply let the app run - the database will be created automatically on first start.
|
||||||
|
|
||||||
|
## Gitea OAuth Configuration
|
||||||
|
|
||||||
|
Update your Gitea OAuth application redirect URI to include:
|
||||||
|
```
|
||||||
|
https://gallus-cms-backend.fly.dev/api/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
```bash
|
||||||
|
flyctl logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Status
|
||||||
|
```bash
|
||||||
|
flyctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSH into Container
|
||||||
|
```bash
|
||||||
|
flyctl ssh console
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scale App
|
||||||
|
```bash
|
||||||
|
flyctl scale count 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Secrets
|
||||||
|
```bash
|
||||||
|
flyctl secrets list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a Secret
|
||||||
|
```bash
|
||||||
|
flyctl secrets set KEY=VALUE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart App
|
||||||
|
```bash
|
||||||
|
flyctl apps restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
curl https://gallus-cms-backend.fly.dev/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Metrics
|
||||||
|
```bash
|
||||||
|
flyctl dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Deployment Fails
|
||||||
|
- Check logs: `flyctl logs`
|
||||||
|
- Verify all secrets are set: `flyctl secrets list`
|
||||||
|
- Ensure Docker builds locally: `docker build -t test .`
|
||||||
|
|
||||||
|
### OAuth Not Working
|
||||||
|
- Verify GITEA_REDIRECT_URI matches Gitea settings exactly
|
||||||
|
- Check CORS_ORIGIN includes frontend domain
|
||||||
|
- Review logs for authentication errors
|
||||||
|
|
||||||
|
### Git Push Fails
|
||||||
|
- Verify GIT_TOKEN has correct permissions
|
||||||
|
- Check GIT_REPO_URL is accessible
|
||||||
|
- Ensure workspace volume is mounted
|
||||||
|
|
||||||
|
### Database Issues
|
||||||
|
- Verify DATABASE_PATH is set correctly
|
||||||
|
- Check volume is mounted: `flyctl ssh console` then `ls -la /app/data`
|
||||||
|
- Verify database file permissions
|
||||||
|
- Run migrations if needed: `flyctl ssh console` then `npm run db:migrate`
|
||||||
|
|
||||||
|
## Cost Optimization
|
||||||
|
|
||||||
|
Current configuration uses:
|
||||||
|
- `shared-cpu-1x` with 512MB RAM
|
||||||
|
- Auto-suspend when idle
|
||||||
|
- 2GB volume for SQLite database + git workspace
|
||||||
|
|
||||||
|
Estimated cost: ~$5-10/month (no separate database cost with SQLite!)
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
To deploy updates:
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
To rollback to previous version:
|
||||||
|
```bash
|
||||||
|
flyctl releases list
|
||||||
|
flyctl releases rollback <version-number>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
All sensitive environment variables should be set as Fly.io secrets (not in fly.toml):
|
||||||
|
|
||||||
|
Note: DATABASE_PATH and GIT_WORKSPACE_DIR are set in fly.toml as they're not sensitive.
|
||||||
|
- `GITEA_CLIENT_ID` - OAuth client ID
|
||||||
|
- `GITEA_CLIENT_SECRET` - OAuth client secret
|
||||||
|
- `GIT_TOKEN` - Gitea personal access token
|
||||||
|
- `JWT_SECRET` - JWT signing secret
|
||||||
|
- `SESSION_SECRET` - Session cookie secret
|
||||||
|
- `GIT_REPO_URL` - Full git repository URL
|
||||||
|
- `GITEA_REDIRECT_URI` - OAuth callback URL
|
||||||
|
- `FRONTEND_URL` - Frontend application URL
|
||||||
|
- `CORS_ORIGIN` - Allowed CORS origin
|
||||||
|
- `GITEA_ALLOWED_USERS` - Comma-separated list of allowed usernames
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] All secrets set and not exposed in logs
|
||||||
|
- [ ] HTTPS enforced (fly.toml: force_https = true)
|
||||||
|
- [ ] CORS configured correctly
|
||||||
|
- [ ] GITEA_ALLOWED_USERS whitelist configured
|
||||||
|
- [ ] Database backups enabled
|
||||||
|
- [ ] Health checks configured
|
||||||
|
- [ ] Monitoring and alerts set up
|
||||||
59
backend/Dockerfile
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Multi-stage build for Gallus CMS Backend
|
||||||
|
|
||||||
|
# Stage 1: Builder
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for native modules (better-sqlite3)
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
||||||
|
RUN npm ci || npm install
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool)
|
||||||
|
RUN apk add --no-cache git sqlite
|
||||||
|
|
||||||
|
# Copy production dependencies from builder (already compiled native modules)
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy built files from builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
RUN mkdir -p /app/workspace /app/data
|
||||||
|
|
||||||
|
# Ensure proper permissions
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV DATABASE_PATH=/app/data/gallus_cms.db
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
|
# Run DB migrations if present, then start application
|
||||||
|
CMD ["/bin/sh", "-lc", "[ -f dist/migrate.js ] && node dist/migrate.js || true; node dist/index.js"]
|
||||||
55
backend/README.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# Gallus Pub CMS Backend
|
||||||
|
|
||||||
|
Headless CMS backend for managing Gallus Pub website content with Gitea OAuth authentication.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `.env` file from `.env.example`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update environment variables in `.env`:
|
||||||
|
- Set Gitea OAuth credentials
|
||||||
|
- Set Git repository URL and token
|
||||||
|
- JWT secrets are already generated
|
||||||
|
|
||||||
|
4. Create data directory and run migrations:
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Generate and run migrations:
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Server will run at http://localhost:3000
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
- `npm run dev` - Start development server with watch mode
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm run start` - Start production server
|
||||||
|
- `npm run db:generate` - Generate database migrations
|
||||||
|
- `npm run db:migrate` - Run database migrations
|
||||||
|
- `npm run db:studio` - Open Drizzle Studio
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
See parent directory for complete documentation:
|
||||||
|
- `CMS_CONCEPT.md` - System architecture
|
||||||
|
- `CMS_GITEA_AUTH.md` - Authentication details
|
||||||
|
- `CMS_IMPLEMENTATION_EXAMPLE.md` - Code examples
|
||||||
|
- `CMS_SETUP_GUIDE.md` - Deployment guide
|
||||||
216
backend/SETUP_QUICK_START.md
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# Quick Start Guide - SQLite Version
|
||||||
|
|
||||||
|
## ✅ Migration Complete: PostgreSQL → SQLite
|
||||||
|
|
||||||
|
The backend now uses **SQLite** instead of PostgreSQL for simplified deployment and lower costs.
|
||||||
|
|
||||||
|
## 🚀 Quick Start (3 Steps)
|
||||||
|
|
||||||
|
### 1. Configure Environment
|
||||||
|
|
||||||
|
Edit `.env` file (already created):
|
||||||
|
```bash
|
||||||
|
# Required: Update these values
|
||||||
|
GITEA_CLIENT_ID=<your-gitea-oauth-client-id>
|
||||||
|
GITEA_CLIENT_SECRET=<your-gitea-oauth-client-secret>
|
||||||
|
GIT_REPO_URL=https://git.bookageek.ch/<yourusername>/Gallus_Pub.git
|
||||||
|
GIT_TOKEN=<your-gitea-personal-access-token>
|
||||||
|
GITEA_ALLOWED_USERS=sabrina,raphael
|
||||||
|
|
||||||
|
# Already set (JWT secrets generated)
|
||||||
|
JWT_SECRET=dOrvUqifjBLvk68kkDOvWPQper/gjsNMlAbWlVBQIrc=
|
||||||
|
SESSION_SECRET=SD0ZrvLkv9GrtI8+3GDkxZXA1UnCN4CE3c4+2vA/fIM=
|
||||||
|
|
||||||
|
# Database (SQLite - no changes needed)
|
||||||
|
DATABASE_PATH=./data/gallus_cms.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Initialize Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration files from schema
|
||||||
|
pnpm run db:generate
|
||||||
|
|
||||||
|
# Run migrations to create tables
|
||||||
|
pnpm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Server will start at **http://localhost:3000**
|
||||||
|
|
||||||
|
## 📝 What Changed?
|
||||||
|
|
||||||
|
### Before (PostgreSQL)
|
||||||
|
- Required PostgreSQL installation
|
||||||
|
- Separate database service
|
||||||
|
- Connection string configuration
|
||||||
|
- ~$15/month hosting cost on Fly.io
|
||||||
|
|
||||||
|
### After (SQLite)
|
||||||
|
- Single file database (`./data/gallus_cms.db`)
|
||||||
|
- No separate database service needed
|
||||||
|
- Works out of the box
|
||||||
|
- **$0 database cost** (included in app volume)
|
||||||
|
|
||||||
|
## 🗂️ Database Location
|
||||||
|
|
||||||
|
- **Local:** `./data/gallus_cms.db`
|
||||||
|
- **Production (Fly.io):** `/app/data/gallus_cms.db` (on persistent volume)
|
||||||
|
- **Git Workspace:** Same `data/` directory
|
||||||
|
|
||||||
|
## 🧪 Test Authentication Flow
|
||||||
|
|
||||||
|
1. Make sure you have Gitea OAuth credentials configured
|
||||||
|
2. Start dev server: `pnpm run dev`
|
||||||
|
3. Visit: http://localhost:3000/api/auth/gitea
|
||||||
|
4. Login with your Gitea credentials
|
||||||
|
5. Should redirect back with JWT token
|
||||||
|
|
||||||
|
## 📚 Available Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Flow
|
||||||
|
```
|
||||||
|
GET /api/auth/gitea - Initiate OAuth
|
||||||
|
GET /api/auth/callback - OAuth callback
|
||||||
|
GET /api/auth/me - Get current user (requires JWT)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Management (all require JWT)
|
||||||
|
```
|
||||||
|
GET/POST/PUT/DELETE /api/events
|
||||||
|
GET/POST/PUT/DELETE /api/gallery
|
||||||
|
GET/PUT /api/content/:section
|
||||||
|
GET/PUT /api/settings/:key
|
||||||
|
POST /api/publish
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Getting Gitea OAuth Credentials
|
||||||
|
|
||||||
|
1. Go to https://git.bookageek.ch/user/settings/applications
|
||||||
|
2. Click "Manage OAuth2 Applications"
|
||||||
|
3. Create new OAuth2 application:
|
||||||
|
- **Name:** Gallus Pub CMS
|
||||||
|
- **Redirect URI:** `http://localhost:3000/api/auth/callback`
|
||||||
|
- **Confidential:** Yes
|
||||||
|
4. Copy Client ID and Client Secret to `.env`
|
||||||
|
|
||||||
|
## 🎫 Getting Gitea Personal Access Token
|
||||||
|
|
||||||
|
1. Go to https://git.bookageek.ch/user/settings/applications
|
||||||
|
2. Generate New Token
|
||||||
|
3. **Name:** Gallus CMS Backend
|
||||||
|
4. **Scopes:** Select `repo` (full repository access)
|
||||||
|
5. Copy token to `.env` as `GIT_TOKEN`
|
||||||
|
|
||||||
|
## 📦 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── data/ # SQLite database & git workspace (gitignored)
|
||||||
|
│ ├── gallus_cms.db # Database file
|
||||||
|
│ └── workspace/ # Git repository clone
|
||||||
|
├── src/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── database.ts # SQLite connection (updated)
|
||||||
|
│ │ └── env.ts # DATABASE_PATH instead of URL
|
||||||
|
│ ├── db/
|
||||||
|
│ │ └── schema.ts # SQLite schema (updated)
|
||||||
|
│ ├── routes/ # API routes
|
||||||
|
│ ├── services/ # Core services
|
||||||
|
│ └── index.ts # Main server
|
||||||
|
├── .env # Your configuration
|
||||||
|
├── package.json # Updated with better-sqlite3
|
||||||
|
└── drizzle.config.ts # SQLite dialect
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # Install dependencies (done)
|
||||||
|
pnpm run dev # Start dev server with watch
|
||||||
|
pnpm run build # Build TypeScript
|
||||||
|
pnpm run start # Start production server
|
||||||
|
pnpm run db:generate # Generate migrations
|
||||||
|
pnpm run db:migrate # Run migrations
|
||||||
|
pnpm run db:studio # Open Drizzle Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deploy to Fly.io
|
||||||
|
|
||||||
|
See `DEPLOYMENT.md` for full deployment guide.
|
||||||
|
|
||||||
|
**Quick version:**
|
||||||
|
```bash
|
||||||
|
# Create volume for database & git workspace
|
||||||
|
flyctl volumes create gallus_data --size 2 --region ams
|
||||||
|
|
||||||
|
# Set secrets
|
||||||
|
flyctl secrets set GITEA_CLIENT_ID=... GITEA_CLIENT_SECRET=... # etc
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cost:** ~$5-10/month (no separate database!)
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "tsx: command not found"
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### "DATABASE_PATH not set"
|
||||||
|
Check `.env` file exists and has `DATABASE_PATH=./data/gallus_cms.db`
|
||||||
|
|
||||||
|
### "Database file not found"
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
pnpm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### "better-sqlite3" build errors
|
||||||
|
Make sure you have build tools:
|
||||||
|
- **Linux:** `apt-get install python3 make g++`
|
||||||
|
- **macOS:** Install Xcode Command Line Tools
|
||||||
|
- **Windows:** Install windows-build-tools
|
||||||
|
|
||||||
|
Then rebuild:
|
||||||
|
```bash
|
||||||
|
pnpm rebuild better-sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✨ Benefits of SQLite
|
||||||
|
|
||||||
|
1. **Simpler** - No database server to manage
|
||||||
|
2. **Faster** - No network overhead
|
||||||
|
3. **Portable** - Single file, easy backups
|
||||||
|
4. **Cost-effective** - No hosting fees
|
||||||
|
5. **Perfect fit** - Low concurrency, simple queries
|
||||||
|
|
||||||
|
## 📖 Documentation
|
||||||
|
|
||||||
|
- `SQLITE_MIGRATION.md` - Detailed migration notes
|
||||||
|
- `DEPLOYMENT.md` - Fly.io deployment guide
|
||||||
|
- `README.md` - General setup instructions
|
||||||
|
- `CMS_GITEA_AUTH.md` - OAuth authentication details (parent dir)
|
||||||
|
- `CMS_CONCEPT.md` - Full system architecture (parent dir)
|
||||||
|
|
||||||
|
## ✅ Ready to Go!
|
||||||
|
|
||||||
|
Your backend is now configured for SQLite. Just:
|
||||||
|
1. Add your Gitea credentials to `.env`
|
||||||
|
2. Run `pnpm run db:generate && pnpm run db:migrate`
|
||||||
|
3. Start with `pnpm run dev`
|
||||||
|
|
||||||
|
Happy coding! 🎉
|
||||||
217
backend/SQLITE_MIGRATION.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# SQLite Migration Summary
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
The backend has been migrated from PostgreSQL to SQLite for both local development and production (Fly.io).
|
||||||
|
|
||||||
|
### Benefits of SQLite
|
||||||
|
|
||||||
|
1. **Simplified Deployment** - No separate database service needed
|
||||||
|
2. **Lower Cost** - Save ~$15/month (no Postgres hosting)
|
||||||
|
3. **Easier Development** - No need to install/run PostgreSQL locally
|
||||||
|
4. **Single File Database** - Easy backups and migrations
|
||||||
|
5. **Perfect for this use case** - Low concurrent writes, simple queries
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- **package.json**
|
||||||
|
- Removed: `pg`, `@types/pg`
|
||||||
|
- Added: `better-sqlite3`, `@types/better-sqlite3`
|
||||||
|
|
||||||
|
### Database Configuration
|
||||||
|
- **src/config/database.ts**
|
||||||
|
- Changed from `drizzle-orm/node-postgres` to `drizzle-orm/better-sqlite3`
|
||||||
|
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
|
||||||
|
- Enabled WAL mode for better concurrent access
|
||||||
|
|
||||||
|
- **src/config/env.ts**
|
||||||
|
- Changed `DATABASE_URL` to `DATABASE_PATH`
|
||||||
|
- Default: `./data/gallus_cms.db`
|
||||||
|
|
||||||
|
- **src/db/schema.ts**
|
||||||
|
- Changed from `pgTable` to `sqliteTable`
|
||||||
|
- Changed `uuid()` to `text()` with `crypto.randomUUID()`
|
||||||
|
- Changed `jsonb()` to `text(..., { mode: 'json' })`
|
||||||
|
- Changed `timestamp()` to `integer(..., { mode: 'timestamp' })`
|
||||||
|
- Changed `boolean()` to `integer(..., { mode: 'boolean' })`
|
||||||
|
- Uses `sql\`(unixepoch())\`` for default timestamps
|
||||||
|
|
||||||
|
- **drizzle.config.ts**
|
||||||
|
- Changed dialect from `postgresql` to `sqlite`
|
||||||
|
- Uses `DATABASE_PATH` instead of `DATABASE_URL`
|
||||||
|
|
||||||
|
### Environment Files
|
||||||
|
- **.env** and **.env.example**
|
||||||
|
- Changed `DATABASE_URL=postgresql://...` to `DATABASE_PATH=./data/gallus_cms.db`
|
||||||
|
- Changed `GIT_WORKSPACE_DIR=/tmp/gallus-repo` to `./data/workspace`
|
||||||
|
|
||||||
|
### Docker Configuration
|
||||||
|
- **Dockerfile**
|
||||||
|
- Added build tools for `better-sqlite3` native module (python3, make, g++)
|
||||||
|
- Added `sqlite` CLI tool
|
||||||
|
- Creates `/app/data` directory for database
|
||||||
|
- Sets `DATABASE_PATH=/app/data/gallus_cms.db`
|
||||||
|
- Proper permissions for non-root user
|
||||||
|
|
||||||
|
- **fly.toml**
|
||||||
|
- Added `DATABASE_PATH` and `GIT_WORKSPACE_DIR` to [env]
|
||||||
|
- Changed volume mount from `gallus_repo_workspace` to `gallus_data`
|
||||||
|
- Mount destination: `/app/data` (contains both DB and git workspace)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **README.md** - Updated setup instructions
|
||||||
|
- **DEPLOYMENT.md** - Removed Postgres setup, updated volume creation
|
||||||
|
- **SQLITE_MIGRATION.md** - This file!
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
```bash
|
||||||
|
# Dependencies already installed
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Create data directory (done)
|
||||||
|
mkdir -p data
|
||||||
|
|
||||||
|
# Database will be created automatically at ./data/gallus_cms.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate and Run Migrations
|
||||||
|
```bash
|
||||||
|
# Generate migration files from schema
|
||||||
|
pnpm run db:generate
|
||||||
|
|
||||||
|
# Run migrations to create tables
|
||||||
|
pnpm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start Development Server
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The database file will be created at `./data/gallus_cms.db` on first run.
|
||||||
|
|
||||||
|
## Production (Fly.io)
|
||||||
|
|
||||||
|
### Volume Setup
|
||||||
|
```bash
|
||||||
|
# Create single volume for both database and git workspace
|
||||||
|
flyctl volumes create gallus_data --size 2 --region ams
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Set in fly.toml (non-sensitive):
|
||||||
|
- `DATABASE_PATH=/app/data/gallus_cms.db`
|
||||||
|
- `GIT_WORKSPACE_DIR=/app/data/workspace`
|
||||||
|
|
||||||
|
Set as secrets (sensitive):
|
||||||
|
- All other env vars (OAuth credentials, tokens, etc.)
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
```bash
|
||||||
|
flyctl deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Database will be created automatically on first start. No need for separate database service!
|
||||||
|
|
||||||
|
## Database Location
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
- **Database:** `./data/gallus_cms.db`
|
||||||
|
- **WAL files:** `./data/gallus_cms.db-wal`, `./data/gallus_cms.db-shm`
|
||||||
|
- **Git workspace:** `./data/workspace/`
|
||||||
|
|
||||||
|
### Production (Fly.io)
|
||||||
|
- **Database:** `/app/data/gallus_cms.db` (on volume)
|
||||||
|
- **Git workspace:** `/app/data/workspace/` (on volume)
|
||||||
|
- **Volume name:** `gallus_data` (2GB)
|
||||||
|
|
||||||
|
## Backup Strategy
|
||||||
|
|
||||||
|
### Manual Backup
|
||||||
|
```bash
|
||||||
|
# Local
|
||||||
|
cp data/gallus_cms.db data/gallus_cms.backup.db
|
||||||
|
|
||||||
|
# Production (Fly.io)
|
||||||
|
flyctl ssh console
|
||||||
|
sqlite3 /app/data/gallus_cms.db ".backup /app/data/backup.db"
|
||||||
|
# Then copy back: flyctl ssh sftp get /app/data/backup.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated Backup (Optional)
|
||||||
|
Consider setting up a cron job or Fly.io machine to periodically:
|
||||||
|
1. Create SQLite backup
|
||||||
|
2. Upload to S3/Backblaze/etc.
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
SQLite is perfect for this use case because:
|
||||||
|
- **Low write concurrency** - Single admin user making changes
|
||||||
|
- **Read-heavy** - Mostly reading content for publish operations
|
||||||
|
- **Small dataset** - Events, gallery images, content sections
|
||||||
|
- **Simple queries** - No complex joins or aggregations
|
||||||
|
|
||||||
|
WAL mode is enabled for:
|
||||||
|
- Better concurrent read access
|
||||||
|
- Safer writes (crash recovery)
|
||||||
|
- Improved performance
|
||||||
|
|
||||||
|
## Migration from Existing Data
|
||||||
|
|
||||||
|
If you had PostgreSQL data to migrate:
|
||||||
|
|
||||||
|
1. Export from Postgres:
|
||||||
|
```sql
|
||||||
|
\copy events TO 'events.csv' CSV HEADER;
|
||||||
|
\copy gallery_images TO 'gallery.csv' CSV HEADER;
|
||||||
|
-- etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Import to SQLite:
|
||||||
|
```sql
|
||||||
|
.mode csv
|
||||||
|
.import events.csv events
|
||||||
|
.import gallery.csv gallery_images
|
||||||
|
-- etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **No native UUID type** - Using TEXT with UUID format
|
||||||
|
2. **No native JSON type** - Using TEXT with JSON serialization (Drizzle handles this)
|
||||||
|
3. **No native TIMESTAMP** - Using INTEGER with Unix epoch (Drizzle handles this)
|
||||||
|
4. **Single writer** - Only one write transaction at a time (not an issue for this use case)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Database is locked" error
|
||||||
|
- WAL mode should prevent this
|
||||||
|
- Check if multiple processes are accessing the database
|
||||||
|
- Ensure proper file permissions
|
||||||
|
|
||||||
|
### Native module build errors
|
||||||
|
- Make sure build tools are installed: `apt-get install python3 make g++` (Linux)
|
||||||
|
- On Alpine: `apk add python3 make g++`
|
||||||
|
- Try rebuilding: `pnpm rebuild better-sqlite3`
|
||||||
|
|
||||||
|
### Database file not found
|
||||||
|
- Check `DATABASE_PATH` is set correctly
|
||||||
|
- Ensure `data/` directory exists
|
||||||
|
- Check file permissions
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Update dependencies
|
||||||
|
2. ✅ Update database configuration
|
||||||
|
3. ✅ Update schema
|
||||||
|
4. ✅ Update Docker configuration
|
||||||
|
5. ⏳ Generate migrations: `pnpm run db:generate`
|
||||||
|
6. ⏳ Run migrations: `pnpm run db:migrate`
|
||||||
|
7. ⏳ Test development server: `pnpm run dev`
|
||||||
|
8. ⏳ Test publish flow
|
||||||
|
9. ⏳ Deploy to Fly.io
|
||||||
|
|
||||||
|
The migration is complete! Just need to generate/run migrations and test.
|
||||||
10
backend/drizzle.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './src/db/migrations',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_PATH || './data/gallus_cms.db',
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
35
backend/fly.toml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Fly.io configuration for Gallus CMS Backend
|
||||||
|
app = "gallus-cms-backend"
|
||||||
|
primary_region = "ams"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
|
||||||
|
[env]
|
||||||
|
PORT = "8080"
|
||||||
|
NODE_ENV = "production"
|
||||||
|
GITEA_URL = "https://git.bookageek.ch"
|
||||||
|
DATABASE_PATH = "/app/data/gallus_cms.db"
|
||||||
|
GIT_WORKSPACE_DIR = "/app/data/workspace"
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 8080
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = "suspend"
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
processes = ["app"]
|
||||||
|
|
||||||
|
[[http_service.checks]]
|
||||||
|
grace_period = "10s"
|
||||||
|
interval = "30s"
|
||||||
|
method = "GET"
|
||||||
|
timeout = "5s"
|
||||||
|
path = "/health"
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
size = "shared-cpu-1x"
|
||||||
|
memory = "512mb"
|
||||||
|
|
||||||
|
[mounts]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
35
backend/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "gallus-cms-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Headless CMS backend for Gallus Pub website",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^9.3.1",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@fastify/jwt": "^8.0.0",
|
||||||
|
"@fastify/multipart": "^8.1.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"better-sqlite3": "^11.10.0",
|
||||||
|
"drizzle-orm": "^0.33.0",
|
||||||
|
"fastify": "^4.26.0",
|
||||||
|
"sharp": "^0.33.2",
|
||||||
|
"simple-git": "^3.22.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/better-sqlite3": "^7.6.9",
|
||||||
|
"@types/node": "^20.11.16",
|
||||||
|
"drizzle-kit": "^0.24.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/config/database.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
if (!env.DATABASE_PATH) {
|
||||||
|
throw new Error('DATABASE_PATH environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(env.DATABASE_PATH);
|
||||||
|
|
||||||
|
// Enable WAL mode for better concurrent access
|
||||||
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
51
backend/src/config/env.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// Environment configuration with validation
|
||||||
|
export const env = {
|
||||||
|
// Database
|
||||||
|
DATABASE_PATH: process.env.DATABASE_PATH || './data/gallus_cms.db',
|
||||||
|
|
||||||
|
// Gitea OAuth
|
||||||
|
GITEA_URL: process.env.GITEA_URL || 'https://git.bookageek.ch',
|
||||||
|
GITEA_CLIENT_ID: process.env.GITEA_CLIENT_ID || '',
|
||||||
|
GITEA_CLIENT_SECRET: process.env.GITEA_CLIENT_SECRET || '',
|
||||||
|
GITEA_REDIRECT_URI: process.env.GITEA_REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
|
||||||
|
GITEA_ALLOWED_USERS: process.env.GITEA_ALLOWED_USERS || '',
|
||||||
|
|
||||||
|
// Git Configuration
|
||||||
|
GIT_REPO_URL: process.env.GIT_REPO_URL || '',
|
||||||
|
GIT_TOKEN: process.env.GIT_TOKEN || '',
|
||||||
|
GIT_USER_NAME: process.env.GIT_USER_NAME || 'Gallus CMS',
|
||||||
|
GIT_USER_EMAIL: process.env.GIT_USER_EMAIL || 'cms@galluspub.ch',
|
||||||
|
GIT_WORKSPACE_DIR: process.env.GIT_WORKSPACE_DIR || '/tmp/gallus-repo',
|
||||||
|
|
||||||
|
// JWT & Session
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET || '',
|
||||||
|
SESSION_SECRET: process.env.SESSION_SECRET || '',
|
||||||
|
|
||||||
|
// Server
|
||||||
|
PORT: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
|
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate required environment variables
|
||||||
|
export function validateEnv() {
|
||||||
|
const required = [
|
||||||
|
'DATABASE_PATH',
|
||||||
|
'GITEA_CLIENT_ID',
|
||||||
|
'GITEA_CLIENT_SECRET',
|
||||||
|
'GIT_REPO_URL',
|
||||||
|
'GIT_TOKEN',
|
||||||
|
'JWT_SECRET',
|
||||||
|
'SESSION_SECRET',
|
||||||
|
];
|
||||||
|
|
||||||
|
const missing = required.filter(key => !env[key as keyof typeof env]);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/db/schema.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Users table - stores Gitea user info for audit and access control
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
giteaId: text('gitea_id').notNull().unique(),
|
||||||
|
giteaUsername: text('gitea_username').notNull(),
|
||||||
|
giteaEmail: text('gitea_email'),
|
||||||
|
displayName: text('display_name'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
role: text('role').default('admin'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
lastLogin: integer('last_login', { mode: 'timestamp' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events table
|
||||||
|
export const events = sqliteTable('events', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
date: text('date').notNull(),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
imageUrl: text('image_url').notNull(),
|
||||||
|
displayOrder: integer('display_order').notNull(),
|
||||||
|
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gallery images table
|
||||||
|
export const galleryImages = sqliteTable('gallery_images', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
imageUrl: text('image_url').notNull(),
|
||||||
|
altText: text('alt_text').notNull(),
|
||||||
|
displayOrder: integer('display_order').notNull(),
|
||||||
|
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content sections table (for text-based sections)
|
||||||
|
export const contentSections = sqliteTable('content_sections', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
sectionName: text('section_name').notNull().unique(),
|
||||||
|
contentJson: text('content_json', { mode: 'json' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Site settings table (global config)
|
||||||
|
export const siteSettings = sqliteTable('site_settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish history (audit log)
|
||||||
|
export const publishHistory = sqliteTable('publish_history', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
userId: text('user_id').references(() => users.id),
|
||||||
|
commitHash: text('commit_hash'),
|
||||||
|
commitMessage: text('commit_message'),
|
||||||
|
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
112
backend/src/index.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import jwt from '@fastify/jwt';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import { authenticate } from './middleware/auth.middleware.js';
|
||||||
|
import { env, validateEnv } from './config/env.js';
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
import authRoute from './routes/auth.js';
|
||||||
|
import eventsRoute from './routes/events.js';
|
||||||
|
import galleryRoute from './routes/gallery.js';
|
||||||
|
import contentRoute from './routes/content.js';
|
||||||
|
import settingsRoute from './routes/settings.js';
|
||||||
|
import publishRoute from './routes/publish.js';
|
||||||
|
|
||||||
|
// Validate environment variables
|
||||||
|
try {
|
||||||
|
validateEnv();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Environment validation failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fastify = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||||
|
transport: env.NODE_ENV === 'development' ? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
translateTime: 'HH:MM:ss Z',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register plugins
|
||||||
|
fastify.register(cors, {
|
||||||
|
origin: env.CORS_ORIGIN,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.register(cookie);
|
||||||
|
|
||||||
|
fastify.register(jwt, {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
cookie: {
|
||||||
|
cookieName: 'token',
|
||||||
|
signed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: env.MAX_FILE_SIZE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decorate fastify with authenticate method
|
||||||
|
fastify.decorate('authenticate', authenticate);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
fastify.register(authRoute, { prefix: '/api' });
|
||||||
|
fastify.register(eventsRoute, { prefix: '/api' });
|
||||||
|
fastify.register(galleryRoute, { prefix: '/api' });
|
||||||
|
fastify.register(contentRoute, { prefix: '/api' });
|
||||||
|
fastify.register(settingsRoute, { prefix: '/api' });
|
||||||
|
fastify.register(publishRoute, { prefix: '/api' });
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
fastify.get('/health', async () => {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: env.NODE_ENV,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Root endpoint
|
||||||
|
fastify.get('/', async () => {
|
||||||
|
return {
|
||||||
|
name: 'Gallus Pub CMS Backend',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
fastify.setErrorHandler((error, request, reply) => {
|
||||||
|
fastify.log.error(error);
|
||||||
|
|
||||||
|
reply.status(error.statusCode || 500).send({
|
||||||
|
error: error.message || 'Internal Server Error',
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const start = async () => {
|
||||||
|
try {
|
||||||
|
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||||
|
console.log(`🚀 Server listening on port ${env.PORT}`);
|
||||||
|
console.log(`📝 Environment: ${env.NODE_ENV}`);
|
||||||
|
console.log(`🔐 CORS Origin: ${env.CORS_ORIGIN}`);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
12
backend/src/middleware/auth.middleware.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
export async function authenticate(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(401).send({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
}
|
||||||
188
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { GiteaService } from '../services/gitea.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
// Use explicit JSON schema for Fastify route validation to avoid provider issues
|
||||||
|
const callbackQueryJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['code', 'state'],
|
||||||
|
properties: {
|
||||||
|
code: { type: 'string' },
|
||||||
|
state: { type: 'string' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
const giteaService = new GiteaService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/gitea
|
||||||
|
* Initiate OAuth flow
|
||||||
|
*/
|
||||||
|
fastify.get('/auth/gitea', async (request, reply) => {
|
||||||
|
// Generate CSRF state token
|
||||||
|
const state = giteaService.generateState();
|
||||||
|
|
||||||
|
// Store state in a short-lived cookie
|
||||||
|
reply.setCookie('oauth_state', state, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
|
||||||
|
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
||||||
|
maxAge: 10 * 60, // 10 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
const authUrl = giteaService.getAuthorizationUrl(state);
|
||||||
|
|
||||||
|
// Redirect to Gitea
|
||||||
|
return reply.redirect(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/callback
|
||||||
|
* OAuth callback endpoint
|
||||||
|
*/
|
||||||
|
fastify.get('/auth/callback', {
|
||||||
|
schema: {
|
||||||
|
querystring: callbackQueryJsonSchema,
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { code, state } = request.query as { code: string; state: string };
|
||||||
|
|
||||||
|
// Verify CSRF state from cookie
|
||||||
|
const expectedState = request.cookies?.oauth_state as string | undefined;
|
||||||
|
if (!expectedState || state !== expectedState) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid state parameter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state cookie
|
||||||
|
reply.clearCookie('oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
// Exchange code for access token
|
||||||
|
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
||||||
|
|
||||||
|
// Fetch user info from Gitea
|
||||||
|
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
|
||||||
|
|
||||||
|
// Check if user is allowed
|
||||||
|
if (!giteaService.isUserAllowed(giteaUser.login)) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Access denied. You are not authorized to access this CMS.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user in database
|
||||||
|
let [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.giteaId, giteaUser.id.toString()))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Create new user
|
||||||
|
[user] = await db.insert(users).values({
|
||||||
|
giteaId: giteaUser.id.toString(),
|
||||||
|
giteaUsername: giteaUser.login,
|
||||||
|
giteaEmail: giteaUser.email,
|
||||||
|
displayName: giteaUser.full_name,
|
||||||
|
avatarUrl: giteaUser.avatar_url,
|
||||||
|
lastLogin: new Date(),
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
// Update existing user
|
||||||
|
[user] = await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
giteaUsername: giteaUser.login,
|
||||||
|
giteaEmail: giteaUser.email,
|
||||||
|
displayName: giteaUser.full_name,
|
||||||
|
avatarUrl: giteaUser.avatar_url,
|
||||||
|
lastLogin: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT for session management
|
||||||
|
const token = fastify.jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
giteaId: user.giteaId,
|
||||||
|
username: user.giteaUsername || '',
|
||||||
|
role: user.role ?? 'admin',
|
||||||
|
},
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
|
||||||
|
reply.setCookie('token', token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
|
||||||
|
maxAge: 60 * 60 * 24, // 24h
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to admin dashboard
|
||||||
|
const frontendUrl = env.FRONTEND_URL;
|
||||||
|
return reply.redirect(`${frontendUrl}/admin`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error({ err: error }, 'OAuth callback error');
|
||||||
|
return reply.code(500).send({ error: 'Authentication failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/me
|
||||||
|
* Get current user info
|
||||||
|
*/
|
||||||
|
fastify.get('/auth/me', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.user.id;
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
giteaUsername: user.giteaUsername,
|
||||||
|
giteaEmail: user.giteaEmail,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/logout
|
||||||
|
* Logout (client-side token deletion)
|
||||||
|
*/
|
||||||
|
fastify.post('/auth/logout', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
// For JWT, logout is primarily client-side (delete token)
|
||||||
|
// You could maintain a token blacklist in Redis for production
|
||||||
|
reply.clearCookie('token', { path: '/' });
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authRoute;
|
||||||
104
backend/src/routes/content.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { contentSections } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for content section body
|
||||||
|
const contentBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['contentJson'],
|
||||||
|
properties: {
|
||||||
|
contentJson: {}, // allow any JSON
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
|
// Get content section
|
||||||
|
fastify.get('/content/:section', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { section } = request.params as { section: string };
|
||||||
|
|
||||||
|
const [content] = await db
|
||||||
|
.select()
|
||||||
|
.from(contentSections)
|
||||||
|
.where(eq(contentSections.sectionName, section))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return reply.code(404).send({ error: 'Content section not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
section: content.sectionName,
|
||||||
|
content: content.contentJson,
|
||||||
|
updatedAt: content.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update content section
|
||||||
|
fastify.put('/content/:section', {
|
||||||
|
schema: {
|
||||||
|
body: contentBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { section } = request.params as { section: string };
|
||||||
|
const { contentJson } = request.body as any;
|
||||||
|
|
||||||
|
// Check if section exists
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(contentSections)
|
||||||
|
.where(eq(contentSections.sectionName, section))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
[result] = await db
|
||||||
|
.update(contentSections)
|
||||||
|
.set({
|
||||||
|
contentJson,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(contentSections.sectionName, section))
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
[result] = await db
|
||||||
|
.insert(contentSections)
|
||||||
|
.values({
|
||||||
|
sectionName: section,
|
||||||
|
contentJson,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
section: result.sectionName,
|
||||||
|
content: result.contentJson,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all content sections
|
||||||
|
fastify.get('/content', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const sections = await db.select().from(contentSections);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections: (sections as any[]).map((s: any) => ({
|
||||||
|
section: s.sectionName,
|
||||||
|
content: s.contentJson,
|
||||||
|
updatedAt: s.updatedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default contentRoute;
|
||||||
89
backend/src/routes/events.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { events } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for event body
|
||||||
|
const eventBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['title', 'date', 'description', 'imageUrl', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
date: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
|
description: { type: 'string', minLength: 1 },
|
||||||
|
imageUrl: { type: 'string', minLength: 1 },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
isPublished: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const reorderBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['orders'],
|
||||||
|
properties: {
|
||||||
|
orders: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
// List all events (by displayOrder)
|
||||||
|
fastify.get('/events', { preHandler: [fastify.authenticate] }, async () => {
|
||||||
|
const all = await db.select().from(events).orderBy(events.displayOrder);
|
||||||
|
return { events: all };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single event
|
||||||
|
fastify.get('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const rows = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||||
|
if (rows.length === 0) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
return { event: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create event
|
||||||
|
fastify.post('/events', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const data = request.body as any;
|
||||||
|
const [row] = await db.insert(events).values(data).returning();
|
||||||
|
return reply.code(201).send({ event: row });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update event
|
||||||
|
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const data = request.body as any;
|
||||||
|
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
||||||
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
return { event: row };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete event
|
||||||
|
fastify.delete('/events/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
||||||
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
return { message: 'Event deleted successfully' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder events (synchronous transaction for better-sqlite3)
|
||||||
|
fastify.put('/events/reorder', { schema: { body: reorderBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
|
db.transaction((tx: any) => {
|
||||||
|
for (const { id, displayOrder } of orders) {
|
||||||
|
tx.update(events).set({ displayOrder }).where(eq(events.id, id)).run?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { message: 'Events reordered successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default eventsRoute;
|
||||||
134
backend/src/routes/gallery.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { galleryImages } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for gallery image body
|
||||||
|
const galleryBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['imageUrl', 'altText', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
imageUrl: { type: 'string', minLength: 1 },
|
||||||
|
altText: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
isPublished: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
|
// List all gallery images
|
||||||
|
fastify.get('/gallery', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
|
||||||
|
return { images };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single gallery image
|
||||||
|
fastify.get('/gallery/:id', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||||
|
|
||||||
|
if (image.length === 0) {
|
||||||
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { image: image[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create gallery image
|
||||||
|
fastify.post('/gallery', {
|
||||||
|
schema: {
|
||||||
|
body: galleryBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const data = request.body as any;
|
||||||
|
|
||||||
|
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({ image: newImage });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update gallery image
|
||||||
|
fastify.put('/gallery/:id', {
|
||||||
|
schema: {
|
||||||
|
body: galleryBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const data = request.body as any;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(galleryImages)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(galleryImages.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { image: updated };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete gallery image
|
||||||
|
fastify.delete('/gallery/:id', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(galleryImages)
|
||||||
|
.where(eq(galleryImages.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Image deleted successfully' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder gallery images
|
||||||
|
fastify.put('/gallery/reorder', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['orders'],
|
||||||
|
properties: {
|
||||||
|
orders: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'displayOrder'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
displayOrder: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||||
|
|
||||||
|
// Update all in synchronous transaction (better-sqlite3 requirement)
|
||||||
|
db.transaction((tx: any) => {
|
||||||
|
for (const { id, displayOrder } of orders) {
|
||||||
|
tx.update(galleryImages).set({ displayOrder }).where(eq(galleryImages.id, id)).run?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Gallery images reordered successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default galleryRoute;
|
||||||
127
backend/src/routes/publish.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { GitService } from '../services/git.service.js';
|
||||||
|
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for publish body
|
||||||
|
const publishBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['commitMessage'],
|
||||||
|
properties: {
|
||||||
|
commitMessage: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
fastify.post('/publish', {
|
||||||
|
schema: {
|
||||||
|
body: publishBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { commitMessage } = request.body as any;
|
||||||
|
const userId = request.user.id;
|
||||||
|
|
||||||
|
fastify.log.info('Starting publish process...');
|
||||||
|
|
||||||
|
// Initialize git service
|
||||||
|
const gitService = new GitService();
|
||||||
|
await gitService.initialize();
|
||||||
|
|
||||||
|
fastify.log.info('Git repository initialized');
|
||||||
|
|
||||||
|
// Fetch all content from database
|
||||||
|
const eventsData = await db
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq(events.isPublished, true))
|
||||||
|
.orderBy(events.displayOrder);
|
||||||
|
|
||||||
|
const galleryData = await db
|
||||||
|
.select()
|
||||||
|
.from(galleryImages)
|
||||||
|
.where(eq(galleryImages.isPublished, true))
|
||||||
|
.orderBy(galleryImages.displayOrder);
|
||||||
|
|
||||||
|
const sectionsData = await db.select().from(contentSections);
|
||||||
|
const sectionsMap = new Map<string, any>(
|
||||||
|
(sectionsData as any[]).map((s: any) => [s.sectionName as string, s.contentJson as any])
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
|
||||||
|
|
||||||
|
// Generate and write files
|
||||||
|
const fileGenerator = new FileGeneratorService();
|
||||||
|
await fileGenerator.writeFiles(
|
||||||
|
gitService.getWorkspacePath(''),
|
||||||
|
(eventsData as any[]).map((e: any) => ({
|
||||||
|
title: e.title,
|
||||||
|
date: e.date,
|
||||||
|
description: e.description,
|
||||||
|
imageUrl: e.imageUrl,
|
||||||
|
})),
|
||||||
|
(galleryData as any[]).map((g: any) => ({
|
||||||
|
imageUrl: g.imageUrl,
|
||||||
|
altText: g.altText,
|
||||||
|
})),
|
||||||
|
sectionsMap
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.log.info('Files generated successfully');
|
||||||
|
|
||||||
|
// Commit and push
|
||||||
|
const commitHash = await gitService.commitAndPush(commitMessage);
|
||||||
|
|
||||||
|
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||||
|
|
||||||
|
// Record in history
|
||||||
|
await db.insert(publishHistory).values({
|
||||||
|
userId,
|
||||||
|
commitHash,
|
||||||
|
commitMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commitHash,
|
||||||
|
message: 'Changes published successfully',
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error({ err: error }, 'Publish error');
|
||||||
|
|
||||||
|
// Attempt to reset git state on error
|
||||||
|
try {
|
||||||
|
const gitService = new GitService();
|
||||||
|
await gitService.reset();
|
||||||
|
} catch (resetError) {
|
||||||
|
fastify.log.error({ err: resetError }, 'Failed to reset git state');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to publish changes',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get publish history
|
||||||
|
fastify.get('/publish/history', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const history = await db
|
||||||
|
.select()
|
||||||
|
.from(publishHistory)
|
||||||
|
.orderBy(publishHistory.publishedAt)
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
return { history };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default publishRoute;
|
||||||
121
backend/src/routes/settings.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { siteSettings } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Fastify JSON schema for settings body
|
||||||
|
const settingBodyJsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['value'],
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
|
// Get all settings
|
||||||
|
fastify.get('/settings', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const settings = await db.select().from(siteSettings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: settings.reduce((acc, setting) => {
|
||||||
|
acc[setting.key] = setting.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single setting
|
||||||
|
fastify.get('/settings/:key', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { key } = request.params as { key: string };
|
||||||
|
|
||||||
|
const [setting] = await db
|
||||||
|
.select()
|
||||||
|
.from(siteSettings)
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
return reply.code(404).send({ error: 'Setting not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: setting.value,
|
||||||
|
updatedAt: setting.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update setting
|
||||||
|
fastify.put('/settings/:key', {
|
||||||
|
schema: {
|
||||||
|
body: settingBodyJsonSchema,
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { key } = request.params as { key: string };
|
||||||
|
const { value } = request.body as any;
|
||||||
|
|
||||||
|
// Check if setting exists
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(siteSettings)
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
[result] = await db
|
||||||
|
.update(siteSettings)
|
||||||
|
.set({
|
||||||
|
value,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
[result] = await db
|
||||||
|
.insert(siteSettings)
|
||||||
|
.values({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: result.key,
|
||||||
|
value: result.value,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete setting
|
||||||
|
fastify.delete('/settings/:key', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { key } = request.params as { key: string };
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(siteSettings)
|
||||||
|
.where(eq(siteSettings.key, key))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: 'Setting not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Setting deleted successfully' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default settingsRoute;
|
||||||
239
backend/src/services/file-generator.service.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GalleryImage {
|
||||||
|
imageUrl: string;
|
||||||
|
altText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentSection {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileGeneratorService {
|
||||||
|
|
||||||
|
escapeQuotes(str: string): string {
|
||||||
|
return str.replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeBackticks(str: string): string {
|
||||||
|
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||||
|
const eventsCode = events.map(e => `\t{
|
||||||
|
\t\timage: "${e.imageUrl}",
|
||||||
|
\t\ttitle: "${this.escapeQuotes(e.title)}",
|
||||||
|
\t\tdate: "${e.date}",
|
||||||
|
\t\tdescription: \`
|
||||||
|
\t\t\t${this.escapeBackticks(e.description)}
|
||||||
|
\t\t\`,
|
||||||
|
\t}`).join(',\n');
|
||||||
|
|
||||||
|
const imagesCode = images.map(g =>
|
||||||
|
`\t{ src: "${g.imageUrl}", alt: "${this.escapeQuotes(g.altText)}" }`
|
||||||
|
).join(',\n');
|
||||||
|
|
||||||
|
return `---
|
||||||
|
import Layout from "../components/Layout.astro";
|
||||||
|
import Hero from "../components/Hero.astro";
|
||||||
|
import Welcome from "../components/Welcome.astro";
|
||||||
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
|
import Drinks from "../components/Drinks.astro";
|
||||||
|
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||||
|
import Contact from "../components/Contact.astro";
|
||||||
|
import About from "../components/About.astro";
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
${eventsCode}
|
||||||
|
];
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
${imagesCode}
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
\t<Hero id="hero" />
|
||||||
|
\t<Welcome id="welcome" />
|
||||||
|
\t<EventsGrid id="events" events={events} />
|
||||||
|
\t<ImageCarousel id="gallery" images={images} />
|
||||||
|
\t<Drinks id="drinks" />
|
||||||
|
</Layout>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateHeroComponent(content: ContentSection): string {
|
||||||
|
return `---
|
||||||
|
// src/components/Hero.astro
|
||||||
|
import "../styles/components/Hero.css"
|
||||||
|
|
||||||
|
const { id } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id={id} class="hero container">
|
||||||
|
|
||||||
|
\t<div class="hero-overlay">
|
||||||
|
|
||||||
|
\t\t<div class="hero-content">
|
||||||
|
|
||||||
|
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
||||||
|
|
||||||
|
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
||||||
|
|
||||||
|
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||||
|
\t\t</div>
|
||||||
|
|
||||||
|
\t</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateWelcomeComponent(content: ContentSection): string {
|
||||||
|
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||||
|
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
return `---
|
||||||
|
// src/components/Welcome.astro
|
||||||
|
import "../styles/components/Welcome.css"
|
||||||
|
|
||||||
|
const { id } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id={id} class="welcome container">
|
||||||
|
|
||||||
|
\t<div class="welcome-text">
|
||||||
|
|
||||||
|
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
||||||
|
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
||||||
|
|
||||||
|
\t\t<p>
|
||||||
|
\t\t\t${content.introText || ''}
|
||||||
|
\t\t</p>
|
||||||
|
|
||||||
|
\t\t<p><b>Unsere Highlights:</b></p>
|
||||||
|
|
||||||
|
\t\t<ul>
|
||||||
|
${highlightsList}
|
||||||
|
\t\t</ul>
|
||||||
|
|
||||||
|
\t\t<p>
|
||||||
|
\t\t\t${content.closingText || ''}
|
||||||
|
\t\t</p>
|
||||||
|
|
||||||
|
\t</div>
|
||||||
|
|
||||||
|
|
||||||
|
\t<div class="welcome-image">
|
||||||
|
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
||||||
|
\t</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDrinksComponent(content: ContentSection): string {
|
||||||
|
return `---
|
||||||
|
import "../styles/components/Drinks.css"
|
||||||
|
|
||||||
|
const { id } = Astro.props;
|
||||||
|
---
|
||||||
|
<section id={id} class="Drinks">
|
||||||
|
<h2 class="title">Drinks</h2>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||||
|
|
||||||
|
<h3 class="monats-hit">Monats Hit</h3>
|
||||||
|
|
||||||
|
<div class="mate-vodka">
|
||||||
|
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
||||||
|
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="circle-row">
|
||||||
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
|
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
|
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
|
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
||||||
|
<span class="circle-label"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFiles(
|
||||||
|
workspaceDir: string,
|
||||||
|
events: Event[],
|
||||||
|
images: GalleryImage[],
|
||||||
|
sections: Map<string, ContentSection>
|
||||||
|
) {
|
||||||
|
// Write index.astro
|
||||||
|
const indexContent = this.generateIndexAstro(events, images);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/pages/index.astro'),
|
||||||
|
indexContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write Hero component
|
||||||
|
if (sections.has('hero')) {
|
||||||
|
const heroContent = this.generateHeroComponent(sections.get('hero')!);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/components/Hero.astro'),
|
||||||
|
heroContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write Welcome component
|
||||||
|
if (sections.has('welcome')) {
|
||||||
|
const welcomeContent = this.generateWelcomeComponent(sections.get('welcome')!);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/components/Welcome.astro'),
|
||||||
|
welcomeContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write Drinks component
|
||||||
|
if (sections.has('drinks')) {
|
||||||
|
const drinksContent = this.generateDrinksComponent(sections.get('drinks')!);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspaceDir, 'src/components/Drinks.astro'),
|
||||||
|
drinksContent,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/services/git.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import simpleGit, { SimpleGit } from 'simple-git';
|
||||||
|
import { mkdir, rm } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export class GitService {
|
||||||
|
private git: SimpleGit;
|
||||||
|
private workspaceDir: string;
|
||||||
|
private repoUrl: string;
|
||||||
|
private token: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.workspaceDir = env.GIT_WORKSPACE_DIR;
|
||||||
|
this.repoUrl = env.GIT_REPO_URL;
|
||||||
|
this.token = env.GIT_TOKEN;
|
||||||
|
this.git = simpleGit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
// Ensure workspace directory exists
|
||||||
|
await mkdir(this.workspaceDir, { recursive: true });
|
||||||
|
|
||||||
|
// Add token to repo URL for authentication
|
||||||
|
const authenticatedUrl = this.repoUrl.replace(
|
||||||
|
'https://',
|
||||||
|
`https://oauth2:${this.token}@`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if repo already exists
|
||||||
|
await this.git.cwd(this.workspaceDir);
|
||||||
|
await this.git.status();
|
||||||
|
console.log('Repository already exists, pulling latest...');
|
||||||
|
await this.git.pull();
|
||||||
|
} catch {
|
||||||
|
// Clone if doesn't exist
|
||||||
|
console.log('Cloning repository...');
|
||||||
|
await rm(this.workspaceDir, { recursive: true, force: true });
|
||||||
|
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
||||||
|
await this.git.cwd(this.workspaceDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user
|
||||||
|
await this.git.addConfig('user.name', env.GIT_USER_NAME);
|
||||||
|
await this.git.addConfig('user.email', env.GIT_USER_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitAndPush(message: string): Promise<string> {
|
||||||
|
await this.git.add('.');
|
||||||
|
await this.git.commit(message);
|
||||||
|
await this.git.push('origin', 'main');
|
||||||
|
|
||||||
|
const log = await this.git.log({ maxCount: 1 });
|
||||||
|
return log.latest?.hash || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkspacePath(relativePath: string): string {
|
||||||
|
return path.join(this.workspaceDir, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
await this.git.reset(['--hard', 'HEAD']);
|
||||||
|
await this.git.clean('f', ['-d']);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
backend/src/services/gitea.service.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
interface GiteaUser {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GiteaService {
|
||||||
|
private giteaUrl: string;
|
||||||
|
private clientId: string;
|
||||||
|
private clientSecret: string;
|
||||||
|
private redirectUri: string;
|
||||||
|
private allowedUsers: Set<string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.giteaUrl = env.GITEA_URL;
|
||||||
|
this.clientId = env.GITEA_CLIENT_ID;
|
||||||
|
this.clientSecret = env.GITEA_CLIENT_SECRET;
|
||||||
|
this.redirectUri = env.GITEA_REDIRECT_URI;
|
||||||
|
|
||||||
|
const allowed = env.GITEA_ALLOWED_USERS;
|
||||||
|
this.allowedUsers = new Set(allowed.split(',').map(u => u.trim()).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate OAuth authorization URL
|
||||||
|
*/
|
||||||
|
getAuthorizationUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.clientId,
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
state,
|
||||||
|
scope: 'read:user',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${this.giteaUrl}/login/oauth/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token
|
||||||
|
*/
|
||||||
|
async exchangeCodeForToken(code: string): Promise<OAuthTokenResponse> {
|
||||||
|
const response = await fetch(`${this.giteaUrl}/login/oauth/access_token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: this.clientId,
|
||||||
|
client_secret: this.clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to exchange code: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user info from Gitea using access token
|
||||||
|
*/
|
||||||
|
async getUserInfo(accessToken: string): Promise<GiteaUser> {
|
||||||
|
const response = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch user info: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is allowed to access the CMS
|
||||||
|
*/
|
||||||
|
isUserAllowed(username: string): boolean {
|
||||||
|
// If no allowed users specified, allow all
|
||||||
|
if (this.allowedUsers.size === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.allowedUsers.has(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random state for CSRF protection
|
||||||
|
*/
|
||||||
|
generateState(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
87
backend/src/services/media.service.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export class MediaService {
|
||||||
|
private allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
private maxFileSize: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.maxFileSize = env.MAX_FILE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file type and size
|
||||||
|
*/
|
||||||
|
async validateFile(file: any): Promise<void> {
|
||||||
|
if (!this.allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
throw new Error(`Invalid file type. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
if (buffer.length > this.maxFileSize) {
|
||||||
|
throw new Error(`File too large. Maximum size: ${this.maxFileSize / 1024 / 1024}MB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate safe filename
|
||||||
|
*/
|
||||||
|
generateFilename(originalName: string): string {
|
||||||
|
const ext = path.extname(originalName);
|
||||||
|
const hash = crypto.randomBytes(8).toString('hex');
|
||||||
|
const timestamp = Date.now();
|
||||||
|
return `${timestamp}-${hash}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimize and save image
|
||||||
|
*/
|
||||||
|
async processAndSaveImage(
|
||||||
|
file: any,
|
||||||
|
destinationDir: string
|
||||||
|
): Promise<{ filename: string; url: string }> {
|
||||||
|
await this.validateFile(file);
|
||||||
|
|
||||||
|
// Ensure destination directory exists
|
||||||
|
await mkdir(destinationDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate filename
|
||||||
|
const filename = this.generateFilename(file.filename);
|
||||||
|
const filepath = path.join(destinationDir, filename);
|
||||||
|
|
||||||
|
// Get file buffer
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
|
||||||
|
// Process image with sharp (optimize and resize if needed)
|
||||||
|
await sharp(buffer)
|
||||||
|
.resize(2000, 2000, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.png({ quality: 85 })
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.toFile(filepath);
|
||||||
|
|
||||||
|
// Return filename and URL path
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
url: `/images/${filename}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save image to git workspace
|
||||||
|
*/
|
||||||
|
async saveToGitWorkspace(
|
||||||
|
file: any,
|
||||||
|
workspaceDir: string
|
||||||
|
): Promise<{ filename: string; url: string }> {
|
||||||
|
const imagesDir = path.join(workspaceDir, 'public', 'images');
|
||||||
|
return this.processAndSaveImage(file, imagesDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/types/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
id: string;
|
||||||
|
giteaId: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastifyRequest {
|
||||||
|
user: JWTPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/jwt' {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: JWTPayload;
|
||||||
|
user: JWTPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
38
docker-compose.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=http://proxy:4321
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env.local
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=8080
|
||||||
|
- DATABASE_PATH=/app/data/gallus_cms.db
|
||||||
|
- GIT_WORKSPACE_DIR=/app/workspace
|
||||||
|
volumes:
|
||||||
|
- backend_data:/app/data
|
||||||
|
- backend_workspace:/app/workspace
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.caddy
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "4321:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_data:
|
||||||
|
backend_workspace:
|
||||||
@ -1,14 +1,17 @@
|
|||||||
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
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
dockerfile = "Dockerfile"
|
dockerfile = "Dockerfile.fly"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "3000"
|
PORT = "3000" # Caddy (serves frontend + proxies /api/*)
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
|
BACKEND_PORT = "8080" # Fastify backend will listen here
|
||||||
|
DATABASE_PATH = "/app/data/gallus_cms.db"
|
||||||
|
GIT_WORKSPACE_DIR = "/app/workspace"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3000
|
internal_port = 3000
|
||||||
@ -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"
|
||||||
@ -39,4 +42,12 @@ kill_timeout = 5
|
|||||||
[[vm]]
|
[[vm]]
|
||||||
memory = "512MB"
|
memory = "512MB"
|
||||||
cpu_kind = "shared"
|
cpu_kind = "shared"
|
||||||
cpus = 1
|
cpus = 1
|
||||||
|
|
||||||
|
[[mounts]]
|
||||||
|
source = "gallus_data"
|
||||||
|
destination = "/app/data"
|
||||||
|
|
||||||
|
[[mounts]]
|
||||||
|
source = "gallus_workspace"
|
||||||
|
destination = "/app/workspace"
|
||||||
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": {
|
||||||
@ -11,4 +11,4 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.12.0"
|
"astro": "^5.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 |
BIN
public/images/Logo.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
public/images/MonthlyHit.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
BIN
public/images/events/event_advents-kalender.jpeg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/images/events/event_ferien.jpeg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/images/events/event_karaoke.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/images/events/event_neujahrs-apero.jpeg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/images/events/event_pub-quiz.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/images/events/event_santa_karaoke.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/images/events/event_schlager-karaoke.jpeg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/events/old/Event2.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/images/events/old/Event3.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/images/events/old/Event4.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/images/gallery/Gallery1.png
Normal file
|
After Width: | Height: | Size: 800 KiB |
BIN
public/images/gallery/Gallery2.png
Normal file
|
After Width: | Height: | Size: 747 KiB |
BIN
public/images/gallery/Gallery3.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/images/gallery/Gallery4.png
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
public/images/gallery/Gallery5.png
Normal file
|
After Width: | Height: | Size: 580 KiB |
BIN
public/images/gallery/Gallery6.png
Normal file
|
After Width: | Height: | Size: 117 KiB |