Please enter the commit message for your changes. Lines starting

with '#' will be ignored, and an empty message aborts the commit.

On branch main

Initial commit

Changes to be committed:
	new file:   .DS_Store
	new file:   .env
	new file:   .gitignore
	new file:   ai-worker/Dockerfile
	new file:   ai-worker/requirements.txt
	new file:   ai-worker/worker.py
	new file:   background-worker/Dockerfile
	new file:   background-worker/go.mod
	new file:   background-worker/go.sum
	new file:   background-worker/main.go
	new file:   background-worker/market.go
	new file:   background-worker/rmv.go
	new file:   background-worker/rss.go
	new file:   background-worker/sql_work.go
	new file:   db/Dockerfile
	new file:   db/init.sql
	new file:   docker-compose.yml
	new file:   server-app/dockerfile
	new file:   server-app/go.mod
	new file:   server-app/go.sum
	new file:   server-app/main.go
	new file:   volumes/.DS_Store
	new file:   volumes/db-init/.DS_Store
	new file:   volumes/db-init/data/news_rss_feeds.csv
	new file:   volumes/web/.DS_Store
	new file:   volumes/web/static/css/blog.css
	new file:   volumes/web/static/css/index-lite.css
	new file:   volumes/web/static/css/index.css
	new file:   volumes/web/static/css/mandelbrot.css
	new file:   volumes/web/static/img/minecraft.png
	new file:   volumes/web/static/js/blog.js
	new file:   volumes/web/static/js/index-lite.js
	new file:   volumes/web/static/js/index.js
	new file:   volumes/web/static/js/mandelbrot.js
	new file:   volumes/web/static/media/cantina.mp3
	new file:   volumes/web/static/media/countdowns.json
	new file:   volumes/web/static/media/gong.mp4
	new file:   volumes/web/template/blog.html
	new file:   volumes/web/template/index-lite.html
	new file:   volumes/web/template/index.html
	new file:   volumes/web/template/mandelbrot.html
This commit is contained in:
hubble_dubble
2026-01-26 00:19:54 +01:00
commit 3667c678e4
41 changed files with 3556 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

7
.env Normal file
View File

@@ -0,0 +1,7 @@
APP_PORT=8080
POSTGRES_USER=app
POSTGRES_PASSWORD=appsecret
POSTGRES_DB=appdb
POSTGRES_PORT=5432
RMV_API_KEY=62cac254-00d6-4c29-a823-0f2317077a89

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
volumes/ai-models/
volumes/images/
volumes/postgres/

7
ai-worker/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "worker.py"]

View File

@@ -0,0 +1,7 @@
psycopg2-binary
diffusers
transformers
torch
safetensors
Pillow
accelerate

183
ai-worker/worker.py Normal file
View File

@@ -0,0 +1,183 @@
import base64
import logging
import os
import socket
import threading
import time
from io import BytesIO
from pathlib import Path
from typing import Optional
import psycopg2
import torch
from diffusers import StableDiffusionPipeline
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
DB_URL = os.environ.get("DATABASE_URL", "postgres://app:appsecret@database:5432/appdb?sslmode=disable")
DEFAULT_MODEL_PATH = Path(__file__).resolve().parent / "models" / "sd15"
MODEL_PATH = Path(DEFAULT_MODEL_PATH)
IMAGE_OUTPUT_DIR = Path(os.environ.get("IMAGE_OUTPUT_DIR") or "/app/images")
IMAGE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
MODEL_ID = "stabilityai/sd-turbo"
WORKER_ID = os.environ.get("WORKER_ID") or f"{socket.gethostname()}:{os.getpid()}"
BATCH_SIZE = int(os.environ.get("IMAGE_BATCH_SIZE") or "3")
CLAIM_TTL_MINUTES = int(os.environ.get("IMAGE_CLAIM_TTL_MINUTES") or "20")
pipe = None
pipe_lock = threading.Lock()
def connect():
return psycopg2.connect(DB_URL)
def load_pipeline() -> Optional[StableDiffusionPipeline]:
global pipe
if pipe is not None:
return pipe
with pipe_lock:
if pipe is not None:
return pipe
try:
# Prüfen, ob lokal schon etwas im Modellordner liegt
has_local_model = MODEL_PATH.is_dir() and any(MODEL_PATH.iterdir())
if has_local_model:
logging.info("Lade Stable Diffusion Modell lokal aus %s", MODEL_PATH)
pipe = StableDiffusionPipeline.from_pretrained(
str(MODEL_PATH),
torch_dtype=torch.float32,
local_files_only=True,
)
else:
logging.info("Kein lokales Modell gefunden lade von Hugging Face (%s)", MODEL_ID)
pipe = StableDiffusionPipeline.from_pretrained(
MODEL_ID,
torch_dtype=torch.float32
)
pipe.save_pretrained(MODEL_PATH)
logging.info("Modell erfolgreich nach %s gespeichert", MODEL_PATH)
pipe = pipe.to("cpu")
pipe.enable_attention_slicing()
return pipe
except Exception as e:
logging.error("Konnte Pipeline nicht laden: %s", e)
pipe = None
return None
def fetch_articles_without_image(cur, limit=BATCH_SIZE):
cur.execute(
"""
WITH claim AS (
SELECT id
FROM articles
WHERE image IS NULL
AND (
image_claimed_at IS NULL
OR image_claimed_at < now() - (%s * INTERVAL '1 minute')
)
ORDER BY created_at DESC
LIMIT %s
FOR UPDATE SKIP LOCKED
)
UPDATE articles AS a
SET image_claimed_at = now(),
image_claimed_by = %s
FROM claim
WHERE a.id = claim.id
RETURNING a.id, a.title, a.article_id
""",
(CLAIM_TTL_MINUTES, limit, WORKER_ID),
)
return cur.fetchall()
def update_image(cur, article_id: int, data_uri: str):
cur.execute(
"""
UPDATE articles
SET image = %s,
image_claimed_at = NULL,
image_claimed_by = NULL
WHERE id = %s
""",
(data_uri, article_id),
)
def release_claim(cur, article_id: int):
cur.execute(
"""
UPDATE articles
SET image_claimed_at = NULL,
image_claimed_by = NULL
WHERE id = %s AND image IS NULL
""",
(article_id,),
)
def safe_filename(name: str) -> str:
safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in name)
return safe or "image"
def generate_image(prompt: str) -> Optional[tuple[str, bytes]]:
model = load_pipeline()
if model is None:
return None
try:
img = model(
prompt=prompt,
num_inference_steps=8,
guidance_scale=7,
).images[0]
buf = BytesIO()
img.save(buf, format="PNG")
img_bytes = buf.getvalue()
data = base64.b64encode(img_bytes).decode("ascii")
return f"data:image/png;base64,{data}", img_bytes
except Exception as e:
logging.error("Bildgenerierung fehlgeschlagen: %s", e)
return None
def main():
while True:
try:
with connect() as conn:
with conn.cursor() as cur:
rows = fetch_articles_without_image(cur)
if not rows:
logging.info("keine neuen Artikel ohne Bild")
for aid, title, article_id in rows:
prompt = title or "news illustration"
result = generate_image(prompt)
if result:
data_uri, img_bytes = result
filename = f"{safe_filename(article_id)}.png"
out_path = IMAGE_OUTPUT_DIR / filename
try:
out_path.write_bytes(img_bytes)
logging.info("Bild gespeichert unter %s", out_path)
except Exception as e:
logging.error("Konnte Bild nicht speichern (%s): %s", out_path, e)
update_image(cur, aid, data_uri)
logging.info("Bild gesetzt für Artikel %s", aid)
else:
release_claim(cur, aid)
conn.commit()
except Exception as e:
logging.error("Fehler im Worker: %s", e)
time.sleep(30)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,9 @@
FROM golang:1.24
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
CMD ["go", "run", "."]

23
background-worker/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module background-worker
go 1.24.0
toolchain go1.24.12
require (
github.com/jackc/pgx/v5 v5.8.0
github.com/mmcdole/gofeed v1.3.0
)
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.4.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

50
background-worker/go.sum Normal file
View File

@@ -0,0 +1,50 @@
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

33
background-worker/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"log"
"time"
)
func main() {
log.Println("[background-worker] started")
tickerRMV := time.NewTicker(5 * time.Minute)
tickerRSS := time.NewTicker(1 * time.Minute)
tickerMarket := time.NewTicker(10 * time.Second)
defer tickerRMV.Stop()
defer tickerRSS.Stop()
defer tickerMarket.Stop()
rmv_request()
fetchMarketData()
fetchRSSFeeds()
for {
select {
case <-tickerRMV.C:
go rmv_request()
case <-tickerRSS.C:
go fetchRSSFeeds()
case <-tickerMarket.C:
go fetchMarketData()
}
}
}

View File

