Please enter the commit message for your changes. Lines starting
with '#' will be ignored, and an empty message aborts the commit. On branch main Initial commit Changes to be committed: new file: .DS_Store new file: .env new file: .gitignore new file: ai-worker/Dockerfile new file: ai-worker/requirements.txt new file: ai-worker/worker.py new file: background-worker/Dockerfile new file: background-worker/go.mod new file: background-worker/go.sum new file: background-worker/main.go new file: background-worker/market.go new file: background-worker/rmv.go new file: background-worker/rss.go new file: background-worker/sql_work.go new file: db/Dockerfile new file: db/init.sql new file: docker-compose.yml new file: server-app/dockerfile new file: server-app/go.mod new file: server-app/go.sum new file: server-app/main.go new file: volumes/.DS_Store new file: volumes/db-init/.DS_Store new file: volumes/db-init/data/news_rss_feeds.csv new file: volumes/web/.DS_Store new file: volumes/web/static/css/blog.css new file: volumes/web/static/css/index-lite.css new file: volumes/web/static/css/index.css new file: volumes/web/static/css/mandelbrot.css new file: volumes/web/static/img/minecraft.png new file: volumes/web/static/js/blog.js new file: volumes/web/static/js/index-lite.js new file: volumes/web/static/js/index.js new file: volumes/web/static/js/mandelbrot.js new file: volumes/web/static/media/cantina.mp3 new file: volumes/web/static/media/countdowns.json new file: volumes/web/static/media/gong.mp4 new file: volumes/web/template/blog.html new file: volumes/web/template/index-lite.html new file: volumes/web/template/index.html new file: volumes/web/template/mandelbrot.html
This commit is contained in:
7
.env
Normal file
7
.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
APP_PORT=8080
|
||||||
|
|
||||||
|
POSTGRES_USER=app
|
||||||
|
POSTGRES_PASSWORD=appsecret
|
||||||
|
POSTGRES_DB=appdb
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
RMV_API_KEY=62cac254-00d6-4c29-a823-0f2317077a89
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
volumes/ai-models/
|
||||||
|
volumes/images/
|
||||||
|
volumes/postgres/
|
||||||
7
ai-worker/Dockerfile
Normal file
7
ai-worker/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
CMD ["python", "worker.py"]
|
||||||
7
ai-worker/requirements.txt
Normal file
7
ai-worker/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
psycopg2-binary
|
||||||
|
diffusers
|
||||||
|
transformers
|
||||||
|
torch
|
||||||
|
safetensors
|
||||||
|
Pillow
|
||||||
|
accelerate
|
||||||
183
ai-worker/worker.py
Normal file
183
ai-worker/worker.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import torch
|
||||||
|
from diffusers import StableDiffusionPipeline
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
DB_URL = os.environ.get("DATABASE_URL", "postgres://app:appsecret@database:5432/appdb?sslmode=disable")
|
||||||
|
DEFAULT_MODEL_PATH = Path(__file__).resolve().parent / "models" / "sd15"
|
||||||
|
MODEL_PATH = Path(DEFAULT_MODEL_PATH)
|
||||||
|
IMAGE_OUTPUT_DIR = Path(os.environ.get("IMAGE_OUTPUT_DIR") or "/app/images")
|
||||||
|
IMAGE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
MODEL_ID = "stabilityai/sd-turbo"
|
||||||
|
WORKER_ID = os.environ.get("WORKER_ID") or f"{socket.gethostname()}:{os.getpid()}"
|
||||||
|
BATCH_SIZE = int(os.environ.get("IMAGE_BATCH_SIZE") or "3")
|
||||||
|
CLAIM_TTL_MINUTES = int(os.environ.get("IMAGE_CLAIM_TTL_MINUTES") or "20")
|
||||||
|
pipe = None
|
||||||
|
pipe_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return psycopg2.connect(DB_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def load_pipeline() -> Optional[StableDiffusionPipeline]:
|
||||||
|
global pipe
|
||||||
|
|
||||||
|
if pipe is not None:
|
||||||
|
return pipe
|
||||||
|
|
||||||
|
with pipe_lock:
|
||||||
|
if pipe is not None:
|
||||||
|
return pipe
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prüfen, ob lokal schon etwas im Modellordner liegt
|
||||||
|
has_local_model = MODEL_PATH.is_dir() and any(MODEL_PATH.iterdir())
|
||||||
|
|
||||||
|
if has_local_model:
|
||||||
|
logging.info("Lade Stable Diffusion Modell lokal aus %s", MODEL_PATH)
|
||||||
|
pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
|
str(MODEL_PATH),
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
local_files_only=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.info("Kein lokales Modell gefunden – lade von Hugging Face (%s)", MODEL_ID)
|
||||||
|
pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
|
MODEL_ID,
|
||||||
|
torch_dtype=torch.float32
|
||||||
|
)
|
||||||
|
pipe.save_pretrained(MODEL_PATH)
|
||||||
|
logging.info("Modell erfolgreich nach %s gespeichert", MODEL_PATH)
|
||||||
|
|
||||||
|
pipe = pipe.to("cpu")
|
||||||
|
pipe.enable_attention_slicing()
|
||||||
|
return pipe
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Konnte Pipeline nicht laden: %s", e)
|
||||||
|
pipe = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_articles_without_image(cur, limit=BATCH_SIZE):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
WITH claim AS (
|
||||||
|
SELECT id
|
||||||
|
FROM articles
|
||||||
|
WHERE image IS NULL
|
||||||
|
AND (
|
||||||
|
image_claimed_at IS NULL
|
||||||
|
OR image_claimed_at < now() - (%s * INTERVAL '1 minute')
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE articles AS a
|
||||||
|
SET image_claimed_at = now(),
|
||||||
|
image_claimed_by = %s
|
||||||
|
FROM claim
|
||||||
|
WHERE a.id = claim.id
|
||||||
|
RETURNING a.id, a.title, a.article_id
|
||||||
|
""",
|
||||||
|
(CLAIM_TTL_MINUTES, limit, WORKER_ID),
|
||||||
|
)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def update_image(cur, article_id: int, data_uri: str):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE articles
|
||||||
|
SET image = %s,
|
||||||
|
image_claimed_at = NULL,
|
||||||
|
image_claimed_by = NULL
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(data_uri, article_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def release_claim(cur, article_id: int):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE articles
|
||||||
|
SET image_claimed_at = NULL,
|
||||||
|
image_claimed_by = NULL
|
||||||
|
WHERE id = %s AND image IS NULL
|
||||||
|
""",
|
||||||
|
(article_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(name: str) -> str:
|
||||||
|
safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in name)
|
||||||
|
return safe or "image"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image(prompt: str) -> Optional[tuple[str, bytes]]:
|
||||||
|
model = load_pipeline()
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
img = model(
|
||||||
|
prompt=prompt,
|
||||||
|
num_inference_steps=8,
|
||||||
|
guidance_scale=7,
|
||||||
|
).images[0]
|
||||||
|
buf = BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
img_bytes = buf.getvalue()
|
||||||
|
data = base64.b64encode(img_bytes).decode("ascii")
|
||||||
|
return f"data:image/png;base64,{data}", img_bytes
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Bildgenerierung fehlgeschlagen: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with connect() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
rows = fetch_articles_without_image(cur)
|
||||||
|
if not rows:
|
||||||
|
logging.info("keine neuen Artikel ohne Bild")
|
||||||
|
for aid, title, article_id in rows:
|
||||||
|
prompt = title or "news illustration"
|
||||||
|
result = generate_image(prompt)
|
||||||
|
if result:
|
||||||
|
data_uri, img_bytes = result
|
||||||
|
filename = f"{safe_filename(article_id)}.png"
|
||||||
|
out_path = IMAGE_OUTPUT_DIR / filename
|
||||||
|
try:
|
||||||
|
out_path.write_bytes(img_bytes)
|
||||||
|
logging.info("Bild gespeichert unter %s", out_path)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Konnte Bild nicht speichern (%s): %s", out_path, e)
|
||||||
|
update_image(cur, aid, data_uri)
|
||||||
|
logging.info("Bild gesetzt für Artikel %s", aid)
|
||||||
|
else:
|
||||||
|
release_claim(cur, aid)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Fehler im Worker: %s", e)
|
||||||
|
|
||||||
|
time.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
background-worker/Dockerfile
Normal file
9
background-worker/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM golang:1.24
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
CMD ["go", "run", "."]
|
||||||
23
background-worker/go.mod
Normal file
23
background-worker/go.mod
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
module background-worker
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.12
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
golang.org/x/net v0.4.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
50
background-worker/go.sum
Normal file
50
background-worker/go.sum
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||||
|
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||||
|
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||||
|
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||||
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||||
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||||
|
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
33
background-worker/main.go
Normal file
33
background-worker/main.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Println("[background-worker] started")
|
||||||
|
|
||||||
|
tickerRMV := time.NewTicker(5 * time.Minute)
|
||||||
|
tickerRSS := time.NewTicker(1 * time.Minute)
|
||||||
|
tickerMarket := time.NewTicker(10 * time.Second)
|
||||||
|
defer tickerRMV.Stop()
|
||||||
|
defer tickerRSS.Stop()
|
||||||
|
defer tickerMarket.Stop()
|
||||||
|
|
||||||
|
rmv_request()
|
||||||
|
fetchMarketData()
|
||||||
|
fetchRSSFeeds()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-tickerRMV.C:
|
||||||
|
go rmv_request()
|
||||||
|
case <-tickerRSS.C:
|
||||||
|
go fetchRSSFeeds()
|
||||||
|
case <-tickerMarket.C:
|
||||||
|
go fetchMarketData()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
87
background-worker/market.go
Normal file
87
background-worker/market.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type marketQuote struct {
|
||||||
|
Instrument string
|
||||||
|
Bid float64
|
||||||
|
QuotedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type sqResponse struct {
|
||||||
|
SpreadProfilePrices []struct {
|
||||||
|
Bid float64 `json:"bid"`
|
||||||
|
} `json:"spreadProfilePrices"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMarketData() {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
type endpoint struct {
|
||||||
|
Instrument string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
endpoints := []endpoint{
|
||||||
|
{Instrument: "USD/JPY", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/USD/JPY"},
|
||||||
|
{Instrument: "OIL/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/OIL/USD"},
|
||||||
|
{Instrument: "XAU/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD"},
|
||||||
|
{Instrument: "EUR/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/EUR/USD"},
|
||||||
|
{Instrument: "USD/CHF", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/USD/CHF"},
|
||||||
|
{Instrument: "GBP/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/GBP/USD"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var quotes []MarketQuote
|
||||||
|
|
||||||
|
for _, ep := range endpoints {
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep.URL, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("market fetch %s failed: %v", ep.Instrument, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Printf("market fetch %s bad status: %s", ep.Instrument, resp.Status)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload []sqResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Printf("decode %s failed: %v", ep.Instrument, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if len(payload) == 0 || len(payload[0].SpreadProfilePrices) == 0 {
|
||||||
|
log.Printf("market fetch %s: empty payload", ep.Instrument)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bid := payload[0].SpreadProfilePrices[0].Bid
|
||||||
|
quotedAt := time.UnixMilli(payload[0].Timestamp)
|
||||||
|
quotes = append(quotes, MarketQuote{
|
||||||
|
Instrument: ep.Instrument,
|
||||||
|
Bid: bid,
|
||||||
|
QuotedAt: quotedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(quotes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveMarketQuotes(ctx, quotes); err != nil {
|
||||||
|
log.Printf("saveMarketQuotes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("market quotes updated: %d instruments", len(quotes))
|
||||||
|
}
|
||||||
108
background-worker/rmv.go
Normal file
108
background-worker/rmv.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RMVResponse struct {
|
||||||
|
Trip []Trip `json:"Trip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Trip struct {
|
||||||
|
LegList LegList `json:"LegList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegList struct {
|
||||||
|
Leg []Leg `json:"Leg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leg struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Origin Stop `json:"Origin"`
|
||||||
|
Destination Stop `json:"Destination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stop struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
RtTime string `json:"rtTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Verbindung struct {
|
||||||
|
Linie string
|
||||||
|
Abfahrt string
|
||||||
|
Ankunft string
|
||||||
|
Von string
|
||||||
|
Nach string
|
||||||
|
}
|
||||||
|
|
||||||
|
func rmv_request() {
|
||||||
|
u, _ := url.Parse("https://www.rmv.de/hapi/trip")
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("originId", "3018009")
|
||||||
|
q.Set("destId", "3029164")
|
||||||
|
q.Set("format", "json")
|
||||||
|
q.Set("accessId", os.Getenv("RMV_API_KEY"))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
resp, err := http.Get(u.String())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var respData RMVResponse
|
||||||
|
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
if err := dec.Decode(&respData); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verbindungen [][]Verbindung
|
||||||
|
|
||||||
|
for _, trip := range respData.Trip {
|
||||||
|
var tripInfo []Verbindung
|
||||||
|
|
||||||
|
for _, leg := range trip.LegList.Leg {
|
||||||
|
if leg.Type == "WALK" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
abfahrt := leg.Origin.RtTime
|
||||||
|
if abfahrt == "" {
|
||||||
|
abfahrt = leg.Origin.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
ankunft := leg.Destination.RtTime
|
||||||
|
if ankunft == "" {
|
||||||
|
ankunft = leg.Destination.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
tripInfo = append(tripInfo, Verbindung{
|
||||||
|
Linie: leg.Name,
|
||||||
|
Abfahrt: abfahrt,
|
||||||
|
Ankunft: ankunft,
|
||||||
|
Von: leg.Origin.Name,
|
||||||
|
Nach: leg.Destination.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tripInfo) > 0 {
|
||||||
|
verbindungen = append(verbindungen, tripInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
writeVerbindungToSQL(verbindungen)
|
||||||
|
|
||||||
|
log.Println("RMV data updated")
|
||||||
|
|
||||||
|
if err := writeVerbindungToSQL(verbindungen); err != nil {
|
||||||
|
log.Printf("failed to persist RMV data: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
background-worker/rss.go
Normal file
66
background-worker/rss.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fetchRSSFeeds() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
feeds, err := fetchPendingFeeds(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("fetchPendingFeeds failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(feeds) == 0 {
|
||||||
|
log.Println("no feeds due for refresh")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := gofeed.NewParser()
|
||||||
|
|
||||||
|
for _, feed := range feeds {
|
||||||
|
fp, err := parser.ParseURL(feed.URL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("parse feed %s: %v", feed.URL, err)
|
||||||
|
if err := setFeedAccess(ctx, feed.ID, false); err != nil {
|
||||||
|
log.Printf("disable feed %d failed: %v", feed.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var articles []Article
|
||||||
|
for _, item := range fp.Items {
|
||||||
|
var published *time.Time
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
published = item.PublishedParsed
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := item.Description
|
||||||
|
if summary == "" {
|
||||||
|
summary = item.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
articles = append(articles, Article{
|
||||||
|
ArticleID: articleIDFromLink(item.Link),
|
||||||
|
FeedID: feed.ID,
|
||||||
|
Title: item.Title,
|
||||||
|
Link: item.Link,
|
||||||
|
Summary: summary,
|
||||||
|
PublishedAt: published,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveArticles(ctx, feed.ID, articles); err != nil {
|
||||||
|
log.Printf("save articles for feed %d: %v", feed.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("processed feed %d (%s) with %d items", feed.ID, feed.URL, len(articles))
|
||||||
|
}
|
||||||
|
}
|
||||||
217
background-worker/sql_work.go
Normal file
217
background-worker/sql_work.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getenv(k, fallback string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDSN() string {
|
||||||
|
if url := os.Getenv("DATABASE_URL"); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
host := getenv("POSTGRES_HOST", "database")
|
||||||
|
port := getenv("POSTGRES_PORT", "5432")
|
||||||
|
user := getenv("POSTGRES_USER", "app")
|
||||||
|
pass := getenv("POSTGRES_PASSWORD", "appsecret")
|
||||||
|
name := getenv("POSTGRES_DB", "appdb")
|
||||||
|
|
||||||
|
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, pass, host, port, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedMeta struct {
|
||||||
|
ID int
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Article struct {
|
||||||
|
ArticleID string
|
||||||
|
FeedID int
|
||||||
|
Title string
|
||||||
|
Link string
|
||||||
|
Summary string
|
||||||
|
PublishedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarketQuote struct {
|
||||||
|
Instrument string
|
||||||
|
Bid float64
|
||||||
|
QuotedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVerbindungToSQL(verbindungen [][]Verbindung) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
dsn := buildDSN()
|
||||||
|
|
||||||
|
conn, err := pgx.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect db: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
tx, err := conn.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, "DELETE FROM rmv_data"); err != nil {
|
||||||
|
return fmt.Errorf("clear rmv_data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt := `INSERT INTO rmv_data (trip_index, leg_index, linie, abfahrt, ankunft, von, nach) VALUES ($1,$2,$3,$4,$5,$6,$7)`
|
||||||
|
|
||||||
|
for tripIdx, trip := range verbindungen {
|
||||||
|
for legIdx, leg := range trip {
|
||||||
|
if _, err := tx.Exec(ctx, stmt, tripIdx, legIdx, leg.Linie, leg.Abfahrt, leg.Ankunft, leg.Von, leg.Nach); err != nil {
|
||||||
|
return fmt.Errorf("insert trip %d leg %d: %w", tripIdx, legIdx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPendingFeeds(ctx context.Context) ([]FeedMeta, error) {
|
||||||
|
dsn := buildDSN()
|
||||||
|
conn, err := pgx.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect db: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, `
|
||||||
|
SELECT id, url
|
||||||
|
FROM feeds
|
||||||
|
WHERE access = TRUE
|
||||||
|
AND (last_checked IS NULL OR last_checked < now() - interval '5 minutes')
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query feeds: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var feeds []FeedMeta
|
||||||
|
for rows.Next() {
|
||||||
|
var f FeedMeta
|
||||||
|
if err := rows.Scan(&f.ID, &f.URL); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan feed: %w", err)
|
||||||
|
}
|
||||||
|
feeds = append(feeds, f)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate feeds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return feeds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func articleIDFromLink(link string) string {
|
||||||
|
h := sha1.Sum([]byte(link))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveArticles(ctx context.Context, feedID int, articles []Article) error {
|
||||||
|
dsn := buildDSN()
|
||||||
|
conn, err := pgx.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect db: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
tx, err := conn.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
stmt := `
|
||||||
|
INSERT INTO articles (article_id, feed_id, title, link, summary, published_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (article_id) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
for _, a := range articles {
|
||||||
|
aid := a.ArticleID
|
||||||
|
if aid == "" {
|
||||||
|
aid = articleIDFromLink(a.Link)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, stmt, aid, feedID, a.Title, a.Link, a.Summary, a.PublishedAt); err != nil {
|
||||||
|
return fmt.Errorf("insert article for feed %d: %w", feedID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE feeds SET last_checked = now() WHERE id = $1`, feedID); err != nil {
|
||||||
|
return fmt.Errorf("update feed timestamp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFeedAccess(ctx context.Context, feedID int, access bool) error {
|
||||||
|
dsn := buildDSN()
|
||||||
|
conn, err := pgx.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect db: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, `UPDATE feeds SET access = $1 WHERE id = $2`, access, feedID); err != nil {
|
||||||
|
return fmt.Errorf("update feed access: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMarketQuotes(ctx context.Context, quotes []MarketQuote) error {
|
||||||
|
dsn := buildDSN()
|
||||||
|
conn, err := pgx.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect db: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
batch := &pgx.Batch{}
|
||||||
|
for _, q := range quotes {
|
||||||
|
batch.Queue(
|
||||||
|
`INSERT INTO market_quotes (instrument, bid, quoted_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (instrument) DO UPDATE
|
||||||
|
SET bid = EXCLUDED.bid, quoted_at = EXCLUDED.quoted_at`,
|
||||||
|
q.Instrument, q.Bid, q.QuotedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
br := conn.SendBatch(ctx, batch)
|
||||||
|
defer br.Close()
|
||||||
|
|
||||||
|
for range quotes {
|
||||||
|
if _, err := br.Exec(); err != nil {
|
||||||
|
return fmt.Errorf("insert market quote: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
3
db/Dockerfile
Normal file
3
db/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM postgres:16
|
||||||
|
|
||||||
|
COPY init.sql /docker-entrypoint-initdb.d/init.sql
|
||||||
94
db/init.sql
Normal file
94
db/init.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'Init gestartet';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload JSONB,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
result JSONB,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT now(),
|
||||||
|
updated_at TIMESTAMP DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rmv_data (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
trip_index INT NOT NULL,
|
||||||
|
leg_index INT NOT NULL,
|
||||||
|
linie TEXT,
|
||||||
|
abfahrt TEXT,
|
||||||
|
ankunft TEXT,
|
||||||
|
von TEXT,
|
||||||
|
nach TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feeds (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
access BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_checked TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMP DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ensure last_checked exists when rerunning against an existing DB
|
||||||
|
ALTER TABLE IF EXISTS feeds ADD COLUMN IF NOT EXISTS last_checked TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
feed_id INT NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
link TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
image TEXT,
|
||||||
|
image_claimed_at TIMESTAMPTZ,
|
||||||
|
image_claimed_by TEXT,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMP DEFAULT now(),
|
||||||
|
CONSTRAINT articles_feed_link_uniq UNIQUE(feed_id, link),
|
||||||
|
CONSTRAINT articles_article_id_uniq UNIQUE(article_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'article_id') THEN
|
||||||
|
ALTER TABLE articles ADD COLUMN article_id TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'image') THEN
|
||||||
|
ALTER TABLE articles ADD COLUMN image TEXT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'image_claimed_at') THEN
|
||||||
|
ALTER TABLE articles ADD COLUMN image_claimed_at TIMESTAMPTZ;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'image_claimed_by') THEN
|
||||||
|
ALTER TABLE articles ADD COLUMN image_claimed_by TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'articles_article_id_uniq') THEN
|
||||||
|
ALTER TABLE articles ADD CONSTRAINT articles_article_id_uniq UNIQUE(article_id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS market_quotes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
instrument TEXT NOT NULL,
|
||||||
|
bid NUMERIC NOT NULL,
|
||||||
|
quoted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMP DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'market_quotes_instrument_uniq') THEN
|
||||||
|
ALTER TABLE market_quotes ADD CONSTRAINT market_quotes_instrument_uniq UNIQUE (instrument);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Seed feeds from CSV (expects file at /docker-entrypoint-initdb.d/data/news_rss_feeds.csv)
|
||||||
|
COPY feeds (url)
|
||||||
|
FROM '/docker-entrypoint-initdb.d/data/news_rss_feeds.csv'
|
||||||
|
WITH (FORMAT csv, HEADER true);
|
||||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
services:
|
||||||
|
database:
|
||||||
|
build: ./db
|
||||||
|
image: bunker-database:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
# optional: nur lokal erreichbar, falls du psql vom Server nutzen willst
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT}:5432"
|
||||||
|
volumes:
|
||||||
|
- ./volumes/postgres:/var/lib/postgresql/data
|
||||||
|
- ./volumes/db-init/data:/docker-entrypoint-initdb.d/data:ro
|
||||||
|
- ./volumes/data:/data
|
||||||
|
|
||||||
|
server-app:
|
||||||
|
build: ./server-app
|
||||||
|
image: bunker-server-app:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
APP_PORT: ${APP_PORT}
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?sslmode=disable
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
volumes:
|
||||||
|
- ./volumes/web:/app/web
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
|
||||||
|
ai-worker:
|
||||||
|
build: ./ai-worker
|
||||||
|
image: bunker-ai-worker:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?sslmode=disable
|
||||||
|
SD_MODEL_PATH: /app/models/sd15
|
||||||
|
IMAGE_OUTPUT_DIR: /app/images
|
||||||
|
volumes:
|
||||||
|
- ./volumes/ai-models:/app/models
|
||||||
|
- ./volumes/images:/app/images
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
|
||||||
|
background-worker:
|
||||||
|
build: ./background-worker
|
||||||
|
image: bunker-background-worker:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?sslmode=disable
|
||||||
|
RMV_API_KEY: ${RMV_API_KEY}
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
11
server-app/dockerfile
Normal file
11
server-app/dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM golang:1.24
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["go", "run", "main.go"]
|
||||||
11
server-app/go.mod
Normal file
11
server-app/go.mod
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module server-app
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
26
server-app/go.sum
Normal file
26
server-app/go.sum
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
450
server-app/main.go
Normal file
450
server-app/main.go
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type defconState struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
Level int `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *defconState) get() int {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return d.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *defconState) set(level int) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.Level = level
|
||||||
|
}
|
||||||
|
|
||||||
|
var defcon = defconState{Level: 5}
|
||||||
|
var marketOrder = []string{
|
||||||
|
"USD/JPY",
|
||||||
|
"OIL/USD",
|
||||||
|
"XAU/USD",
|
||||||
|
"USD/EUR",
|
||||||
|
"USD/CHF",
|
||||||
|
"USD/GBP",
|
||||||
|
}
|
||||||
|
|
||||||
|
var marketAliases = map[string]string{
|
||||||
|
"yen": "USD/JPY",
|
||||||
|
"oil": "OIL/USD",
|
||||||
|
"gold": "XAU/USD",
|
||||||
|
"usd_eur": "USD/EUR",
|
||||||
|
"usd_chf": "USD/CHF",
|
||||||
|
"usd_gbp": "USD/GBP",
|
||||||
|
"EUR/USD": "USD/EUR",
|
||||||
|
"EUR/GBP": "USD/GBP",
|
||||||
|
}
|
||||||
|
|
||||||
|
var uniMainzNet = mustParseCIDR("134.93.0.0/16")
|
||||||
|
|
||||||
|
func mustParseCIDR(cidr string) *net.IPNet {
|
||||||
|
_, block, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid CIDR %q: %v", cidr, err)
|
||||||
|
}
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) net.IP {
|
||||||
|
var candidates []string
|
||||||
|
|
||||||
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||||
|
parts := strings.Split(fwd, ",")
|
||||||
|
for _, p := range parts {
|
||||||
|
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||||
|
candidates = append(candidates, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
|
||||||
|
candidates = append(candidates, real)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err == nil && host != "" {
|
||||||
|
candidates = append(candidates, host)
|
||||||
|
} else if r.RemoteAddr != "" {
|
||||||
|
candidates = append(candidates, r.RemoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range candidates {
|
||||||
|
ip := net.ParseIP(raw)
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
return ip4
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDSN() string {
|
||||||
|
if url := os.Getenv("DATABASE_URL"); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
host := envOr("POSTGRES_HOST", "database")
|
||||||
|
port := envOr("POSTGRES_PORT", "5432")
|
||||||
|
user := envOr("POSTGRES_USER", "app")
|
||||||
|
pass := envOr("POSTGRES_PASSWORD", "appsecret")
|
||||||
|
name := envOr("POSTGRES_DB", "appdb")
|
||||||
|
|
||||||
|
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, pass, host, port, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalInstrumentName(name string) (string, bool) {
|
||||||
|
if canon, ok := marketAliases[name]; ok {
|
||||||
|
name = canon
|
||||||
|
}
|
||||||
|
for _, target := range marketOrder {
|
||||||
|
if target == name {
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestQuotes(ctx context.Context, conn *pgx.Conn, requested []string) (map[string]float64, error) {
|
||||||
|
lookup := append([]string{}, requested...)
|
||||||
|
for alias := range marketAliases {
|
||||||
|
lookup = append(lookup, alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, `
|
||||||
|
SELECT instrument, bid
|
||||||
|
FROM market_quotes
|
||||||
|
WHERE instrument = ANY($1)
|
||||||
|
ORDER BY quoted_at DESC
|
||||||
|
`, lookup)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
result := map[string]float64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var instrument string
|
||||||
|
var bid float64
|
||||||
|
if err := rows.Scan(&instrument, &bid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
canon, ok := canonicalInstrumentName(instrument)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := result[canon]; !exists {
|
||||||
|
result[canon] = bid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func withConn(r *http.Request, fn func(conn *pgx.Conn) error) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
conn, err := pgx.Connect(ctx, buildDSN())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
return fn(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveFile(path string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, filepath.Clean(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||||
|
|
||||||
|
http.HandleFunc("/", serveFile("web/template/index.html"))
|
||||||
|
http.HandleFunc("/lite", serveFile("web/template/index-lite.html"))
|
||||||
|
http.HandleFunc("/blog", serveFile("web/template/blog.html"))
|
||||||
|
http.HandleFunc("/mandelbrot", serveFile("web/template/mandelbrot.html"))
|
||||||
|
http.HandleFunc("/media/gong", serveFile("web/static/media/gong.mp4"))
|
||||||
|
http.HandleFunc("/media/cantina", serveFile("web/static/media/cantina.mp3"))
|
||||||
|
|
||||||
|
http.HandleFunc("/api/artikeltext", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||||
|
|
||||||
|
rows, err := conn.Query(r.Context(), `
|
||||||
|
SELECT title, link, summary, image, published_at
|
||||||
|
FROM articles
|
||||||
|
WHERE image IS NOT NULL
|
||||||
|
ORDER BY COALESCE(published_at, created_at) DESC
|
||||||
|
LIMIT 100
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// 🔑 Header einmal setzen
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
// hilft bei manchen Proxies:
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Kickstart: sofort 1 Byte senden, damit Streaming „anfängt“
|
||||||
|
|
||||||
|
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("streaming not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte("\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var title, link, summary string
|
||||||
|
var image *string
|
||||||
|
var published *time.Time
|
||||||
|
|
||||||
|
if err := rows.Scan(&title, &link, &summary, &image, &published); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"link": link,
|
||||||
|
"text": summary,
|
||||||
|
"image": image,
|
||||||
|
"publishedAt": published,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 👇 EIN Artikel = EIN JSON
|
||||||
|
if err := enc.Encode(item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher.Flush() // 🚀 sofort senden
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.Err()
|
||||||
|
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("artikeltext query failed: %v", err)
|
||||||
|
http.Error(w, "stream error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/images", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||||
|
rows, err := conn.Query(r.Context(), `
|
||||||
|
SELECT title, image, published_at
|
||||||
|
FROM articles
|
||||||
|
ORDER BY COALESCE(published_at, created_at) DESC
|
||||||
|
LIMIT 10000
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("streaming not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var title string
|
||||||
|
var image *string
|
||||||
|
var published *time.Time
|
||||||
|
if err := rows.Scan(&title, &image, &published); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"image": image,
|
||||||
|
"publishedAt": published,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := enc.Encode(item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
|
||||||
|
}); err != nil {
|
||||||
|
// Fallback: keine News statt Fehler, damit die Seite weiterlädt.
|
||||||
|
log.Printf("Image query failed, returning empty list: %v", err)
|
||||||
|
writeJSON(w, http.StatusOK, []map[string]interface{}{})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/rmv", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||||
|
rows, err := conn.Query(r.Context(), `
|
||||||
|
SELECT trip_index, leg_index, linie, abfahrt, ankunft, von, nach
|
||||||
|
FROM rmv_data
|
||||||
|
ORDER BY trip_index, leg_index
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type leg struct {
|
||||||
|
Linie string `json:"linie"`
|
||||||
|
Abfahrt string `json:"abfahrt"`
|
||||||
|
Ankunft string `json:"ankunft"`
|
||||||
|
Von string `json:"von"`
|
||||||
|
Nach string `json:"nach"`
|
||||||
|
}
|
||||||
|
byTrip := map[int][]leg{}
|
||||||
|
var maxTrip int
|
||||||
|
for rows.Next() {
|
||||||
|
var tripIdx, legIdx int
|
||||||
|
var l leg
|
||||||
|
if err := rows.Scan(&tripIdx, &legIdx, &l.Linie, &l.Abfahrt, &l.Ankunft, &l.Von, &l.Nach); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byTrip[tripIdx] = append(byTrip[tripIdx], l)
|
||||||
|
if tripIdx > maxTrip {
|
||||||
|
maxTrip = tripIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
var trips [][]leg
|
||||||
|
for i := 0; i <= maxTrip; i++ {
|
||||||
|
if legs, ok := byTrip[i]; ok {
|
||||||
|
trips = append(trips, legs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"abfahrten": trips})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("rmv query failed: %v", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/market", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := withConn(r, func(conn *pgx.Conn) error {
|
||||||
|
result, err := fetchLatestQuotes(r.Context(), conn, marketOrder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type quote struct {
|
||||||
|
Instrument string `json:"instrument"`
|
||||||
|
Bid float64 `json:"bid"`
|
||||||
|
}
|
||||||
|
var ordered []quote
|
||||||
|
for _, inst := range marketOrder {
|
||||||
|
if bid, ok := result[inst]; ok {
|
||||||
|
ordered = append(ordered, quote{Instrument: inst, Bid: bid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ordered)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("market query failed: %v", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/defcon", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"level": defcon.get()})
|
||||||
|
case http.MethodPost:
|
||||||
|
var body struct {
|
||||||
|
Level int `json:"level"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Level < 1 || body.Level > 5 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "level must be 1-5"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defcon.set(body.Level)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"level": body.Level})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/bunker-status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := clientIP(r)
|
||||||
|
var ipString string
|
||||||
|
if ip != nil {
|
||||||
|
ipString = ip.String()
|
||||||
|
}
|
||||||
|
inMainz := ip != nil && uniMainzNet.Contains(ip)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"online": inMainz,
|
||||||
|
"ts": time.Now().UTC(),
|
||||||
|
"client_ip": ipString,
|
||||||
|
"network": uniMainzNet.String(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/api/countdowns", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, filepath.Clean("web/static/media/countdowns.json"))
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Println("Server läuft auf http://0.0.0.0:8080")
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
|
}
|
||||||
BIN
volumes/.DS_Store
vendored
Normal file
BIN
volumes/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
volumes/db-init/.DS_Store
vendored
Normal file
BIN
volumes/db-init/.DS_Store
vendored
Normal file
Binary file not shown.
33
volumes/db-init/data/news_rss_feeds.csv
Normal file
33
volumes/db-init/data/news_rss_feeds.csv
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
RSS
|
||||||
|
https://feeds.bbci.co.uk/news/politics/rss.xml
|
||||||
|
https://feeds.bbci.co.uk/news/business/rss.xml
|
||||||
|
https://rss.nytimes.com/services/xml/rss/nyt/World.xml
|
||||||
|
https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml
|
||||||
|
https://rss.nytimes.com/services/xml/rss/nyt/Business.xml
|
||||||
|
https://www.reutersagency.com/feed/?best-topics=business-finance&post_type=best
|
||||||
|
https://www.reutersagency.com/feed/?best-topics=politics&post_type=best
|
||||||
|
https://www.euronews.com/rss?level=theme&name=News
|
||||||
|
https://www.ft.com/?format=rss
|
||||||
|
https://www.aljazeera.com/xml/rss/all.xml
|
||||||
|
https://www.cnbc.com/id/100003114/device/rss/rss.html
|
||||||
|
https://feeds.a.dj.com/rss/RSSWorldNews.xml
|
||||||
|
https://feeds.a.dj.com/rss/RSSPolitics.xml
|
||||||
|
https://feeds.a.dj.com/rss/RSSMarketsMain.xml
|
||||||
|
https://apnews.com/apf-politics
|
||||||
|
https://apnews.com/apf-business
|
||||||
|
https://www.theguardian.com/world/rss
|
||||||
|
https://www.theguardian.com/politics/rss
|
||||||
|
https://www.theguardian.com/business/rss
|
||||||
|
https://www.politico.com/rss/politics08.xml
|
||||||
|
https://www.politico.eu/feed/
|
||||||
|
https://www.npr.org/rss/rss.php?id=1001
|
||||||
|
https://www.npr.org/rss/rss.php?id=1012
|
||||||
|
https://www.npr.org/rss/rss.php?id=1006
|
||||||
|
https://www.haaretz.com/cmlink/haaretz-rss-feed-1.804163
|
||||||
|
https://www.jpost.com/Rss/RssFeedsHeadlines.aspx
|
||||||
|
https://www.timesofisrael.com/feed/
|
||||||
|
https://www.tagesschau.de/infoservices/alle-meldungen-100~rss2.xml
|
||||||
|
https://www.faz.net/rss/aktuell
|
||||||
|
https://feeds.washingtonpost.com/rss/world
|
||||||
|
https://www.scmp.com/rss/91/feed/
|
||||||
|
https://rss.sueddeutsche.de/rss/Alles
|
||||||
|
BIN
volumes/web/.DS_Store
vendored
Normal file
BIN
volumes/web/.DS_Store
vendored
Normal file
Binary file not shown.
90
volumes/web/static/css/blog.css
Normal file
90
volumes/web/static/css/blog.css
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
:root {
|
||||||
|
--tile-size: 512px;
|
||||||
|
--gap: 0px;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #000;
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
main.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--gap);
|
||||||
|
width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
width: min(var(--tile-size), 100%);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.6);
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||||
|
}
|
||||||
|
.tile:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.tile img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: filter 0.25s ease;
|
||||||
|
}
|
||||||
|
.tile.open img {
|
||||||
|
filter: blur(2px) brightness(0.4);
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.78), rgba(0,0,0,0.92));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.tile.open .overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.overlay h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.overlay p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #d8d8d8;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.overlay a {
|
||||||
|
margin-top: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
color: #8ad8ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.overlay a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
112
volumes/web/static/css/index-lite.css
Normal file
112
volumes/web/static/css/index-lite.css
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #121212;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #121212;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions a,
|
||||||
|
.actions button {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #e6e6e6;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-item {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-item:first-child {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-text {
|
||||||
|
color: #c9c9c9;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-link {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-link a {
|
||||||
|
color: #8ecbff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-list,
|
||||||
|
.rmv-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #9a9a9a;
|
||||||
|
}
|
||||||
295
volumes/web/static/css/index.css
Normal file
295
volumes/web/static/css/index.css
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #121212;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: inherit;
|
||||||
|
padding: 0.75rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
HEADER: RESPONSIVE / HARMONISCH
|
||||||
|
========================= */
|
||||||
|
:root {
|
||||||
|
/* Höhe/Größe hängt von viewport-Breite ab:
|
||||||
|
- min: 44px (kleine Screens)
|
||||||
|
- ideal: ~6vw
|
||||||
|
- max: 60px (große Screens) */
|
||||||
|
--hdr-h: clamp(44px, 6vw, 60px);
|
||||||
|
|
||||||
|
/* Rundung skaliert leicht mit */
|
||||||
|
--hdr-r: clamp(8px, 1.2vw, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: clamp(8px, 1.2vw, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links: BUNKER/Hazard so schmal wie möglich */
|
||||||
|
#hazard-symbol {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
height: var(--hdr-h);
|
||||||
|
padding: 0 clamp(10px, 1.2vw, 14px);
|
||||||
|
|
||||||
|
/* wichtig: nicht breitziehen */
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: var(--hdr-r);
|
||||||
|
|
||||||
|
font-size: clamp(1.35rem, 2.6vw, 1.65rem); /* noch größer */
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rechts: Button-Container */
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(8px, 1vw, 12px);
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Basis-Button */
|
||||||
|
.header-actions a,
|
||||||
|
.header-actions button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
height: var(--hdr-h);
|
||||||
|
border-radius: var(--hdr-r);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(1.3rem, 2.45vw, 1.58rem); /* noch größer */
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0; /* wird unten je nach Typ gesetzt */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rechts: ALLE ICONS (a) quadratisch
|
||||||
|
(außer DEFCON, das ist button) */
|
||||||
|
.header-actions a {
|
||||||
|
width: var(--hdr-h);
|
||||||
|
min-width: var(--hdr-h);
|
||||||
|
padding: 0;
|
||||||
|
font-size: clamp(1.5rem, 3.1vw, 1.95rem); /* noch größer */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DEFCON: bekommt genug Platz, damit Text ohne Umbruch passt */
|
||||||
|
#defcon-button {
|
||||||
|
height: var(--hdr-h);
|
||||||
|
|
||||||
|
/* Breite: automatisch nach Inhalt, aber mit Mindestbreite */
|
||||||
|
width: auto;
|
||||||
|
min-width: clamp(130px, 18vw, 190px);
|
||||||
|
|
||||||
|
padding: 0 clamp(12px, 1.6vw, 18px);
|
||||||
|
gap: clamp(6px, 1vw, 10px);
|
||||||
|
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--hdr-r);
|
||||||
|
|
||||||
|
/* garantiert: kein Umbruch/Spacing-Problem */
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional, falls du im DEFCON-Button ein Emoji + Text hast:
|
||||||
|
<button id="defcon-button"><span>🚨</span><span>DEFCON 3</span></button> */
|
||||||
|
#defcon-button span {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Farben wie bei dir (minimal angepasst möglich, aber ich lasse sie) */
|
||||||
|
.header-actions a:nth-child(1) { background-color: #ff0000; }
|
||||||
|
.header-actions a:nth-child(2) { background-color: #6fff93; color: #000; }
|
||||||
|
.header-actions a:nth-child(3) { background-color: #339933; }
|
||||||
|
.header-actions a:nth-child(4),
|
||||||
|
.header-actions a:nth-child(5) { background-color: #3333cc; }
|
||||||
|
.header-actions button { background-color: #ffaa00; color: #000; }
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
TICKER
|
||||||
|
========================= */
|
||||||
|
.market-ticker {
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid #2b2b2b;
|
||||||
|
border-bottom: 1px solid #2b2b2b;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-ticker::before,
|
||||||
|
.market-ticker::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 80px;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-ticker::before {
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(90deg, #121212, rgba(18, 18, 18, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-ticker::after {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(270deg, #121212, rgba(18, 18, 18, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-ticker-track {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: ticker-move var(--ticker-duration, 30s) linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-ticker-track:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ticker-move {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
NEWS
|
||||||
|
========================= */
|
||||||
|
.news-item {
|
||||||
|
background: #1e1e1e;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-text {
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: #cccccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-link a {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #4da6ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-thumb {
|
||||||
|
width: 160px;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0f0f0f;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#news-container {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
SIDEBAR / ABFAHRT
|
||||||
|
========================= */
|
||||||
|
#abfahrt-wrapper {
|
||||||
|
flex: 0 0 300px;
|
||||||
|
position: static;
|
||||||
|
background-color: #1e1e2f;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-top: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#abfahrt-wrapper h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abfahrt-eintrag {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-block {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
padding: 0.5em;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-highlight {
|
||||||
|
background-color: #a00000;
|
||||||
|
color: white;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
216
volumes/web/static/css/mandelbrot.css
Normal file
216
volumes/web/static/css/mandelbrot.css
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
body {
|
||||||
|
background: black;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-container {
|
||||||
|
position: relative;
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 32px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background-color: red;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg) translateY(-184px);
|
||||||
|
animation: rotate 60s linear infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg) translateY(-184px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg) translateY(-184px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-circle-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 40px;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% - 60px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-circle {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border: 20px solid;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-dot {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg) translateY(-90px);
|
||||||
|
animation: rotate-small 60s linear infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate-small {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg) translateY(-90px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg) translateY(-90px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#fullscreen-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: rgb(0, 0, 0);
|
||||||
|
color: black;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-timer-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-details {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 320px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.4);
|
||||||
|
color: #f1f1f1;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-details.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-details h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-details .timer-meta {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-entry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-entry .label {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #eaeaea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-entry .countdown {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #9fd3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-details .close-btn {
|
||||||
|
background: #222;
|
||||||
|
color: #f1f1f1;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-details .close-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#twitter-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
BIN
volumes/web/static/img/minecraft.png
Normal file
BIN
volumes/web/static/img/minecraft.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
90
volumes/web/static/js/blog.js
Normal file
90
volumes/web/static/js/blog.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const grid = document.getElementById("grid");
|
||||||
|
|
||||||
|
function createTile(artikel) {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "tile";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = artikel.image;
|
||||||
|
img.alt = artikel.title || "Artikelbild";
|
||||||
|
tile.appendChild(img);
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "overlay";
|
||||||
|
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.textContent = artikel.title || "Ohne Titel";
|
||||||
|
overlay.appendChild(title);
|
||||||
|
|
||||||
|
tile.appendChild(overlay);
|
||||||
|
|
||||||
|
tile.addEventListener("click", () => tile.classList.toggle("open"));
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeGalerie() {
|
||||||
|
grid.innerHTML = "";
|
||||||
|
let foundAny = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/images", {
|
||||||
|
headers: { "Accept": "application/x-ndjson" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
if (!res.body) throw new Error("Streaming nicht unterstützt");
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const s = line.trim();
|
||||||
|
if (!s) continue;
|
||||||
|
|
||||||
|
let artikel;
|
||||||
|
try {
|
||||||
|
artikel = JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artikel?.image) {
|
||||||
|
grid.appendChild(createTile(artikel));
|
||||||
|
foundAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest verarbeiten
|
||||||
|
const last = buffer.trim();
|
||||||
|
if (last) {
|
||||||
|
try {
|
||||||
|
const artikel = JSON.parse(last);
|
||||||
|
if (artikel?.image) {
|
||||||
|
grid.appendChild(createTile(artikel));
|
||||||
|
foundAny = true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundAny) {
|
||||||
|
grid.innerHTML = '<div class="empty">Keine Bilder gefunden.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
grid.innerHTML = '<div class="empty">Fehler beim Laden der Galerie.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ladeGalerie();
|
||||||
372
volumes/web/static/js/index-lite.js
Normal file
372
volumes/web/static/js/index-lite.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
const stripTags = (html) => (typeof html === "string" ? html.replace(/<[^>]*>/g, "") : "");
|
||||||
|
const formatBid = (value) => {
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return "n/a";
|
||||||
|
const abs = Math.abs(num);
|
||||||
|
if (abs >= 100) return num.toFixed(2);
|
||||||
|
if (abs >= 10) return num.toFixed(3);
|
||||||
|
return num.toFixed(4);
|
||||||
|
};
|
||||||
|
const instrumentEmoji = (inst) => {
|
||||||
|
const map = {
|
||||||
|
"USD/JPY": "$/¥",
|
||||||
|
"OIL/USD": "🛢️",
|
||||||
|
"XAU/USD": "⛏️",
|
||||||
|
"USD/EUR": "€/$",
|
||||||
|
"USD/CHF": "$/CHF",
|
||||||
|
"USD/GBP": "£/$",
|
||||||
|
};
|
||||||
|
return map[inst] || inst;
|
||||||
|
};
|
||||||
|
|
||||||
|
const newsContainer = document.getElementById("news-container");
|
||||||
|
const abfahrtWrapper = document.getElementById("abfahrt-wrapper");
|
||||||
|
const abfahrtInfo = document.getElementById("abfahrt-info");
|
||||||
|
const marketTrack = document.getElementById("market-ticker-track");
|
||||||
|
const marketContainer = document.getElementById("market-ticker");
|
||||||
|
|
||||||
|
async function loadNews() {
|
||||||
|
if (!newsContainer) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/artikeltext", {
|
||||||
|
headers: { "Accept": "application/x-ndjson" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
items.push({
|
||||||
|
title: stripTags(parsed.title),
|
||||||
|
text: stripTags(parsed.text),
|
||||||
|
link: parsed.link,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("NDJSON parse error", err);
|
||||||
|
}
|
||||||
|
if (items.length >= 60) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
newsContainer.innerHTML = "";
|
||||||
|
if (!items.length) {
|
||||||
|
newsContainer.innerHTML = '<div class="empty">Keine News gefunden.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "news-item";
|
||||||
|
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "news-title";
|
||||||
|
title.textContent = item.title || "Kein Titel";
|
||||||
|
|
||||||
|
const textEl = document.createElement("div");
|
||||||
|
textEl.className = "news-text";
|
||||||
|
textEl.textContent = item.text || "[Kein Text verfuegbar]";
|
||||||
|
|
||||||
|
const linkWrap = document.createElement("div");
|
||||||
|
linkWrap.className = "news-link";
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = item.link || "#";
|
||||||
|
link.textContent = "Zum Artikel";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
linkWrap.appendChild(link);
|
||||||
|
|
||||||
|
wrapper.appendChild(title);
|
||||||
|
wrapper.appendChild(textEl);
|
||||||
|
wrapper.appendChild(linkWrap);
|
||||||
|
fragment.appendChild(wrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
newsContainer.appendChild(fragment);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("News Fehler:", err);
|
||||||
|
newsContainer.innerHTML = '<div class="empty">Fehler beim Laden der News.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarketTicker(quotes) {
|
||||||
|
if (!marketTrack || !marketContainer) return;
|
||||||
|
|
||||||
|
marketTrack.innerHTML = "";
|
||||||
|
if (!quotes || quotes.length === 0) {
|
||||||
|
marketTrack.textContent = "Keine Marktdaten verfuegbar";
|
||||||
|
marketTrack.style.animation = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const addItems = () => quotes.forEach((q) => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "ticker-item";
|
||||||
|
span.textContent = `${instrumentEmoji(q.instrument)} ${formatBid(q.bid)}`;
|
||||||
|
fragment.appendChild(span);
|
||||||
|
});
|
||||||
|
addItems();
|
||||||
|
addItems();
|
||||||
|
|
||||||
|
marketTrack.appendChild(fragment);
|
||||||
|
const duration = Math.max(90, Math.min(180, quotes.length * 10));
|
||||||
|
marketTrack.style.setProperty("--ticker-duration", `${duration}s`);
|
||||||
|
marketTrack.style.animation = "none";
|
||||||
|
void marketTrack.offsetWidth;
|
||||||
|
marketTrack.style.animation = `ticker-move var(--ticker-duration, ${duration}s) linear infinite`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMarket() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/market", { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error("Market API nicht verfuegbar");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!Array.isArray(data)) throw new Error("Ungueltige Marktdaten");
|
||||||
|
renderMarketTicker(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Marktdaten Fehler:", err);
|
||||||
|
renderMarketTicker([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRMV() {
|
||||||
|
if (!abfahrtInfo || !abfahrtWrapper) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/rmv", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error("RMV-JSON nicht erreichbar");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
abfahrtWrapper.style.display = "block";
|
||||||
|
|
||||||
|
if (!data.abfahrten || data.abfahrten.length === 0) {
|
||||||
|
abfahrtInfo.innerHTML = "<p>Keine Abfahrten gefunden.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripHTML = data.abfahrten.map((trip) => {
|
||||||
|
const legsHTML = trip.map((leg) => `
|
||||||
|
<div>
|
||||||
|
<strong>${leg.linie}</strong><br>
|
||||||
|
${leg.abfahrt} -> ${leg.ankunft}<br>
|
||||||
|
${leg.von} -> ${leg.nach}
|
||||||
|
</div>
|
||||||
|
`).join("<hr style='border-color: rgba(255,255,255,0.2);'>");
|
||||||
|
|
||||||
|
return `<div class="trip-block"><div class="abfahrt-eintrag">${legsHTML}</div></div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
abfahrtInfo.innerHTML = tripHTML;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("RMV-Info nicht geladen:", err);
|
||||||
|
abfahrtInfo.innerText = "Fehler beim Laden.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMensaIframe() {
|
||||||
|
const container = document.getElementById("mensa-iframe-container");
|
||||||
|
container.style.display = (container.style.display === "none") ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeGongVideo() {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.style.position = "fixed";
|
||||||
|
overlay.style.top = 0;
|
||||||
|
overlay.style.left = 0;
|
||||||
|
overlay.style.width = "100vw";
|
||||||
|
overlay.style.height = "100vh";
|
||||||
|
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
overlay.style.alignItems = "center";
|
||||||
|
overlay.style.justifyContent = "center";
|
||||||
|
overlay.style.zIndex = 9999;
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "/media/gong";
|
||||||
|
video.autoplay = true;
|
||||||
|
video.controls = true;
|
||||||
|
video.style.maxWidth = "90%";
|
||||||
|
video.style.maxHeight = "90%";
|
||||||
|
|
||||||
|
overlay.appendChild(video);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
video.onended = () => document.body.removeChild(overlay);
|
||||||
|
overlay.onclick = () => document.body.removeChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeYouTubeVideo() {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.style.position = "fixed";
|
||||||
|
overlay.style.top = 0;
|
||||||
|
overlay.style.left = 0;
|
||||||
|
overlay.style.width = "100vw";
|
||||||
|
overlay.style.height = "100vh";
|
||||||
|
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
overlay.style.alignItems = "center";
|
||||||
|
overlay.style.justifyContent = "center";
|
||||||
|
overlay.style.zIndex = 9999;
|
||||||
|
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
const videoIds = [
|
||||||
|
"zhDwjnYZiCo",
|
||||||
|
"Na0w3Mz46GA",
|
||||||
|
"OO2kPK5-qno",
|
||||||
|
"Yqk13qPcXis",
|
||||||
|
"MZhivjxcF-M",
|
||||||
|
"uMEvzhckqBw",
|
||||||
|
"uMEvzhckqBw",
|
||||||
|
"TGan48YE9Us",
|
||||||
|
"-Xh4BNbxpI8",
|
||||||
|
"r7kxh_vuBpo",
|
||||||
|
"bdUbACCWmoY",
|
||||||
|
];
|
||||||
|
const zufallsId = videoIds[Math.floor(Math.random() * videoIds.length)];
|
||||||
|
iframe.src = `https://www.youtube.com/embed/${zufallsId}?autoplay=1`;
|
||||||
|
iframe.allow = "autoplay; fullscreen";
|
||||||
|
iframe.allowFullscreen = true;
|
||||||
|
iframe.style.width = "90%";
|
||||||
|
iframe.style.height = "90%";
|
||||||
|
iframe.style.border = "none";
|
||||||
|
|
||||||
|
overlay.appendChild(iframe);
|
||||||
|
overlay.onclick = () => document.body.removeChild(overlay);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
if (overlay.requestFullscreen) {
|
||||||
|
overlay.requestFullscreen();
|
||||||
|
} else if (overlay.webkitRequestFullscreen) {
|
||||||
|
overlay.webkitRequestFullscreen();
|
||||||
|
} else if (overlay.msRequestFullscreen) {
|
||||||
|
overlay.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const hazard = document.getElementById("hazard-symbol");
|
||||||
|
if (hazard) {
|
||||||
|
hazard.addEventListener("click", () => {
|
||||||
|
const elem = document.documentElement;
|
||||||
|
if (elem.requestFullscreen) {
|
||||||
|
elem.requestFullscreen();
|
||||||
|
} else if (elem.webkitRequestFullscreen) {
|
||||||
|
elem.webkitRequestFullscreen();
|
||||||
|
} else if (elem.msRequestFullscreen) {
|
||||||
|
elem.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentDefcon = 5;
|
||||||
|
|
||||||
|
function aktualisiereDefconButton(level) {
|
||||||
|
const btn = document.getElementById("defcon-button");
|
||||||
|
if (!btn) return;
|
||||||
|
const farben = {
|
||||||
|
1: "#ff0000",
|
||||||
|
2: "#ff9900",
|
||||||
|
3: "#ffff00",
|
||||||
|
4: "#0000ff",
|
||||||
|
5: "#000000",
|
||||||
|
};
|
||||||
|
const farbe = farben[level] || "#555";
|
||||||
|
btn.textContent = `⚠️ DEFCON ${level}`;
|
||||||
|
btn.style.backgroundColor = farbe;
|
||||||
|
btn.style.color = level === 3 ? "black" : "white";
|
||||||
|
btn.style.fontWeight = "bold";
|
||||||
|
btn.style.transition = "background-color 0.5s, color 0.5s";
|
||||||
|
|
||||||
|
if (level === 1) {
|
||||||
|
document.body.style.backgroundColor = farbe;
|
||||||
|
} else {
|
||||||
|
document.body.style.backgroundColor = "#000000";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDefconStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/defcon");
|
||||||
|
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||||
|
const data = await res.json();
|
||||||
|
currentDefcon = data.level;
|
||||||
|
aktualisiereDefconButton(currentDefcon);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("DEFCON Abruf fehlgeschlagen:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendeNeuesDefconLevel(level) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/defcon", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ level: level }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Fehler beim Senden des neuen DEFCON-Levels");
|
||||||
|
|
||||||
|
await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fehler beim POST:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDefconStatus() {
|
||||||
|
currentDefcon -= 1;
|
||||||
|
if (currentDefcon < 1) {
|
||||||
|
currentDefcon = 5;
|
||||||
|
}
|
||||||
|
sendeNeuesDefconLevel(currentDefcon);
|
||||||
|
aktualisiereDefconButton(currentDefcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBunkerStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bunker-status");
|
||||||
|
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const icon = document.getElementById("hazard-symbol");
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
const inMainzNetz = Boolean(data && data.online);
|
||||||
|
|
||||||
|
if (inMainzNetz) {
|
||||||
|
icon.style.backgroundColor = "limegreen";
|
||||||
|
icon.style.color = "black";
|
||||||
|
icon.style.padding = "4px 8px";
|
||||||
|
icon.style.borderRadius = "4px";
|
||||||
|
} else {
|
||||||
|
icon.style.backgroundColor = "";
|
||||||
|
icon.style.color = "";
|
||||||
|
icon.title = "";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Bunker-Status nicht abrufbar:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBunkerStatus();
|
||||||
|
loadDefconStatus();
|
||||||
|
loadNews();
|
||||||
|
loadRMV();
|
||||||
|
loadMarket();
|
||||||
|
|
||||||
|
setInterval(loadBunkerStatus, 5 * 60 * 1000);
|
||||||
|
setInterval(loadDefconStatus, 2 * 60 * 1000);
|
||||||
|
setInterval(loadNews, 2 * 60 * 1000);
|
||||||
|
setInterval(loadRMV, 10 * 60 * 1000);
|
||||||
|
setInterval(loadMarket, 2 * 60 * 1000);
|
||||||
|
|
||||||
|
const defconBtn = document.getElementById("defcon-button");
|
||||||
|
if (defconBtn) {
|
||||||
|
defconBtn.addEventListener("click", newDefconStatus);
|
||||||
|
}
|
||||||
488
volumes/web/static/js/index.js
Normal file
488
volumes/web/static/js/index.js
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
window.addEventListener("load", () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get("lofi") === "true") {
|
||||||
|
zeigeYouTubeVideo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripTags = (html) => (typeof html === "string" ? html.replace(/<[^>]*>/g, "") : "");
|
||||||
|
const formatBid = (value) => {
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return "n/a";
|
||||||
|
const abs = Math.abs(num);
|
||||||
|
if (abs >= 100) return num.toFixed(2);
|
||||||
|
if (abs >= 10) return num.toFixed(3);
|
||||||
|
return num.toFixed(4);
|
||||||
|
};
|
||||||
|
const instrumentEmoji = (inst) => {
|
||||||
|
const map = {
|
||||||
|
"USD/JPY": "$/¥",
|
||||||
|
"OIL/USD": "🛢️",
|
||||||
|
"XAU/USD": "⛏️",
|
||||||
|
"USD/EUR": "€/$",
|
||||||
|
"USD/CHF": "$/CHF",
|
||||||
|
"USD/GBP": "£/$",
|
||||||
|
};
|
||||||
|
return map[inst] || inst;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ladeNews() {
|
||||||
|
const container = document.getElementById("news-container");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const loadingEl = null;
|
||||||
|
|
||||||
|
const newsIndex = new Map();
|
||||||
|
|
||||||
|
const getKey = (nachricht) => {
|
||||||
|
if (nachricht?.link) return `link:${nachricht.link}`;
|
||||||
|
if (nachricht?.title) return `title:${stripTags(nachricht.title)}`;
|
||||||
|
if (nachricht?.publishedAt) return `ts:${nachricht.publishedAt}`;
|
||||||
|
return `idx:${newsIndex.size}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildItem = (nachricht) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "news-item";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "news-thumb";
|
||||||
|
img.loading = "lazy";
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "news-content";
|
||||||
|
|
||||||
|
const titleEl = document.createElement("div");
|
||||||
|
titleEl.className = "news-title";
|
||||||
|
|
||||||
|
const textEl = document.createElement("div");
|
||||||
|
textEl.className = "news-text";
|
||||||
|
|
||||||
|
const linkWrap = document.createElement("div");
|
||||||
|
linkWrap.className = "news-link";
|
||||||
|
const linkEl = document.createElement("a");
|
||||||
|
linkEl.target = "_blank";
|
||||||
|
linkEl.rel = "noopener noreferrer";
|
||||||
|
linkWrap.appendChild(linkEl);
|
||||||
|
|
||||||
|
content.appendChild(titleEl);
|
||||||
|
content.appendChild(textEl);
|
||||||
|
content.appendChild(linkWrap);
|
||||||
|
item.appendChild(content);
|
||||||
|
|
||||||
|
return { item, img, titleEl, textEl, linkEl };
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFields = (state, nachricht) => {
|
||||||
|
const title = stripTags(nachricht.title) || "Kein Titel";
|
||||||
|
const link = nachricht.link || "#";
|
||||||
|
const rawText = stripTags(nachricht.text) || "[Kein Text verfügbar]";
|
||||||
|
const text = rawText.length > 140 ? `${rawText.slice(0, 140)}...` : rawText;
|
||||||
|
|
||||||
|
if (title && state.title !== title) {
|
||||||
|
state.title = title;
|
||||||
|
state.titleEl.textContent = title;
|
||||||
|
}
|
||||||
|
if (text && state.text !== text) {
|
||||||
|
state.text = text;
|
||||||
|
state.textEl.textContent = text;
|
||||||
|
}
|
||||||
|
if (link && state.link !== link) {
|
||||||
|
state.link = link;
|
||||||
|
state.linkEl.href = link;
|
||||||
|
state.linkEl.textContent = "🔗 Zum Artikel";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDataImg = typeof nachricht.image === "string" && nachricht.image.startsWith("data:image");
|
||||||
|
if (isDataImg && state.image !== nachricht.image) {
|
||||||
|
state.image = nachricht.image;
|
||||||
|
state.img.src = nachricht.image;
|
||||||
|
state.img.alt = title || "Artikelbild";
|
||||||
|
if (!state.img.isConnected) {
|
||||||
|
state.item.prepend(state.img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = (nachricht) => {
|
||||||
|
const key = getKey(nachricht);
|
||||||
|
const existing = newsIndex.get(key);
|
||||||
|
if (existing) {
|
||||||
|
updateFields(existing, nachricht);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = buildItem(nachricht);
|
||||||
|
updateFields(state, nachricht);
|
||||||
|
newsIndex.set(key, state);
|
||||||
|
container.appendChild(state.item);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/artikeltext", {
|
||||||
|
headers: { "Accept": "application/x-ndjson" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${res.status}: ${t.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
if (!res.body) throw new Error("Streaming wird vom Browser nicht unterstützt.");
|
||||||
|
|
||||||
|
let foundAny = false;
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const s = line.trim();
|
||||||
|
if (!s) continue;
|
||||||
|
try {
|
||||||
|
renderItem(JSON.parse(s));
|
||||||
|
foundAny = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("NDJSON parse error:", s.slice(0, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = buffer.trim();
|
||||||
|
if (last) {
|
||||||
|
try {
|
||||||
|
renderItem(JSON.parse(last));
|
||||||
|
foundAny = true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingEl && loadingEl.isConnected) loadingEl.remove();
|
||||||
|
if (!foundAny && !container.hasChildNodes()) {
|
||||||
|
container.innerHTML = '<div class="empty">Keine News gefunden.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ladeNews Fehler:", error);
|
||||||
|
if (loadingEl && loadingEl.isConnected) loadingEl.remove();
|
||||||
|
if (!container.hasChildNodes()) {
|
||||||
|
container.innerHTML = '<div class="empty">Fehler beim Laden der News.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeRMV() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/rmv");
|
||||||
|
if (!response.ok) throw new Error("RMV-JSON nicht erreichbar");
|
||||||
|
const daten = await response.json();
|
||||||
|
|
||||||
|
const infoBox = document.getElementById("abfahrt-info");
|
||||||
|
const wrapper = document.getElementById("abfahrt-wrapper");
|
||||||
|
wrapper.style.display = "block";
|
||||||
|
|
||||||
|
if (!daten.abfahrten || daten.abfahrten.length === 0) {
|
||||||
|
infoBox.innerHTML = "<p>Keine Abfahrten gefunden.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripHTML = daten.abfahrten.map(trip => {
|
||||||
|
const legsHTML = trip.map(leg => `
|
||||||
|
<div>
|
||||||
|
<strong>${leg.linie}</strong><br>
|
||||||
|
${leg.abfahrt} → ${leg.ankunft}<br>
|
||||||
|
${leg.von} → ${leg.nach}
|
||||||
|
</div>
|
||||||
|
`).join("<hr style='border-color: rgba(255,255,255,0.2);'>");
|
||||||
|
|
||||||
|
return `<div class="trip-block"><div class="abfahrt-eintrag">${legsHTML}</div></div>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
infoBox.innerHTML = tripHTML;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("RMV-Info nicht geladen:", error);
|
||||||
|
document.getElementById("abfahrt-info").innerText = "Fehler beim Laden.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMensaIframe() {
|
||||||
|
const container = document.getElementById("mensa-iframe-container");
|
||||||
|
container.style.display = (container.style.display === "none") ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeGongVideo() {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.style.position = "fixed";
|
||||||
|
overlay.style.top = 0;
|
||||||
|
overlay.style.left = 0;
|
||||||
|
overlay.style.width = "100vw";
|
||||||
|
overlay.style.height = "100vh";
|
||||||
|
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
overlay.style.alignItems = "center";
|
||||||
|
overlay.style.justifyContent = "center";
|
||||||
|
overlay.style.zIndex = 9999;
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = "/media/gong";
|
||||||
|
video.autoplay = true;
|
||||||
|
video.controls = true;
|
||||||
|
video.style.maxWidth = "90%";
|
||||||
|
video.style.maxHeight = "90%";
|
||||||
|
|
||||||
|
overlay.appendChild(video);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
video.onended = () => document.body.removeChild(overlay);
|
||||||
|
overlay.onclick = () => document.body.removeChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fxCache = [];
|
||||||
|
let fxIndex = 0;
|
||||||
|
function rotiereFX() {
|
||||||
|
const el = document.getElementById("fx-rotator");
|
||||||
|
if (!el || fxCache.length === 0) return;
|
||||||
|
const item = fxCache[fxIndex % fxCache.length];
|
||||||
|
el.textContent = `${instrumentEmoji(item.instrument)} ${formatBid(item.bid)}`;
|
||||||
|
fxIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarketTicker(quotes) {
|
||||||
|
const track = document.getElementById("market-ticker-track");
|
||||||
|
const container = document.getElementById("market-ticker");
|
||||||
|
if (!track || !container) return;
|
||||||
|
|
||||||
|
track.innerHTML = "";
|
||||||
|
if (!quotes || quotes.length === 0) {
|
||||||
|
track.textContent = "Keine Marktdaten verfügbar";
|
||||||
|
track.style.animation = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const addItems = () => quotes.forEach(q => {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "ticker-item";
|
||||||
|
span.textContent = `${instrumentEmoji(q.instrument)} ${formatBid(q.bid)}`;
|
||||||
|
fragment.appendChild(span);
|
||||||
|
});
|
||||||
|
addItems();
|
||||||
|
addItems();
|
||||||
|
|
||||||
|
track.appendChild(fragment);
|
||||||
|
const duration = Math.max(18, Math.min(60, quotes.length * 4));
|
||||||
|
track.style.setProperty("--ticker-duration", `${duration}s`);
|
||||||
|
track.style.animation = "none";
|
||||||
|
void track.offsetWidth;
|
||||||
|
track.style.animation = `ticker-move var(--ticker-duration, ${duration}s) linear infinite`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeMarketDaten() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/market", { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error("Market API nicht verfügbar");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!Array.isArray(data)) throw new Error("Ungültige Marktdaten");
|
||||||
|
fxCache = data;
|
||||||
|
rotiereFX();
|
||||||
|
renderMarketTicker(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Marktdaten Fehler:", err);
|
||||||
|
const el = document.getElementById("fx-rotator");
|
||||||
|
if (el) el.textContent = "FX Fehler";
|
||||||
|
renderMarketTicker([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeYouTubeVideo() {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.style.position = "fixed";
|
||||||
|
overlay.style.top = 0;
|
||||||
|
overlay.style.left = 0;
|
||||||
|
overlay.style.width = "100vw";
|
||||||
|
overlay.style.height = "100vh";
|
||||||
|
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
overlay.style.alignItems = "center";
|
||||||
|
overlay.style.justifyContent = "center";
|
||||||
|
overlay.style.zIndex = 9999;
|
||||||
|
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
const videoIds = [
|
||||||
|
"zhDwjnYZiCo",
|
||||||
|
"Na0w3Mz46GA",
|
||||||
|
"OO2kPK5-qno",
|
||||||
|
"Yqk13qPcXis",
|
||||||
|
"MZhivjxcF-M",
|
||||||
|
"uMEvzhckqBw",
|
||||||
|
"uMEvzhckqBw",
|
||||||
|
"TGan48YE9Us",
|
||||||
|
"-Xh4BNbxpI8",
|
||||||
|
"r7kxh_vuBpo",
|
||||||
|
"bdUbACCWmoY",
|
||||||
|
];
|
||||||
|
const zufallsId = videoIds[Math.floor(Math.random() * videoIds.length)];
|
||||||
|
iframe.src = `https://www.youtube.com/embed/${zufallsId}?autoplay=1`;
|
||||||
|
iframe.allow = "autoplay; fullscreen";
|
||||||
|
iframe.allowFullscreen = true;
|
||||||
|
iframe.style.width = "90%";
|
||||||
|
iframe.style.height = "90%";
|
||||||
|
iframe.style.border = "none";
|
||||||
|
|
||||||
|
overlay.appendChild(iframe);
|
||||||
|
overlay.onclick = () => document.body.removeChild(overlay);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
if (overlay.requestFullscreen) {
|
||||||
|
overlay.requestFullscreen();
|
||||||
|
} else if (overlay.webkitRequestFullscreen) {
|
||||||
|
overlay.webkitRequestFullscreen();
|
||||||
|
} else if (overlay.msRequestFullscreen) {
|
||||||
|
overlay.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const hazard = document.getElementById("hazard-symbol");
|
||||||
|
if (hazard) {
|
||||||
|
hazard.addEventListener("click", () => {
|
||||||
|
const elem = document.documentElement;
|
||||||
|
if (elem.requestFullscreen) {
|
||||||
|
elem.requestFullscreen();
|
||||||
|
} else if (elem.webkitRequestFullscreen) {
|
||||||
|
elem.webkitRequestFullscreen();
|
||||||
|
} else if (elem.msRequestFullscreen) {
|
||||||
|
elem.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentDefcon = 5;
|
||||||
|
let letzteDefconÄnderung = Date.now();
|
||||||
|
|
||||||
|
function aktualisiereDefconButton(level) {
|
||||||
|
const btn = document.getElementById("defcon-button");
|
||||||
|
if (!btn) return;
|
||||||
|
const farben = {
|
||||||
|
1: "#ff0000",
|
||||||
|
2: "#ff9900",
|
||||||
|
3: "#ffff00",
|
||||||
|
4: "#0000ff",
|
||||||
|
5: "#000000",
|
||||||
|
};
|
||||||
|
const farbe = farben[level] || "#555";
|
||||||
|
btn.textContent = `⚠️ DEFCON ${level}`;
|
||||||
|
btn.style.backgroundColor = farbe;
|
||||||
|
btn.style.color = level === 3 ? "black" : "white";
|
||||||
|
btn.style.fontWeight = "bold";
|
||||||
|
btn.style.transition = "background-color 0.5s, color 0.5s";
|
||||||
|
|
||||||
|
if (level === 1) {
|
||||||
|
document.body.style.backgroundColor = farbe;
|
||||||
|
} else {
|
||||||
|
document.body.style.backgroundColor = "#000000";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeDefconStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/defcon");
|
||||||
|
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||||
|
const data = await res.json();
|
||||||
|
currentDefcon = data.level;
|
||||||
|
aktualisiereDefconButton(currentDefcon);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("DEFCON Abruf fehlgeschlagen:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendeNeuesDefconLevel(level) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/defcon", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ level: level }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Fehler beim Senden des neuen DEFCON-Levels");
|
||||||
|
|
||||||
|
await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fehler beim POST:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDefconStatus() {
|
||||||
|
currentDefcon -= 1;
|
||||||
|
if (currentDefcon < 1) {
|
||||||
|
currentDefcon = 5;
|
||||||
|
}
|
||||||
|
sendeNeuesDefconLevel(currentDefcon);
|
||||||
|
aktualisiereDefconButton(currentDefcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ladeBunkerStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bunker-status");
|
||||||
|
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const icon = document.getElementById("hazard-symbol");
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
const inMainzNetz = Boolean(data && data.online);
|
||||||
|
|
||||||
|
if (inMainzNetz) {
|
||||||
|
icon.style.backgroundColor = "limegreen";
|
||||||
|
icon.style.color = "black";
|
||||||
|
icon.style.padding = "4px 8px";
|
||||||
|
icon.style.borderRadius = "4px";
|
||||||
|
} else {
|
||||||
|
icon.style.backgroundColor = "";
|
||||||
|
icon.style.color = "";
|
||||||
|
icon.title = "";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Bunker-Status nicht abrufbar:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ladeBunkerStatus();
|
||||||
|
setInterval(ladeBunkerStatus, 60 * 1000);
|
||||||
|
|
||||||
|
ladeDefconStatus();
|
||||||
|
ladeNews();
|
||||||
|
ladeRMV();
|
||||||
|
ladeMarketDaten();
|
||||||
|
|
||||||
|
setInterval(ladeNews, 30 * 1000);
|
||||||
|
setInterval(ladeRMV, 5 * 60 * 1000);
|
||||||
|
setInterval(ladeDefconStatus, 60 * 1000);
|
||||||
|
setInterval(() => {
|
||||||
|
rotiereFX();
|
||||||
|
}, 8 * 1000);
|
||||||
|
setInterval(ladeMarketDaten, 30 * 1000);
|
||||||
|
|
||||||
|
const defconBtn = document.getElementById("defcon-button");
|
||||||
|
if (defconBtn) {
|
||||||
|
defconBtn.addEventListener("click", newDefconStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
(function initLpaButton() {
|
||||||
|
const TARGET_LPA = "https://aerztepruefung.service24.rlp.de/intelliform/admin/intelliForm-Spaces/LPA/Studentenbereich";
|
||||||
|
const btn = document.getElementById("lpa-button");
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => window.open(TARGET_LPA, "_blank"));
|
||||||
|
})();
|
||||||
188
volumes/web/static/js/mandelbrot.js
Normal file
188
volumes/web/static/js/mandelbrot.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
let timersData = [];
|
||||||
|
let detailOpenFor = null;
|
||||||
|
|
||||||
|
function formatDiff(target) {
|
||||||
|
const targetDate = target instanceof Date ? target : new Date(target);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = targetDate - now;
|
||||||
|
|
||||||
|
if (diff <= 0) return "Abgelaufen";
|
||||||
|
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||||
|
const minutes = Math.floor((diff / (1000 * 60)) % 60);
|
||||||
|
const seconds = Math.floor((diff / 1000) % 60);
|
||||||
|
|
||||||
|
return `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch("/api/countdowns")
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
timersData = data.timers || [];
|
||||||
|
|
||||||
|
const mainTargetDate = new Date(data.main.target);
|
||||||
|
const mainLabel = data.main.label || "";
|
||||||
|
const mainLabelEl = document.getElementById("main-label");
|
||||||
|
const mainCountdownEl = document.getElementById("main-countdown");
|
||||||
|
|
||||||
|
function updateMainTimer() {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = mainTargetDate - now;
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
mainLabelEl.textContent = mainLabel;
|
||||||
|
mainCountdownEl.textContent = "Abgelaufen";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLabelEl.textContent = mainLabel;
|
||||||
|
mainCountdownEl.textContent = formatDiff(mainTargetDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSmallTimers() {
|
||||||
|
timersData.forEach(t => {
|
||||||
|
const el = document.getElementById(t.id);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nextEntry = t.targets
|
||||||
|
.map(e => ({ ...e, date: new Date(e.time) }))
|
||||||
|
.find(e => e.date > now);
|
||||||
|
|
||||||
|
if (nextEntry) {
|
||||||
|
el.textContent = `${nextEntry.label}\n${formatDiff(nextEntry.date)}`;
|
||||||
|
} else {
|
||||||
|
el.textContent = `${t.label}\nAbgelaufen`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDetailsPanel() {
|
||||||
|
if (!detailOpenFor) return;
|
||||||
|
const timer = timersData.find(t => t.id === detailOpenFor);
|
||||||
|
if (!timer) return;
|
||||||
|
|
||||||
|
timer.targets.forEach((target, idx) => {
|
||||||
|
const el = document.getElementById(`detail-${timer.id}-${idx}`);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = formatDiff(target.time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(timer) {
|
||||||
|
const detailEl = document.getElementById("timer-details");
|
||||||
|
if (!detailEl) return;
|
||||||
|
|
||||||
|
detailOpenFor = timer.id;
|
||||||
|
const entries = timer.targets.map((target, idx) => {
|
||||||
|
const entryId = `detail-${timer.id}-${idx}`;
|
||||||
|
return `
|
||||||
|
<div class="timer-entry">
|
||||||
|
<div class="label">${target.label}</div>
|
||||||
|
<div class="countdown" id="${entryId}"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
detailEl.innerHTML = `
|
||||||
|
<button class="close-btn" id="timer-details-close">Schließen</button>
|
||||||
|
<h3>${timer.label}</h3>
|
||||||
|
<div class="timer-meta">${timer.targets.length} Termine</div>
|
||||||
|
${entries}
|
||||||
|
`;
|
||||||
|
|
||||||
|
detailEl.classList.remove("hidden");
|
||||||
|
|
||||||
|
const closeBtn = document.getElementById("timer-details-close");
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener("click", () => {
|
||||||
|
detailOpenFor = null;
|
||||||
|
detailEl.classList.add("hidden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDetailsPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
updateMainTimer();
|
||||||
|
updateSmallTimers();
|
||||||
|
updateDetailsPanel();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
updateMainTimer();
|
||||||
|
updateSmallTimers();
|
||||||
|
|
||||||
|
document.querySelectorAll(".small-circle").forEach(circle => {
|
||||||
|
circle.addEventListener("click", () => {
|
||||||
|
const timerId = circle.getAttribute("data-timer");
|
||||||
|
const timer = timersData.find(t => t.id === timerId);
|
||||||
|
if (timer) renderDetails(timer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("fullscreen-btn").addEventListener("click", () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cantinaAudio = document.getElementById("cantina-audio");
|
||||||
|
const circleContainer = document.querySelector(".circle-container");
|
||||||
|
|
||||||
|
function playCantina() {
|
||||||
|
if (!cantinaAudio) return;
|
||||||
|
cantinaAudio.currentTime = 0;
|
||||||
|
cantinaAudio.play().catch(err => console.warn("Cantina playback blocked:", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (circleContainer) {
|
||||||
|
circleContainer.addEventListener("click", playCantina);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextFullHour() {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setMinutes(0, 0, 0);
|
||||||
|
if (next <= now) {
|
||||||
|
next.setHours(next.getHours() + 1);
|
||||||
|
}
|
||||||
|
const delay = next - now;
|
||||||
|
setTimeout(() => {
|
||||||
|
playCantina();
|
||||||
|
scheduleNextFullHour();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNextFullHour();
|
||||||
|
|
||||||
|
const overlay = document.getElementById("twitter-overlay");
|
||||||
|
|
||||||
|
function showOverlay() {
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
const gongVideo = document.getElementById("gong-video-fullscreen");
|
||||||
|
if (gongVideo) {
|
||||||
|
gongVideo.currentTime = 0;
|
||||||
|
gongVideo.play().catch(err => console.warn("Autoplay-Fehler:", err));
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.style.display = "none";
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const target = new Date();
|
||||||
|
target.setHours(23, 40, 0, 0);
|
||||||
|
|
||||||
|
if (now > target && now - target < 60000) {
|
||||||
|
showOverlay();
|
||||||
|
} else if (now <= target) {
|
||||||
|
const delay = target - now;
|
||||||
|
setTimeout(showOverlay, delay);
|
||||||
|
}
|
||||||
|
})();
|
||||||
BIN
volumes/web/static/media/cantina.mp3
Normal file
BIN
volumes/web/static/media/cantina.mp3
Normal file
Binary file not shown.
45
volumes/web/static/media/countdowns.json
Normal file
45
volumes/web/static/media/countdowns.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"label": "M2",
|
||||||
|
"target": "2026-04-14T00:00:00"
|
||||||
|
},
|
||||||
|
"timers": [
|
||||||
|
{
|
||||||
|
"id": "timer1",
|
||||||
|
"label": "8. Semester Klausuren",
|
||||||
|
"targets": [
|
||||||
|
{ "label": "Pr. Allgemeinmedizin", "time": "2026-01-30T09:00:00" },
|
||||||
|
{ "label": "Pr. Chirurgie (OSCE)", "time": "2026-02-02T07:45:00" },
|
||||||
|
{ "label": "Pr. Arbeits- und Sozialmedizin II", "time": "2026-02-10T08:00:00" },
|
||||||
|
{ "label": "Pr. Psychiatrie & Psychotherapie", "time": "2026-02-11T13:30:00" },
|
||||||
|
{ "label": "Q5", "time": "2026-02-13T09:15:00" },
|
||||||
|
{ "label": "Innere Medizin (schriftlich)", "time": "2026-02-20T08:00:00" },
|
||||||
|
{ "label": "Q14", "time": "2026-02-23T15:30:00" },
|
||||||
|
{ "label": "Pr. Neurologie", "time": "2026-02-25T10:00:00" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "timer2",
|
||||||
|
"label": "10. Semester Klausuren",
|
||||||
|
"targets": [
|
||||||
|
{ "label": "Q10", "time": "2026-01-15T15:30:00" },
|
||||||
|
{ "label": "Q9", "time": "2026-01-21T16:30:00" },
|
||||||
|
{ "label": "Pr. Orthopädie", "time": "2026-02-02T17:00:00" },
|
||||||
|
{ "label": "Q7", "time": "2026-02-04T14:30:00" },
|
||||||
|
{ "label": "Pr. Urologie", "time": "2026-02-10T10:30:00" },
|
||||||
|
{ "label": "Pr. Gynäkologie", "time": "2026-02-11T09:30:00" },
|
||||||
|
{ "label": "BP Gynäkologie", "time": "2026-02-11T10:15:00" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "timer3",
|
||||||
|
"label": "PJ-Tertiale Frühjahr 2025",
|
||||||
|
"targets": [
|
||||||
|
{ "label": "Innere Medizin", "time": "2025-05-19T00:00:00" },
|
||||||
|
{ "label": "Chirurgie", "time": "2025-09-08T00:00:00" },
|
||||||
|
{ "label": "Wahlfach", "time": "2025-12-29T00:00:00" },
|
||||||
|
{ "label": "PJ Ende", "time": "2026-04-19T00:00:00" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
volumes/web/static/media/gong.mp4
Normal file
BIN
volumes/web/static/media/gong.mp4
Normal file
Binary file not shown.
13
volumes/web/template/blog.html
Normal file
13
volumes/web/template/blog.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Artikel-Galerie</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/blog.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="grid" class="grid"></main>
|
||||||
|
|
||||||
|
<script src="/static/js/blog.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
volumes/web/template/index-lite.html
Normal file
47
volumes/web/template/index-lite.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Live Nachrichtenticker (Lite)</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/index.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-top">
|
||||||
|
<span id="hazard-symbol" style="cursor: pointer;">☣ Bunker</span>
|
||||||
|
<div id="market-ticker" class="market-ticker">
|
||||||
|
<div id="market-ticker-track" class="market-ticker-track">
|
||||||
|
<span class="ticker-item">Marktdaten werden geladen...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="#" onclick="zeigeYouTubeVideo(); return false;">▶️</a>
|
||||||
|
<a href="#" onclick="zeigeGongVideo(); return false;">🦅</a>
|
||||||
|
<a href="#" onclick="toggleMensaIframe(); return false;">🍽️</a>
|
||||||
|
<a href="/blog">📖</a>
|
||||||
|
<a href="/mandelbrot">🌀</a>
|
||||||
|
<button id="defcon-button">⚠️ DEFCON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="mensa-iframe-container" style="display: none; margin-bottom: 1rem;">
|
||||||
|
<iframe
|
||||||
|
id="mensa-iframe"
|
||||||
|
src="https://www.studierendenwerk-mainz.de/essentrinken/speiseplan2?building_id=1&display_type=1"
|
||||||
|
style="width: calc(100% - 320px); height: calc(100vh - 100px); border: none; border-radius: 8px; transform: scale(0.95); transform-origin: top left;">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content-wrapper" style="display: flex; gap: 2rem; align-items: flex-start;">
|
||||||
|
<div id="news-container"></div>
|
||||||
|
<div id="abfahrt-wrapper" style="display: none;">
|
||||||
|
<h2>🚉 Abfahrten</h2>
|
||||||
|
<div id="abfahrt-info">Wird geladen...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/index-lite.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
volumes/web/template/index.html
Normal file
45
volumes/web/template/index.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Live Nachrichtenticker + RMV</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/index.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-top">
|
||||||
|
<span id="hazard-symbol" style="cursor: pointer;">☣ Bunker</span>
|
||||||
|
<div id="market-ticker" class="market-ticker">
|
||||||
|
<div id="market-ticker-track" class="market-ticker-track">
|
||||||
|
<span class="ticker-item">Marktdaten werden geladen...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="#" onclick="zeigeYouTubeVideo(); return false;">▶️</a>
|
||||||
|
<a href="#" onclick="zeigeGongVideo(); return false;">🦅</a>
|
||||||
|
<a href="#" onclick="toggleMensaIframe(); return false;">🍽️</a>
|
||||||
|
<a href="/blog">📖</a>
|
||||||
|
<a href="/mandelbrot">🌀</a>
|
||||||
|
<button id="defcon-button">⚠️ DEFCON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="mensa-iframe-container" style="display: none; margin-bottom: 1rem;">
|
||||||
|
<iframe
|
||||||
|
id="mensa-iframe"
|
||||||
|
src="https://www.studierendenwerk-mainz.de/essentrinken/speiseplan2?building_id=1&display_type=1"
|
||||||
|
style="width: calc(100% - 320px); height: calc(100vh - 100px); border: none; border-radius: 8px; transform: scale(0.95); transform-origin: top left;">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
<div id="content-wrapper" style="display: flex; gap: 2rem; align-items: flex-start;">
|
||||||
|
<div id="news-container"></div>
|
||||||
|
<div id="abfahrt-wrapper" style="display: none;">
|
||||||
|
<h2>🚉 Abfahrten</h2>
|
||||||
|
<div id="abfahrt-info">Wird geladen...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
volumes/web/template/mandelbrot.html
Normal file
69
volumes/web/template/mandelbrot.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Mandelbrot</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/mandelbrot.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<button id="fullscreen-btn">Vollbild</button>
|
||||||
|
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div class="circle-container">
|
||||||
|
<div class="circle"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="timer">
|
||||||
|
<div id="main-label"></div>
|
||||||
|
<div id="main-countdown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small-circle-container">
|
||||||
|
<div>
|
||||||
|
<div class="small-circle" style="border-color: red;" data-timer="timer1">
|
||||||
|
<div class="small-dot"></div>
|
||||||
|
<div class="small-timer-text" id="timer1">Countdown 1</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="small-circle" style="border-color: blue;" data-timer="timer2">
|
||||||
|
<div class="small-dot"></div>
|
||||||
|
<div class="small-timer-text" id="timer2">Countdown 2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="small-circle" style="border-color: green;" data-timer="timer3">
|
||||||
|
<div class="small-dot"></div>
|
||||||
|
<div class="small-timer-text" id="timer3">Countdown 3</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="timer-details" class="timer-details hidden"></div>
|
||||||
|
|
||||||
|
<audio id="cantina-audio" preload="auto">
|
||||||
|
<source src="/media/cantina" type="audio/mp3">
|
||||||
|
Dein Browser unterstützt das Audio-Tag nicht.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<div id="twitter-overlay" style="
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.85);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
">
|
||||||
|
<video id="gong-video-fullscreen" playsinline style="max-width: 90%; max-height: 90%;" controls>
|
||||||
|
<source src="/media/gong" type="video/mp4">
|
||||||
|
Dein Browser unterstützt das Video-Tag nicht.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/mandelbrot.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user