Compare commits
82 Commits
89e1683eee
...
main-backu
| Author | SHA1 | Date | |
|---|---|---|---|
| cb43b4a7b5 | |||
| cbcb17a35c | |||
| 5922d5d274 | |||
| 96322a4776 | |||
| a5bdf7b4f5 | |||
| 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 | |||
| 03671a4d3e | |||
| 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 | |||
| 0f13e8eb66 | |||
| b830f2edaf | |||
| 3b011bc463 | |||
| 5247bd9816 | |||
| 50c06b3a8a | |||
| 5ab62f2b3b | |||
| 6120f04c95 | |||
| 179de67386 | |||
| 3da1b63a50 | |||
| 6b79e08684 | |||
| 7d5e77df76 | |||
| 23b47a7e85 | |||
| f4c75ea941 | |||
| 58522f2ae0 | |||
| 2a0aa7a6c8 | |||
| bcd86c9c68 |
31
.env.example
Normal file
@ -0,0 +1,31 @@
|
||||
# Copy this file to .env.local for local development
|
||||
# Then run: npm run dev:local
|
||||
|
||||
# Public base URL for your local dev server
|
||||
PUBLIC_BASE_URL=http://localhost:4321
|
||||
|
||||
# OAuth (Gitea) settings for local development
|
||||
# Create an OAuth2 Application in your Gitea with Redirect URI:
|
||||
# http://localhost:4321/api/auth/callback
|
||||
# Then paste the resulting Client ID/Secret below
|
||||
OAUTH_PROVIDER=gitea
|
||||
OAUTH_CLIENT_ID=
|
||||
OAUTH_CLIENT_SECRET=
|
||||
OAUTH_AUTHORIZE_URL=https://git.bookageek.ch/login/oauth/authorize
|
||||
OAUTH_TOKEN_URL=https://git.bookageek.ch/login/oauth/access_token
|
||||
OAUTH_USERINFO_URL=https://git.bookageek.ch/api/v1/user
|
||||
|
||||
# Optional access control
|
||||
# OAUTH_ALLOWED_USERS=user1,user2
|
||||
# OAUTH_ALLOWED_ORG=your-org
|
||||
|
||||
# Gitea API for committing content changes (service account PAT)
|
||||
GITEA_BASE=https://git.bookageek.ch
|
||||
GITEA_OWNER=
|
||||
GITEA_REPO=
|
||||
GITEA_TOKEN=
|
||||
GIT_BRANCH=main
|
||||
|
||||
# Session and CSRF secrets (use random long strings in .env.local)
|
||||
SESSION_SECRET=
|
||||
CSRF_SECRET=
|
||||
3
Gallus_Pub_v1/.gitignore → .gitignore
vendored
@ -1,5 +1,6 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
@ -12,10 +13,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
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
|
||||
29
Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built app and minimal runtime files
|
||||
COPY --from=build /app/dist /app/dist
|
||||
COPY --from=build /app/package*.json /app/
|
||||
|
||||
RUN npm pkg delete devDependencies || true
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Run Astro server entry (node adapter standalone)
|
||||
CMD ["node", "dist/server/entry.mjs"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||
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).
|
||||
@ -1,5 +0,0 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({});
|
||||
4875
Gallus_Pub_v1/package-lock.json
generated
@ -1,25 +0,0 @@
|
||||
---
|
||||
import "../../styles/components/Drinks.css"
|
||||
---
|
||||
<div class="Drinks">
|
||||
<div class="title">Drinks</div>
|
||||
|
||||
<a href="/pdf/Menu.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
|
||||
<div class="monats-hit">Monats Hit</div>
|
||||
|
||||
<div class="mate-vodka">
|
||||
<div class="circle"></div>
|
||||
<div>Mate Vodka</div>
|
||||
</div>
|
||||
|
||||
<div class="circle-row">
|
||||
<div class="circle"></div>
|
||||
<div class="circle"></div>
|
||||
<div class="circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
Wir bieten eine Auswahl...
|
||||
</div>
|
||||
</div>
|
||||
@ -1,27 +0,0 @@
|
||||
---
|
||||
// src/components/EventsGrid.astro
|
||||
interface Event {
|
||||
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) => (
|
||||
|
||||
<article class="event-card">
|
||||
|
||||
<h3>{event.title}</h3>
|
||||
|
||||
<p class="date">{event.date}</p>
|
||||
<p class="description">{event.description}</p>
|
||||
|
||||
</article>
|
||||
|
||||
))}
|
||||
|
||||
</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,36 +0,0 @@
|
||||
---
|
||||
// src/components/Hero.astro
|
||||
import "../../styles/components/Hero.css"
|
||||
---
|
||||
|
||||
<section class="hero container">
|
||||
|
||||
<div class="hero-overlay">
|
||||
|
||||
<div class="hero-content">
|
||||
|
||||
<h1>Dein Irish Pub</h1>
|
||||
|
||||
<p>Dein Irish Pub</p>
|
||||
|
||||
<a href="#" class="button">Aktuelles ↓</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero-overlay {
|
||||
background-image: url('/images/Background.png');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding-top: 2em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
---
|
||||
// src/components/HoverCard.astro
|
||||
const { title, description, image, link } = Astro.props;
|
||||
---
|
||||
|
||||
<article class="hover-card">
|
||||
|
||||
<a href={link}>
|
||||
|
||||
<img src={image} alt={title} />
|
||||
|
||||
<div class="info">
|
||||
|
||||
<h3>{title}</h3>
|
||||
|
||||
<p>{description}</p>
|
||||
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</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,24 +0,0 @@
|
||||
---
|
||||
// src/pages/index.astro
|
||||
import "../../styles/index.css"
|
||||
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 = [
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ title: 'Live-Musik', date: 'Sa, 26. Juli 2025', description: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod' },
|
||||
{ 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,57 +0,0 @@
|
||||
.Drinks {
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
color: #ceb39b;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
border: 1px solid #000;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(45deg, #ffa500, #ff7f00);
|
||||
}
|
||||
|
||||
.monats-hit {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mate-vodka {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.circle {
|
||||
height: 6em;
|
||||
width: 6em;
|
||||
border: 2px solid #000;
|
||||
border-radius: 50%;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.circle-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 2rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
article {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.events-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 3rem 0;
|
||||
}
|
||||
.event-card {
|
||||
background-color: #2a2a2a;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.event-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
.event-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffa500;
|
||||
}
|
||||
.event-card .date {
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.event-card .description {
|
||||
font-size: 1rem;
|
||||
color: #eee;
|
||||
}
|
||||
@ -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,115 +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;
|
||||
}
|
||||
|
||||
.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: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.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,20 +0,0 @@
|
||||
/* === Global Reset === */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Georgia', serif;
|
||||
background-color: #1f1414;
|
||||
color: #f5f5f5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* === Container für zentralen Content === */
|
||||
.container {
|
||||
max-width: 1140px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
59
README.md
@ -1,6 +1,55 @@
|
||||
# Gallus_Pub
|
||||
# Gallus Pub Website
|
||||
|
||||
Reposetory für die Website vom Gallus Pub
|
||||
|
||||
Auftragsgeber: Sabrina Signer
|
||||
Geschäfft: Gallus Pub
|
||||
This is the Gallus Pub website built with Astro. It includes an admin area at `/admin` for editing content (events, gallery, texts). Changes are committed back to the Git repository via the Gitea API which triggers your Woodpecker + Fly.io deployment pipeline.
|
||||
|
||||
## Local development
|
||||
|
||||
To run the site locally with OAuth login (Gitea):
|
||||
|
||||
1. Copy the example env file and fill values:
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
- Create a Gitea OAuth application with Redirect URI: `http://localhost:4321/api/auth/callback`.
|
||||
- Set `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` from Gitea.
|
||||
- Set `GITEA_OWNER`, `GITEA_REPO`, and a `GITEA_TOKEN` (PAT) with write access to the repo.
|
||||
- Generate random secrets for sessions/CSRF (e.g. `openssl rand -hex 32`).
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start dev server using your local env file:
|
||||
```bash
|
||||
npm run dev:local
|
||||
```
|
||||
The site runs at http://localhost:4321. Visit http://localhost:4321/admin to log in via Gitea OAuth.
|
||||
|
||||
Notes:
|
||||
- If OAuth variables are missing or malformed, the auth endpoints return a clear 500 with guidance instead of crashing.
|
||||
- Production secrets are configured on Fly.io; `.env.local` is ignored by Git.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
/
|
||||
├── public/ # static assets
|
||||
├── src/
|
||||
│ ├── content/ # editable JSON content (events, gallery)
|
||||
│ ├── pages/ # Astro pages, includes /admin and API routes
|
||||
│ ├── components/ # UI components
|
||||
│ └── utils/ # session helpers
|
||||
├── .env.example # template for local env
|
||||
├── fly.toml # Fly.io config
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm install` – install deps
|
||||
- `npm run dev` – dev server without loading .env.local (expects env to be present in the shell)
|
||||
- `npm run dev:local` – dev server loading `.env.local` via dotenv-cli
|
||||
- `npm run build` – production build (SSR via @astrojs/node)
|
||||
- `npm run preview` – preview the production build
|
||||
|
||||
9
astro.config.mjs
Normal file
@ -0,0 +1,9 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: node({ mode: 'standalone' })
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
app = "gallus-pub"
|
||||
primary_region = "fra" # Frankfurt region, change if needed
|
||||
primary_region = "fra"
|
||||
kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
|
||||
@ -26,7 +26,7 @@ kill_timeout = 5
|
||||
[[http_service.checks]]
|
||||
interval = "30s"
|
||||
timeout = "5s"
|
||||
grace_period = "10s"
|
||||
grace_period = "30s"
|
||||
method = "GET"
|
||||
path = "/"
|
||||
protocol = "http"
|
||||
5038
package-lock.json
generated
@ -1,14 +1,19 @@
|
||||
{
|
||||
"name": "gallus-pub-v1",
|
||||
"name": "gallus-pub",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:local": "dotenv -e .env.local -- astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.11.0"
|
||||
"astro": "^5.12.8",
|
||||
"@astrojs/node": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^7.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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/Event1.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/images/Event2.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/images/Event3.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/images/Event4.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/images/Gallery1.png
Normal file
|
After Width: | Height: | Size: 800 KiB |
BIN
public/images/Gallery2.png
Normal file
|
After Width: | Height: | Size: 747 KiB |
BIN
public/images/Gallery3.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/images/Gallery4.png
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
public/images/Gallery5.png
Normal file
|
After Width: | Height: | Size: 580 KiB |
BIN
public/images/Gallery6.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/images/Gallery7.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/images/Gallery8.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/images/Gallery9.png
Normal file
|
After Width: | Height: | Size: 567 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/Whiskey1.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
public/images/Whiskey2.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
public/images/Whiskey3.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
public/images/karaoke.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/images/pub_quiz.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/pdf/Getraenke_Gallus_2025.pdf
Normal file
42
src/components/Contact.astro
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
import Layout from "../components/Layout.astro";
|
||||
import "../styles/components/ContactForm.css";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div class="contact-container">
|
||||
<h1 class="contact-title">Kontakt</h1>
|
||||
|
||||
<form class="contact-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subject">Betreff</label>
|
||||
<input type="text" id="subject" name="subject" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Nachricht</label>
|
||||
<textarea id="message" name="message" rows="5" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button">Senden</button>
|
||||
</form>
|
||||
|
||||
<div class="whatsapp-container">
|
||||
<p>Oder kontaktiere uns direkt über WhatsApp:</p>
|
||||
<a href="https://wa.me/41772322770" class="whatsapp-link" target="_blank" rel="noopener noreferrer">
|
||||
<span class="whatsapp-icon">📱</span>
|
||||
<span>WhatsApp Chat starten</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
44
src/components/Drinks.astro
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
import "../styles/components/Drinks.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
<section id={id} class="Drinks">
|
||||
<h2 class="title">Drinks</h2>
|
||||
|
||||
<p class="note">
|
||||
Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter
|
||||
Atmosphäre das Leben genießen. Natürlich dürfen auch Cocktails nicht fehlen. Vieles kreieren wir auch selber - Sláinte!
|
||||
</p>
|
||||
|
||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
|
||||
<h3 class="monats-hit">Monats Hit</h3>
|
||||
|
||||
<div class="mate-vodka">
|
||||
<div class="circle" title="Mate Vodka">
|
||||
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div>Mate Vodka</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.
|
||||
</p>
|
||||
|
||||
<div class="circle-row">
|
||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||
<img src="/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="/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="/images/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
31
src/components/EventsGrid.astro
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
// src/components/EventsGrid.astro
|
||||
|
||||
import HoverCard from "./HoverCard.astro";
|
||||
|
||||
interface Event {
|
||||
image: string;
|
||||
title: string;
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
const { events = [], id }: { events?: Event[]; id?: string } = Astro.props as {
|
||||
events?: Event[];
|
||||
id?: string;
|
||||
};
|
||||
import "../styles/components/EventsGrid.css";
|
||||
---
|
||||
|
||||
<h2 class="section-title">Events</h2>
|
||||
<section id={id} class="events-gird container">
|
||||
{
|
||||
events.map((event: Event) => (
|
||||
<HoverCard
|
||||
title={event.title}
|
||||
date={event.date}
|
||||
description={event.description}
|
||||
image={event.image}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
@ -6,10 +6,6 @@ const currentYear = new Date().getFullYear();
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<div class="copyright">
|
||||
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
||||
</div>
|
||||
|
||||
<div class="footer-sections">
|
||||
<div class="footer-section">
|
||||
<h3>Öffnungszeiten</h3>
|
||||
@ -24,20 +20,22 @@ const currentYear = new Date().getFullYear();
|
||||
<p>Gallus Pub</p>
|
||||
<p>Metzgergasse 13</p>
|
||||
<p>9000 St. Gallen</p>
|
||||
<p>Reservierungen via Whatsapp</p>
|
||||
<p><a href="tel:0772322770">077 232 27 70</a></p>
|
||||
<p>Email:</p>
|
||||
<p><a href="mailto:info@gallus-pub.ch">info@gallus-pub.ch</a></p>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h3>Raumreservationen</h3>
|
||||
<p>Du planst einen Event?</p>
|
||||
<p>Der "St.Gallerruum" im 2.OG</p>
|
||||
<p>kann gemietet werden.</p>
|
||||
<br/>
|
||||
<p>Gerne öffnen wir auf Anfrage</p>
|
||||
<p>auch ausserhalb unserer</p>
|
||||
<p>Betriebszeiten.</p>
|
||||
<p>Kann gemietet werden.</p>
|
||||
<p>Reservierungen via Whatsapp</p>
|
||||
<p><a href="tel:0772322770">077 232 27 70</a></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="copyright">
|
||||
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
81
src/components/Header.astro
Normal file
@ -0,0 +1,81 @@
|
||||
---
|
||||
// src/components/Header.astro
|
||||
const { url } = Astro;
|
||||
import "../styles/components/Header.css";
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
<!-- Desktop Layout -->
|
||||
<div class="desktop-layout">
|
||||
<div class="logo-container">
|
||||
<a href="/">
|
||||
<img src="/images/Logo.png" alt="Logo" class="logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="nav-main">
|
||||
<div class="desktop-menu">
|
||||
<a href="/#hero">Home</a>
|
||||
<a href="/#events">Events</a>
|
||||
<a href="/#gallery">Galerie</a>
|
||||
<a href="/#drinks">Drinks</a>
|
||||
<a href="/#footer">Contact</a>
|
||||
<!--<a href="/#about">About</a>
|
||||
<a href="/#contact">Contact</a>-->
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout -->
|
||||
<div class="mobile-layout">
|
||||
<!-- Centered Logo -->
|
||||
<div class="mobile-logo-container">
|
||||
<a href="/">
|
||||
<img src="/images/Logo.png" alt="Logo" class="logo" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Burger Menu Below Logo -->
|
||||
<div class="burger-menu">
|
||||
<div class="burger-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu (Dropdown) -->
|
||||
<div class="mobile-menu">
|
||||
<a href="/#hero">Home</a>
|
||||
<a href="/#events">Events</a>
|
||||
<a href="/#gallery">Galerie</a>
|
||||
<a href="/#drinks">Drinks</a>
|
||||
<a href="/#footer">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="header-spacer"></div>
|
||||
|
||||
<script>
|
||||
// Toggle mobile menu when burger icon is clicked
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const burgerIcon = document.querySelector('.burger-icon');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
const mobileMenuLinks = document.querySelectorAll('.mobile-menu a');
|
||||
|
||||
// Toggle menu when burger icon is clicked
|
||||
burgerIcon.addEventListener('click', () => {
|
||||
burgerIcon.classList.toggle('active');
|
||||
mobileMenu.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Close menu when a navigation link is clicked
|
||||
mobileMenuLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
burgerIcon.classList.remove('active');
|
||||
mobileMenu.classList.remove('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
27
src/components/Hero.astro
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
// src/components/Hero.astro
|
||||
import "../styles/components/Hero.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section id={id} class="hero container">
|
||||
|
||||
<div class="hero-overlay">
|
||||
|
||||
<div class="hero-content">
|
||||
|
||||
<h1>Dein Irish Pub</h1>
|
||||
|
||||
<p>Im Herzen von St.Gallen</p>
|
||||
|
||||
<a href="#" class="button">Aktuelles ↓</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
48
src/components/HoverCard.astro
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
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>
|
||||
|
||||
<div class="hover-text">
|
||||
<div>
|
||||
<p set:html={description} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hoverCards = document.querySelectorAll('.hover-card');
|
||||
|
||||
hoverCards.forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
// Only toggle on mobile devices
|
||||
if (window.innerWidth <= 768) {
|
||||
e.preventDefault();
|
||||
|
||||
// Close all other active cards first
|
||||
hoverCards.forEach(otherCard => {
|
||||
if (otherCard !== card) {
|
||||
otherCard.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current card
|
||||
card.classList.toggle('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Close card when clicking outside (mobile only)
|
||||
document.addEventListener('click', (e) => {
|
||||
if (window.innerWidth <= 768 && !card.contains(e.target)) {
|
||||
card.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
114
src/components/ImageCarousel.astro
Normal file
@ -0,0 +1,114 @@
|
||||
---
|
||||
// src/components/ImageCarousel.astro
|
||||
import "../styles/components/ImageCarousel.css";
|
||||
|
||||
interface Image {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const { images = [], id } = Astro.props as { images: Image[], id?: string };
|
||||
---
|
||||
|
||||
<section id={id} class="image-carousel-container">
|
||||
<h2 class="section-title">Galerie</h2>
|
||||
<div class="image-carousel">
|
||||
<button class="nav-button prev-button" aria-label="Previous image">
|
||||
<span class="arrow">❮</span>
|
||||
</button>
|
||||
|
||||
<div class="carousel-images">
|
||||
<div class="carousel-track">
|
||||
{images.map((image, index) => (
|
||||
<div class="carousel-slide" data-index={index}>
|
||||
<img src={image.src} alt={image.alt} class="carousel-image" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="nav-button next-button" aria-label="Next image">
|
||||
<span class="arrow">❯</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="carousel-indicators">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
class="indicator-dot"
|
||||
data-index={index}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Initialize carousel functionality
|
||||
function initCarousel() {
|
||||
const carousel = document.querySelector('.image-carousel');
|
||||
const track = document.querySelector('.carousel-track');
|
||||
const slides = document.querySelectorAll('.carousel-slide');
|
||||
const prevButton = document.querySelector('.prev-button');
|
||||
const nextButton = document.querySelector('.next-button');
|
||||
const indicators = document.querySelectorAll('.indicator-dot');
|
||||
|
||||
if (!carousel || !track || !slides.length || !prevButton || !nextButton) return;
|
||||
|
||||
let currentIndex = 0;
|
||||
const slideCount = slides.length;
|
||||
|
||||
// Set initial active state
|
||||
updateCarousel();
|
||||
|
||||
// Add event listeners
|
||||
prevButton.addEventListener('click', () => {
|
||||
currentIndex = (currentIndex - 1 + slideCount) % slideCount;
|
||||
updateCarousel();
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', () => {
|
||||
currentIndex = (currentIndex + 1) % slideCount;
|
||||
updateCarousel();
|
||||
});
|
||||
|
||||
// Add click events to indicators
|
||||
indicators.forEach((dot, index) => {
|
||||
dot.addEventListener('click', () => {
|
||||
currentIndex = index;
|
||||
updateCarousel();
|
||||
});
|
||||
});
|
||||
|
||||
// Function to update carousel display
|
||||
function updateCarousel() {
|
||||
// Update active class on slides
|
||||
slides.forEach((slide, index) => {
|
||||
const position = index - currentIndex;
|
||||
|
||||
// Remove all position classes
|
||||
slide.classList.remove('prev', 'current', 'next');
|
||||
|
||||
// Add appropriate position class
|
||||
if (position === -1 || (position === slideCount - 1 && currentIndex === 0)) {
|
||||
slide.classList.add('prev');
|
||||
} else if (position === 0) {
|
||||
slide.classList.add('current');
|
||||
} else if (position === 1 || (position === -(slideCount - 1) && currentIndex === slideCount - 1)) {
|
||||
slide.classList.add('next');
|
||||
}
|
||||
});
|
||||
|
||||
// Update indicators
|
||||
indicators.forEach((dot, index) => {
|
||||
dot.classList.toggle('active', index === currentIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run initialization when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', initCarousel);
|
||||
|
||||
// Re-initialize on astro:page-load for Astro View Transitions
|
||||
document.addEventListener('astro:page-load', initCarousel);
|
||||
</script>
|
||||
@ -2,6 +2,9 @@
|
||||
// src/components/Layout.astro
|
||||
import Header from "./Header.astro";
|
||||
import Footer from "./Footer.astro";
|
||||
import "../styles/components/Layout.css"
|
||||
import "../styles/variables.css"
|
||||
import "../styles/index.css"
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@ -13,8 +16,6 @@ import Footer from "./Footer.astro";
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gallus Pub</title>
|
||||
<link rel="stylesheet" href="/Gallus_Pub_v1/styles/index.css" />
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@ -1,13 +1,16 @@
|
||||
---
|
||||
// src/components/Welcome.astro
|
||||
import "../../styles/components/Welcome.css"
|
||||
import "../styles/components/Welcome.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="welcome container">
|
||||
<section id={id} class="welcome container">
|
||||
|
||||
<div class="welcome-text">
|
||||
|
||||
<h2>Herzlich willkommen im Gallus Pub!</h2>
|
||||
<h2>Herzlich willkommen im</h2>
|
||||
<h2>Gallus Pub!</h2>
|
||||
|
||||
<p>
|
||||
Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung
|
||||
@ -49,7 +52,7 @@ import "../../styles/components/Welcome.css"
|
||||
|
||||
|
||||
<div class="welcome-image">
|
||||
<img src="/images/Welcome.png" alt="Welcome backgrount image" />
|
||||
<img src="/images/Welcome.png" alt="Welcome background image" />
|
||||
</div>
|
||||
|
||||
</section>
|
||||
26
src/content/events.json
Normal file
@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"image": "/images/karaoke.jpg",
|
||||
"title": "Karaoke",
|
||||
"date": "Mittwoch - Samstag",
|
||||
"description": "Bei uns gibt es Karaoke Mi-Sa!! <br>\nSeid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>\nReserviere am besten gleich per Whatsapp <a href=\"tel:+41772322770\">077 232 27 70</a>"
|
||||
},
|
||||
{
|
||||
"image": "/images/pub_quiz.jpg",
|
||||
"title": "Pub Quiz",
|
||||
"date": "Jeden Freitag",
|
||||
"description": "Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>\nJede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>\nAuch Einzelpersonen sind herzlich willkommen! <br>\n*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF"
|
||||
},
|
||||
{
|
||||
"image": "/images/crepes_sucette.jpg",
|
||||
"title": "Crepes Sucette <br /> Live Music im Gallus Pub!",
|
||||
"date": "Do, 04. September 2025",
|
||||
"description": "<b>20:00 Uhr</b> <br>\n<a href=\"Metzgergasse 13, 9000 St. Gallen\">Metzgergasse 13, 9000 St. Gallen</a> <br>\nErlebt einen musikalischen Abend mit der Band <b>Crepes Sucette</b> <br>\nJetzt reservieren: <a href=\"tel:+41772322770\">077 232 27 70</a>"
|
||||
},
|
||||
{
|
||||
"image": "/images/kevin_mcflannigan.jpeg",
|
||||
"title": "Kevin McFlannigan <br> Live Music im Gallus Pub!",
|
||||
"date": "Sa, 27. September 2025",
|
||||
"description": "<b>ab 20:00 Uhr</b> <br>\nSinger & Songwriter Kevin McFlannigan <br>\nEintritt ist Frei / Hutkollekte <br>"
|
||||
}
|
||||
]
|
||||
12
src/content/gallery.json
Normal file
@ -0,0 +1,12 @@
|
||||
[
|
||||
{ "src": "/images/Logo.png", "alt": "Erstes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Zweites Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Drittes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Viertes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Fünftes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Sechstes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Siebtes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Achtes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Neuntes Bild" },
|
||||
{ "src": "/images/Logo.png", "alt": "Zehntes Bild" }
|
||||
]
|
||||
94
src/pages/admin/index.astro
Normal file
@ -0,0 +1,94 @@
|
||||
---
|
||||
import Layout from "../../components/Layout.astro";
|
||||
import eventsData from "../../content/events.json";
|
||||
import imagesData from "../../content/gallery.json";
|
||||
import { getSessionFromRequest } from "../../utils/session";
|
||||
|
||||
const session = getSessionFromRequest(Astro.request);
|
||||
if (!session?.user) {
|
||||
// Not logged in: redirect to OAuth login
|
||||
return Astro.redirect("/api/auth/login");
|
||||
}
|
||||
const csrf = session.csrf;
|
||||
const events = eventsData;
|
||||
const images = imagesData;
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<section>
|
||||
<h1>Admin</h1>
|
||||
<p>Eingeloggt als {session.user.login}</p>
|
||||
<form id="editor">
|
||||
<h2>Events (JSON)</h2>
|
||||
<textarea id="events" name="events" rows="16" style="width:100%;font-family:monospace;">{JSON.stringify(events, null, 2)}</textarea>
|
||||
|
||||
<h2>Galerie (JSON)</h2>
|
||||
<textarea id="images" name="images" rows="10" style="width:100%;font-family:monospace;">{JSON.stringify(images, null, 2)}</textarea>
|
||||
|
||||
<h2>Bilder hochladen</h2>
|
||||
<input type="file" id="fileInput" multiple accept="image/*" />
|
||||
|
||||
<div style="margin-top:1rem;display:flex;gap:.5rem;">
|
||||
<button id="saveBtn" type="button">Speichern</button>
|
||||
<button id="logoutBtn" type="button">Logout</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<meta name="csrf" content={csrf} />
|
||||
<script type="module">
|
||||
const csrf = document.querySelector('meta[name="csrf"]').content;
|
||||
|
||||
async function uploadFiles(files){
|
||||
const uploads = [];
|
||||
for (const file of files){
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
uploads.push({ path: `public/images/${file.name}`, content: base64 });
|
||||
}
|
||||
return uploads;
|
||||
}
|
||||
|
||||
async function save(){
|
||||
let events, images;
|
||||
try{
|
||||
events = JSON.parse(document.getElementById('events').value);
|
||||
images = JSON.parse(document.getElementById('images').value);
|
||||
}catch(e){
|
||||
alert('JSON fehlerhaft: ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = [
|
||||
{ path: 'src/content/events.json', content: JSON.stringify(events, null, 2) },
|
||||
{ path: 'src/content/gallery.json', content: JSON.stringify(images, null, 2) },
|
||||
];
|
||||
|
||||
const input = document.getElementById('fileInput');
|
||||
if (input.files && input.files.length){
|
||||
const imageFiles = await uploadFiles(input.files);
|
||||
files.push(...imageFiles);
|
||||
}
|
||||
|
||||
const res = await fetch('/api/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf },
|
||||
body: JSON.stringify({ files, message: 'Admin-Inhalte aktualisiert' })
|
||||
});
|
||||
if (!res.ok){
|
||||
const t = await res.text();
|
||||
alert('Fehler beim Speichern: ' + t);
|
||||
return;
|
||||
}
|
||||
alert('Gespeichert! Build wird gestartet.');
|
||||
// optional: Seite neu laden
|
||||
location.reload();
|
||||
}
|
||||
|
||||
document.getElementById('saveBtn').addEventListener('click', save);
|
||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
location.href = '/';
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
116
src/pages/api/auth/callback.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { createSessionCookie, sessionCookieHeader, randomToken } from "../../../utils/session";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const cookie = request.headers.get("cookie") || "";
|
||||
const stateCookie = cookie.match(/(?:^|; )oauth_state=([^;]+)/)?.[1];
|
||||
|
||||
if (!code || !state || !stateCookie || stateCookie !== state) {
|
||||
return new Response("Invalid OAuth state", { status: 400 });
|
||||
}
|
||||
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||
const tokenUrlRaw = process.env.OAUTH_TOKEN_URL;
|
||||
const userinfoUrl = process.env.OAUTH_USERINFO_URL;
|
||||
if (!clientId || !clientSecret || !tokenUrlRaw || !userinfoUrl) {
|
||||
return new Response("OAuth not fully configured. Please set OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_TOKEN_URL, OAUTH_USERINFO_URL.", { status: 500 });
|
||||
}
|
||||
|
||||
// Compute redirect_uri consistent with login, robust against invalid PUBLIC_BASE_URL
|
||||
let redirectUri: string;
|
||||
try {
|
||||
if (process.env.PUBLIC_BASE_URL) {
|
||||
const base = new URL(process.env.PUBLIC_BASE_URL);
|
||||
redirectUri = new URL("/api/auth/callback", base).toString();
|
||||
} else {
|
||||
const reqUrl = new URL(request.url);
|
||||
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
||||
}
|
||||
} catch {
|
||||
const reqUrl = new URL(request.url);
|
||||
redirectUri = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
||||
}
|
||||
|
||||
// Validate token URL
|
||||
let tokenUrl: URL;
|
||||
try {
|
||||
tokenUrl = new URL(tokenUrlRaw);
|
||||
} catch {
|
||||
return new Response("Invalid OAUTH_TOKEN_URL", { status: 500 });
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
const params = new URLSearchParams();
|
||||
params.set("client_id", clientId);
|
||||
params.set("client_secret", clientSecret);
|
||||
params.set("code", code);
|
||||
params.set("grant_type", "authorization_code");
|
||||
params.set("redirect_uri", redirectUri);
|
||||
|
||||
const tokenRes = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const t = await tokenRes.text();
|
||||
return new Response(`OAuth token error: ${tokenRes.status} ${t}`, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json().catch(async () => {
|
||||
// Some Gitea versions return application/x-www-form-urlencoded
|
||||
const text = await tokenRes.text();
|
||||
const usp = new URLSearchParams(text);
|
||||
return Object.fromEntries(usp.entries());
|
||||
});
|
||||
const accessToken = tokenData.access_token || tokenData["access_token"];
|
||||
if (!accessToken) {
|
||||
return new Response("No access token", { status: 500 });
|
||||
}
|
||||
|
||||
const userRes = await fetch(userinfoUrl, {
|
||||
headers: { Authorization: `token ${accessToken}` },
|
||||
});
|
||||
if (!userRes.ok) {
|
||||
const t = await userRes.text();
|
||||
return new Response(`Userinfo error: ${userRes.status} ${t}`, { status: 500 });
|
||||
}
|
||||
const user = await userRes.json();
|
||||
|
||||
// Optional allowlist
|
||||
const allowedUsers = (process.env.OAUTH_ALLOWED_USERS || "").split(/[\,\s]+/).filter(Boolean);
|
||||
const allowedOrg = process.env.OAUTH_ALLOWED_ORG;
|
||||
if (allowedUsers.length && !allowedUsers.includes(user.login)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
if (allowedOrg) {
|
||||
// Best-effort org check
|
||||
try {
|
||||
const orgsRes = await fetch(process.env.GITEA_BASE + "/api/v1/user/orgs", {
|
||||
headers: { Authorization: `token ${accessToken}` },
|
||||
});
|
||||
if (orgsRes.ok) {
|
||||
const orgs = await orgsRes.json();
|
||||
const inOrg = Array.isArray(orgs) && orgs.some((o: any) => o.username === allowedOrg || o.login === allowedOrg || o.name === allowedOrg);
|
||||
if (!inOrg) return new Response("Forbidden (org)", { status: 403 });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const csrf = randomToken(16);
|
||||
const sessionValue = createSessionCookie({
|
||||
user: { id: user.id, login: user.login, name: user.full_name || user.username || user.login, email: user.email },
|
||||
csrf,
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Set-Cookie", sessionCookieHeader(sessionValue));
|
||||
headers.append("Set-Cookie", "oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");
|
||||
headers.set("Location", "/admin");
|
||||
return new Response(null, { status: 302, headers });
|
||||
};
|
||||
53
src/pages/api/auth/login.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { randomToken, setTempCookie } from "../../../utils/session";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const authorizeUrlRaw = process.env.OAUTH_AUTHORIZE_URL;
|
||||
if (!clientId || !authorizeUrlRaw) {
|
||||
return new Response(
|
||||
"OAuth not configured. Please set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL (and related secrets) for local dev.",
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine callback URL
|
||||
let finalRedirect: string;
|
||||
try {
|
||||
if (process.env.PUBLIC_BASE_URL) {
|
||||
// Ensure PUBLIC_BASE_URL is an absolute URL
|
||||
const base = new URL(process.env.PUBLIC_BASE_URL);
|
||||
finalRedirect = new URL("/api/auth/callback", base).toString();
|
||||
} else {
|
||||
// Fallback to current request origin
|
||||
const reqUrl = new URL(request.url);
|
||||
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
||||
}
|
||||
} catch {
|
||||
// As a last resort, use request URL
|
||||
const reqUrl = new URL(request.url);
|
||||
finalRedirect = new URL("/api/auth/callback", `${reqUrl.protocol}//${reqUrl.host}`).toString();
|
||||
}
|
||||
|
||||
// Validate authorize URL
|
||||
let authorizeUrl: URL;
|
||||
try {
|
||||
authorizeUrl = new URL(authorizeUrlRaw);
|
||||
} catch {
|
||||
return new Response("Invalid OAUTH_AUTHORIZE_URL", { status: 500 });
|
||||
}
|
||||
|
||||
const state = randomToken(16);
|
||||
authorizeUrl.searchParams.set("client_id", clientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", finalRedirect);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("state", state);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: authorizeUrl.toString(),
|
||||
"Set-Cookie": setTempCookie("oauth_state", state),
|
||||
},
|
||||
});
|
||||
};
|
||||
11
src/pages/api/auth/logout.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { clearCookieHeader } from "../../../utils/session";
|
||||
|
||||
export const POST: APIRoute = async () => {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Set-Cookie": clearCookieHeader(),
|
||||
},
|
||||
});
|
||||
};
|
||||
97
src/pages/api/save.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getSessionFromRequest } from "../../utils/session";
|
||||
|
||||
const GITEA_BASE = process.env.GITEA_BASE!;
|
||||
const GITEA_OWNER = process.env.GITEA_OWNER!;
|
||||
const GITEA_REPO = process.env.GITEA_REPO!;
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN!;
|
||||
const DEFAULT_BRANCH = process.env.GIT_BRANCH || "main";
|
||||
|
||||
function isAllowedPath(path: string) {
|
||||
if (path === "src/content/events.json") return true;
|
||||
if (path === "src/content/gallery.json") return true;
|
||||
if (path.startsWith("public/images/")) {
|
||||
return /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getShaIfExists(path: string): Promise<string | undefined> {
|
||||
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}?ref=${encodeURIComponent(DEFAULT_BRANCH)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `token ${GITEA_TOKEN}` },
|
||||
});
|
||||
if (res.status === 404) return undefined;
|
||||
if (!res.ok) throw new Error(`Gitea get sha error ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.sha;
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const session = getSessionFromRequest(request);
|
||||
if (!session?.user) return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
|
||||
// CSRF header required
|
||||
const csrfHeader = request.headers.get("x-csrf") || request.headers.get("X-CSRF");
|
||||
if (!csrfHeader || csrfHeader !== session.csrf) {
|
||||
return new Response(JSON.stringify({ error: "invalid csrf" }), { status: 403 });
|
||||
}
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "invalid json" }), { status: 400 });
|
||||
}
|
||||
if (!payload || !Array.isArray(payload.files)) {
|
||||
return new Response(JSON.stringify({ error: "missing files" }), { status: 400 });
|
||||
}
|
||||
|
||||
const results: any[] = [];
|
||||
for (const file of payload.files) {
|
||||
const path = String(file.path || "");
|
||||
if (!isAllowedPath(path)) {
|
||||
return new Response(JSON.stringify({ error: `path not allowed: ${path}` }), { status: 400 });
|
||||
}
|
||||
let contentBase64: string;
|
||||
if (path.startsWith("public/images/")) {
|
||||
// Expect already base64 string of binary
|
||||
contentBase64 = String(file.content || "");
|
||||
// Remove possible data URL prefix
|
||||
const match = contentBase64.match(/^data:[^;]+;base64,(.*)$/);
|
||||
if (match) contentBase64 = match[1];
|
||||
} else {
|
||||
// Text file
|
||||
contentBase64 = Buffer.from(String(file.content ?? ""), "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
const sha = await getShaIfExists(path).catch(() => undefined);
|
||||
const url = `${GITEA_BASE}/api/v1/repos/${encodeURIComponent(GITEA_OWNER)}/${encodeURIComponent(GITEA_REPO)}/contents/${encodeURIComponent(path)}`;
|
||||
const body: any = {
|
||||
content: contentBase64,
|
||||
message: payload.message || `Update ${path}`,
|
||||
branch: DEFAULT_BRANCH,
|
||||
};
|
||||
if (sha) body.sha = sha;
|
||||
if (session.user) {
|
||||
body.author = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
|
||||
body.committer = { name: session.user.name || session.user.login, email: session.user.email || `${session.user.login}@users.noreply` };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `token ${GITEA_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
return new Response(JSON.stringify({ error: `Gitea error for ${path}: ${res.status} ${t}` }), { status: 500 });
|
||||
}
|
||||
results.push(await res.json());
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
};
|
||||
23
src/pages/index.astro
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
// 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";
|
||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||
import Contact from "../components/Contact.astro";
|
||||
import About from "../components/About.astro";
|
||||
|
||||
// Inhalte aus Dateien laden (editierbar über Admin)
|
||||
import events from "../content/events.json";
|
||||
import images from "../content/gallery.json";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Hero id="hero" />
|
||||
<Welcome id="welcome" />
|
||||
<EventsGrid id="events" events={events} />
|
||||
<ImageCarousel id="gallery" images={images} />
|
||||
<Drinks id="drinks" />
|
||||
</Layout>
|
||||
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
9
src/public/favicon.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 506 KiB |
BIN
src/public/images/Logo.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
src/public/images/Welcome.png
Normal file
|
After Width: | Height: | Size: 57 KiB |