Compare commits
8 Commits
perf/impro
...
48bae59264
| Author | SHA1 | Date | |
|---|---|---|---|
| 48bae59264 | |||
| 761ab5d5b5 | |||
| cb43b4a7b5 | |||
| cbcb17a35c | |||
| 5922d5d274 | |||
| 96322a4776 | |||
| a5bdf7b4f5 | |||
| 03671a4d3e |
25
.env.example
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Local development configuration for OAuth + Gitea
|
||||||
|
# Copy this file to .env.local and fill in values, then run: npm run dev:local
|
||||||
|
|
||||||
|
PUBLIC_BASE_URL=http://localhost:4321
|
||||||
|
|
||||||
|
# Gitea OAuth app created with redirect URI: http://localhost:4321/api/auth/callback
|
||||||
|
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 allow-list (comma separated usernames)
|
||||||
|
# OAUTH_ALLOWED_USERS=
|
||||||
|
|
||||||
|
# Gitea API for commits (service account PAT must have write:repository)
|
||||||
|
GITEA_BASE=https://git.bookageek.ch
|
||||||
|
GITEA_OWNER=
|
||||||
|
GITEA_REPO=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GIT_BRANCH=main
|
||||||
|
|
||||||
|
# Secrets (use long random strings)
|
||||||
|
SESSION_SECRET=
|
||||||
|
CSRF_SECRET=
|
||||||
1
.gitignore
vendored
@ -16,6 +16,7 @@ pnpm-debug.log*
|
|||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
.env.local
|
||||||
|
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
10
Dockerfile
@ -4,19 +4,17 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
# Ensure CSS variables are present
|
|
||||||
RUN mkdir -p public/styles
|
|
||||||
RUN cp -r styles/* public/styles/ || true
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm install -g serve
|
ENV NODE_ENV=production
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
|
# install minimal deps to run node if needed (alpine already has node)
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["serve", "-s", "dist", "-l", "3000"]
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||||
96
README.md
@ -1,47 +1,65 @@
|
|||||||
# Astro Starter Kit: Minimal
|
# Gallus Pub Website – Admin mit Gitea OAuth und Git-Commits
|
||||||
|
|
||||||
```sh
|
Dieses Projekt stellt eine Astro‑Seite bereit und enthält eine Admin‑Oberfläche unter `/admin`, mit der Inhalte (Events, Galerie und Bilder) ohne Datenbank gepflegt werden können. Änderungen werden als Commits direkt in das Gitea‑Repository geschrieben. Woodpecker baut daraufhin und Fly.io deployt.
|
||||||
npm create astro@latest -- --template minimal
|
|
||||||
|
## Inhalte (Headless, Git‑basiert)
|
||||||
|
- Editierbare Dateien im Repo:
|
||||||
|
- `src/content/events.json`
|
||||||
|
- `src/content/gallery.json`
|
||||||
|
- Bilder: `public/images/*`
|
||||||
|
- Die Startseite importiert diese Dateien und rendert sie.
|
||||||
|
|
||||||
|
## Admin & Auth
|
||||||
|
- Admin‑Seite: `https://<domain>/admin` (kein Link im UI, nur direkter Pfad)
|
||||||
|
- Login via Gitea OAuth:
|
||||||
|
- `/api/auth/login` → Gitea → `/api/auth/callback`
|
||||||
|
- Session als HttpOnly‑Cookie, CSRF‑Cookie für POSTs
|
||||||
|
- Speichern: `/api/save` validiert und committet die Dateien via Gitea‑API
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
1) `.env.example` nach `.env.local` kopieren und ausfüllen (Gitea OAuth‑App mit Redirect `http://localhost:4321/api/auth/callback`).
|
||||||
|
2) Installieren und starten:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev:local
|
||||||
```
|
```
|
||||||
|
3) Browser öffnen: `http://localhost:4321/admin` → Mit Gitea anmelden → Inhalte bearbeiten → Speichern.
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
Hinweis: Für lokales HTTP sind Cookies ohne `Secure` gesetzt. In Produktion werden Cookies automatisch als `Secure` markiert.
|
||||||
[](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!
|
## Produktion (Fly.io)
|
||||||
|
- Dockerfile baut Astro als SSR und startet `node dist/server/entry.mjs` auf Port 3000.
|
||||||
## 🚀 Project Structure
|
- Secrets auf Fly.io setzen (Beispiele, Werte anpassen):
|
||||||
|
|
||||||
Inside of your Astro project, you'll see the following folders and files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/
|
|
||||||
├── public/
|
|
||||||
├── src/
|
|
||||||
│ └── pages/
|
|
||||||
│ └── index.astro
|
|
||||||
└── package.json
|
|
||||||
```
|
```
|
||||||
|
flyctl secrets set \
|
||||||
|
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 \
|
||||||
|
GITEA_BASE=https://git.bookageek.ch \
|
||||||
|
GITEA_OWNER=OWNER \
|
||||||
|
GITEA_REPO=REPO \
|
||||||
|
GITEA_TOKEN=PAT \
|
||||||
|
GIT_BRANCH=main \
|
||||||
|
SESSION_SECRET=RANDOM \
|
||||||
|
CSRF_SECRET=RANDOM
|
||||||
|
```
|
||||||
|
- Optional: `PUBLIC_BASE_URL=https://gallus-pub.ch` setzen.
|
||||||
|
|
||||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
## Wichtige Pfad‑Konvention
|
||||||
|
- Statische Assets immer unter `public/` ablegen (z. B. `public/images/...`).
|
||||||
|
- Die Admin‑Uploads schreiben automatisch nach `public/images/*`.
|
||||||
|
|
||||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
## Befehle
|
||||||
|
- `npm install` – Abhängigkeiten
|
||||||
|
- `npm run dev` – Standard Dev
|
||||||
|
- `npm run dev:local` – Dev mit `.env.local` (OAuth/Gitea)
|
||||||
|
- `npm run build` – Produktion builden
|
||||||
|
- `npm run preview` – Build lokal testen
|
||||||
|
|
||||||
Any static assets, like images, can be placed in the `public/` directory.
|
## Sicherheit
|
||||||
|
- Kein PAT im Browser – nur serverseitig in Secrets
|
||||||
## 🧞 Commands
|
- CSRF‑Schutz und Pfad‑Allowlist
|
||||||
|
- Optional nutzerbasierte Zulassung: `OAUTH_ALLOWED_USERS` (Komma‑Liste)
|
||||||
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 +1,9 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({});
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
adapter: node({ mode: 'standalone' }),
|
||||||
|
});
|
||||||
|
|||||||
1021
package-lock.json
generated
10
package.json
@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "Gallus Pub Site",
|
"name": "gallus-pub",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
|
"dev:local": "dotenv -e .env.local -- astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.12.0"
|
"astro": "^5.15.4",
|
||||||
|
"@astrojs/node": "^9.0.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv-cli": "^7.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 706 KiB After Width: | Height: | Size: 706 KiB |
BIN
public/images/Event1.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 800 KiB |
|
Before Width: | Height: | Size: 747 KiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 604 KiB After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 580 KiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 567 KiB After Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 66 KiB |
@ -1,10 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import "../styles/components/Drinks.css"
|
import "../styles/components/Drinks.css"
|
||||||
import MonthlyHit from "../assets/images/MonthlyHit.png";
|
|
||||||
import Whiskey1 from "../assets/images/whiskey/Whiskey1.png";
|
|
||||||
import Whiskey2 from "../assets/images/whiskey/Whiskey2.png";
|
|
||||||
import Whiskey3 from "../assets/images/whiskey/Whiskey3.png";
|
|
||||||
|
|
||||||
const { id } = Astro.props;
|
const { id } = Astro.props;
|
||||||
---
|
---
|
||||||
@ -22,7 +17,7 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
<div class="mate-vodka">
|
<div class="mate-vodka">
|
||||||
<div class="circle" title="Mate Vodka">
|
<div class="circle" title="Mate Vodka">
|
||||||
<Image src={MonthlyHit} alt="Monats Hit" class="circle-image" />
|
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>Mate Vodka</div>
|
<div>Mate Vodka</div>
|
||||||
@ -34,15 +29,15 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
<div class="circle-row">
|
<div class="circle-row">
|
||||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
<Image src={Whiskey1} alt="Whiskey 1" class="circle-image" />
|
<img src="/images/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
<Image src={Whiskey2} alt="Whiskey 2" class="circle-image" />
|
<img src="/images/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
<Image src={Whiskey3} alt="Whiskey 3" class="circle-image" />
|
<img src="/images/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,10 +2,9 @@
|
|||||||
// src/components/EventsGrid.astro
|
// src/components/EventsGrid.astro
|
||||||
|
|
||||||
import HoverCard from "./HoverCard.astro";
|
import HoverCard from "./HoverCard.astro";
|
||||||
import type { ImageMetadata } from "astro";
|
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
image: ImageMetadata;
|
image: string;
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -20,13 +19,17 @@ import "../styles/components/EventsGrid.css";
|
|||||||
<h2 class="section-title">Events</h2>
|
<h2 class="section-title">Events</h2>
|
||||||
<section id={id} class="events-gird container">
|
<section id={id} class="events-gird container">
|
||||||
{
|
{
|
||||||
events.map((event: Event) => (
|
events.length === 0 ? (
|
||||||
<HoverCard
|
<p style="text-align:center; width:100%; opacity:0.7;">Keine Events vorhanden. Bitte im Admin-Bereich hinzufügen.</p>
|
||||||
title={event.title}
|
) : (
|
||||||
date={event.date}
|
events.map((event: Event) => (
|
||||||
description={event.description}
|
<HoverCard
|
||||||
image={event.image}
|
title={event.title}
|
||||||
/>
|
date={event.date}
|
||||||
))
|
description={event.description}
|
||||||
|
image={event.image}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
---
|
---
|
||||||
// src/components/Footer.astro
|
// src/components/Footer.astro
|
||||||
import "../styles/components/Footer.css"
|
import "/styles/components/Footer.css"
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="footer" id="footer">
|
<footer class="footer">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
|
|
||||||
|
|
||||||
<div class="footer-sections">
|
<div class="footer-sections">
|
||||||
<div class="footer-section">
|
<div class="footer-section">
|
||||||
<h3>Öffnungszeiten</h3>
|
<h3>Öffnungszeiten</h3>
|
||||||
@ -22,7 +20,7 @@ const currentYear = new Date().getFullYear();
|
|||||||
<p>Gallus Pub</p>
|
<p>Gallus Pub</p>
|
||||||
<p>Metzgergasse 13</p>
|
<p>Metzgergasse 13</p>
|
||||||
<p>9000 St. Gallen</p>
|
<p>9000 St. Gallen</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>
|
<p><a href="mailto:info@gallus-pub.ch">info@gallus-pub.ch</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -30,9 +28,11 @@ const currentYear = new Date().getFullYear();
|
|||||||
<h3>Raumreservationen</h3>
|
<h3>Raumreservationen</h3>
|
||||||
<p>Du planst einen Event?</p>
|
<p>Du planst einen Event?</p>
|
||||||
<p>Der "St.Gallerruum" im 2.OG</p>
|
<p>Der "St.Gallerruum" im 2.OG</p>
|
||||||
<p>kann gemietet werden.</p>
|
<p>Kann gemietet werden.</p>
|
||||||
<p>Reservierungen via Whatsapp</p>
|
<p>Reservierungen via Whatsapp</p>
|
||||||
|
<p><a href="tel:0772322770">077 232 27 70</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="copyright">
|
<div class="copyright">
|
||||||
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
© {currentYear} Gallus Pub. Alle Rechte vorbehalten.
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
---
|
---
|
||||||
// src/components/Header.astro
|
// src/components/Header.astro
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import Logo from "../assets/images/Logo.png";
|
|
||||||
const { url } = Astro;
|
const { url } = Astro;
|
||||||
import "../styles/components/Header.css";
|
import "../styles/components/Header.css";
|
||||||
---
|
---
|
||||||
@ -11,7 +9,7 @@ import "../styles/components/Header.css";
|
|||||||
<div class="desktop-layout">
|
<div class="desktop-layout">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<Image src={Logo} alt="Logo" class="logo" />
|
<img src="/images/Logo.png" alt="Logo" class="logo" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -33,7 +31,7 @@ import "../styles/components/Header.css";
|
|||||||
<!-- Centered Logo -->
|
<!-- Centered Logo -->
|
||||||
<div class="mobile-logo-container">
|
<div class="mobile-logo-container">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<Image src={Logo} alt="Logo" class="logo" />
|
<img src="/images/Logo.png" alt="Logo" class="logo" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -67,16 +65,16 @@ import "../styles/components/Header.css";
|
|||||||
const mobileMenuLinks = document.querySelectorAll('.mobile-menu a');
|
const mobileMenuLinks = document.querySelectorAll('.mobile-menu a');
|
||||||
|
|
||||||
// Toggle menu when burger icon is clicked
|
// Toggle menu when burger icon is clicked
|
||||||
burgerIcon?.addEventListener('click', () => {
|
burgerIcon.addEventListener('click', () => {
|
||||||
burgerIcon.classList.toggle('active');
|
burgerIcon.classList.toggle('active');
|
||||||
mobileMenu?.classList.toggle('active');
|
mobileMenu.classList.toggle('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close menu when a navigation link is clicked
|
// Close menu when a navigation link is clicked
|
||||||
mobileMenuLinks.forEach(link => {
|
mobileMenuLinks.forEach(link => {
|
||||||
link.addEventListener('click', () => {
|
link.addEventListener('click', () => {
|
||||||
burgerIcon?.classList.remove('active');
|
burgerIcon.classList.remove('active');
|
||||||
mobileMenu?.classList.remove('active');
|
mobileMenu.classList.remove('active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
<p>Im Herzen von St.Gallen</p>
|
<p>Im Herzen von St.Gallen</p>
|
||||||
|
|
||||||
<a href="#welcome" class="button">Aktuelles ↓</a>
|
<a href="#" class="button">Aktuelles ↓</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,18 +1,11 @@
|
|||||||
---
|
---
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import type { ImageMetadata } from "astro";
|
|
||||||
import "../styles/components/HoverCard.css";
|
import "../styles/components/HoverCard.css";
|
||||||
const {title, description, image, date} = Astro.props as {
|
const {title, description, image = "", date} = Astro.props;
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
image: ImageMetadata;
|
|
||||||
date: string;
|
|
||||||
};
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<article class="hover-card">
|
<article class="hover-card">
|
||||||
<div class="image-container">
|
<div class="image-container">
|
||||||
<Image class="card-image" src={image} alt={title} />
|
<img class="card-image" src={image} alt={title} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hover-text">
|
<div class="hover-text">
|
||||||
@ -46,7 +39,7 @@ const {title, description, image, date} = Astro.props as {
|
|||||||
|
|
||||||
// Close card when clicking outside (mobile only)
|
// Close card when clicking outside (mobile only)
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (window.innerWidth <= 768 && !card.contains(e.target as Node)) {
|
if (window.innerWidth <= 768 && !card.contains(e.target)) {
|
||||||
card.classList.remove('active');
|
card.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,48 +1,52 @@
|
|||||||
---
|
---
|
||||||
// src/components/ImageCarousel.astro
|
// src/components/ImageCarousel.astro
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import type { ImageMetadata } from "astro";
|
|
||||||
import "../styles/components/ImageCarousel.css";
|
import "../styles/components/ImageCarousel.css";
|
||||||
|
|
||||||
interface ImageData {
|
interface Image {
|
||||||
src: ImageMetadata;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { images = [], id } = Astro.props as { images: ImageData[], id?: string };
|
const { images = [], id } = Astro.props as { images: Image[], id?: string };
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={id} class="image-carousel-container">
|
<section id={id} class="image-carousel-container">
|
||||||
<h2 class="section-title">Galerie</h2>
|
<h2 class="section-title">Galerie</h2>
|
||||||
<div class="image-carousel">
|
{images.length === 0 ? (
|
||||||
<button class="nav-button prev-button" aria-label="Previous image">
|
<p style="text-align:center; width:100%; opacity:0.7;">Keine Bilder vorhanden. Bitte im Admin-Bereich hinzufügen.</p>
|
||||||
<span class="arrow">❮</span>
|
) : (
|
||||||
</button>
|
<>
|
||||||
|
<div class="image-carousel">
|
||||||
<div class="carousel-images">
|
<button class="nav-button prev-button" aria-label="Previous image">
|
||||||
<div class="carousel-track">
|
<span class="arrow">❮</span>
|
||||||
{images.map((image, index) => (
|
</button>
|
||||||
<div class="carousel-slide" data-index={index}>
|
|
||||||
<Image src={image.src} alt={image.alt} class="carousel-image" />
|
<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>
|
||||||
|
</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>
|
</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>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -1,60 +1,46 @@
|
|||||||
---
|
---
|
||||||
// src/components/Welcome.astro
|
// src/components/Welcome.astro
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import "../styles/components/Welcome.css"
|
import "../styles/components/Welcome.css"
|
||||||
import WelcomeImg from "../assets/images/Welcome.png";
|
import content from "../content/texts.json";
|
||||||
|
|
||||||
const { id } = Astro.props;
|
const { id } = Astro.props;
|
||||||
|
const welcome = (content as any).welcome || {};
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={id} class="welcome container">
|
<section id={id} class="welcome container">
|
||||||
|
|
||||||
<div class="welcome-text">
|
<div class="welcome-text">
|
||||||
|
|
||||||
<h2>Herzlich willkommen im</h2>
|
{(welcome.titleLines || ["Herzlich willkommen im","Gallus Pub!"]).map((line: string) => (
|
||||||
<h2>Gallus Pub!</h2>
|
<h2>{line}</h2>
|
||||||
|
))}
|
||||||
|
|
||||||
<p>
|
{(welcome.paragraphs || [
|
||||||
Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung
|
"Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du selbst. Unsere Türen stehen offen für alle, die Spass haben wollen und gute Gesellschaft suchen!"
|
||||||
oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du
|
]).map((p: string) => (
|
||||||
selbst. Unsere Türen stehen offen für alle, die Spass haben wollen
|
<p>{p}</p>
|
||||||
und gute Gesellschaft suchen!
|
))}
|
||||||
</p>
|
|
||||||
|
|
||||||
<p><b>Unsere Highlights:</b></p>
|
<p><b>Unsere Highlights:</b></p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
{(welcome.highlights || [
|
||||||
<b>Karaoke:</b> Von Mittwoch bis Samstag kannst du deine
|
{ title: "Karaoke", text: "Von Mittwoch bis Samstag kannst du deine Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat mieten." },
|
||||||
Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht
|
{ title: "Pub Quiz", text: "Jeden Freitag ab 20:00 Uhr testet ihr euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues Thema und einen neuen Champion." },
|
||||||
nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst
|
{ title: "Getränke", text: "Geniesst frisches Guinness, Smithwicks, Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot." }
|
||||||
gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat
|
]).map((h: any) => (
|
||||||
mieten.
|
<li>
|
||||||
</li>
|
<b>{h.title}:</b> {h.text}
|
||||||
|
</li>
|
||||||
<li>
|
))}
|
||||||
<b>Pub Quiz:</b> Jeden Freitag ab 20:00 Uhr testet ihr
|
|
||||||
euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues
|
|
||||||
Thema und einen neuen Champion.
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<b>Getränke:</b> Geniesst frisches Guinness, Smithwicks,
|
|
||||||
Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber
|
|
||||||
haben wir erlesene Sorten aus Schottland und Irland im Angebot.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>{welcome.closing || "Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael"}</p>
|
||||||
Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst
|
|
||||||
uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="welcome-image">
|
<div class="welcome-image">
|
||||||
<Image src={WelcomeImg} alt="Welcome background image" />
|
<img src={welcome.image || "/images/Welcome.png"} alt="Welcome background image" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
32
src/content/events.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"image": "/images/Event1.png",
|
||||||
|
"title": "Karaoke auf 2 Etagen",
|
||||||
|
"date": "Mittwoch – Samstag",
|
||||||
|
"description": "Bei uns gibt's Karaoke auf gleich zwei Etagen! Ob Solo, Duett oder ganze Crew – Hauptsache Spass. Den 2. OG kannst du auch privat mieten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/Event2.png",
|
||||||
|
"title": "Pub Quiz",
|
||||||
|
"date": "Jeden Freitag, Start 20:00 Uhr",
|
||||||
|
"description": "Teste dein Wissen in mehreren Runden – jede Woche ein neues Thema und ein neuer Champion. Schnapp dir deine Freunde und macht mit!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/MonthlyHit.png",
|
||||||
|
"title": "Monthly Hit",
|
||||||
|
"date": "Einmal pro Monat",
|
||||||
|
"description": "Unser Special des Monats – wechselnde Highlights, Aktionen und Überraschungen. Folge uns, um das nächste Datum nicht zu verpassen!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/Event3.png",
|
||||||
|
"title": "Live Music & Open Stage",
|
||||||
|
"date": "Regelmässig – Daten auf Socials",
|
||||||
|
"description": "Lokale Künstlerinnen und Künstler live im Gallus Pub. Offene Bühne für alle, die Musik lieben – meldet euch bei uns!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/images/Event4.png",
|
||||||
|
"title": "Special Nights",
|
||||||
|
"date": "Variiert",
|
||||||
|
"description": "Themenabende, Game Nights, Tastings und mehr. Schaut in der Galerie vorbei oder fragt unser Team nach den nächsten Specials."
|
||||||
|
}
|
||||||
|
]
|
||||||
11
src/content/gallery.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{ "src": "/images/Gallery1.png", "alt": "Galerie Bild 1" },
|
||||||
|
{ "src": "/images/Gallery2.png", "alt": "Galerie Bild 2" },
|
||||||
|
{ "src": "/images/Gallery3.png", "alt": "Galerie Bild 3" },
|
||||||
|
{ "src": "/images/Gallery4.png", "alt": "Galerie Bild 4" },
|
||||||
|
{ "src": "/images/Gallery5.png", "alt": "Galerie Bild 5" },
|
||||||
|
{ "src": "/images/Gallery6.png", "alt": "Galerie Bild 6" },
|
||||||
|
{ "src": "/images/Gallery7.png", "alt": "Galerie Bild 7" },
|
||||||
|
{ "src": "/images/Gallery8.png", "alt": "Galerie Bild 8" },
|
||||||
|
{ "src": "/images/Gallery9.png", "alt": "Galerie Bild 9" }
|
||||||
|
]
|
||||||
27
src/content/texts.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"welcome": {
|
||||||
|
"titleLines": [
|
||||||
|
"Herzlich willkommen im",
|
||||||
|
"Gallus Pub!"
|
||||||
|
],
|
||||||
|
"paragraphs": [
|
||||||
|
"Wie die meisten bereits wissen, ist hier jeder willkommen - ob jung oder alt, Rocker, Elf, Nerd, Meerjungfrau oder einfach nur du selbst. Unsere Türen stehen offen für alle, die Spass haben wollen und gute Gesellschaft suchen!"
|
||||||
|
],
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"title": "Karaoke",
|
||||||
|
"text": "Von Mittwoch bis Samstag kannst du deine Stimme auf zwei Stockwerken zum Besten geben. Keine Sorge, es geht nicht darum, perfekt zu sein, sondern einfach Spass zu haben! Du singst gerne, aber lieber für dich? Dann kannst du den 2. OG auch privat mieten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pub Quiz",
|
||||||
|
"text": "Jeden Freitag ab 20:00 Uhr testet ihr euer Wissen in verschiedenen Runden. Jede Woche gibt es ein neues Thema und einen neuen Champion."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Getränke",
|
||||||
|
"text": "Geniesst frisches Guinness, Smithwicks, Gallus Old Style Ale und unsere leckeren Cocktails. Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"closing": "Wir freuen uns darauf, euch bald bei uns begrüssen zu können! Lasst uns gemeinsam unvergessliche Abende feiern. - Sabrina & Raphael",
|
||||||
|
"image": "/images/Welcome.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/lib/csrf.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export function createCsrfToken(): string {
|
||||||
|
const raw = crypto.randomBytes(16).toString('base64url');
|
||||||
|
const sig = sign(raw);
|
||||||
|
return `${raw}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyCsrfToken(token: string | null | undefined): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const [raw, sig] = token.split('.');
|
||||||
|
if (!raw || !sig) return false;
|
||||||
|
const expected = sign(raw);
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(input: string): string {
|
||||||
|
if (!env.CSRF_SECRET) throw new Error('CSRF_SECRET missing');
|
||||||
|
return crypto.createHmac('sha256', env.CSRF_SECRET).update(input).digest('hex');
|
||||||
|
}
|
||||||
29
src/lib/env.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export const env = {
|
||||||
|
OAUTH_PROVIDER: process.env.OAUTH_PROVIDER,
|
||||||
|
OAUTH_CLIENT_ID: process.env.OAUTH_CLIENT_ID,
|
||||||
|
OAUTH_CLIENT_SECRET: process.env.OAUTH_CLIENT_SECRET,
|
||||||
|
OAUTH_AUTHORIZE_URL: process.env.OAUTH_AUTHORIZE_URL,
|
||||||
|
OAUTH_TOKEN_URL: process.env.OAUTH_TOKEN_URL,
|
||||||
|
OAUTH_USERINFO_URL: process.env.OAUTH_USERINFO_URL,
|
||||||
|
OAUTH_ALLOWED_USERS: process.env.OAUTH_ALLOWED_USERS,
|
||||||
|
OAUTH_ALLOWED_ORG: process.env.OAUTH_ALLOWED_ORG,
|
||||||
|
PUBLIC_BASE_URL: process.env.PUBLIC_BASE_URL,
|
||||||
|
GITEA_BASE: process.env.GITEA_BASE,
|
||||||
|
GITEA_OWNER: process.env.GITEA_OWNER,
|
||||||
|
GITEA_REPO: process.env.GITEA_REPO,
|
||||||
|
GITEA_TOKEN: process.env.GITEA_TOKEN,
|
||||||
|
GIT_BRANCH: process.env.GIT_BRANCH || 'main',
|
||||||
|
SESSION_SECRET: process.env.SESSION_SECRET,
|
||||||
|
CSRF_SECRET: process.env.CSRF_SECRET,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBaseUrlFromRequest(req: Request): string {
|
||||||
|
try {
|
||||||
|
if (env.PUBLIC_BASE_URL) return new URL(env.PUBLIC_BASE_URL).toString().replace(/\/$/, '');
|
||||||
|
} catch {}
|
||||||
|
const url = new URL(req.url);
|
||||||
|
url.pathname = '';
|
||||||
|
url.search = '';
|
||||||
|
url.hash = '';
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
}
|
||||||
87
src/lib/session.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'gp_session';
|
||||||
|
const STATE_COOKIE = 'oauth_state';
|
||||||
|
const CSRF_COOKIE = 'gp_csrf';
|
||||||
|
|
||||||
|
export type SessionData = {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function hmac(value: string) {
|
||||||
|
if (!env.SESSION_SECRET) throw new Error('SESSION_SECRET missing');
|
||||||
|
return crypto.createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSession(data: SessionData): string {
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
const b64 = Buffer.from(json, 'utf-8').toString('base64url');
|
||||||
|
const sig = hmac(b64);
|
||||||
|
return `${b64}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeSession(token?: string | null): SessionData | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const [b64, sig] = parts;
|
||||||
|
const expected = hmac(b64);
|
||||||
|
// timing-safe compare
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(b64, 'base64url').toString('utf-8')) as SessionData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cookieAttrs({ httpOnly = true, maxAge, path = '/', sameSite = 'Lax', secure }: { httpOnly?: boolean; maxAge?: number; path?: string; sameSite?: 'Lax'|'Strict'|'None'; secure?: boolean } = {}) {
|
||||||
|
const attrs = [`Path=${path}`, `SameSite=${sameSite}`];
|
||||||
|
if (httpOnly) attrs.push('HttpOnly');
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
const useSecure = secure ?? isProd;
|
||||||
|
if (useSecure) attrs.push('Secure');
|
||||||
|
if (typeof maxAge === 'number') attrs.push(`Max-Age=${maxAge}`);
|
||||||
|
return attrs.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(data: SessionData, maxAgeDays = 7): string {
|
||||||
|
const token = encodeSession(data);
|
||||||
|
const maxAge = maxAgeDays * 24 * 60 * 60;
|
||||||
|
return `${SESSION_COOKIE}=${token}; ${cookieAttrs({ maxAge })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(): string {
|
||||||
|
return `${SESSION_COOKIE}=; ${cookieAttrs({})}; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionFromRequest(req: Request): SessionData | null {
|
||||||
|
const cookie = parseCookies(req.headers.get('cookie'))[SESSION_COOKIE];
|
||||||
|
return decodeSession(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTempCookie(name: string, value: string, maxAgeSeconds = 600): string {
|
||||||
|
return `${name}=${value}; ${cookieAttrs({ maxAge: maxAgeSeconds })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCookie(name: string): string {
|
||||||
|
return `${name}=; ${cookieAttrs({})}; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCookies(header: string | null | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const [k, ...rest] = part.trim().split('=');
|
||||||
|
out[k] = decodeURIComponent(rest.join('='));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SESSION_COOKIE, STATE_COOKIE, CSRF_COOKIE };
|
||||||
8
src/middleware.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { MiddlewareHandler } from 'astro';
|
||||||
|
import { getSessionFromRequest } from './lib/session';
|
||||||
|
|
||||||
|
export const onRequest: MiddlewareHandler = async (context, next) => {
|
||||||
|
const session = getSessionFromRequest(context.request);
|
||||||
|
(context.locals as any).user = session?.user || null;
|
||||||
|
return next();
|
||||||
|
};
|
||||||
111
src/pages/admin/index.astro
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../components/Layout.astro";
|
||||||
|
const user = Astro.locals.user as any;
|
||||||
|
import events from "../../content/events.json";
|
||||||
|
import images from "../../content/gallery.json";
|
||||||
|
import texts from "../../content/texts.json";
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Guard: if not logged in, show login link only -->
|
||||||
|
{!user && (
|
||||||
|
<Layout>
|
||||||
|
<section style="padding:2rem; text-align:center">
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>Bitte mit Gitea anmelden, um Inhalte zu bearbeiten.</p>
|
||||||
|
<p><a class="button" href="/api/auth/login">Mit Gitea anmelden</a></p>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<Layout>
|
||||||
|
<section class="admin" style="padding:2rem;">
|
||||||
|
<h1>Admin Bereich</h1>
|
||||||
|
<p>Eingeloggt als <b>{user.username}</b></p>
|
||||||
|
<form id="editor">
|
||||||
|
<h2>Welcome/Textbausteine (JSON)</h2>
|
||||||
|
<textarea id="texts" style="width:100%;height:240px">{JSON.stringify(texts, null, 2)}</textarea>
|
||||||
|
|
||||||
|
<h2>Events (JSON)</h2>
|
||||||
|
<textarea id="events" style="width:100%;height:220px">{JSON.stringify(events, null, 2)}</textarea>
|
||||||
|
|
||||||
|
<h2>Gallerie (JSON)</h2>
|
||||||
|
<textarea id="gallery" style="width:100%;height:160px">{JSON.stringify(images, null, 2)}</textarea>
|
||||||
|
|
||||||
|
<h2>Bild hochladen</h2>
|
||||||
|
<input id="imageInput" type="file" accept="image/*" multiple />
|
||||||
|
|
||||||
|
<div style="margin-top:1rem; display:flex; gap:1rem;">
|
||||||
|
<button type="button" id="saveBtn">Speichern</button>
|
||||||
|
<button type="button" id="logoutBtn">Logout</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function getCsrf() {
|
||||||
|
const m = document.cookie.match(/(?:^|; )gp_csrf=([^;]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const files = [];
|
||||||
|
try {
|
||||||
|
const textsText = (document.getElementById('texts')).value;
|
||||||
|
const textsJson = JSON.stringify(JSON.parse(textsText), null, 2);
|
||||||
|
files.push({ path: 'src/content/texts.json', content: textsJson, encoding: 'utf8' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Texts JSON ist ungültig: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const eventsText = (document.getElementById('events')).value;
|
||||||
|
const eventsJson = JSON.stringify(JSON.parse(eventsText), null, 2);
|
||||||
|
files.push({ path: 'src/content/events.json', content: eventsJson, encoding: 'utf8' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Events JSON ist ungültig: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const galleryText = (document.getElementById('gallery')).value;
|
||||||
|
const galleryJson = JSON.stringify(JSON.parse(galleryText), null, 2);
|
||||||
|
files.push({ path: 'src/content/gallery.json', content: galleryJson, encoding: 'utf8' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Galerie JSON ist ungültig: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle image uploads
|
||||||
|
const input = document.getElementById('imageInput');
|
||||||
|
const toUpload = Array.from(input.files || []);
|
||||||
|
for (const f of toUpload) {
|
||||||
|
const buf = await f.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||||
|
files.push({ path: `public/images/${f.name}`, content: base64, encoding: 'base64' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF': await getCsrf(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: 'Admin content update', files })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
alert('Fehler beim Speichern: ' + t);
|
||||||
|
} else {
|
||||||
|
alert('Änderungen gespeichert. Build/Deploy wird ausgelöst.');
|
||||||
|
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>
|
||||||
|
)}
|
||||||
83
src/pages/api/auth/callback.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env, getBaseUrlFromRequest } from '../../../lib/env';
|
||||||
|
import { STATE_COOKIE, parseCookies, clearCookie, setSessionCookie, CSRF_COOKIE } from '../../../lib/session';
|
||||||
|
import { createCsrfToken } from '../../../lib/csrf';
|
||||||
|
|
||||||
|
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 cookies = parseCookies(request.headers.get('cookie'));
|
||||||
|
|
||||||
|
if (!code || !state || cookies[STATE_COOKIE] !== state) {
|
||||||
|
return new Response('Invalid OAuth state', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_CLIENT_SECRET || !env.OAUTH_TOKEN_URL || !env.OAUTH_USERINFO_URL) {
|
||||||
|
return new Response('OAuth not fully configured', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${getBaseUrlFromRequest(request)}/api/auth/callback`;
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
let token: string;
|
||||||
|
try {
|
||||||
|
const res = await fetch(env.OAUTH_TOKEN_URL!, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: env.OAUTH_CLIENT_ID!,
|
||||||
|
client_secret: env.OAUTH_CLIENT_SECRET!,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
return new Response(`Token exchange failed: ${res.status} ${txt}`, { status: 500 });
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(async () => {
|
||||||
|
// Some providers may return querystring
|
||||||
|
const text = await res.text();
|
||||||
|
const params = new URLSearchParams(text);
|
||||||
|
return { access_token: params.get('access_token') };
|
||||||
|
});
|
||||||
|
token = data.access_token;
|
||||||
|
if (!token) throw new Error('No access_token');
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response(`Token error: ${e?.message || e}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const userRes = await fetch(env.OAUTH_USERINFO_URL!, {
|
||||||
|
headers: { Authorization: `token ${token}` },
|
||||||
|
});
|
||||||
|
if (!userRes.ok) {
|
||||||
|
const txt = await userRes.text();
|
||||||
|
return new Response(`Userinfo error: ${userRes.status} ${txt}`, { status: 500 });
|
||||||
|
}
|
||||||
|
const user = await userRes.json();
|
||||||
|
|
||||||
|
// Optional allow list
|
||||||
|
if (env.OAUTH_ALLOWED_USERS) {
|
||||||
|
const allowed = env.OAUTH_ALLOWED_USERS.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (!allowed.includes(user?.login || user?.username)) {
|
||||||
|
return new Response('forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session and CSRF
|
||||||
|
const sessionHeader = setSessionCookie({ user: { id: user.id, username: user.login || user.username, email: user.email, displayName: user.full_name || user.name } });
|
||||||
|
const csrf = createCsrfToken();
|
||||||
|
// CSRF cookie should NOT be HttpOnly so frontend can read it and send in header
|
||||||
|
const csrfHeader = `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax${process.env.NODE_ENV === 'production' ? '; Secure' : ''}`;
|
||||||
|
|
||||||
|
const headers = new Headers({ Location: '/admin' });
|
||||||
|
headers.append('Set-Cookie', clearCookie(STATE_COOKIE));
|
||||||
|
headers.append('Set-Cookie', sessionHeader);
|
||||||
|
headers.append('Set-Cookie', csrfHeader);
|
||||||
|
headers.append('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers });
|
||||||
|
};
|
||||||
45
src/pages/api/auth/login.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env, getBaseUrlFromRequest } from '../../../lib/env';
|
||||||
|
import { STATE_COOKIE, setTempCookie, cookieAttrs } from '../../../lib/session';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
if (!env.OAUTH_CLIENT_ID || !env.OAUTH_AUTHORIZE_URL) {
|
||||||
|
return new Response('OAuth not configured. Set OAUTH_CLIENT_ID and OAUTH_AUTHORIZE_URL.', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = cryptoRandomString();
|
||||||
|
const base = getBaseUrlFromRequest(request);
|
||||||
|
const redirectUri = `${base}/api/auth/callback`;
|
||||||
|
|
||||||
|
let authUrl: URL;
|
||||||
|
try {
|
||||||
|
authUrl = new URL(env.OAUTH_AUTHORIZE_URL!);
|
||||||
|
} catch {
|
||||||
|
return new Response('Invalid OAUTH_AUTHORIZE_URL', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
authUrl.searchParams.set('client_id', env.OAUTH_CLIENT_ID!);
|
||||||
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
|
authUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
const headers = new Headers({ Location: authUrl.toString() });
|
||||||
|
headers.append('Set-Cookie', setTempCookie(STATE_COOKIE, state, 600));
|
||||||
|
// also ensure any previous session cookies aren't cached
|
||||||
|
headers.append('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
return new Response(null, { status: 302, headers });
|
||||||
|
};
|
||||||
|
|
||||||
|
function cryptoRandomString(len = 24) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(len));
|
||||||
|
return Buffer.from(bytes).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure Web Crypto for Node
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
if (!(globalThis as any).crypto?.getRandomValues) {
|
||||||
|
(globalThis as any).crypto = {
|
||||||
|
getRandomValues: (arr: Uint8Array) => (crypto.webcrypto.getRandomValues(arr))
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
9
src/pages/api/auth/logout.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { clearSessionCookie, CSRF_COOKIE, clearCookie } from '../../../lib/session';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async () => {
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.append('Set-Cookie', clearSessionCookie());
|
||||||
|
headers.append('Set-Cookie', clearCookie(CSRF_COOKIE));
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
||||||
|
};
|
||||||
87
src/pages/api/save.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { env } from '../../lib/env';
|
||||||
|
import { verifyCsrfToken } from '../../lib/csrf';
|
||||||
|
import { parseCookies } from '../../lib/session';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const FileSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
encoding: z.enum(['utf8', 'base64']).default('utf8'),
|
||||||
|
});
|
||||||
|
const PayloadSchema = z.object({
|
||||||
|
message: z.string().min(1).default('Update content'),
|
||||||
|
files: z.array(FileSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isAllowedPath(p: string): boolean {
|
||||||
|
return (
|
||||||
|
p === 'src/content/events.json' ||
|
||||||
|
p === 'src/content/gallery.json' ||
|
||||||
|
p === 'src/content/texts.json' ||
|
||||||
|
p.startsWith('public/images/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
const user = (locals as any).user;
|
||||||
|
if (!user) return new Response('unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const csrf = request.headers.get('x-csrf');
|
||||||
|
const cookies = parseCookies(request.headers.get('cookie'));
|
||||||
|
if (!verifyCsrfToken(csrf || cookies['gp_csrf'])) {
|
||||||
|
return new Response('bad csrf', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: z.infer<typeof PayloadSchema>;
|
||||||
|
try {
|
||||||
|
const json = await request.json();
|
||||||
|
payload = PayloadSchema.parse(json);
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response('invalid payload: ' + (e?.message || e), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.GITEA_BASE || !env.GITEA_OWNER || !env.GITEA_REPO || !env.GITEA_TOKEN) {
|
||||||
|
return new Response('server not configured for Gitea', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of payload.files) {
|
||||||
|
if (!isAllowedPath(f.path)) {
|
||||||
|
return new Response('path not allowed: ' + f.path, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: any[] = [];
|
||||||
|
for (const f of payload.files) {
|
||||||
|
const url = `${env.GITEA_BASE}/api/v1/repos/${encodeURIComponent(env.GITEA_OWNER!)}/${encodeURIComponent(env.GITEA_REPO!)}/contents/${encodeURIComponent(f.path)}`;
|
||||||
|
const body: any = {
|
||||||
|
content: f.encoding === 'base64' ? f.content : Buffer.from(f.content, 'utf-8').toString('base64'),
|
||||||
|
message: payload.message,
|
||||||
|
branch: env.GIT_BRANCH || 'main',
|
||||||
|
author: {
|
||||||
|
name: user.displayName || user.username,
|
||||||
|
email: user.email || `${user.username}@users.noreply.local`,
|
||||||
|
},
|
||||||
|
committer: {
|
||||||
|
name: user.displayName || user.username,
|
||||||
|
email: user.email || `${user.username}@users.noreply.local`,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${env.GITEA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
return new Response(`Gitea error for ${f.path}: ${res.status} ${text}`, { status: 500 });
|
||||||
|
}
|
||||||
|
results.push(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ ok: true, results }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
};
|
||||||
@ -5,94 +5,10 @@ import Welcome from "../components/Welcome.astro";
|
|||||||
import EventsGrid from "../components/EventsGrid.astro";
|
import EventsGrid from "../components/EventsGrid.astro";
|
||||||
import Drinks from "../components/Drinks.astro";
|
import Drinks from "../components/Drinks.astro";
|
||||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||||
|
import Contact from "../components/Contact.astro";
|
||||||
// Import event images
|
import About from "../components/About.astro";
|
||||||
import eventKaraoke from "../assets/images/events/event_karaoke.jpg";
|
import events from "../content/events.json";
|
||||||
import eventPubQuiz from "../assets/images/events/event_pub-quiz.jpg";
|
import images from "../content/gallery.json";
|
||||||
import eventSchlager from "../assets/images/events/event_schlager-karaoke.jpeg";
|
|
||||||
import eventAdvent from "../assets/images/events/event_advents-kalender.jpeg";
|
|
||||||
import eventFerien from "../assets/images/events/event_ferien.jpeg";
|
|
||||||
import eventNeujahr from "../assets/images/events/event_neujahrs-apero.jpeg";
|
|
||||||
|
|
||||||
// Import gallery images
|
|
||||||
import Gallery1 from "../assets/images/gallery/Gallery1.png";
|
|
||||||
import Gallery2 from "../assets/images/gallery/Gallery2.png";
|
|
||||||
import Gallery3 from "../assets/images/gallery/Gallery3.png";
|
|
||||||
import Gallery4 from "../assets/images/gallery/Gallery4.png";
|
|
||||||
import Gallery5 from "../assets/images/gallery/Gallery5.png";
|
|
||||||
import Gallery6 from "../assets/images/gallery/Gallery6.png";
|
|
||||||
import Gallery7 from "../assets/images/gallery/Gallery7.png";
|
|
||||||
import Gallery8 from "../assets/images/gallery/Gallery8.png";
|
|
||||||
import Gallery9 from "../assets/images/gallery/Gallery9.png";
|
|
||||||
|
|
||||||
const events = [
|
|
||||||
{
|
|
||||||
image: eventKaraoke,
|
|
||||||
title: "Karaoke",
|
|
||||||
date: "Mittwoch - Samstag",
|
|
||||||
description: `
|
|
||||||
Bei uns gibt es Karaoke Mi-Sa!! <br>
|
|
||||||
Seid ihr eine Gruppe und lieber unter euch? ..unseren 2.Stock kannst du auch mieten ;) <br>
|
|
||||||
Reserviere am besten gleich per Whatsapp <a href="tel:+41772322770">077 232 27 70</a>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: eventPubQuiz,
|
|
||||||
title: "Pub Quiz",
|
|
||||||
date: "Jeden Freitag",
|
|
||||||
description: `
|
|
||||||
Jeden Freitag findet unser <b>Pub Quiz</b> statt. Gespielt wird tischweise in 3-4 Runden. <br>
|
|
||||||
Jede Woche gibt es ein anderes Thema. Es geht um Ruhm und Ehre und zusätzlich werden die Sieger der Herzen durch das Publikum gekürt! <3 <br>
|
|
||||||
Auch Einzelpersonen sind herzlich willkommen! <br>
|
|
||||||
*zum mitmachen minimum 1 Getränk konsumieren oder 5CHF
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: eventSchlager,
|
|
||||||
title: "Schlager Hüttenzauber Karaoke",
|
|
||||||
date: "27. November - 19:00 Uhr",
|
|
||||||
description: `
|
|
||||||
Ab 19:00 Uhr Eintritt ist Frei! Reservieren unter <a href="tel:+41772322770">077 232 27 70</a>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: eventAdvent,
|
|
||||||
title: "Adventskalender",
|
|
||||||
date: "03. Dezember - 20. Dezember 2025",
|
|
||||||
description: `
|
|
||||||
Jeden Tag neue Überraschungen! Check unsere Social Media Stories!
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: eventFerien,
|
|
||||||
title: "Weihnachtsferien",
|
|
||||||
date: "21. Dezember 2025 - 01. Januar 2026",
|
|
||||||
description: `
|
|
||||||
Wir sind ab 02.01.2026 wieder wie gewohnt für euch da! 🍀. <br> Für Anfragen WA <a href="tel:+41772322770">077 232 27 70</a> Antwort innerhalb 48h
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
image: eventNeujahr,
|
|
||||||
title: "Neujahrs-Apero",
|
|
||||||
date: "02. Januar 2026 - 18:00-20:00 Uhr",
|
|
||||||
description: `
|
|
||||||
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
const images = [
|
|
||||||
{ src: Gallery7, alt: "Siebtes Bild" },
|
|
||||||
{ src: Gallery8, alt: "Achtes Bild" },
|
|
||||||
{ src: Gallery9, alt: "Neuntes Bild" },
|
|
||||||
{ src: Gallery6, alt: "Sechstes Bild" },
|
|
||||||
{ src: Gallery1, alt: "Erstes Bild" },
|
|
||||||
{ src: Gallery2, alt: "Zweites Bild" },
|
|
||||||
{ src: Gallery3, alt: "Drittes Bild" },
|
|
||||||
{ src: Gallery4, alt: "Viertes Bild" },
|
|
||||||
{ src: Gallery5, alt: "Fünftes Bild" },
|
|
||||||
];
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.Drinks {
|
.Drinks {
|
||||||
font-family: var(--font-family-primary);
|
font-family: var(--font-family-primary), serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: var(--font-size-large);
|
font-size: var(--font-size-large);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -25,7 +25,6 @@
|
|||||||
.card-link {
|
.card-link {
|
||||||
border: 2px solid var(--color-accent-beige);
|
border: 2px solid var(--color-accent-beige);
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
margin-top: 2.5rem;
|
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
@ -69,6 +68,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
width: 80%;
|
width: 80%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
@ -81,8 +81,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.circle {
|
.circle {
|
||||||
height: 35vh;
|
height: 9em;
|
||||||
width: 35vh;
|
width: 9em;
|
||||||
border: 2px solid var(--color-accent-beige);
|
border: 2px solid var(--color-accent-beige);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
@ -94,7 +94,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle:hover {
|
.circle:hover {
|
||||||
@ -110,25 +109,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
transition: opacity var(--transition-standard);
|
transition: opacity var(--transition-standard);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle:hover .circle-label {
|
.circle:hover .circle-label {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle-image {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-row {
|
.circle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -163,6 +149,10 @@
|
|||||||
width: 90%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
height: 5em;
|
||||||
|
width: 5em;
|
||||||
|
}
|
||||||
|
|
||||||
.circle-label {
|
.circle-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
.hover-card {
|
.hover-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 25rem;
|
width: 400px;
|
||||||
height: 25rem;
|
height: 400px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
background-color: var(--color-accent-green);
|
background-color: var(--color-accent-green);
|
||||||
box-shadow: var(--box-shadow);
|
box-shadow: var(--box-shadow);
|
||||||
@ -12,28 +12,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover effects only for devices that support hover */
|
.hover-card:hover {
|
||||||
@media (hover: hover) and (pointer: fine) {
|
transform: translateY(-5px);
|
||||||
.hover-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-card:hover .hover-text {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-card:hover .card-image {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
padding: 15px 15px 5px 15px;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-accent-beige);
|
|
||||||
font-size: var(--font-size-medium);
|
|
||||||
text-align: center;
|
|
||||||
order: -2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card_date {
|
.card_date {
|
||||||
@ -104,12 +84,11 @@
|
|||||||
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
|
scrollbar-color: var(--color-accent-beige) rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active state for mobile tap functionality */
|
.hover-card:hover .hover-text {
|
||||||
.hover-card.active .hover-text {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-card.active .card-image {
|
.hover-card:hover .card-image {
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,34 +101,5 @@
|
|||||||
.hover-card {
|
.hover-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
/* Maintain square aspect ratio */
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
height: auto;
|
|
||||||
/* Add cursor pointer to indicate it's clickable */
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add visual feedback for tap */
|
|
||||||
.hover-card:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-card::after {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
color: var(--color-accent-beige);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
opacity: 0.8;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide the hint when card is active */
|
|
||||||
.hover-card.active::after {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
74
src/utils/session.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export type SessionData = {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
csrf?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COOKIE_NAME = "gp_session";
|
||||||
|
|
||||||
|
function b64url(input: Buffer | string) {
|
||||||
|
return Buffer.from(input)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/=/g, "")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(payload: string, secret: string) {
|
||||||
|
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionCookie(data: SessionData, secret = process.env.SESSION_SECRET || "") {
|
||||||
|
const payload = b64url(JSON.stringify(data));
|
||||||
|
const sig = sign(payload, secret);
|
||||||
|
return `${payload}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionCookie(cookieValue: string | undefined, secret = process.env.SESSION_SECRET || ""): SessionData | undefined {
|
||||||
|
if (!cookieValue) return undefined;
|
||||||
|
const [payload, sig] = cookieValue.split(".");
|
||||||
|
if (!payload || !sig) return undefined;
|
||||||
|
const expected = sign(payload, secret);
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return undefined;
|
||||||
|
try {
|
||||||
|
const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCookieHeader(name = COOKIE_NAME) {
|
||||||
|
return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieHeader(value: string, name = COOKIE_NAME) {
|
||||||
|
// 7 days
|
||||||
|
const maxAge = 60 * 60 * 24 * 7;
|
||||||
|
return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionFromRequest(req: Request, secret = process.env.SESSION_SECRET || ""): SessionData | undefined {
|
||||||
|
const cookie = req.headers.get("cookie") || "";
|
||||||
|
const match = cookie.match(/(?:^|; )gp_session=([^;]+)/);
|
||||||
|
if (!match) return undefined;
|
||||||
|
return parseSessionCookie(match[1], secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomToken(bytes = 32) {
|
||||||
|
return crypto.randomBytes(bytes).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COOKIE_NAME_STATE = "oauth_state";
|
||||||
|
|
||||||
|
export function setTempCookie(name: string, value: string) {
|
||||||
|
// short lived: 10 minutes
|
||||||
|
const maxAge = 60 * 10;
|
||||||
|
return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
|
||||||
|
}
|
||||||