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