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
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>