commit 3667c678e4e00e290afe771cead99415a8dda192 Author: hubble_dubble Date: Mon Jan 26 00:19:54 2026 +0100 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 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b98efc9 Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env new file mode 100644 index 0000000..6c47aa7 --- /dev/null +++ b/.env @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba3a005 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +volumes/ai-models/ +volumes/images/ +volumes/postgres/ \ No newline at end of file diff --git a/ai-worker/Dockerfile b/ai-worker/Dockerfile new file mode 100644 index 0000000..3a4bd3e --- /dev/null +++ b/ai-worker/Dockerfile @@ -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"] \ No newline at end of file diff --git a/ai-worker/requirements.txt b/ai-worker/requirements.txt new file mode 100644 index 0000000..bf08970 --- /dev/null +++ b/ai-worker/requirements.txt @@ -0,0 +1,7 @@ +psycopg2-binary +diffusers +transformers +torch +safetensors +Pillow +accelerate \ No newline at end of file diff --git a/ai-worker/worker.py b/ai-worker/worker.py new file mode 100644 index 0000000..d9aeb18 --- /dev/null +++ b/ai-worker/worker.py @@ -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() diff --git a/background-worker/Dockerfile b/background-worker/Dockerfile new file mode 100644 index 0000000..16f20f4 --- /dev/null +++ b/background-worker/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.24 + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +CMD ["go", "run", "."] diff --git a/background-worker/go.mod b/background-worker/go.mod new file mode 100644 index 0000000..cf22fa7 --- /dev/null +++ b/background-worker/go.mod @@ -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 +) diff --git a/background-worker/go.sum b/background-worker/go.sum new file mode 100644 index 0000000..fe845b9 --- /dev/null +++ b/background-worker/go.sum @@ -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= diff --git a/background-worker/main.go b/background-worker/main.go new file mode 100644 index 0000000..25ff722 --- /dev/null +++ b/background-worker/main.go @@ -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() + } + + } +} diff --git a/background-worker/market.go b/background-worker/market.go new file mode 100644 index 0000000..6804905 --- /dev/null +++ b/background-worker/market.go @@ -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)) +} diff --git a/background-worker/rmv.go b/background-worker/rmv.go new file mode 100644 index 0000000..a253e14 --- /dev/null +++ b/background-worker/rmv.go @@ -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) + } +} diff --git a/background-worker/rss.go b/background-worker/rss.go new file mode 100644 index 0000000..4976068 --- /dev/null +++ b/background-worker/rss.go @@ -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)) + } +} diff --git a/background-worker/sql_work.go b/background-worker/sql_work.go new file mode 100644 index 0000000..7396065 --- /dev/null +++ b/background-worker/sql_work.go @@ -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 +} diff --git a/db/Dockerfile b/db/Dockerfile new file mode 100644 index 0000000..158ce14 --- /dev/null +++ b/db/Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:16 + +COPY init.sql /docker-entrypoint-initdb.d/init.sql diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..a87eb39 --- /dev/null +++ b/db/init.sql @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dcbc8a6 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/server-app/dockerfile b/server-app/dockerfile new file mode 100644 index 0000000..9085d9e --- /dev/null +++ b/server-app/dockerfile @@ -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"] diff --git a/server-app/go.mod b/server-app/go.mod new file mode 100644 index 0000000..e0a61cb --- /dev/null +++ b/server-app/go.mod @@ -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 +) diff --git a/server-app/go.sum b/server-app/go.sum new file mode 100644 index 0000000..87a6c8a --- /dev/null +++ b/server-app/go.sum @@ -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= diff --git a/server-app/main.go b/server-app/main.go new file mode 100644 index 0000000..e36587d --- /dev/null +++ b/server-app/main.go @@ -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)) +} diff --git a/volumes/.DS_Store b/volumes/.DS_Store new file mode 100644 index 0000000..1ca13a2 Binary files /dev/null and b/volumes/.DS_Store differ diff --git a/volumes/db-init/.DS_Store b/volumes/db-init/.DS_Store new file mode 100644 index 0000000..b71ec72 Binary files /dev/null and b/volumes/db-init/.DS_Store differ diff --git a/volumes/db-init/data/news_rss_feeds.csv b/volumes/db-init/data/news_rss_feeds.csv new file mode 100644 index 0000000..0e84ad4 --- /dev/null +++ b/volumes/db-init/data/news_rss_feeds.csv @@ -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 \ No newline at end of file diff --git a/volumes/web/.DS_Store b/volumes/web/.DS_Store new file mode 100644 index 0000000..760decd Binary files /dev/null and b/volumes/web/.DS_Store differ diff --git a/volumes/web/static/css/blog.css b/volumes/web/static/css/blog.css new file mode 100644 index 0000000..e9914a7 --- /dev/null +++ b/volumes/web/static/css/blog.css @@ -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; +} diff --git a/volumes/web/static/css/index-lite.css b/volumes/web/static/css/index-lite.css new file mode 100644 index 0000000..a2c040b --- /dev/null +++ b/volumes/web/static/css/index-lite.css @@ -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; +} diff --git a/volumes/web/static/css/index.css b/volumes/web/static/css/index.css new file mode 100644 index 0000000..b9d2f23 --- /dev/null +++ b/volumes/web/static/css/index.css @@ -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: + */ +#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; +} diff --git a/volumes/web/static/css/mandelbrot.css b/volumes/web/static/css/mandelbrot.css new file mode 100644 index 0000000..11bff0f --- /dev/null +++ b/volumes/web/static/css/mandelbrot.css @@ -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; +} diff --git a/volumes/web/static/img/minecraft.png b/volumes/web/static/img/minecraft.png new file mode 100644 index 0000000..d79def1 Binary files /dev/null and b/volumes/web/static/img/minecraft.png differ diff --git a/volumes/web/static/js/blog.js b/volumes/web/static/js/blog.js new file mode 100644 index 0000000..9250459 --- /dev/null +++ b/volumes/web/static/js/blog.js @@ -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 = '
Keine Bilder gefunden.
'; + } + + } catch (err) { + console.error(err); + grid.innerHTML = '
Fehler beim Laden der Galerie.
'; + } +} + +ladeGalerie(); \ No newline at end of file diff --git a/volumes/web/static/js/index-lite.js b/volumes/web/static/js/index-lite.js new file mode 100644 index 0000000..afe4044 --- /dev/null +++ b/volumes/web/static/js/index-lite.js @@ -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 = '
Keine News gefunden.
'; + 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 = '
Fehler beim Laden der News.
'; + } +} + +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 = "