@@ -0,0 +1,87 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
)
type marketQuote struct {
Instrument string
Bid float64
QuotedAt time.Time
}
type sqResponse struct {
SpreadProfilePrices []struct {
Bid float64 `json:"bid"`
} `json:"spreadProfilePrices"`
Timestamp int64 `json:"timestamp"`
}
func fetchMarketData() {
ctx := context.Background()
client := http.Client{Timeout: 10 * time.Second}
type endpoint struct {
Instrument string
URL string
}
endpoints := []endpoint{
{Instrument: "USD/JPY", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/USD/JPY"},
{Instrument: "OIL/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/OIL/USD"},
{Instrument: "XAU/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD"},
{Instrument: "EUR/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/EUR/USD"},
{Instrument: "USD/CHF", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/USD/CHF"},
{Instrument: "GBP/USD", URL: "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/GBP/USD"},
}
var quotes []MarketQuote
for _, ep := range endpoints {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep.URL, nil)
resp, err := client.Do(req)
if err != nil {
log.Printf("market fetch %s failed: %v", ep.Instrument, err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
log.Printf("market fetch %s bad status: %s", ep.Instrument, resp.Status)
continue
}
var payload []sqResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
resp.Body.Close()
log.Printf("decode %s failed: %v", ep.Instrument, err)
continue
}
resp.Body.Close()
if len(payload) == 0 || len(payload[0].SpreadProfilePrices) == 0 {
log.Printf("market fetch %s: empty payload", ep.Instrument)
continue
}
bid := payload[0].SpreadProfilePrices[0].Bid
quotedAt := time.UnixMilli(payload[0].Timestamp)
quotes = append(quotes, MarketQuote{
Instrument: ep.Instrument,
Bid: bid,
QuotedAt: quotedAt,
})
}
if len(quotes) == 0 {
return
}
if err := saveMarketQuotes(ctx, quotes); err != nil {
log.Printf("saveMarketQuotes failed: %v", err)
}
log.Printf("market quotes updated: %d instruments", len(quotes))
}

108
background-worker/rmv.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"log"
"net/http"
"net/url"
"os"
)
type RMVResponse struct {
Trip []Trip `json:"Trip"`
}
type Trip struct {
LegList LegList `json:"LegList"`
}
type LegList struct {
Leg []Leg `json:"Leg"`
}
type Leg struct {
Type string `json:"type"`
Name string `json:"name"`
Origin Stop `json:"Origin"`
Destination Stop `json:"Destination"`
}
type Stop struct {
Name string `json:"name"`
Time string `json:"time"`
RtTime string `json:"rtTime"`
}
type Verbindung struct {
Linie string
Abfahrt string
Ankunft string
Von string
Nach string
}
func rmv_request() {
u, _ := url.Parse("https://www.rmv.de/hapi/trip")
q := u.Query()
q.Set("originId", "3018009")
q.Set("destId", "3029164")
q.Set("format", "json")
q.Set("accessId", os.Getenv("RMV_API_KEY"))
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
if err != nil {
panic(err)
}
defer resp.Body.Close()
var respData RMVResponse
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&respData); err != nil {
log.Fatal(err)
}
var verbindungen [][]Verbindung
for _, trip := range respData.Trip {
var tripInfo []Verbindung
for _, leg := range trip.LegList.Leg {
if leg.Type == "WALK" {
continue
}
abfahrt := leg.Origin.RtTime
if abfahrt == "" {
abfahrt = leg.Origin.Time
}
ankunft := leg.Destination.RtTime
if ankunft == "" {
ankunft = leg.Destination.Time
}
tripInfo = append(tripInfo, Verbindung{
Linie: leg.Name,
Abfahrt: abfahrt,
Ankunft: ankunft,
Von: leg.Origin.Name,
Nach: leg.Destination.Name,
})
}
if len(tripInfo) > 0 {
verbindungen = append(verbindungen, tripInfo)
}
}
writeVerbindungToSQL(verbindungen)
log.Println("RMV data updated")
if err := writeVerbindungToSQL(verbindungen); err != nil {
log.Printf("failed to persist RMV data: %v", err)
}
}

66
background-worker/rss.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"context"
"log"
"time"
"github.com/mmcdole/gofeed"
)
func fetchRSSFeeds() {
ctx := context.Background()
feeds, err := fetchPendingFeeds(ctx)
if err != nil {
log.Printf("fetchPendingFeeds failed: %v", err)
return
}
if len(feeds) == 0 {
log.Println("no feeds due for refresh")
return
}
parser := gofeed.NewParser()
for _, feed := range feeds {
fp, err := parser.ParseURL(feed.URL)
if err != nil {
log.Printf("parse feed %s: %v", feed.URL, err)
if err := setFeedAccess(ctx, feed.ID, false); err != nil {
log.Printf("disable feed %d failed: %v", feed.ID, err)
}
continue
}
var articles []Article
for _, item := range fp.Items {
var published *time.Time
if item.PublishedParsed != nil {
published = item.PublishedParsed
}
summary := item.Description
if summary == "" {
summary = item.Content
}
articles = append(articles, Article{
ArticleID: articleIDFromLink(item.Link),
FeedID: feed.ID,
Title: item.Title,
Link: item.Link,
Summary: summary,
PublishedAt: published,
})
}
if err := saveArticles(ctx, feed.ID, articles); err != nil {
log.Printf("save articles for feed %d: %v", feed.ID, err)
continue
}
log.Printf("processed feed %d (%s) with %d items", feed.ID, feed.URL, len(articles))
}
}

View File

