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:
90
volumes/web/static/css/blog.css
Normal file
90
volumes/web/static/css/blog.css
Normal file
@@ -0,0 +1,90 @@
|
||||
:root {
|
||||
--tile-size: 512px;
|
||||
--gap: 0px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #000;
|
||||
color: #f5f5f5;
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
main.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--gap);
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
width: min(var(--tile-size), 100%);
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #111;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.6);
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
.tile:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
.tile.open img {
|
||||
filter: blur(2px) brightness(0.4);
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.78), rgba(0,0,0,0.92));
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
overflow: auto;
|
||||
}
|
||||
.tile.open .overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.overlay h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.overlay p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: #d8d8d8;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.overlay a {
|
||||
margin-top: auto;
|
||||
align-self: flex-start;
|
||||
color: #8ad8ff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.overlay a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 40px 0;
|
||||
}
|
||||
112
volumes/web/static/css/index-lite.css
Normal file
112
volumes/web/static/css/index-lite.css
Normal file
@@ -0,0 +1,112 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #121212;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #121212;
|
||||
padding-bottom: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions a,
|
||||
.actions button {
|
||||
background: #2a2a2a;
|
||||
color: #e6e6e6;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.news-item {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.news-item:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.news-text {
|
||||
color: #c9c9c9;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.news-link {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.news-link a {
|
||||
color: #8ecbff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.market-list,
|
||||
.rmv-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
295
volumes/web/static/css/index.css
Normal file
295
volumes/web/static/css/index.css
Normal file
@@ -0,0 +1,295 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background-color: inherit;
|
||||
padding: 0.75rem 0 0.5rem;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
HEADER: RESPONSIVE / HARMONISCH
|
||||
========================= */
|
||||
:root {
|
||||
/* Höhe/Größe hängt von viewport-Breite ab:
|
||||
- min: 44px (kleine Screens)
|
||||
- ideal: ~6vw
|
||||
- max: 60px (große Screens) */
|
||||
--hdr-h: clamp(44px, 6vw, 60px);
|
||||
|
||||
/* Rundung skaliert leicht mit */
|
||||
--hdr-r: clamp(8px, 1.2vw, 12px);
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: clamp(8px, 1.2vw, 16px);
|
||||
}
|
||||
|
||||
/* Links: BUNKER/Hazard so schmal wie möglich */
|
||||
#hazard-symbol {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: var(--hdr-h);
|
||||
padding: 0 clamp(10px, 1.2vw, 14px);
|
||||
|
||||
/* wichtig: nicht breitziehen */
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--hdr-r);
|
||||
|
||||
font-size: clamp(1.35rem, 2.6vw, 1.65rem); /* noch größer */
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Rechts: Button-Container */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(8px, 1vw, 12px);
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Basis-Button */
|
||||
.header-actions a,
|
||||
.header-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: var(--hdr-h);
|
||||
border-radius: var(--hdr-r);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.3rem, 2.45vw, 1.58rem); /* noch größer */
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
padding: 0; /* wird unten je nach Typ gesetzt */
|
||||
}
|
||||
|
||||
/* Rechts: ALLE ICONS (a) quadratisch
|
||||
(außer DEFCON, das ist button) */
|
||||
.header-actions a {
|
||||
width: var(--hdr-h);
|
||||
min-width: var(--hdr-h);
|
||||
padding: 0;
|
||||
font-size: clamp(1.5rem, 3.1vw, 1.95rem); /* noch größer */
|
||||
}
|
||||
|
||||
/* DEFCON: bekommt genug Platz, damit Text ohne Umbruch passt */
|
||||
#defcon-button {
|
||||
height: var(--hdr-h);
|
||||
|
||||
/* Breite: automatisch nach Inhalt, aber mit Mindestbreite */
|
||||
width: auto;
|
||||
min-width: clamp(130px, 18vw, 190px);
|
||||
|
||||
padding: 0 clamp(12px, 1.6vw, 18px);
|
||||
gap: clamp(6px, 1vw, 10px);
|
||||
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
border-radius: var(--hdr-r);
|
||||
|
||||
/* garantiert: kein Umbruch/Spacing-Problem */
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Optional, falls du im DEFCON-Button ein Emoji + Text hast:
|
||||
<button id="defcon-button"><span>🚨</span><span>DEFCON 3</span></button> */
|
||||
#defcon-button span {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Farben wie bei dir (minimal angepasst möglich, aber ich lasse sie) */
|
||||
.header-actions a:nth-child(1) { background-color: #ff0000; }
|
||||
.header-actions a:nth-child(2) { background-color: #6fff93; color: #000; }
|
||||
.header-actions a:nth-child(3) { background-color: #339933; }
|
||||
.header-actions a:nth-child(4),
|
||||
.header-actions a:nth-child(5) { background-color: #3333cc; }
|
||||
.header-actions button { background-color: #ffaa00; color: #000; }
|
||||
|
||||
/* =========================
|
||||
TICKER
|
||||
========================= */
|
||||
.market-ticker {
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #2b2b2b;
|
||||
border-bottom: 1px solid #2b2b2b;
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.market-ticker::before,
|
||||
.market-ticker::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.market-ticker::before {
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, #121212, rgba(18, 18, 18, 0));
|
||||
}
|
||||
|
||||
.market-ticker::after {
|
||||
right: 0;
|
||||
background: linear-gradient(270deg, #121212, rgba(18, 18, 18, 0));
|
||||
}
|
||||
|
||||
.market-ticker-track {
|
||||
display: inline-flex;
|
||||
gap: 1.25rem;
|
||||
white-space: nowrap;
|
||||
animation: ticker-move var(--ticker-duration, 30s) linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.market-ticker-track:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.ticker-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@keyframes ticker-move {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* =========================
|
||||
NEWS
|
||||
========================= */
|
||||
.news-item {
|
||||
background: #1e1e1e;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.news-text {
|
||||
font-size: 0.95em;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.news-link a {
|
||||
font-size: 0.9em;
|
||||
color: #4da6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.news-thumb {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: #0f0f0f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#news-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
SIDEBAR / ABFAHRT
|
||||
========================= */
|
||||
#abfahrt-wrapper {
|
||||
flex: 0 0 300px;
|
||||
position: static;
|
||||
background-color: #1e1e2f;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
font-size: 0.9em;
|
||||
margin-top: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#abfahrt-wrapper h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.abfahrt-eintrag {
|
||||
margin-bottom: 0.5em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.trip-block {
|
||||
margin-bottom: 1em;
|
||||
padding: 0.5em;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.blog-highlight {
|
||||
background-color: #a00000;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#content-wrapper {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
216
volumes/web/static/css/mandelbrot.css
Normal file
216
volumes/web/static/css/mandelbrot.css
Normal file
@@ -0,0 +1,216 @@
|
||||
body {
|
||||
background: black;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.circle-container {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 100px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 32px solid white;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: red;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(0deg) translateY(-184px);
|
||||
animation: rotate 60s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg) translateY(-184px);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg) translateY(-184px);
|
||||
}
|
||||
}
|
||||
|
||||
.timer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.small-circle-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-top: 40px;
|
||||
position: absolute;
|
||||
top: calc(100% - 60px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.small-circle {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 20px solid;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.small-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(0deg) translateY(-90px);
|
||||
animation: rotate-small 60s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes rotate-small {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg) translateY(-90px);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg) translateY(-90px);
|
||||
}
|
||||
}
|
||||
|
||||
#fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
color: black;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.small-timer-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.timer-details {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.4);
|
||||
color: #f1f1f1;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.timer-details.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timer-details h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.timer-details .timer-meta {
|
||||
margin-bottom: 10px;
|
||||
color: #bfbfbf;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.timer-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.timer-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.timer-entry .label {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: #eaeaea;
|
||||
}
|
||||
|
||||
.timer-entry .countdown {
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
color: #9fd3ff;
|
||||
}
|
||||
|
||||
.timer-details .close-btn {
|
||||
background: #222;
|
||||
color: #f1f1f1;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timer-details .close-btn:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
#twitter-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
BIN
volumes/web/static/img/minecraft.png
Normal file
BIN
volumes/web/static/img/minecraft.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
90
volumes/web/static/js/blog.js
Normal file
90
volumes/web/static/js/blog.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const grid = document.getElementById("grid");
|
||||
|
||||
function createTile(artikel) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "tile";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = artikel.image;
|
||||
img.alt = artikel.title || "Artikelbild";
|
||||
tile.appendChild(img);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = artikel.title || "Ohne Titel";
|
||||
overlay.appendChild(title);
|
||||
|
||||
tile.appendChild(overlay);
|
||||
|
||||
tile.addEventListener("click", () => tile.classList.toggle("open"));
|
||||
return tile;
|
||||
}
|
||||
|
||||
async function ladeGalerie() {
|
||||
grid.innerHTML = "";
|
||||
let foundAny = false;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/images", {
|
||||
headers: { "Accept": "application/x-ndjson" }
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!res.body) throw new Error("Streaming nicht unterstützt");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const s = line.trim();
|
||||
if (!s) continue;
|
||||
|
||||
let artikel;
|
||||
try {
|
||||
artikel = JSON.parse(s);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (artikel?.image) {
|
||||
grid.appendChild(createTile(artikel));
|
||||
foundAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rest verarbeiten
|
||||
const last = buffer.trim();
|
||||
if (last) {
|
||||
try {
|
||||
const artikel = JSON.parse(last);
|
||||
if (artikel?.image) {
|
||||
grid.appendChild(createTile(artikel));
|
||||
foundAny = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!foundAny) {
|
||||
grid.innerHTML = '<div class="empty">Keine Bilder gefunden.</div>';
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
grid.innerHTML = '<div class="empty">Fehler beim Laden der Galerie.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
ladeGalerie();
|
||||
372
volumes/web/static/js/index-lite.js
Normal file
372
volumes/web/static/js/index-lite.js
Normal file
@@ -0,0 +1,372 @@
|
||||
const stripTags = (html) => (typeof html === "string" ? html.replace(/<[^>]*>/g, "") : "");
|
||||
const formatBid = (value) => {
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) return "n/a";
|
||||
const abs = Math.abs(num);
|
||||
if (abs >= 100) return num.toFixed(2);
|
||||
if (abs >= 10) return num.toFixed(3);
|
||||
return num.toFixed(4);
|
||||
};
|
||||
const instrumentEmoji = (inst) => {
|
||||
const map = {
|
||||
"USD/JPY": "$/¥",
|
||||
"OIL/USD": "🛢️",
|
||||
"XAU/USD": "⛏️",
|
||||
"USD/EUR": "€/$",
|
||||
"USD/CHF": "$/CHF",
|
||||
"USD/GBP": "£/$",
|
||||
};
|
||||
return map[inst] || inst;
|
||||
};
|
||||
|
||||
const newsContainer = document.getElementById("news-container");
|
||||
const abfahrtWrapper = document.getElementById("abfahrt-wrapper");
|
||||
const abfahrtInfo = document.getElementById("abfahrt-info");
|
||||
const marketTrack = document.getElementById("market-ticker-track");
|
||||
const marketContainer = document.getElementById("market-ticker");
|
||||
|
||||
async function loadNews() {
|
||||
if (!newsContainer) return;
|
||||
try {
|
||||
const res = await fetch("/api/artikeltext", {
|
||||
headers: { "Accept": "application/x-ndjson" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const items = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
items.push({
|
||||
title: stripTags(parsed.title),
|
||||
text: stripTags(parsed.text),
|
||||
link: parsed.link,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("NDJSON parse error", err);
|
||||
}
|
||||
if (items.length >= 60) break;
|
||||
}
|
||||
|
||||
newsContainer.innerHTML = "";
|
||||
if (!items.length) {
|
||||
newsContainer.innerHTML = '<div class="empty">Keine News gefunden.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
items.forEach((item) => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "news-item";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "news-title";
|
||||
title.textContent = item.title || "Kein Titel";
|
||||
|
||||
const textEl = document.createElement("div");
|
||||
textEl.className = "news-text";
|
||||
textEl.textContent = item.text || "[Kein Text verfuegbar]";
|
||||
|
||||
const linkWrap = document.createElement("div");
|
||||
linkWrap.className = "news-link";
|
||||
const link = document.createElement("a");
|
||||
link.href = item.link || "#";
|
||||
link.textContent = "Zum Artikel";
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
linkWrap.appendChild(link);
|
||||
|
||||
wrapper.appendChild(title);
|
||||
wrapper.appendChild(textEl);
|
||||
wrapper.appendChild(linkWrap);
|
||||
fragment.appendChild(wrapper);
|
||||
});
|
||||
|
||||
newsContainer.appendChild(fragment);
|
||||
} catch (err) {
|
||||
console.warn("News Fehler:", err);
|
||||
newsContainer.innerHTML = '<div class="empty">Fehler beim Laden der News.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarketTicker(quotes) {
|
||||
if (!marketTrack || !marketContainer) return;
|
||||
|
||||
marketTrack.innerHTML = "";
|
||||
if (!quotes || quotes.length === 0) {
|
||||
marketTrack.textContent = "Keine Marktdaten verfuegbar";
|
||||
marketTrack.style.animation = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const addItems = () => quotes.forEach((q) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "ticker-item";
|
||||
span.textContent = `${instrumentEmoji(q.instrument)} ${formatBid(q.bid)}`;
|
||||
fragment.appendChild(span);
|
||||
});
|
||||
addItems();
|
||||
addItems();
|
||||
|
||||
marketTrack.appendChild(fragment);
|
||||
const duration = Math.max(90, Math.min(180, quotes.length * 10));
|
||||
marketTrack.style.setProperty("--ticker-duration", `${duration}s`);
|
||||
marketTrack.style.animation = "none";
|
||||
void marketTrack.offsetWidth;
|
||||
marketTrack.style.animation = `ticker-move var(--ticker-duration, ${duration}s) linear infinite`;
|
||||
}
|
||||
|
||||
async function loadMarket() {
|
||||
try {
|
||||
const res = await fetch("/api/market", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("Market API nicht verfuegbar");
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) throw new Error("Ungueltige Marktdaten");
|
||||
renderMarketTicker(data);
|
||||
} catch (err) {
|
||||
console.warn("Marktdaten Fehler:", err);
|
||||
renderMarketTicker([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRMV() {
|
||||
if (!abfahrtInfo || !abfahrtWrapper) return;
|
||||
try {
|
||||
const response = await fetch("/api/rmv", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("RMV-JSON nicht erreichbar");
|
||||
const data = await response.json();
|
||||
|
||||
abfahrtWrapper.style.display = "block";
|
||||
|
||||
if (!data.abfahrten || data.abfahrten.length === 0) {
|
||||
abfahrtInfo.innerHTML = "<p>Keine Abfahrten gefunden.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const tripHTML = data.abfahrten.map((trip) => {
|
||||
const legsHTML = trip.map((leg) => `
|
||||
<div>
|
||||
<strong>${leg.linie}</strong><br>
|
||||
${leg.abfahrt} -> ${leg.ankunft}<br>
|
||||
${leg.von} -> ${leg.nach}
|
||||
</div>
|
||||
`).join("<hr style='border-color: rgba(255,255,255,0.2);'>");
|
||||
|
||||
return `<div class="trip-block"><div class="abfahrt-eintrag">${legsHTML}</div></div>`;
|
||||
}).join("");
|
||||
|
||||
abfahrtInfo.innerHTML = tripHTML;
|
||||
} catch (err) {
|
||||
console.warn("RMV-Info nicht geladen:", err);
|
||||
abfahrtInfo.innerText = "Fehler beim Laden.";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMensaIframe() {
|
||||
const container = document.getElementById("mensa-iframe-container");
|
||||
container.style.display = (container.style.display === "none") ? "block" : "none";
|
||||
}
|
||||
|
||||
function zeigeGongVideo() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.top = 0;
|
||||
overlay.style.left = 0;
|
||||
overlay.style.width = "100vw";
|
||||
overlay.style.height = "100vh";
|
||||
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.zIndex = 9999;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = "/media/gong";
|
||||
video.autoplay = true;
|
||||
video.controls = true;
|
||||
video.style.maxWidth = "90%";
|
||||
video.style.maxHeight = "90%";
|
||||
|
||||
overlay.appendChild(video);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
video.onended = () => document.body.removeChild(overlay);
|
||||
overlay.onclick = () => document.body.removeChild(overlay);
|
||||
}
|
||||
|
||||
function zeigeYouTubeVideo() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.top = 0;
|
||||
overlay.style.left = 0;
|
||||
overlay.style.width = "100vw";
|
||||
overlay.style.height = "100vh";
|
||||
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.zIndex = 9999;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
const videoIds = [
|
||||
"zhDwjnYZiCo",
|
||||
"Na0w3Mz46GA",
|
||||
"OO2kPK5-qno",
|
||||
"Yqk13qPcXis",
|
||||
"MZhivjxcF-M",
|
||||
"uMEvzhckqBw",
|
||||
"uMEvzhckqBw",
|
||||
"TGan48YE9Us",
|
||||
"-Xh4BNbxpI8",
|
||||
"r7kxh_vuBpo",
|
||||
"bdUbACCWmoY",
|
||||
];
|
||||
const zufallsId = videoIds[Math.floor(Math.random() * videoIds.length)];
|
||||
iframe.src = `https://www.youtube.com/embed/${zufallsId}?autoplay=1`;
|
||||
iframe.allow = "autoplay; fullscreen";
|
||||
iframe.allowFullscreen = true;
|
||||
iframe.style.width = "90%";
|
||||
iframe.style.height = "90%";
|
||||
iframe.style.border = "none";
|
||||
|
||||
overlay.appendChild(iframe);
|
||||
overlay.onclick = () => document.body.removeChild(overlay);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (overlay.requestFullscreen) {
|
||||
overlay.requestFullscreen();
|
||||
} else if (overlay.webkitRequestFullscreen) {
|
||||
overlay.webkitRequestFullscreen();
|
||||
} else if (overlay.msRequestFullscreen) {
|
||||
overlay.msRequestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const hazard = document.getElementById("hazard-symbol");
|
||||
if (hazard) {
|
||||
hazard.addEventListener("click", () => {
|
||||
const elem = document.documentElement;
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen();
|
||||
} else if (elem.webkitRequestFullscreen) {
|
||||
elem.webkitRequestFullscreen();
|
||||
} else if (elem.msRequestFullscreen) {
|
||||
elem.msRequestFullscreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentDefcon = 5;
|
||||
|
||||
function aktualisiereDefconButton(level) {
|
||||
const btn = document.getElementById("defcon-button");
|
||||
if (!btn) return;
|
||||
const farben = {
|
||||
1: "#ff0000",
|
||||
2: "#ff9900",
|
||||
3: "#ffff00",
|
||||
4: "#0000ff",
|
||||
5: "#000000",
|
||||
};
|
||||
const farbe = farben[level] || "#555";
|
||||
btn.textContent = `⚠️ DEFCON ${level}`;
|
||||
btn.style.backgroundColor = farbe;
|
||||
btn.style.color = level === 3 ? "black" : "white";
|
||||
btn.style.fontWeight = "bold";
|
||||
btn.style.transition = "background-color 0.5s, color 0.5s";
|
||||
|
||||
if (level === 1) {
|
||||
document.body.style.backgroundColor = farbe;
|
||||
} else {
|
||||
document.body.style.backgroundColor = "#000000";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDefconStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/defcon");
|
||||
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||
const data = await res.json();
|
||||
currentDefcon = data.level;
|
||||
aktualisiereDefconButton(currentDefcon);
|
||||
} catch (err) {
|
||||
console.warn("DEFCON Abruf fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendeNeuesDefconLevel(level) {
|
||||
try {
|
||||
const response = await fetch("/api/defcon", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ level: level }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Fehler beim Senden des neuen DEFCON-Levels");
|
||||
|
||||
await response.json();
|
||||
} catch (err) {
|
||||
console.error("Fehler beim POST:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function newDefconStatus() {
|
||||
currentDefcon -= 1;
|
||||
if (currentDefcon < 1) {
|
||||
currentDefcon = 5;
|
||||
}
|
||||
sendeNeuesDefconLevel(currentDefcon);
|
||||
aktualisiereDefconButton(currentDefcon);
|
||||
}
|
||||
|
||||
async function loadBunkerStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/bunker-status");
|
||||
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||
const data = await res.json();
|
||||
|
||||
const icon = document.getElementById("hazard-symbol");
|
||||
if (!icon) return;
|
||||
|
||||
const inMainzNetz = Boolean(data && data.online);
|
||||
|
||||
if (inMainzNetz) {
|
||||
icon.style.backgroundColor = "limegreen";
|
||||
icon.style.color = "black";
|
||||
icon.style.padding = "4px 8px";
|
||||
icon.style.borderRadius = "4px";
|
||||
} else {
|
||||
icon.style.backgroundColor = "";
|
||||
icon.style.color = "";
|
||||
icon.title = "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Bunker-Status nicht abrufbar:", err);
|
||||
}
|
||||
}
|
||||
|
||||
loadBunkerStatus();
|
||||
loadDefconStatus();
|
||||
loadNews();
|
||||
loadRMV();
|
||||
loadMarket();
|
||||
|
||||
setInterval(loadBunkerStatus, 5 * 60 * 1000);
|
||||
setInterval(loadDefconStatus, 2 * 60 * 1000);
|
||||
setInterval(loadNews, 2 * 60 * 1000);
|
||||
setInterval(loadRMV, 10 * 60 * 1000);
|
||||
setInterval(loadMarket, 2 * 60 * 1000);
|
||||
|
||||
const defconBtn = document.getElementById("defcon-button");
|
||||
if (defconBtn) {
|
||||
defconBtn.addEventListener("click", newDefconStatus);
|
||||
}
|
||||
488
volumes/web/static/js/index.js
Normal file
488
volumes/web/static/js/index.js
Normal file
@@ -0,0 +1,488 @@
|
||||
window.addEventListener("load", () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("lofi") === "true") {
|
||||
zeigeYouTubeVideo();
|
||||
}
|
||||
});
|
||||
|
||||
const stripTags = (html) => (typeof html === "string" ? html.replace(/<[^>]*>/g, "") : "");
|
||||
const formatBid = (value) => {
|
||||
const num = Number(value);
|
||||
if (Number.isNaN(num)) return "n/a";
|
||||
const abs = Math.abs(num);
|
||||
if (abs >= 100) return num.toFixed(2);
|
||||
if (abs >= 10) return num.toFixed(3);
|
||||
return num.toFixed(4);
|
||||
};
|
||||
const instrumentEmoji = (inst) => {
|
||||
const map = {
|
||||
"USD/JPY": "$/¥",
|
||||
"OIL/USD": "🛢️",
|
||||
"XAU/USD": "⛏️",
|
||||
"USD/EUR": "€/$",
|
||||
"USD/CHF": "$/CHF",
|
||||
"USD/GBP": "£/$",
|
||||
};
|
||||
return map[inst] || inst;
|
||||
};
|
||||
|
||||
async function ladeNews() {
|
||||
const container = document.getElementById("news-container");
|
||||
if (!container) return;
|
||||
|
||||
const loadingEl = null;
|
||||
|
||||
const newsIndex = new Map();
|
||||
|
||||
const getKey = (nachricht) => {
|
||||
if (nachricht?.link) return `link:${nachricht.link}`;
|
||||
if (nachricht?.title) return `title:${stripTags(nachricht.title)}`;
|
||||
if (nachricht?.publishedAt) return `ts:${nachricht.publishedAt}`;
|
||||
return `idx:${newsIndex.size}`;
|
||||
};
|
||||
|
||||
const buildItem = (nachricht) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "news-item";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "news-thumb";
|
||||
img.loading = "lazy";
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "news-content";
|
||||
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "news-title";
|
||||
|
||||
const textEl = document.createElement("div");
|
||||
textEl.className = "news-text";
|
||||
|
||||
const linkWrap = document.createElement("div");
|
||||
linkWrap.className = "news-link";
|
||||
const linkEl = document.createElement("a");
|
||||
linkEl.target = "_blank";
|
||||
linkEl.rel = "noopener noreferrer";
|
||||
linkWrap.appendChild(linkEl);
|
||||
|
||||
content.appendChild(titleEl);
|
||||
content.appendChild(textEl);
|
||||
content.appendChild(linkWrap);
|
||||
item.appendChild(content);
|
||||
|
||||
return { item, img, titleEl, textEl, linkEl };
|
||||
};
|
||||
|
||||
const updateFields = (state, nachricht) => {
|
||||
const title = stripTags(nachricht.title) || "Kein Titel";
|
||||
const link = nachricht.link || "#";
|
||||
const rawText = stripTags(nachricht.text) || "[Kein Text verfügbar]";
|
||||
const text = rawText.length > 140 ? `${rawText.slice(0, 140)}...` : rawText;
|
||||
|
||||
if (title && state.title !== title) {
|
||||
state.title = title;
|
||||
state.titleEl.textContent = title;
|
||||
}
|
||||
if (text && state.text !== text) {
|
||||
state.text = text;
|
||||
state.textEl.textContent = text;
|
||||
}
|
||||
if (link && state.link !== link) {
|
||||
state.link = link;
|
||||
state.linkEl.href = link;
|
||||
state.linkEl.textContent = "🔗 Zum Artikel";
|
||||
}
|
||||
|
||||
const isDataImg = typeof nachricht.image === "string" && nachricht.image.startsWith("data:image");
|
||||
if (isDataImg && state.image !== nachricht.image) {
|
||||
state.image = nachricht.image;
|
||||
state.img.src = nachricht.image;
|
||||
state.img.alt = title || "Artikelbild";
|
||||
if (!state.img.isConnected) {
|
||||
state.item.prepend(state.img);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = (nachricht) => {
|
||||
const key = getKey(nachricht);
|
||||
const existing = newsIndex.get(key);
|
||||
if (existing) {
|
||||
updateFields(existing, nachricht);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = buildItem(nachricht);
|
||||
updateFields(state, nachricht);
|
||||
newsIndex.set(key, state);
|
||||
container.appendChild(state.item);
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/artikeltext", {
|
||||
headers: { "Accept": "application/x-ndjson" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status}: ${t.slice(0, 200)}`);
|
||||
}
|
||||
if (!res.body) throw new Error("Streaming wird vom Browser nicht unterstützt.");
|
||||
|
||||
let foundAny = false;
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const s = line.trim();
|
||||
if (!s) continue;
|
||||
try {
|
||||
renderItem(JSON.parse(s));
|
||||
foundAny = true;
|
||||
} catch (e) {
|
||||
console.warn("NDJSON parse error:", s.slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const last = buffer.trim();
|
||||
if (last) {
|
||||
try {
|
||||
renderItem(JSON.parse(last));
|
||||
foundAny = true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (loadingEl && loadingEl.isConnected) loadingEl.remove();
|
||||
if (!foundAny && !container.hasChildNodes()) {
|
||||
container.innerHTML = '<div class="empty">Keine News gefunden.</div>';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("ladeNews Fehler:", error);
|
||||
if (loadingEl && loadingEl.isConnected) loadingEl.remove();
|
||||
if (!container.hasChildNodes()) {
|
||||
container.innerHTML = '<div class="empty">Fehler beim Laden der News.</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeRMV() {
|
||||
try {
|
||||
const response = await fetch("/api/rmv");
|
||||
if (!response.ok) throw new Error("RMV-JSON nicht erreichbar");
|
||||
const daten = await response.json();
|
||||
|
||||
const infoBox = document.getElementById("abfahrt-info");
|
||||
const wrapper = document.getElementById("abfahrt-wrapper");
|
||||
wrapper.style.display = "block";
|
||||
|
||||
if (!daten.abfahrten || daten.abfahrten.length === 0) {
|
||||
infoBox.innerHTML = "<p>Keine Abfahrten gefunden.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const tripHTML = daten.abfahrten.map(trip => {
|
||||
const legsHTML = trip.map(leg => `
|
||||
<div>
|
||||
<strong>${leg.linie}</strong><br>
|
||||
${leg.abfahrt} → ${leg.ankunft}<br>
|
||||
${leg.von} → ${leg.nach}
|
||||
</div>
|
||||
`).join("<hr style='border-color: rgba(255,255,255,0.2);'>");
|
||||
|
||||
return `<div class="trip-block"><div class="abfahrt-eintrag">${legsHTML}</div></div>`;
|
||||
}).join("");
|
||||
|
||||
infoBox.innerHTML = tripHTML;
|
||||
|
||||
} catch (error) {
|
||||
console.warn("RMV-Info nicht geladen:", error);
|
||||
document.getElementById("abfahrt-info").innerText = "Fehler beim Laden.";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMensaIframe() {
|
||||
const container = document.getElementById("mensa-iframe-container");
|
||||
container.style.display = (container.style.display === "none") ? "block" : "none";
|
||||
}
|
||||
|
||||
function zeigeGongVideo() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.top = 0;
|
||||
overlay.style.left = 0;
|
||||
overlay.style.width = "100vw";
|
||||
overlay.style.height = "100vh";
|
||||
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.zIndex = 9999;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = "/media/gong";
|
||||
video.autoplay = true;
|
||||
video.controls = true;
|
||||
video.style.maxWidth = "90%";
|
||||
video.style.maxHeight = "90%";
|
||||
|
||||
overlay.appendChild(video);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
video.onended = () => document.body.removeChild(overlay);
|
||||
overlay.onclick = () => document.body.removeChild(overlay);
|
||||
}
|
||||
|
||||
let fxCache = [];
|
||||
let fxIndex = 0;
|
||||
function rotiereFX() {
|
||||
const el = document.getElementById("fx-rotator");
|
||||
if (!el || fxCache.length === 0) return;
|
||||
const item = fxCache[fxIndex % fxCache.length];
|
||||
el.textContent = `${instrumentEmoji(item.instrument)} ${formatBid(item.bid)}`;
|
||||
fxIndex++;
|
||||
}
|
||||
|
||||
function renderMarketTicker(quotes) {
|
||||
const track = document.getElementById("market-ticker-track");
|
||||
const container = document.getElementById("market-ticker");
|
||||
if (!track || !container) return;
|
||||
|
||||
track.innerHTML = "";
|
||||
if (!quotes || quotes.length === 0) {
|
||||
track.textContent = "Keine Marktdaten verfügbar";
|
||||
track.style.animation = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const addItems = () => quotes.forEach(q => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "ticker-item";
|
||||
span.textContent = `${instrumentEmoji(q.instrument)} ${formatBid(q.bid)}`;
|
||||
fragment.appendChild(span);
|
||||
});
|
||||
addItems();
|
||||
addItems();
|
||||
|
||||
track.appendChild(fragment);
|
||||
const duration = Math.max(18, Math.min(60, quotes.length * 4));
|
||||
track.style.setProperty("--ticker-duration", `${duration}s`);
|
||||
track.style.animation = "none";
|
||||
void track.offsetWidth;
|
||||
track.style.animation = `ticker-move var(--ticker-duration, ${duration}s) linear infinite`;
|
||||
}
|
||||
|
||||
async function ladeMarketDaten() {
|
||||
try {
|
||||
const res = await fetch("/api/market", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("Market API nicht verfügbar");
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) throw new Error("Ungültige Marktdaten");
|
||||
fxCache = data;
|
||||
rotiereFX();
|
||||
renderMarketTicker(data);
|
||||
} catch (err) {
|
||||
console.warn("Marktdaten Fehler:", err);
|
||||
const el = document.getElementById("fx-rotator");
|
||||
if (el) el.textContent = "FX Fehler";
|
||||
renderMarketTicker([]);
|
||||
}
|
||||
}
|
||||
|
||||
function zeigeYouTubeVideo() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.top = 0;
|
||||
overlay.style.left = 0;
|
||||
overlay.style.width = "100vw";
|
||||
overlay.style.height = "100vh";
|
||||
overlay.style.backgroundColor = "rgba(0, 0, 0, 0.9)";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.zIndex = 9999;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
const videoIds = [
|
||||
"zhDwjnYZiCo",
|
||||
"Na0w3Mz46GA",
|
||||
"OO2kPK5-qno",
|
||||
"Yqk13qPcXis",
|
||||
"MZhivjxcF-M",
|
||||
"uMEvzhckqBw",
|
||||
"uMEvzhckqBw",
|
||||
"TGan48YE9Us",
|
||||
"-Xh4BNbxpI8",
|
||||
"r7kxh_vuBpo",
|
||||
"bdUbACCWmoY",
|
||||
];
|
||||
const zufallsId = videoIds[Math.floor(Math.random() * videoIds.length)];
|
||||
iframe.src = `https://www.youtube.com/embed/${zufallsId}?autoplay=1`;
|
||||
iframe.allow = "autoplay; fullscreen";
|
||||
iframe.allowFullscreen = true;
|
||||
iframe.style.width = "90%";
|
||||
iframe.style.height = "90%";
|
||||
iframe.style.border = "none";
|
||||
|
||||
overlay.appendChild(iframe);
|
||||
overlay.onclick = () => document.body.removeChild(overlay);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (overlay.requestFullscreen) {
|
||||
overlay.requestFullscreen();
|
||||
} else if (overlay.webkitRequestFullscreen) {
|
||||
overlay.webkitRequestFullscreen();
|
||||
} else if (overlay.msRequestFullscreen) {
|
||||
overlay.msRequestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const hazard = document.getElementById("hazard-symbol");
|
||||
if (hazard) {
|
||||
hazard.addEventListener("click", () => {
|
||||
const elem = document.documentElement;
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen();
|
||||
} else if (elem.webkitRequestFullscreen) {
|
||||
elem.webkitRequestFullscreen();
|
||||
} else if (elem.msRequestFullscreen) {
|
||||
elem.msRequestFullscreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentDefcon = 5;
|
||||
let letzteDefconÄnderung = Date.now();
|
||||
|
||||
function aktualisiereDefconButton(level) {
|
||||
const btn = document.getElementById("defcon-button");
|
||||
if (!btn) return;
|
||||
const farben = {
|
||||
1: "#ff0000",
|
||||
2: "#ff9900",
|
||||
3: "#ffff00",
|
||||
4: "#0000ff",
|
||||
5: "#000000",
|
||||
};
|
||||
const farbe = farben[level] || "#555";
|
||||
btn.textContent = `⚠️ DEFCON ${level}`;
|
||||
btn.style.backgroundColor = farbe;
|
||||
btn.style.color = level === 3 ? "black" : "white";
|
||||
btn.style.fontWeight = "bold";
|
||||
btn.style.transition = "background-color 0.5s, color 0.5s";
|
||||
|
||||
if (level === 1) {
|
||||
document.body.style.backgroundColor = farbe;
|
||||
} else {
|
||||
document.body.style.backgroundColor = "#000000";
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeDefconStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/defcon");
|
||||
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||
const data = await res.json();
|
||||
currentDefcon = data.level;
|
||||
aktualisiereDefconButton(currentDefcon);
|
||||
} catch (err) {
|
||||
console.warn("DEFCON Abruf fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendeNeuesDefconLevel(level) {
|
||||
try {
|
||||
const response = await fetch("/api/defcon", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ level: level }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Fehler beim Senden des neuen DEFCON-Levels");
|
||||
|
||||
await response.json();
|
||||
} catch (err) {
|
||||
console.error("Fehler beim POST:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function newDefconStatus() {
|
||||
currentDefcon -= 1;
|
||||
if (currentDefcon < 1) {
|
||||
currentDefcon = 5;
|
||||
}
|
||||
sendeNeuesDefconLevel(currentDefcon);
|
||||
aktualisiereDefconButton(currentDefcon);
|
||||
}
|
||||
|
||||
async function ladeBunkerStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/bunker-status");
|
||||
if (!res.ok) throw new Error("Fehler beim Abruf");
|
||||
const data = await res.json();
|
||||
|
||||
const icon = document.getElementById("hazard-symbol");
|
||||
if (!icon) return;
|
||||
|
||||
const inMainzNetz = Boolean(data && data.online);
|
||||
|
||||
if (inMainzNetz) {
|
||||
icon.style.backgroundColor = "limegreen";
|
||||
icon.style.color = "black";
|
||||
icon.style.padding = "4px 8px";
|
||||
icon.style.borderRadius = "4px";
|
||||
} else {
|
||||
icon.style.backgroundColor = "";
|
||||
icon.style.color = "";
|
||||
icon.title = "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Bunker-Status nicht abrufbar:", err);
|
||||
}
|
||||
}
|
||||
|
||||
ladeBunkerStatus();
|
||||
setInterval(ladeBunkerStatus, 60 * 1000);
|
||||
|
||||
ladeDefconStatus();
|
||||
ladeNews();
|
||||
ladeRMV();
|
||||
ladeMarketDaten();
|
||||
|
||||
setInterval(ladeNews, 30 * 1000);
|
||||
setInterval(ladeRMV, 5 * 60 * 1000);
|
||||
setInterval(ladeDefconStatus, 60 * 1000);
|
||||
setInterval(() => {
|
||||
rotiereFX();
|
||||
}, 8 * 1000);
|
||||
setInterval(ladeMarketDaten, 30 * 1000);
|
||||
|
||||
const defconBtn = document.getElementById("defcon-button");
|
||||
if (defconBtn) {
|
||||
defconBtn.addEventListener("click", newDefconStatus);
|
||||
}
|
||||
|
||||
(function initLpaButton() {
|
||||
const TARGET_LPA = "https://aerztepruefung.service24.rlp.de/intelliform/admin/intelliForm-Spaces/LPA/Studentenbereich";
|
||||
const btn = document.getElementById("lpa-button");
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener("click", () => window.open(TARGET_LPA, "_blank"));
|
||||
})();
|
||||
188
volumes/web/static/js/mandelbrot.js
Normal file
188
volumes/web/static/js/mandelbrot.js
Normal file
@@ -0,0 +1,188 @@
|
||||
let timersData = [];
|
||||
let detailOpenFor = null;
|
||||
|
||||
function formatDiff(target) {
|
||||
const targetDate = target instanceof Date ? target : new Date(target);
|
||||
const now = new Date();
|
||||
const diff = targetDate - now;
|
||||
|
||||
if (diff <= 0) return "Abgelaufen";
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
const minutes = Math.floor((diff / (1000 * 60)) % 60);
|
||||
const seconds = Math.floor((diff / 1000) % 60);
|
||||
|
||||
return `${days}d ${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
fetch("/api/countdowns")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
timersData = data.timers || [];
|
||||
|
||||
const mainTargetDate = new Date(data.main.target);
|
||||
const mainLabel = data.main.label || "";
|
||||
const mainLabelEl = document.getElementById("main-label");
|
||||
const mainCountdownEl = document.getElementById("main-countdown");
|
||||
|
||||
function updateMainTimer() {
|
||||
const now = new Date();
|
||||
const diff = mainTargetDate - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
mainLabelEl.textContent = mainLabel;
|
||||
mainCountdownEl.textContent = "Abgelaufen";
|
||||
return;
|
||||
}
|
||||
|
||||
mainLabelEl.textContent = mainLabel;
|
||||
mainCountdownEl.textContent = formatDiff(mainTargetDate);
|
||||
}
|
||||
|
||||
function updateSmallTimers() {
|
||||
timersData.forEach(t => {
|
||||
const el = document.getElementById(t.id);
|
||||
if (!el) return;
|
||||
|
||||
const now = new Date();
|
||||
const nextEntry = t.targets
|
||||
.map(e => ({ ...e, date: new Date(e.time) }))
|
||||
.find(e => e.date > now);
|
||||
|
||||
if (nextEntry) {
|
||||
el.textContent = `${nextEntry.label}\n${formatDiff(nextEntry.date)}`;
|
||||
} else {
|
||||
el.textContent = `${t.label}\nAbgelaufen`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateDetailsPanel() {
|
||||
if (!detailOpenFor) return;
|
||||
const timer = timersData.find(t => t.id === detailOpenFor);
|
||||
if (!timer) return;
|
||||
|
||||
timer.targets.forEach((target, idx) => {
|
||||
const el = document.getElementById(`detail-${timer.id}-${idx}`);
|
||||
if (!el) return;
|
||||
el.textContent = formatDiff(target.time);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetails(timer) {
|
||||
const detailEl = document.getElementById("timer-details");
|
||||
if (!detailEl) return;
|
||||
|
||||
detailOpenFor = timer.id;
|
||||
const entries = timer.targets.map((target, idx) => {
|
||||
const entryId = `detail-${timer.id}-${idx}`;
|
||||
return `
|
||||
<div class="timer-entry">
|
||||
<div class="label">${target.label}</div>
|
||||
<div class="countdown" id="${entryId}"></div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
detailEl.innerHTML = `
|
||||
<button class="close-btn" id="timer-details-close">Schließen</button>
|
||||
<h3>${timer.label}</h3>
|
||||
<div class="timer-meta">${timer.targets.length} Termine</div>
|
||||
${entries}
|
||||
`;
|
||||
|
||||
detailEl.classList.remove("hidden");
|
||||
|
||||
const closeBtn = document.getElementById("timer-details-close");
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", () => {
|
||||
detailOpenFor = null;
|
||||
detailEl.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
updateDetailsPanel();
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
updateMainTimer();
|
||||
updateSmallTimers();
|
||||
updateDetailsPanel();
|
||||
}, 1000);
|
||||
|
||||
updateMainTimer();
|
||||
updateSmallTimers();
|
||||
|
||||
document.querySelectorAll(".small-circle").forEach(circle => {
|
||||
circle.addEventListener("click", () => {
|
||||
const timerId = circle.getAttribute("data-timer");
|
||||
const timer = timersData.find(t => t.id === timerId);
|
||||
if (timer) renderDetails(timer);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("fullscreen-btn").addEventListener("click", () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
const cantinaAudio = document.getElementById("cantina-audio");
|
||||
const circleContainer = document.querySelector(".circle-container");
|
||||
|
||||
function playCantina() {
|
||||
if (!cantinaAudio) return;
|
||||
cantinaAudio.currentTime = 0;
|
||||
cantinaAudio.play().catch(err => console.warn("Cantina playback blocked:", err));
|
||||
}
|
||||
|
||||
if (circleContainer) {
|
||||
circleContainer.addEventListener("click", playCantina);
|
||||
}
|
||||
|
||||
function scheduleNextFullHour() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setMinutes(0, 0, 0);
|
||||
if (next <= now) {
|
||||
next.setHours(next.getHours() + 1);
|
||||
}
|
||||
const delay = next - now;
|
||||
setTimeout(() => {
|
||||
playCantina();
|
||||
scheduleNextFullHour();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
scheduleNextFullHour();
|
||||
|
||||
const overlay = document.getElementById("twitter-overlay");
|
||||
|
||||
function showOverlay() {
|
||||
overlay.style.display = "flex";
|
||||
const gongVideo = document.getElementById("gong-video-fullscreen");
|
||||
if (gongVideo) {
|
||||
gongVideo.currentTime = 0;
|
||||
gongVideo.play().catch(err => console.warn("Autoplay-Fehler:", err));
|
||||
}
|
||||
setTimeout(() => {
|
||||
overlay.style.display = "none";
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
(() => {
|
||||
const now = new Date();
|
||||
const target = new Date();
|
||||
target.setHours(23, 40, 0, 0);
|
||||
|
||||
if (now > target && now - target < 60000) {
|
||||
showOverlay();
|
||||
} else if (now <= target) {
|
||||
const delay = target - now;
|
||||
setTimeout(showOverlay, delay);
|
||||
}
|
||||
})();
|
||||
BIN
volumes/web/static/media/cantina.mp3
Normal file
BIN
volumes/web/static/media/cantina.mp3
Normal file
Binary file not shown.
45
volumes/web/static/media/countdowns.json
Normal file
45
volumes/web/static/media/countdowns.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"main": {
|
||||
"label": "M2",
|
||||
"target": "2026-04-14T00:00:00"
|
||||
},
|
||||
"timers": [
|
||||
{
|
||||
"id": "timer1",
|
||||
"label": "8. Semester Klausuren",
|
||||
"targets": [
|
||||
{ "label": "Pr. Allgemeinmedizin", "time": "2026-01-30T09:00:00" },
|
||||
{ "label": "Pr. Chirurgie (OSCE)", "time": "2026-02-02T07:45:00" },
|
||||
{ "label": "Pr. Arbeits- und Sozialmedizin II", "time": "2026-02-10T08:00:00" },
|
||||
{ "label": "Pr. Psychiatrie & Psychotherapie", "time": "2026-02-11T13:30:00" },
|
||||
{ "label": "Q5", "time": "2026-02-13T09:15:00" },
|
||||
{ "label": "Innere Medizin (schriftlich)", "time": "2026-02-20T08:00:00" },
|
||||
{ "label": "Q14", "time": "2026-02-23T15:30:00" },
|
||||
{ "label": "Pr. Neurologie", "time": "2026-02-25T10:00:00" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "timer2",
|
||||
"label": "10. Semester Klausuren",
|
||||
"targets": [
|
||||
{ "label": "Q10", "time": "2026-01-15T15:30:00" },
|
||||
{ "label": "Q9", "time": "2026-01-21T16:30:00" },
|
||||
{ "label": "Pr. Orthopädie", "time": "2026-02-02T17:00:00" },
|
||||
{ "label": "Q7", "time": "2026-02-04T14:30:00" },
|
||||
{ "label": "Pr. Urologie", "time": "2026-02-10T10:30:00" },
|
||||
{ "label": "Pr. Gynäkologie", "time": "2026-02-11T09:30:00" },
|
||||
{ "label": "BP Gynäkologie", "time": "2026-02-11T10:15:00" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "timer3",
|
||||
"label": "PJ-Tertiale Frühjahr 2025",
|
||||
"targets": [
|
||||
{ "label": "Innere Medizin", "time": "2025-05-19T00:00:00" },
|
||||
{ "label": "Chirurgie", "time": "2025-09-08T00:00:00" },
|
||||
{ "label": "Wahlfach", "time": "2025-12-29T00:00:00" },
|
||||
{ "label": "PJ Ende", "time": "2026-04-19T00:00:00" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
volumes/web/static/media/gong.mp4
Normal file
BIN
volumes/web/static/media/gong.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user