Keine Abfahrten gefunden.

"; + return; + } + + const tripHTML = data.abfahrten.map((trip) => { + const legsHTML = trip.map((leg) => ` +
+ ${leg.linie}
+ ${leg.abfahrt} -> ${leg.ankunft}
+ ${leg.von} -> ${leg.nach} +
+ `).join("
"); + + return `
${legsHTML}
`; + }).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); +} diff --git a/volumes/web/static/js/index.js b/volumes/web/static/js/index.js new file mode 100644 index 0000000..847453b --- /dev/null +++ b/volumes/web/static/js/index.js @@ -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 = '
Keine News gefunden.
'; + } + + } catch (error) { + console.error("ladeNews Fehler:", error); + if (loadingEl && loadingEl.isConnected) loadingEl.remove(); + if (!container.hasChildNodes()) { + container.innerHTML = '
Fehler beim Laden der News.
'; + } + } +} + +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 = "

Keine Abfahrten gefunden.

"; + return; + } + + const tripHTML = daten.abfahrten.map(trip => { + const legsHTML = trip.map(leg => ` +
+ ${leg.linie}
+ ${leg.abfahrt} → ${leg.ankunft}
+ ${leg.von} → ${leg.nach} +
+ `).join("
"); + + return `
${legsHTML}
`; + }).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")); +})(); diff --git a/volumes/web/static/js/mandelbrot.js b/volumes/web/static/js/mandelbrot.js new file mode 100644 index 0000000..d813942 --- /dev/null +++ b/volumes/web/static/js/mandelbrot.js @@ -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 ` +
+
${target.label}
+
+
+ `; + }).join(""); + + detailEl.innerHTML = ` + +

${timer.label}

+
${timer.targets.length} Termine
+ ${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); + } +})(); diff --git a/volumes/web/static/media/cantina.mp3 b/volumes/web/static/media/cantina.mp3 new file mode 100644 index 0000000..982e7b8 Binary files /dev/null and b/volumes/web/static/media/cantina.mp3 differ diff --git a/volumes/web/static/media/countdowns.json b/volumes/web/static/media/countdowns.json new file mode 100644 index 0000000..2253a50 --- /dev/null +++ b/volumes/web/static/media/countdowns.json @@ -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" } + ] + } + ] +} diff --git a/volumes/web/static/media/gong.mp4 b/volumes/web/static/media/gong.mp4 new file mode 100644 index 0000000..fa2719e Binary files /dev/null and b/volumes/web/static/media/gong.mp4 differ diff --git a/volumes/web/template/blog.html b/volumes/web/template/blog.html new file mode 100644 index 0000000..8491582 --- /dev/null +++ b/volumes/web/template/blog.html @@ -0,0 +1,13 @@ + + + + + Artikel-Galerie + + + +
+ + + + diff --git a/volumes/web/template/index-lite.html b/volumes/web/template/index-lite.html new file mode 100644 index 0000000..3c0a111 --- /dev/null +++ b/volumes/web/template/index-lite.html @@ -0,0 +1,47 @@ + + + + + + Live Nachrichtenticker (Lite) + + + + + + + +
+
+ +
+ + + + diff --git a/volumes/web/template/index.html b/volumes/web/template/index.html new file mode 100644 index 0000000..a3cb495 --- /dev/null +++ b/volumes/web/template/index.html @@ -0,0 +1,45 @@ + + + + + Live Nachrichtenticker + RMV + + + + + + +
+
+ +
+ + + + diff --git a/volumes/web/template/mandelbrot.html b/volumes/web/template/mandelbrot.html new file mode 100644 index 0000000..b591d55 --- /dev/null +++ b/volumes/web/template/mandelbrot.html @@ -0,0 +1,69 @@ + + + + + Mandelbrot + + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
Countdown 1
+
+
+
+
+
+
Countdown 2
+
+
+
+
+
+
Countdown 3
+
+
+
+
+ + + + + +
+ +
+ + +