@@ -0,0 +1,217 @@
package main
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5"
)
func getenv(k, fallback string) string {
if v := os.Getenv(k); v != "" {
return v
}
return fallback
}
func buildDSN() string {
if url := os.Getenv("DATABASE_URL"); url != "" {
return url
}
host := getenv("POSTGRES_HOST", "database")
port := getenv("POSTGRES_PORT", "5432")
user := getenv("POSTGRES_USER", "app")
pass := getenv("POSTGRES_PASSWORD", "appsecret")
name := getenv("POSTGRES_DB", "appdb")
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, pass, host, port, name)
}
type FeedMeta struct {
ID int
URL string
}
type Article struct {
ArticleID string
FeedID int
Title string
Link string
Summary string
PublishedAt *time.Time
}
type MarketQuote struct {
Instrument string
Bid float64
QuotedAt time.Time
}
func writeVerbindungToSQL(verbindungen [][]Verbindung) error {
ctx := context.Background()
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
tx, err := conn.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "DELETE FROM rmv_data"); err != nil {
return fmt.Errorf("clear rmv_data: %w", err)
}
stmt := `INSERT INTO rmv_data (trip_index, leg_index, linie, abfahrt, ankunft, von, nach) VALUES ($1,$2,$3,$4,$5,$6,$7)`
for tripIdx, trip := range verbindungen {
for legIdx, leg := range trip {
if _, err := tx.Exec(ctx, stmt, tripIdx, legIdx, leg.Linie, leg.Abfahrt, leg.Ankunft, leg.Von, leg.Nach); err != nil {
return fmt.Errorf("insert trip %d leg %d: %w", tripIdx, legIdx, err)
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func fetchPendingFeeds(ctx context.Context) ([]FeedMeta, error) {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
rows, err := conn.Query(ctx, `
SELECT id, url
FROM feeds
WHERE access = TRUE
AND (last_checked IS NULL OR last_checked < now() - interval '5 minutes')
`)
if err != nil {
return nil, fmt.Errorf("query feeds: %w", err)
}
defer rows.Close()
var feeds []FeedMeta
for rows.Next() {
var f FeedMeta
if err := rows.Scan(&f.ID, &f.URL); err != nil {
return nil, fmt.Errorf("scan feed: %w", err)
}
feeds = append(feeds, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate feeds: %w", err)
}
return feeds, nil
}
func articleIDFromLink(link string) string {
h := sha1.Sum([]byte(link))
return hex.EncodeToString(h[:])
}
func saveArticles(ctx context.Context, feedID int, articles []Article) error {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
tx, err := conn.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
stmt := `
INSERT INTO articles (article_id, feed_id, title, link, summary, published_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (article_id) DO NOTHING
`
for _, a := range articles {
aid := a.ArticleID
if aid == "" {
aid = articleIDFromLink(a.Link)
}
if _, err := tx.Exec(ctx, stmt, aid, feedID, a.Title, a.Link, a.Summary, a.PublishedAt); err != nil {
return fmt.Errorf("insert article for feed %d: %w", feedID, err)
}
}
if _, err := tx.Exec(ctx, `UPDATE feeds SET last_checked = now() WHERE id = $1`, feedID); err != nil {
return fmt.Errorf("update feed timestamp: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
func setFeedAccess(ctx context.Context, feedID int, access bool) error {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, `UPDATE feeds SET access = $1 WHERE id = $2`, access, feedID); err != nil {
return fmt.Errorf("update feed access: %w", err)
}
return nil
}
func saveMarketQuotes(ctx context.Context, quotes []MarketQuote) error {
dsn := buildDSN()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer conn.Close(ctx)
batch := &pgx.Batch{}
for _, q := range quotes {
batch.Queue(
`INSERT INTO market_quotes (instrument, bid, quoted_at)
VALUES ($1, $2, $3)
ON CONFLICT (instrument) DO UPDATE
SET bid = EXCLUDED.bid, quoted_at = EXCLUDED.quoted_at`,
q.Instrument, q.Bid, q.QuotedAt,
)
}
br := conn.SendBatch(ctx, batch)
defer br.Close()
for range quotes {
if _, err := br.Exec(); err != nil {
return fmt.Errorf("insert market quote: %w", err)
}
}
return nil
}

3
db/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM postgres:16
COPY init.sql /docker-entrypoint-initdb.d/init.sql

94
db/init.sql Normal file
View File

@@ -0,0 +1,94 @@
DO $$
BEGIN
RAISE NOTICE 'Init gestartet';
END $$;
CREATE TABLE IF NOT EXISTS jobs (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL,
payload JSONB,
status TEXT NOT NULL DEFAULT 'queued',
result JSONB,
error TEXT,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
CREATE TABLE IF NOT EXISTS rmv_data (
id BIGSERIAL PRIMARY KEY,
trip_index INT NOT NULL,
leg_index INT NOT NULL,
linie TEXT,
abfahrt TEXT,
ankunft TEXT,
von TEXT,
nach TEXT
);
CREATE TABLE IF NOT EXISTS feeds (
id SERIAL PRIMARY KEY,
url TEXT NOT NULL,
access BOOLEAN NOT NULL DEFAULT TRUE,
last_checked TIMESTAMPTZ,
created_at TIMESTAMP DEFAULT now()
);
-- Ensure last_checked exists when rerunning against an existing DB
ALTER TABLE IF EXISTS feeds ADD COLUMN IF NOT EXISTS last_checked TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS articles (
id BIGSERIAL PRIMARY KEY,
article_id TEXT NOT NULL,
feed_id INT NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
title TEXT NOT NULL,
link TEXT NOT NULL,
summary TEXT,
image TEXT,
image_claimed_at TIMESTAMPTZ,
image_claimed_by TEXT,
published_at TIMESTAMPTZ,
created_at TIMESTAMP DEFAULT now(),
CONSTRAINT articles_feed_link_uniq UNIQUE(feed_id, link),
CONSTRAINT articles_article_id_uniq UNIQUE(article_id)
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'article_id') THEN
ALTER TABLE articles ADD COLUMN article_id TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'image') THEN
ALTER TABLE articles ADD COLUMN image TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'image_claimed_at') THEN
ALTER TABLE articles ADD COLUMN image_claimed_at TIMESTAMPTZ;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'articles'::regclass AND attname = 'image_claimed_by') THEN
ALTER TABLE articles ADD COLUMN image_claimed_by TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'articles_article_id_uniq') THEN
ALTER TABLE articles ADD CONSTRAINT articles_article_id_uniq UNIQUE(article_id);
END IF;
END $$;
CREATE TABLE IF NOT EXISTS market_quotes (
id BIGSERIAL PRIMARY KEY,
instrument TEXT NOT NULL,
bid NUMERIC NOT NULL,
quoted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMP DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'market_quotes_instrument_uniq') THEN
ALTER TABLE market_quotes ADD CONSTRAINT market_quotes_instrument_uniq UNIQUE (instrument);
END IF;
END $$;
-- Seed feeds from CSV (expects file at /docker-entrypoint-initdb.d/data/news_rss_feeds.csv)
COPY feeds (url)
FROM '/docker-entrypoint-initdb.d/data/news_rss_feeds.csv'
WITH (FORMAT csv, HEADER true);

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
services:
database:
build: ./db
image: bunker-database:latest
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
# optional: nur lokal erreichbar, falls du psql vom Server nutzen willst
ports:
- "127.0.0.1:${POSTGRES_PORT}:5432"
volumes:
- ./volumes/postgres:/var/lib/postgresql/data
- ./volumes/db-init/data:/docker-entrypoint-initdb.d/data:ro
- ./volumes/data:/data
server-app:
build: ./server-app
image: bunker-server-app:latest
restart: unless-stopped
environment:
APP_PORT: ${APP_PORT}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?sslmode=disable
expose:
- "8080"
volumes:
- ./volumes/web:/app/web
depends_on:
- database
ai-worker:
build: ./ai-worker
image: bunker-ai-worker:latest
restart: unless-stopped
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?sslmode=disable
SD_MODEL_PATH: /app/models/sd15
IMAGE_OUTPUT_DIR: /app/images
volumes:
- ./volumes/ai-models:/app/models
- ./volumes/images:/app/images
depends_on:
- database
background-worker:
build: ./background-worker
image: bunker-background-worker:latest
restart: unless-stopped
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?sslmode=disable
RMV_API_KEY: ${RMV_API_KEY}
depends_on:
- database
volumes:
caddy_data:
caddy_config:

11
server-app/dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM golang:1.24
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
EXPOSE 8080
CMD ["go", "run", "main.go"]

11
server-app/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module server-app
go 1.24.0
require github.com/jackc/pgx/v5 v5.8.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
golang.org/x/text v0.29.0 // indirect
)

26
server-app/go.sum Normal file
View File

@@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

450
server-app/main.go Normal file
View File

@@ -0,0 +1,450 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
)
type defconState struct {
mu sync.RWMutex
Level int `json:"level"`
}
func (d *defconState) get() int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.Level
}
func (d *defconState) set(level int) {
d.mu.Lock()
defer d.mu.Unlock()
d.Level = level
}
var defcon = defconState{Level: 5}
var marketOrder = []string{
"USD/JPY",
"OIL/USD",
"XAU/USD",
"USD/EUR",
"USD/CHF",
"USD/GBP",
}
var marketAliases = map[string]string{
"yen": "USD/JPY",
"oil": "OIL/USD",
"gold": "XAU/USD",
"usd_eur": "USD/EUR",
"usd_chf": "USD/CHF",
"usd_gbp": "USD/GBP",
"EUR/USD": "USD/EUR",
"EUR/GBP": "USD/GBP",
}
var uniMainzNet = mustParseCIDR("134.93.0.0/16")
func mustParseCIDR(cidr string) *net.IPNet {
_, block, err := net.ParseCIDR(cidr)
if err != nil {
log.Fatalf("invalid CIDR %q: %v", cidr, err)
}
return block
}
func clientIP(r *http.Request) net.IP {
var candidates []string
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.Split(fwd, ",")
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
candidates = append(candidates, trimmed)
}
}
}
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
candidates = append(candidates, real)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
candidates = append(candidates, host)
} else if r.RemoteAddr != "" {
candidates = append(candidates, r.RemoteAddr)
}
for _, raw := range candidates {
ip := net.ParseIP(raw)
if ip == nil {
continue
}
if ip4 := ip.To4(); ip4 != nil {
return ip4
}
return ip
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func buildDSN() string {
if url := os.Getenv("DATABASE_URL"); url != "" {
return url
}
host := envOr("POSTGRES_HOST", "database")
port := envOr("POSTGRES_PORT", "5432")
user := envOr("POSTGRES_USER", "app")
pass := envOr("POSTGRES_PASSWORD", "appsecret")
name := envOr("POSTGRES_DB", "appdb")
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, pass, host, port, name)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func canonicalInstrumentName(name string) (string, bool) {
if canon, ok := marketAliases[name]; ok {
name = canon
}
for _, target := range marketOrder {
if target == name {
return name, true
}
}
return "", false
}
func fetchLatestQuotes(ctx context.Context, conn *pgx.Conn, requested []string) (map[string]float64, error) {
lookup := append([]string{}, requested...)
for alias := range marketAliases {
lookup = append(lookup, alias)
}
rows, err := conn.Query(ctx, `
SELECT instrument, bid
FROM market_quotes
WHERE instrument = ANY($1)
ORDER BY quoted_at DESC
`, lookup)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[string]float64{}
for rows.Next() {
var instrument string
var bid float64
if err := rows.Scan(&instrument, &bid); err != nil {
return nil, err
}
canon, ok := canonicalInstrumentName(instrument)
if !ok {
continue
}
if _, exists := result[canon]; !exists {
result[canon] = bid
}
}
if rows.Err() != nil {
return nil, rows.Err()
}
return result, nil
}
func withConn(r *http.Request, fn func(conn *pgx.Conn) error) error {
ctx := r.Context()
conn, err := pgx.Connect(ctx, buildDSN())
if err != nil {
return err
}
defer conn.Close(ctx)
return fn(conn)
}
func serveFile(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Clean(path))
}
}
func main() {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
http.HandleFunc("/", serveFile("web/template/index.html"))
http.HandleFunc("/lite", serveFile("web/template/index-lite.html"))
http.HandleFunc("/blog", serveFile("web/template/blog.html"))
http.HandleFunc("/mandelbrot", serveFile("web/template/mandelbrot.html"))
http.HandleFunc("/media/gong", serveFile("web/static/media/gong.mp4"))
http.HandleFunc("/media/cantina", serveFile("web/static/media/cantina.mp3"))
http.HandleFunc("/api/artikeltext", func(w http.ResponseWriter, r *http.Request) {
if err := withConn(r, func(conn *pgx.Conn) error {
rows, err := conn.Query(r.Context(), `
SELECT title, link, summary, image, published_at
FROM articles
WHERE image IS NOT NULL
ORDER BY COALESCE(published_at, created_at) DESC
LIMIT 100
`)
if err != nil {
return err
}
defer rows.Close()
// 🔑 Header einmal setzen
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// hilft bei manchen Proxies:
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
// Kickstart: sofort 1 Byte senden, damit Streaming „anfängt“
enc := json.NewEncoder(w)
flusher, ok := w.(http.Flusher)
if !ok {
return fmt.Errorf("streaming not supported")
}
w.Write([]byte("\n"))
flusher.Flush()
for rows.Next() {
var title, link, summary string
var image *string
var published *time.Time
if err := rows.Scan(&title, &link, &summary, &image, &published); err != nil {
return err
}
item := map[string]interface{}{
"title": title,
"link": link,
"text": summary,
"image": image,
"publishedAt": published,
}
// 👇 EIN Artikel = EIN JSON
if err := enc.Encode(item); err != nil {
return err
}
flusher.Flush() // 🚀 sofort senden
}
return rows.Err()
}); err != nil {
log.Printf("artikeltext query failed: %v", err)
http.Error(w, "stream error", http.StatusInternalServerError)
}
})
http.HandleFunc("/api/images", func(w http.ResponseWriter, r *http.Request) {
if err := withConn(r, func(conn *pgx.Conn) error {
rows, err := conn.Query(r.Context(), `
SELECT title, image, published_at
FROM articles
ORDER BY COALESCE(published_at, created_at) DESC
LIMIT 10000
`)
if err != nil {
return err
}
defer rows.Close()
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
flusher, ok := w.(http.Flusher)
if !ok {
return fmt.Errorf("streaming not supported")
}
for rows.Next() {
var title string
var image *string
var published *time.Time
if err := rows.Scan(&title, &image, &published); err != nil {
return err
}
item := map[string]interface{}{
"title": title,
"image": image,
"publishedAt": published,
}
if err := enc.Encode(item); err != nil {
return err
}
flusher.Flush()
}
if rows.Err() != nil {
return rows.Err()
}
return rows.Err()
}); err != nil {
// Fallback: keine News statt Fehler, damit die Seite weiterlädt.
log.Printf("Image query failed, returning empty list: %v", err)
writeJSON(w, http.StatusOK, []map[string]interface{}{})
}
})
http.HandleFunc("/api/rmv", func(w http.ResponseWriter, r *http.Request) {
if err := withConn(r, func(conn *pgx.Conn) error {
rows, err := conn.Query(r.Context(), `
SELECT trip_index, leg_index, linie, abfahrt, ankunft, von, nach
FROM rmv_data
ORDER BY trip_index, leg_index
`)
if err != nil {
return err
}
defer rows.Close()
type leg struct {
Linie string `json:"linie"`
Abfahrt string `json:"abfahrt"`
Ankunft string `json:"ankunft"`
Von string `json:"von"`
Nach string `json:"nach"`
}
byTrip := map[int][]leg{}
var maxTrip int
for rows.Next() {
var tripIdx, legIdx int
var l leg
if err := rows.Scan(&tripIdx, &legIdx, &l.Linie, &l.Abfahrt, &l.Ankunft, &l.Von, &l.Nach); err != nil {
return err
}
byTrip[tripIdx] = append(byTrip[tripIdx], l)
if tripIdx > maxTrip {
maxTrip = tripIdx
}
}
if rows.Err() != nil {
return rows.Err()
}
var trips [][]leg
for i := 0; i <= maxTrip; i++ {
if legs, ok := byTrip[i]; ok {
trips = append(trips, legs)
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"abfahrten": trips})
return nil
}); err != nil {
log.Printf("rmv query failed: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"})
}
})
http.HandleFunc("/api/market", func(w http.ResponseWriter, r *http.Request) {
if err := withConn(r, func(conn *pgx.Conn) error {
result, err := fetchLatestQuotes(r.Context(), conn, marketOrder)
if err != nil {
return err
}
type quote struct {
Instrument string `json:"instrument"`
Bid float64 `json:"bid"`
}
var ordered []quote
for _, inst := range marketOrder {
if bid, ok := result[inst]; ok {
ordered = append(ordered, quote{Instrument: inst, Bid: bid})
}
}
writeJSON(w, http.StatusOK, ordered)
return nil
}); err != nil {
log.Printf("market query failed: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db error"})
}
})
http.HandleFunc("/api/defcon", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]int{"level": defcon.get()})
case http.MethodPost:
var body struct {
Level int `json:"level"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if body.Level < 1 || body.Level > 5 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "level must be 1-5"})
return
}
defcon.set(body.Level)
writeJSON(w, http.StatusOK, map[string]int{"level": body.Level})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/api/bunker-status", func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
var ipString string
if ip != nil {
ipString = ip.String()
}
inMainz := ip != nil && uniMainzNet.Contains(ip)
writeJSON(w, http.StatusOK, map[string]interface{}{
"online": inMainz,
"ts": time.Now().UTC(),
"client_ip": ipString,
"network": uniMainzNet.String(),
})
})
http.HandleFunc("/api/countdowns", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Clean("web/static/media/countdowns.json"))
})
log.Println("Server läuft auf http://0.0.0.0:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

BIN
volumes/.DS_Store vendored Normal file

Binary file not shown.

BIN
volumes/db-init/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,33 @@
RSS
https://feeds.bbci.co.uk/news/politics/rss.xml
https://feeds.bbci.co.uk/news/business/rss.xml
https://rss.nytimes.com/services/xml/rss/nyt/World.xml
https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml
https://rss.nytimes.com/services/xml/rss/nyt/Business.xml
https://www.reutersagency.com/feed/?best-topics=business-finance&post_type=best
https://www.reutersagency.com/feed/?best-topics=politics&post_type=best
https://www.euronews.com/rss?level=theme&name=News
https://www.ft.com/?format=rss
https://www.aljazeera.com/xml/rss/all.xml
https://www.cnbc.com/id/100003114/device/rss/rss.html
https://feeds.a.dj.com/rss/RSSWorldNews.xml
https://feeds.a.dj.com/rss/RSSPolitics.xml
https://feeds.a.dj.com/rss/RSSMarketsMain.xml
https://apnews.com/apf-politics
https://apnews.com/apf-business
https://www.theguardian.com/world/rss
https://www.theguardian.com/politics/rss
https://www.theguardian.com/business/rss
https://www.politico.com/rss/politics08.xml
https://www.politico.eu/feed/
https://www.npr.org/rss/rss.php?id=1001
https://www.npr.org/rss/rss.php?id=1012
https://www.npr.org/rss/rss.php?id=1006
https://www.haaretz.com/cmlink/haaretz-rss-feed-1.804163
https://www.jpost.com/Rss/RssFeedsHeadlines.aspx
https://www.timesofisrael.com/feed/
https://www.tagesschau.de/infoservices/alle-meldungen-100~rss2.xml
https://www.faz.net/rss/aktuell
https://feeds.washingtonpost.com/rss/world
https://www.scmp.com/rss/91/feed/
https://rss.sueddeutsche.de/rss/Alles
1 RSS
2 https://feeds.bbci.co.uk/news/politics/rss.xml
3 https://feeds.bbci.co.uk/news/business/rss.xml
4 https://rss.nytimes.com/services/xml/rss/nyt/World.xml
5 https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml
6 https://rss.nytimes.com/services/xml/rss/nyt/Business.xml
7 https://www.reutersagency.com/feed/?best-topics=business-finance&post_type=best
8 https://www.reutersagency.com/feed/?best-topics=politics&post_type=best
9 https://www.euronews.com/rss?level=theme&name=News
10 https://www.ft.com/?format=rss
11 https://www.aljazeera.com/xml/rss/all.xml
12 https://www.cnbc.com/id/100003114/device/rss/rss.html
13 https://feeds.a.dj.com/rss/RSSWorldNews.xml
14 https://feeds.a.dj.com/rss/RSSPolitics.xml
15 https://feeds.a.dj.com/rss/RSSMarketsMain.xml
16 https://apnews.com/apf-politics
17 https://apnews.com/apf-business
18 https://www.theguardian.com/world/rss
19 https://www.theguardian.com/politics/rss
20 https://www.theguardian.com/business/rss
21 https://www.politico.com/rss/politics08.xml
22 https://www.politico.eu/feed/
23 https://www.npr.org/rss/rss.php?id=1001
24 https://www.npr.org/rss/rss.php?id=1012
25 https://www.npr.org/rss/rss.php?id=1006
26 https://www.haaretz.com/cmlink/haaretz-rss-feed-1.804163
27 https://www.jpost.com/Rss/RssFeedsHeadlines.aspx
28 https://www.timesofisrael.com/feed/
29 https://www.tagesschau.de/infoservices/alle-meldungen-100~rss2.xml
30 https://www.faz.net/rss/aktuell
31 https://feeds.washingtonpost.com/rss/world
32 https://www.scmp.com/rss/91/feed/
33 https://rss.sueddeutsche.de/rss/Alles

BIN
volumes/web/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,90 @@
:root {
--tile-size: 512px;
--gap: 0px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #000;
color: #f5f5f5;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
main.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--gap);
width: 100vw;
min-height: 100vh;
}
.tile {
position: relative;
width: min(var(--tile-size), 100%);
aspect-ratio: 1 / 1;
background: #111;
border-radius: 0;
overflow: hidden;
cursor: pointer;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.6);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.tile:hover {
transform: translateY(-4px);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.7);
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 0.25s ease;
}
.tile.open img {
filter: blur(2px) brightness(0.4);
}
.overlay {
position: absolute;
inset: 0;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
background: linear-gradient(180deg, rgba(0,0,0,0.78), rgba(0,0,0,0.92));
opacity: 0;
transition: opacity 0.25s ease;
overflow: auto;
}
.tile.open .overlay {
opacity: 1;
}
.overlay h3 {
margin: 0;
font-size: 18px;
line-height: 1.3;
}
.overlay p {
margin: 0;
font-size: 14px;
line-height: 1.45;
color: #d8d8d8;
white-space: pre-line;
}
.overlay a {
margin-top: auto;
align-self: flex-start;
color: #8ad8ff;
text-decoration: none;
font-weight: 600;
letter-spacing: 0.2px;
}
.overlay a:hover {
text-decoration: underline;
}
.empty {
grid-column: 1 / -1;
text-align: center;
color: #888;
padding: 40px 0;
}

View File

@@ -0,0 +1,112 @@
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 16px;
font-family: Arial, sans-serif;
background: #121212;
color: #e6e6e6;
}
.page-header {
position: sticky;
top: 0;
background: #121212;
padding-bottom: 12px;
z-index: 10;
}
.header-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-size: 1.3rem;
font-weight: 700;
}
.actions {
display: flex;
gap: 8px;
align-items: center;
}
.actions a,
.actions button {
background: #2a2a2a;
color: #e6e6e6;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 6px 10px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.status {
margin-top: 8px;
font-size: 0.9rem;
color: #bdbdbd;
}
.panel {
margin-top: 16px;
padding: 12px;
border: 1px solid #2a2a2a;
border-radius: 8px;
background: #1a1a1a;
}
.panel h2 {
margin: 0 0 10px;
font-size: 1.05rem;
}
.news-item {
padding: 10px 0;
border-top: 1px solid #2a2a2a;
}
.news-item:first-child {
border-top: none;
padding-top: 0;
}
.news-title {
font-weight: 700;
margin-bottom: 4px;
}
.news-text {
color: #c9c9c9;
font-size: 0.95rem;
}
.news-link {
margin-top: 6px;
}
.news-link a {
color: #8ecbff;
text-decoration: none;
}
.market-list,
.rmv-list {
display: grid;
gap: 6px;
}
.empty {
color: #9a9a9a;
}

View File

@@ -0,0 +1,295 @@
body {
font-family: Arial, sans-serif;
background-color: #121212;
color: #e0e0e0;
padding: 1rem;
margin: 0;
}
.page-header {
position: sticky;
top: 0;
z-index: 1000;
background-color: inherit;
padding: 0.75rem 0 0.5rem;
}
/* =========================
HEADER: RESPONSIVE / HARMONISCH
========================= */
:root {
/* Höhe/Größe hängt von viewport-Breite ab:
- min: 44px (kleine Screens)
- ideal: ~6vw
- max: 60px (große Screens) */
--hdr-h: clamp(44px, 6vw, 60px);
/* Rundung skaliert leicht mit */
--hdr-r: clamp(8px, 1.2vw, 12px);
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(8px, 1.2vw, 16px);
}
/* Links: BUNKER/Hazard so schmal wie möglich */
#hazard-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--hdr-h);
padding: 0 clamp(10px, 1.2vw, 14px);
/* wichtig: nicht breitziehen */
min-width: 0;
width: auto;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.06);
border-radius: var(--hdr-r);
font-size: clamp(1.35rem, 2.6vw, 1.65rem); /* noch größer */
line-height: 1;
white-space: nowrap;
}
/* Rechts: Button-Container */
.header-actions {
display: flex;
align-items: center;
gap: clamp(8px, 1vw, 12px);
justify-content: flex-end;
flex-wrap: nowrap;
flex: 0 0 auto;
}
/* Basis-Button */
.header-actions a,
.header-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--hdr-h);
border-radius: var(--hdr-r);
border: none;
cursor: pointer;
text-decoration: none;
font-weight: 700;
font-size: clamp(1.3rem, 2.45vw, 1.58rem); /* noch größer */
line-height: 1;
white-space: nowrap;
padding: 0; /* wird unten je nach Typ gesetzt */
}
/* Rechts: ALLE ICONS (a) quadratisch
(außer DEFCON, das ist button) */
.header-actions a {
width: var(--hdr-h);
min-width: var(--hdr-h);
padding: 0;
font-size: clamp(1.5rem, 3.1vw, 1.95rem); /* noch größer */
}
/* DEFCON: bekommt genug Platz, damit Text ohne Umbruch passt */
#defcon-button {
height: var(--hdr-h);
/* Breite: automatisch nach Inhalt, aber mit Mindestbreite */
width: auto;
min-width: clamp(130px, 18vw, 190px);
padding: 0 clamp(12px, 1.6vw, 18px);
gap: clamp(6px, 1vw, 10px);
background: rgba(255, 255, 255, 0.06);
color: #fff;
border-radius: var(--hdr-r);
/* garantiert: kein Umbruch/Spacing-Problem */
white-space: nowrap;
letter-spacing: 0.02em;
}
/* Optional, falls du im DEFCON-Button ein Emoji + Text hast:
<button id="defcon-button"><span>🚨</span><span>DEFCON 3</span></button> */
#defcon-button span {
line-height: 1;
}
/* Farben wie bei dir (minimal angepasst möglich, aber ich lasse sie) */
.header-actions a:nth-child(1) { background-color: #ff0000; }
.header-actions a:nth-child(2) { background-color: #6fff93; color: #000; }
.header-actions a:nth-child(3) { background-color: #339933; }
.header-actions a:nth-child(4),
.header-actions a:nth-child(5) { background-color: #3333cc; }
.header-actions button { background-color: #ffaa00; color: #000; }
/* =========================
TICKER
========================= */
.market-ticker {
overflow: hidden;
border-top: 1px solid #2b2b2b;
border-bottom: 1px solid #2b2b2b;
margin: 0;
padding: 6px 10px;
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
position: relative;
flex: 1 1 auto;
display: flex;
align-items: center;
border-radius: 10px;
}
.market-ticker::before,
.market-ticker::after {
content: "";
position: absolute;
top: 0;
width: 80px;
height: 100%;
pointer-events: none;
z-index: 1;
}
.market-ticker::before {
left: 0;
background: linear-gradient(90deg, #121212, rgba(18, 18, 18, 0));
}
.market-ticker::after {
right: 0;
background: linear-gradient(270deg, #121212, rgba(18, 18, 18, 0));
}
.market-ticker-track {
display: inline-flex;
gap: 1.25rem;
white-space: nowrap;
animation: ticker-move var(--ticker-duration, 30s) linear infinite;
will-change: transform;
}
.market-ticker-track:hover {
animation-play-state: paused;
}
.ticker-item {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
}
@keyframes ticker-move {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
/* =========================
NEWS
========================= */
.news-item {
background: #1e1e1e;
padding: 15px;
margin-bottom: 15px;
border-radius: 6px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
display: flex;
gap: 12px;
align-items: flex-start;
}
.news-title {
font-size: 1.1em;
font-weight: bold;
margin-bottom: 5px;
color: #ffffff;
}
.news-text {
font-size: 0.95em;
color: #cccccc;
}
.news-link a {
font-size: 0.9em;
color: #4da6ff;
text-decoration: none;
}
.news-thumb {
width: 160px;
height: 100px;
object-fit: cover;
border-radius: 6px;
background: #0f0f0f;
flex-shrink: 0;
}
.news-content {
flex: 1;
}
#news-container {
flex: 1;
}
/* =========================
SIDEBAR / ABFAHRT
========================= */
#abfahrt-wrapper {
flex: 0 0 300px;
position: static;
background-color: #1e1e2f;
color: white;
padding: 1rem;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
font-size: 0.9em;
margin-top: 0;
max-width: 100%;
}
#abfahrt-wrapper h2 {
margin-top: 0;
font-size: 1.1em;
}
.abfahrt-eintrag {
margin-bottom: 0.5em;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding-bottom: 0.5em;
}
.trip-block {
margin-bottom: 1em;
padding: 0.5em;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 6px;
}
.blog-highlight {
background-color: #a00000;
color: white;
padding: 1em;
border-radius: 8px;
margin-bottom: 2rem;
}
#content-wrapper {
display: flex;
gap: 2rem;
align-items: flex-start;
}

View File

@@ -0,0 +1,216 @@
body {
background: black;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.circle-container {
position: relative;
width: 400px;
height: 400px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 100px;
}
.circle {
width: 100%;
height: 100%;
border: 32px solid white;
border-radius: 50%;
position: relative;
box-sizing: border-box;
}
.dot {
width: 32px;
height: 32px;
background-color: red;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(0deg) translateY(-184px);
animation: rotate 60s linear infinite;
transform-origin: center;
}
@keyframes rotate {
from {
transform: translate(-50%, -50%) rotate(0deg) translateY(-184px);
}
to {
transform: translate(-50%, -50%) rotate(360deg) translateY(-184px);
}
}
.timer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-family: monospace;
font-size: 24px;
text-align: center;
}
.small-circle-container {
display: flex;
justify-content: space-between;
gap: 20px;
margin-top: 40px;
position: absolute;
top: calc(100% - 60px);
left: 50%;
transform: translateX(-50%);
}
.small-circle {
width: 200px;
height: 200px;
border: 20px solid;
border-radius: 50%;
position: relative;
box-sizing: border-box;
cursor: pointer;
}
.small-dot {
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(0deg) translateY(-90px);
animation: rotate-small 60s linear infinite;
transform-origin: center;
}
@keyframes rotate-small {
from {
transform: translate(-50%, -50%) rotate(0deg) translateY(-90px);
}
to {
transform: translate(-50%, -50%) rotate(360deg) translateY(-90px);
}
}
#fullscreen-btn {
position: absolute;
top: 20px;
right: 20px;
padding: 8px 16px;
font-size: 14px;
background-color: rgb(0, 0, 0);
color: black;
border: none;
border-radius: 4px;
cursor: pointer;
z-index: 999;
}
.small-timer-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-family: monospace;
font-size: 16px;
text-align: center;
line-height: 1.4;
white-space: pre-line;
}
.timer-details {
position: fixed;
top: 20px;
right: 20px;
width: 320px;
max-height: 80vh;
overflow-y: auto;
padding: 16px;
background: #0d0d0d;
border: 1px solid #333;
border-radius: 12px;
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.4);
color: #f1f1f1;
z-index: 1000;
}
.timer-details.hidden {
display: none;
}
.timer-details h3 {
margin-top: 0;
margin-bottom: 8px;
font-size: 1.1rem;
}
.timer-details .timer-meta {
margin-bottom: 10px;
color: #bfbfbf;
font-size: 0.9rem;
}
.timer-entry {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #222;
}
.timer-entry:last-child {
border-bottom: none;
}
.timer-entry .label {
flex: 1;
font-weight: 600;
color: #eaeaea;
}
.timer-entry .countdown {
white-space: nowrap;
font-family: monospace;
color: #9fd3ff;
}
.timer-details .close-btn {
background: #222;
color: #f1f1f1;
border: 1px solid #444;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
font-size: 0.85rem;
margin-bottom: 10px;
}
.timer-details .close-btn:hover {
background: #333;
}
#twitter-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
z-index: 9999;
justify-content: center;
align-items: center;
flex-direction: column;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,90 @@
const grid = document.getElementById("grid");
function createTile(artikel) {
const tile = document.createElement("div");
tile.className = "tile";
const img = document.createElement("img");
img.src = artikel.image;
img.alt = artikel.title || "Artikelbild";
tile.appendChild(img);
const overlay = document.createElement("div");
overlay.className = "overlay";
const title = document.createElement("h3");
title.textContent = artikel.title || "Ohne Titel";
overlay.appendChild(title);
tile.appendChild(overlay);
tile.addEventListener("click", () => tile.classList.toggle("open"));
return tile;
}
async function ladeGalerie() {
grid.innerHTML = "";
let foundAny = false;
try {
const res = await fetch("/api/images", {
headers: { "Accept": "application/x-ndjson" }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.body) throw new Error("Streaming nicht unterstützt");
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const s = line.trim();
if (!s) continue;
let artikel;
try {
artikel = JSON.parse(s);
} catch {
continue;
}
if (artikel?.image) {
grid.appendChild(createTile(artikel));
foundAny = true;
}
}
}
// Rest verarbeiten
const last = buffer.trim();
if (last) {
try {
const artikel = JSON.parse(last);
if (artikel?.image) {
grid.appendChild(createTile(artikel));
foundAny = true;
}
} catch {}
}
if (!foundAny) {
grid.innerHTML = '<div class="empty">Keine Bilder gefunden.</div>';
}
} catch (err) {
console.error(err);
grid.innerHTML = '<div class="empty">Fehler beim Laden der Galerie.</div>';
}
}
ladeGalerie();

View File

@@ -0,0 +1,372 @@
const stripTags = (html) => (typeof html === "string" ? html.replace(/<[^>]*>/g, "") : "");
const formatBid = (value) => {
const num = Number(value);
if (Number.isNaN(num)) return "n/a";
const abs = Math.abs(num);
if (abs >= 100) return num.toFixed(2);
if (abs >= 10) return num.toFixed(3);
return num.toFixed(4);
};
const instrumentEmoji = (inst) => {
const map = {
"USD/JPY": "$/¥",
"OIL/USD": "🛢️",
"XAU/USD": "⛏️",
"USD/EUR": "€/$",
"USD/CHF": "$/CHF",
"USD/GBP": "£/$",
};
return map[inst] || inst;
};
const newsContainer = document.getElementById("news-container");
const abfahrtWrapper = document.getElementById("abfahrt-wrapper");
const abfahrtInfo = document.getElementById("abfahrt-info");
const marketTrack = document.getElementById("market-ticker-track");
const marketContainer = document.getElementById("market-ticker");
async function loadNews() {
if (!newsContainer) return;
try {
const res = await fetch("/api/artikeltext", {
headers: { "Accept": "application/x-ndjson" },
cache: "no-store",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
const items = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line);
items.push({
title: stripTags(parsed.title),
text: stripTags(parsed.text),
link: parsed.link,
});
} catch (err) {
console.warn("NDJSON parse error", err);
}
if (items.length >= 60) break;
}
newsContainer.innerHTML = "";
if (!items.length) {
newsContainer.innerHTML = '<div class="empty">Keine News gefunden.</div>';
return;
}
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const wrapper = document.createElement("div");
wrapper.className = "news-item";
const title = document.createElement("div");
title.className = "news-title";
title.textContent = item.title || "Kein Titel";
const textEl = document.createElement("div");
textEl.className = "news-text";
textEl.textContent = item.text || "[Kein Text verfuegbar]";
const linkWrap = document.createElement("div");
linkWrap.className = "news-link";
const link = document.createElement("a");
link.href = item.link || "#";
link.textContent = "Zum Artikel";
link.target = "_blank";
link.rel = "noopener noreferrer";
linkWrap.appendChild(link);
wrapper.appendChild(title);
wrapper.appendChild(textEl);
wrapper.appendChild(linkWrap);
fragment.appendChild(wrapper);
});
newsContainer.appendChild(fragment);
} catch (err) {
console.warn("News Fehler:", err);
newsContainer.innerHTML = '<div class="empty">Fehler beim Laden der News.</div>';
}
}
function renderMarketTicker(quotes) {
if (!marketTrack || !marketContainer) return;
marketTrack.innerHTML = "";
if (!quotes || quotes.length === 0) {
marketTrack.textContent = "Keine Marktdaten verfuegbar";
marketTrack.style.animation = "none";
return;
}
const fragment = document.createDocumentFragment();
const addItems = () => quotes.forEach((q) => {
const span = document.createElement("span");
span.className = "ticker-item";
span.textContent = `${instrumentEmoji(q.instrument)} ${formatBid(q.bid)}`;
fragment.appendChild(span);
});
addItems();
addItems();
marketTrack.appendChild(fragment);
const duration = Math.max(90, Math.min(180, quotes.length * 10));
marketTrack.style.setProperty("--ticker-duration", `${duration}s`);
marketTrack.style.animation = "none";
void marketTrack.offsetWidth;
marketTrack.style.animation = `ticker-move var(--ticker-duration, ${duration}s) linear infinite`;
}
async function loadMarket() {
try {
const res = await fetch("/api/market", { cache: "no-store" });
if (!res.ok) throw new Error("Market API nicht verfuegbar");
const data = await res.json();
if (!Array.isArray(data)) throw new Error("Ungueltige Marktdaten");
renderMarketTicker(data);
} catch (err) {
console.warn("Marktdaten Fehler:", err);
renderMarketTicker([]);
}
}
async function loadRMV() {
if (!abfahrtInfo || !abfahrtWrapper) return;
try {
const response = await fetch("/api/rmv", { cache: "no-store" });
if (!response.ok) throw new Error("RMV-JSON nicht erreichbar");
const data = await response.json();
abfahrtWrapper.style.display = "block";
if (!data.abfahrten || data.abfahrten.length === 0) {
abfahrtInfo.innerHTML = "<p>Keine Abfahrten gefunden.</p>";
return;
}
const tripHTML = data.abfahrten.map((trip) => {
const legsHTML = trip.map((leg) => `
<div>
<strong>${leg.linie}</strong><br>
${leg.abfahrt} -> ${leg.ankunft}<br>
${leg.von} -> ${leg.nach}
</div>
`).join("<hr style='border-color: rgba(255,255,255,0.2);'>");
return `<div class="trip-block"><div class="abfahrt-eintrag">${legsHTML}</div></div>`;
}).join("");
abfahrtInfo.innerHTML = tripHTML;
} catch (err) {
console.warn("RMV-Info nicht geladen:", err);
abfahrtInfo.innerText = "Fehler beim Laden.";
}
}
function toggleMensaIframe() {
const container = document.getElementById("mensa-iframe-container");
container.style.display = (container.style.display === "none") ? "block" : "none";
}
function zeigeGongVideo() {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = "100vw";
overlay.style.height = "100vh";
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.zIndex = 9999;
const video = document.createElement("video");
video.src = "/media/gong";
video.autoplay = true;
video.controls = true;
video.style.maxWidth = "90%";
video.style.maxHeight = "90%";
overlay.appendChild(video);
document.body.appendChild(overlay);
video.onended = () => document.body.removeChild(overlay);
overlay.onclick = () => document.body.removeChild(overlay);
}
function zeigeYouTubeVideo() {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = "100vw";
overlay.style.height = "100vh";
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.zIndex = 9999;
const iframe = document.createElement("iframe");
const videoIds = [
"zhDwjnYZiCo",
"Na0w3Mz46GA",
"OO2kPK5-qno",
"Yqk13qPcXis",
"MZhivjxcF-M",
"uMEvzhckqBw",
"uMEvzhckqBw",
"TGan48YE9Us",
"-Xh4BNbxpI8",
"r7kxh_vuBpo",
"bdUbACCWmoY",
];
const zufallsId = videoIds[Math.floor(Math.random() * videoIds.length)];
iframe.src = `https://www.youtube.com/embed/${zufallsId}?autoplay=1`;
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
iframe.style.width = "90%";
iframe.style.height = "90%";
iframe.style.border = "none";
overlay.appendChild(iframe);
overlay.onclick = () => document.body.removeChild(overlay);
document.body.appendChild(overlay);
if (overlay.requestFullscreen) {
overlay.requestFullscreen();
} else if (overlay.webkitRequestFullscreen) {
overlay.webkitRequestFullscreen();
} else if (overlay.msRequestFullscreen) {
overlay.msRequestFullscreen();
}
}
document.addEventListener("DOMContentLoaded", () => {
const hazard = document.getElementById("hazard-symbol");
if (hazard) {
hazard.addEventListener("click", () => {
const elem = document.documentElement;
if (elem.requestFullscreen) {
elem.requestFullscreen();
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
} else if (elem.msRequestFullscreen) {
elem.msRequestFullscreen();
}
});
}
});
let currentDefcon = 5;
function aktualisiereDefconButton(level) {
const btn = document.getElementById("defcon-button");
if (!btn) return;
const farben = {
1: "#ff0000",
2: "#ff9900",
3: "#ffff00",
4: "#0000ff",
5: "#000000",
};
const farbe = farben[level] || "#555";
btn.textContent = `⚠️ DEFCON ${level}`;
btn.style.backgroundColor = farbe;
btn.style.color = level === 3 ? "black" : "white";
btn.style.fontWeight = "bold";
btn.style.transition = "background-color 0.5s, color 0.5s";
if (level === 1) {
document.body.style.backgroundColor = farbe;
} else {
document.body.style.backgroundColor = "#000000";
}
}
async function loadDefconStatus() {
try {
const res = await fetch("/api/defcon");
if (!res.ok) throw new Error("Fehler beim Abruf");
const data = await res.json();
currentDefcon = data.level;
aktualisiereDefconButton(currentDefcon);
} catch (err) {
console.warn("DEFCON Abruf fehlgeschlagen:", err);
}
}
async function sendeNeuesDefconLevel(level) {
try {
const response = await fetch("/api/defcon", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ level: level }),
});
if (!response.ok) throw new Error("Fehler beim Senden des neuen DEFCON-Levels");
await response.json();
} catch (err) {
console.error("Fehler beim POST:", err);
}
}
function newDefconStatus() {
currentDefcon -= 1;
if (currentDefcon < 1) {
currentDefcon = 5;
}
sendeNeuesDefconLevel(currentDefcon);
aktualisiereDefconButton(currentDefcon);
}
async function loadBunkerStatus() {
try {
const res = await fetch("/api/bunker-status");
if (!res.ok) throw new Error("Fehler beim Abruf");
const data = await res.json();
const icon = document.getElementById("hazard-symbol");
if (!icon) return;
const inMainzNetz = Boolean(data && data.online);
if (inMainzNetz) {
icon.style.backgroundColor = "limegreen";
icon.style.color = "black";
icon.style.padding = "4px 8px";
icon.style.borderRadius = "4px";
} else {
icon.style.backgroundColor = "";
icon.style.color = "";
icon.title = "";
}
} catch (err) {
console.warn("Bunker-Status nicht abrufbar:", err);
}
}
loadBunkerStatus();
loadDefconStatus();
loadNews();
loadRMV();
loadMarket();
setInterval(loadBunkerStatus, 5 * 60 * 1000);
setInterval(loadDefconStatus, 2 * 60 * 1000);
setInterval(loadNews, 2 * 60 * 1000);
setInterval(loadRMV, 10 * 60 * 1000);
setInterval(loadMarket, 2 * 60 * 1000);
const defconBtn = document.getElementById("defcon-button");
if (defconBtn) {
defconBtn.addEventListener("click", newDefconStatus);
}

View File

@@ -0,0 +1,488 @@
window.addEventListener("load", () => {
const params = new URLSearchParams(window.location.search);
if (params.get("lofi") === "true") {
zeigeYouTubeVideo();
}
});
const stripTags = (html) => (typeof html === "string" ? html.replace(/<[^>]*>/g, "") : "");
const formatBid = (value) => {
const num = Number(value);
if (Number.isNaN(num)) return "n/a";
const abs = Math.abs(num);
if (abs >= 100) return num.toFixed(2);
if (abs >= 10) return num.toFixed(3);
return num.toFixed(4);
};
const instrumentEmoji = (inst) => {
const map = {
"USD/JPY": "$/¥",
"OIL/USD": "🛢️",
"XAU/USD": "⛏️",
"USD/EUR": "€/$",
"USD/CHF": "$/CHF",
"USD/GBP": "£/$",
};
return map[inst] || inst;
};
async function ladeNews() {
const container = document.getElementById("news-container");
if (!container) return;
const loadingEl = null;
const newsIndex = new Map();
const getKey = (nachricht) => {
if (nachricht?.link) return `link:${nachricht.link}`;
if (nachricht?.title) return `title:${stripTags(nachricht.title)}`;
if (nachricht?.publishedAt) return `ts:${nachricht.publishedAt}`;
return `idx:${newsIndex.size}`;
};
const buildItem = (nachricht) => {
const item = document.createElement("div");
item.className = "news-item";
const img = document.createElement("img");
img.className = "news-thumb";
img.loading = "lazy";
const content = document.createElement("div");
content.className = "news-content";
const titleEl = document.createElement("div");
titleEl.className = "news-title";
const textEl = document.createElement("div");
textEl.className = "news-text";
const linkWrap = document.createElement("div");
linkWrap.className = "news-link";
const linkEl = document.createElement("a");
linkEl.target = "_blank";
linkEl.rel = "noopener noreferrer";
linkWrap.appendChild(linkEl);
content.appendChild(titleEl);
content.appendChild(textEl);
content.appendChild(linkWrap);
item.appendChild(content);
return { item, img, titleEl, textEl, linkEl };
};
const updateFields = (state, nachricht) => {
const title = stripTags(nachricht.title) || "Kein Titel";
const link = nachricht.link || "#";
const rawText = stripTags(nachricht.text) || "[Kein Text verfügbar]";
const text = rawText.length > 140 ? `${rawText.slice(0, 140)}...` : rawText;
if (title && state.title !== title) {
state.title = title;
state.titleEl.textContent = title;
}
if (text && state.text !== text) {
state.text = text;
state.textEl.textContent = text;
}
if (link && state.link !== link) {
state.link = link;
state.linkEl.href = link;
state.linkEl.textContent = "🔗 Zum Artikel";
}
const isDataImg = typeof nachricht.image === "string" && nachricht.image.startsWith("data:image");
if (isDataImg && state.image !== nachricht.image) {
state.image = nachricht.image;
state.img.src = nachricht.image;
state.img.alt = title || "Artikelbild";
if (!state.img.isConnected) {
state.item.prepend(state.img);
}
}
};
const renderItem = (nachricht) => {
const key = getKey(nachricht);
const existing = newsIndex.get(key);
if (existing) {
updateFields(existing, nachricht);
return;
}
const state = buildItem(nachricht);
updateFields(state, nachricht);
newsIndex.set(key, state);
container.appendChild(state.item);
};
try {
const res = await fetch("/api/artikeltext", {
headers: { "Accept": "application/x-ndjson" },
cache: "no-store",
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${t.slice(0, 200)}`);
}
if (!res.body) throw new Error("Streaming wird vom Browser nicht unterstützt.");
let foundAny = false;
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const s = line.trim();
if (!s) continue;
try {
renderItem(JSON.parse(s));
foundAny = true;
} catch (e) {
console.warn("NDJSON parse error:", s.slice(0, 200));
}
}
}
const last = buffer.trim();
if (last) {
try {
renderItem(JSON.parse(last));
foundAny = true;
} catch {}
}
if (loadingEl && loadingEl.isConnected) loadingEl.remove();
if (!foundAny && !container.hasChildNodes()) {
container.innerHTML = '<div class="empty">Keine News gefunden.</div>';
}
} catch (error) {
console.error("ladeNews Fehler:", error);
if (loadingEl && loadingEl.isConnected) loadingEl.remove();
if (!container.hasChildNodes()) {
container.innerHTML = '<div class="empty">Fehler beim Laden der News.</div>';
}
}
}
async function ladeRMV() {
try {
const response = await fetch("/api/rmv");
if (!response.ok) throw new Error("RMV-JSON nicht erreichbar");
const daten = await response.json();
const infoBox = document.getElementById("abfahrt-info");
const wrapper = document.getElementById("abfahrt-wrapper");
wrapper.style.display = "block";
if (!daten.abfahrten || daten.abfahrten.length === 0) {
infoBox.innerHTML = "<p>Keine Abfahrten gefunden.</p>";
return;
}
const tripHTML = daten.abfahrten.map(trip => {
const legsHTML = trip.map(leg => `
<div>
<strong>${leg.linie}</strong><br>
${leg.abfahrt}${leg.ankunft}<br>
${leg.von}${leg.nach}
</div>
`).join("<hr style='border-color: rgba(255,255,255,0.2);'>");
return `<div class="trip-block"><div class="abfahrt-eintrag">${legsHTML}</div></div>`;
}).join("");
infoBox.innerHTML = tripHTML;
} catch (error) {
console.warn("RMV-Info nicht geladen:", error);
document.getElementById("abfahrt-info").innerText = "Fehler beim Laden.";
}
}
function toggleMensaIframe() {
const container = document.getElementById("mensa-iframe-container");
container.style.display = (container.style.display === "none") ? "block" : "none";
}
function zeigeGongVideo() {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = "100vw";
overlay.style.height = "100vh";
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.zIndex = 9999;
const video = document.createElement("video");
video.src = "/media/gong";
video.autoplay = true;
video.controls = true;
video.style.maxWidth = "90%";
video.style.maxHeight = "90%";
overlay.appendChild(video);
document.body.appendChild(overlay);
video.onended = () => document.body.removeChild(overlay);
overlay.onclick = () => document.body.removeChild(overlay);
}
let fxCache = [];
let fxIndex = 0;
function rotiereFX() {
const el = document.getElementById("fx-rotator");
if (!el || fxCache.length === 0) return;
const item = fxCache[fxIndex % fxCache.length];
el.textContent = `${instrumentEmoji(item.instrument)} ${formatBid(item.bid)}`;
fxIndex++;
}
function renderMarketTicker(quotes) {
const track = document.getElementById("market-ticker-track");
const container = document.getElementById("market-ticker");
if (!track || !container) return;
track.innerHTML = "";
if (!quotes || quotes.length === 0) {
track.textContent = "Keine Marktdaten verfügbar";
track.style.animation = "none";
return;
}
const fragment = document.createDocumentFragment();
const addItems = () => quotes.forEach(q => {
const span = document.createElement("span");
span.className = "ticker-item";
span.textContent = `${instrumentEmoji(q.instrument)} ${formatBid(q.bid)}`;
fragment.appendChild(span);
});
addItems();
addItems();
track.appendChild(fragment);
const duration = Math.max(18, Math.min(60, quotes.length * 4));
track.style.setProperty("--ticker-duration", `${duration}s`);
track.style.animation = "none";
void track.offsetWidth;
track.style.animation = `ticker-move var(--ticker-duration, ${duration}s) linear infinite`;
}
async function ladeMarketDaten() {
try {
const res = await fetch("/api/market", { cache: "no-store" });
if (!res.ok) throw new Error("Market API nicht verfügbar");
const data = await res.json();
if (!Array.isArray(data)) throw new Error("Ungültige Marktdaten");
fxCache = data;
rotiereFX();
renderMarketTicker(data);
} catch (err) {
console.warn("Marktdaten Fehler:", err);
const el = document.getElementById("fx-rotator");
if (el) el.textContent = "FX Fehler";
renderMarketTicker([]);
}
}
function zeigeYouTubeVideo() {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = "100vw";
overlay.style.height = "100vh";
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.zIndex = 9999;
const iframe = document.createElement("iframe");
const videoIds = [
"zhDwjnYZiCo",
"Na0w3Mz46GA",
"OO2kPK5-qno",
"Yqk13qPcXis",
"MZhivjxcF-M",
"uMEvzhckqBw",
"uMEvzhckqBw",
"TGan48YE9Us",
"-Xh4BNbxpI8",
"r7kxh_vuBpo",
"bdUbACCWmoY",
];
const zufallsId = videoIds[Math.floor(Math.random() * videoIds.length)];
iframe.src = `https://www.youtube.com/embed/${zufallsId}?autoplay=1`;
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
iframe.style.width = "90%";
iframe.style.height = "90%";
iframe.style.border = "none";
overlay.appendChild(iframe);
overlay.onclick = () => document.body.removeChild(overlay);
document.body.appendChild(overlay);
if (overlay.requestFullscreen) {
overlay.requestFullscreen();
} else if (overlay.webkitRequestFullscreen) {
overlay.webkitRequestFullscreen();
} else if (overlay.msRequestFullscreen) {
overlay.msRequestFullscreen();
}
}
document.addEventListener("DOMContentLoaded", () => {
const hazard = document.getElementById("hazard-symbol");
if (hazard) {
hazard.addEventListener("click", () => {
const elem = document.documentElement;
if (elem.requestFullscreen) {
elem.requestFullscreen();
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
} else if (elem.msRequestFullscreen) {
elem.msRequestFullscreen();
}
});
}
});
let currentDefcon = 5;
let letzteDefconÄnderung = Date.now();
function aktualisiereDefconButton(level) {
const btn = document.getElementById("defcon-button");
if (!btn) return;
const farben = {
1: "#ff0000",
2: "#ff9900",
3: "#ffff00",
4: "#0000ff",
5: "#000000",
};
const farbe = farben[level] || "#555";
btn.textContent = `⚠️ DEFCON ${level}`;
btn.style.backgroundColor = farbe;
btn.style.color = level === 3 ? "black" : "white";
btn.style.fontWeight = "bold";
btn.style.transition = "background-color 0.5s, color 0.5s";
if (level === 1) {
document.body.style.backgroundColor = farbe;
} else {
document.body.style.backgroundColor = "#000000";
}
}
async function ladeDefconStatus() {
try {
const res = await fetch("/api/defcon");
if (!res.ok) throw new Error("Fehler beim Abruf");
const data = await res.json();
currentDefcon = data.level;
aktualisiereDefconButton(currentDefcon);
} catch (err) {
console.warn("DEFCON Abruf fehlgeschlagen:", err);
}
}
async function sendeNeuesDefconLevel(level) {
try {
const response = await fetch("/api/defcon", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ level: level }),
});
if (!response.ok) throw new Error("Fehler beim Senden des neuen DEFCON-Levels");
await response.json();
} catch (err) {
console.error("Fehler beim POST:", err);
}
}
function newDefconStatus() {
currentDefcon -= 1;
if (currentDefcon < 1) {
currentDefcon = 5;
}
sendeNeuesDefconLevel(currentDefcon);
aktualisiereDefconButton(currentDefcon);
}
async function ladeBunkerStatus() {
try {
const res = await fetch("/api/bunker-status");
if (!res.ok) throw new Error("Fehler beim Abruf");
const data = await res.json();
const icon = document.getElementById("hazard-symbol");
if (!icon) return;
const inMainzNetz = Boolean(data && data.online);
if (inMainzNetz) {
icon.style.backgroundColor = "limegreen";
icon.style.color = "black";
icon.style.padding = "4px 8px";
icon.style.borderRadius = "4px";
} else {
icon.style.backgroundColor = "";
icon.style.color = "";
icon.title = "";
}
} catch (err) {
console.warn("Bunker-Status nicht abrufbar:", err);
}
}
ladeBunkerStatus();
setInterval(ladeBunkerStatus, 60 * 1000);
ladeDefconStatus();
ladeNews();
ladeRMV();
ladeMarketDaten();
setInterval(ladeNews, 30 * 1000);
setInterval(ladeRMV, 5 * 60 * 1000);
setInterval(ladeDefconStatus, 60 * 1000);
setInterval(() => {
rotiereFX();
}, 8 * 1000);
setInterval(ladeMarketDaten, 30 * 1000);
const defconBtn = document.getElementById("defcon-button");
if (defconBtn) {
defconBtn.addEventListener("click", newDefconStatus);
}
(function initLpaButton() {
const TARGET_LPA = "https://aerztepruefung.service24.rlp.de/intelliform/admin/intelliForm-Spaces/LPA/Studentenbereich";
const btn = document.getElementById("lpa-button");
if (!btn) return;
btn.addEventListener("click", () => window.open(TARGET_LPA, "_blank"));
})();

View File

@@ -0,0 +1,188 @@
let timersData = [];
let detailOpenFor = null;
function formatDiff(target) {
const targetDate = target instanceof Date ? target : new Date(target);
const now = new Date();
const diff = targetDate - now;
if (diff <= 0) return "Abgelaufen";
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((diff / (1000 * 60)) % 60);
const seconds = Math.floor((diff / 1000) % 60);
return `${days}d ${hours}h ${minutes}m ${seconds}s`;
}
fetch("/api/countdowns")
.then(res => res.json())
.then(data => {
timersData = data.timers || [];
const mainTargetDate = new Date(data.main.target);
const mainLabel = data.main.label || "";
const mainLabelEl = document.getElementById("main-label");
const mainCountdownEl = document.getElementById("main-countdown");
function updateMainTimer() {
const now = new Date();
const diff = mainTargetDate - now;
if (diff <= 0) {
mainLabelEl.textContent = mainLabel;
mainCountdownEl.textContent = "Abgelaufen";
return;
}
mainLabelEl.textContent = mainLabel;
mainCountdownEl.textContent = formatDiff(mainTargetDate);
}
function updateSmallTimers() {
timersData.forEach(t => {
const el = document.getElementById(t.id);
if (!el) return;
const now = new Date();
const nextEntry = t.targets
.map(e => ({ ...e, date: new Date(e.time) }))
.find(e => e.date > now);
if (nextEntry) {
el.textContent = `${nextEntry.label}\n${formatDiff(nextEntry.date)}`;
} else {
el.textContent = `${t.label}\nAbgelaufen`;
}
});
}
function updateDetailsPanel() {
if (!detailOpenFor) return;
const timer = timersData.find(t => t.id === detailOpenFor);
if (!timer) return;
timer.targets.forEach((target, idx) => {
const el = document.getElementById(`detail-${timer.id}-${idx}`);
if (!el) return;
el.textContent = formatDiff(target.time);
});
}
function renderDetails(timer) {
const detailEl = document.getElementById("timer-details");
if (!detailEl) return;
detailOpenFor = timer.id;
const entries = timer.targets.map((target, idx) => {
const entryId = `detail-${timer.id}-${idx}`;
return `
<div class="timer-entry">
<div class="label">${target.label}</div>
<div class="countdown" id="${entryId}"></div>
</div>
`;
}).join("");
detailEl.innerHTML = `
<button class="close-btn" id="timer-details-close">Schließen</button>
<h3>${timer.label}</h3>
<div class="timer-meta">${timer.targets.length} Termine</div>
${entries}
`;
detailEl.classList.remove("hidden");
const closeBtn = document.getElementById("timer-details-close");
if (closeBtn) {
closeBtn.addEventListener("click", () => {
detailOpenFor = null;
detailEl.classList.add("hidden");
});
}
updateDetailsPanel();
}
setInterval(() => {
updateMainTimer();
updateSmallTimers();
updateDetailsPanel();
}, 1000);
updateMainTimer();
updateSmallTimers();
document.querySelectorAll(".small-circle").forEach(circle => {
circle.addEventListener("click", () => {
const timerId = circle.getAttribute("data-timer");
const timer = timersData.find(t => t.id === timerId);
if (timer) renderDetails(timer);
});
});
});
document.getElementById("fullscreen-btn").addEventListener("click", () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
});
const cantinaAudio = document.getElementById("cantina-audio");
const circleContainer = document.querySelector(".circle-container");
function playCantina() {
if (!cantinaAudio) return;
cantinaAudio.currentTime = 0;
cantinaAudio.play().catch(err => console.warn("Cantina playback blocked:", err));
}
if (circleContainer) {
circleContainer.addEventListener("click", playCantina);
}
function scheduleNextFullHour() {
const now = new Date();
const next = new Date(now);
next.setMinutes(0, 0, 0);
if (next <= now) {
next.setHours(next.getHours() + 1);
}
const delay = next - now;
setTimeout(() => {
playCantina();
scheduleNextFullHour();
}, delay);
}
scheduleNextFullHour();
const overlay = document.getElementById("twitter-overlay");
function showOverlay() {
overlay.style.display = "flex";
const gongVideo = document.getElementById("gong-video-fullscreen");
if (gongVideo) {
gongVideo.currentTime = 0;
gongVideo.play().catch(err => console.warn("Autoplay-Fehler:", err));
}
setTimeout(() => {
overlay.style.display = "none";
}, 60000);
}
(() => {
const now = new Date();
const target = new Date();
target.setHours(23, 40, 0, 0);
if (now > target && now - target < 60000) {
showOverlay();
} else if (now <= target) {
const delay = target - now;
setTimeout(showOverlay, delay);
}
})();

Binary file not shown.

View File

@@ -0,0 +1,45 @@
{
"main": {
"label": "M2",
"target": "2026-04-14T00:00:00"
},
"timers": [
{
"id": "timer1",
"label": "8. Semester Klausuren",
"targets": [
{ "label": "Pr. Allgemeinmedizin", "time": "2026-01-30T09:00:00" },
{ "label": "Pr. Chirurgie (OSCE)", "time": "2026-02-02T07:45:00" },
{ "label": "Pr. Arbeits- und Sozialmedizin II", "time": "2026-02-10T08:00:00" },
{ "label": "Pr. Psychiatrie & Psychotherapie", "time": "2026-02-11T13:30:00" },
{ "label": "Q5", "time": "2026-02-13T09:15:00" },
{ "label": "Innere Medizin (schriftlich)", "time": "2026-02-20T08:00:00" },
{ "label": "Q14", "time": "2026-02-23T15:30:00" },
{ "label": "Pr. Neurologie", "time": "2026-02-25T10:00:00" }
]
},
{
"id": "timer2",
"label": "10. Semester Klausuren",
"targets": [
{ "label": "Q10", "time": "2026-01-15T15:30:00" },
{ "label": "Q9", "time": "2026-01-21T16:30:00" },
{ "label": "Pr. Orthopädie", "time": "2026-02-02T17:00:00" },
{ "label": "Q7", "time": "2026-02-04T14:30:00" },
{ "label": "Pr. Urologie", "time": "2026-02-10T10:30:00" },
{ "label": "Pr. Gynäkologie", "time": "2026-02-11T09:30:00" },
{ "label": "BP Gynäkologie", "time": "2026-02-11T10:15:00" }
]
},
{
"id": "timer3",
"label": "PJ-Tertiale Frühjahr 2025",
"targets": [
{ "label": "Innere Medizin", "time": "2025-05-19T00:00:00" },
{ "label": "Chirurgie", "time": "2025-09-08T00:00:00" },
{ "label": "Wahlfach", "time": "2025-12-29T00:00:00" },
{ "label": "PJ Ende", "time": "2026-04-19T00:00:00" }
]
}
]
}

Binary file not shown.

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Artikel-Galerie</title>
<link rel="stylesheet" href="/static/css/blog.css">
</head>
<body>
<main id="grid" class="grid"></main>
<script src="/static/js/blog.js"></script>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Live Nachrichtenticker (Lite)</title>
<link rel="stylesheet" href="/static/css/index.css">
</head>
<body>
<header class="page-header">
<div class="header-top">
<span id="hazard-symbol" style="cursor: pointer;">☣ Bunker</span>
<div id="market-ticker" class="market-ticker">
<div id="market-ticker-track" class="market-ticker-track">
<span class="ticker-item">Marktdaten werden geladen...</span>
</div>
</div>
<div class="header-actions">
<a href="#" onclick="zeigeYouTubeVideo(); return false;">▶️</a>
<a href="#" onclick="zeigeGongVideo(); return false;">🦅</a>
<a href="#" onclick="toggleMensaIframe(); return false;">🍽️</a>
<a href="/blog">📖</a>
<a href="/mandelbrot">🌀</a>
<button id="defcon-button">⚠️ DEFCON</button>
</div>
</div>
</header>
<div id="mensa-iframe-container" style="display: none; margin-bottom: 1rem;">
<iframe
id="mensa-iframe"
src="https://www.studierendenwerk-mainz.de/essentrinken/speiseplan2?building_id=1&display_type=1"
style="width: calc(100% - 320px); height: calc(100vh - 100px); border: none; border-radius: 8px; transform: scale(0.95); transform-origin: top left;">
</iframe>
</div>
<div id="content-wrapper" style="display: flex; gap: 2rem; align-items: flex-start;">
<div id="news-container"></div>
<div id="abfahrt-wrapper" style="display: none;">
<h2>🚉 Abfahrten</h2>
<div id="abfahrt-info">Wird geladen...</div>
</div>
</div>
<script src="/static/js/index-lite.js"></script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Live Nachrichtenticker + RMV</title>
<link rel="stylesheet" href="/static/css/index.css">
</head>
<body>
<header class="page-header">
<div class="header-top">
<span id="hazard-symbol" style="cursor: pointer;">☣ Bunker</span>
<div id="market-ticker" class="market-ticker">
<div id="market-ticker-track" class="market-ticker-track">
<span class="ticker-item">Marktdaten werden geladen...</span>
</div>
</div>
<div class="header-actions">
<a href="#" onclick="zeigeYouTubeVideo(); return false;">▶️</a>
<a href="#" onclick="zeigeGongVideo(); return false;">🦅</a>
<a href="#" onclick="toggleMensaIframe(); return false;">🍽️</a>
<a href="/blog">📖</a>
<a href="/mandelbrot">🌀</a>
<button id="defcon-button">⚠️ DEFCON</button>
</div>
</div>
</header>
<div id="mensa-iframe-container" style="display: none; margin-bottom: 1rem;">
<iframe
id="mensa-iframe"
src="https://www.studierendenwerk-mainz.de/essentrinken/speiseplan2?building_id=1&display_type=1"
style="width: calc(100% - 320px); height: calc(100vh - 100px); border: none; border-radius: 8px; transform: scale(0.95); transform-origin: top left;">
</iframe>
</div>
<div id="content-wrapper" style="display: flex; gap: 2rem; align-items: flex-start;">
<div id="news-container"></div>
<div id="abfahrt-wrapper" style="display: none;">
<h2>🚉 Abfahrten</h2>
<div id="abfahrt-info">Wird geladen...</div>
</div>
</div>
<script src="/static/js/index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Mandelbrot</title>
<link rel="stylesheet" href="/static/css/mandelbrot.css">
</head>
<body>
<button id="fullscreen-btn">Vollbild</button>
<div style="position: relative;">
<div class="circle-container">
<div class="circle"></div>
<div class="dot"></div>
<div class="timer">
<div id="main-label"></div>
<div id="main-countdown"></div>
</div>
</div>
<div class="small-circle-container">
<div>
<div class="small-circle" style="border-color: red;" data-timer="timer1">
<div class="small-dot"></div>
<div class="small-timer-text" id="timer1">Countdown 1</div>
</div>
</div>
<div>
<div class="small-circle" style="border-color: blue;" data-timer="timer2">
<div class="small-dot"></div>
<div class="small-timer-text" id="timer2">Countdown 2</div>
</div>
</div>
<div>
<div class="small-circle" style="border-color: green;" data-timer="timer3">
<div class="small-dot"></div>
<div class="small-timer-text" id="timer3">Countdown 3</div>
</div>
</div>
</div>
</div>
<div id="timer-details" class="timer-details hidden"></div>
<audio id="cantina-audio" preload="auto">
<source src="/media/cantina" type="audio/mp3">
Dein Browser unterstützt das Audio-Tag nicht.
</audio>
<div id="twitter-overlay" style="
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background-color: rgba(0,0,0,0.85);
z-index: 9999;
justify-content: center;
align-items: center;
flex-direction: column;
">
<video id="gong-video-fullscreen" playsinline style="max-width: 90%; max-height: 90%;" controls>
<source src="/media/gong" type="video/mp4">
Dein Browser unterstützt das Video-Tag nicht.
</video>
</div>
<script src="/static/js/mandelbrot.js"></script>
</body>
</html>