Some checks failed
Build games-landing / build (push) Failing after 13s
Includes: - Game Night landing page with health checks - FTB StoneBlock 4 server info & command reference - Dockerfile for nginx deployment - Forgejo CI/CD workflow
502 lines
18 KiB
HTML
502 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Game Night</title>
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: #07070f;
|
|
color: #d0d0d0;
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.bg {
|
|
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
|
background:
|
|
radial-gradient(ellipse 80% 60% at 15% 45%, rgba(124, 58, 237, 0.12) 0%, transparent 60%),
|
|
radial-gradient(ellipse 60% 50% at 85% 25%, rgba(59, 130, 246, 0.08) 0%, transparent 55%),
|
|
radial-gradient(ellipse 70% 50% at 50% 90%, rgba(168, 85, 247, 0.06) 0%, transparent 50%);
|
|
}
|
|
.bg::after {
|
|
content: ''; position: absolute; inset: 0;
|
|
background-image: radial-gradient(rgba(255,255,255,0.03) 1px, transparent 1px);
|
|
background-size: 32px 32px;
|
|
}
|
|
|
|
.container {
|
|
position: relative; z-index: 1;
|
|
max-width: 960px; margin: 0 auto;
|
|
padding: 3.5rem 1.5rem 4rem;
|
|
}
|
|
|
|
header { text-align: center; margin-bottom: 2.5rem; }
|
|
.logo { font-size: 3.2rem; margin-bottom: 0.3rem; filter: drop-shadow(0 0 24px rgba(139,92,246,0.3)); }
|
|
h1 {
|
|
font-size: 2.4rem; font-weight: 800; letter-spacing: -0.03em;
|
|
background: linear-gradient(135deg, #c4b5fd 0%, #818cf8 40%, #60a5fa 100%);
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
.subtitle { color: #555; font-size: 0.9rem; margin-top: 0.4rem; }
|
|
|
|
.status-bar {
|
|
display: inline-flex; align-items: center; gap: 0.5rem;
|
|
margin-top: 1.2rem; padding: 0.45rem 1.2rem;
|
|
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
|
|
border-radius: 99px; font-size: 0.78rem; color: #777;
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
.status-bar .dot {
|
|
width: 7px; height: 7px; border-radius: 50%; background: #555;
|
|
transition: all 0.4s;
|
|
}
|
|
.status-bar .dot.all-good { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.5); }
|
|
.status-bar .dot.some-down { background: #f59e0b; box-shadow: 0 0 8px rgba(245,158,11,0.4); }
|
|
|
|
.games {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.game {
|
|
display: flex; flex-direction: column;
|
|
background: rgba(255,255,255,0.025);
|
|
border: 1px solid rgba(255,255,255,0.05);
|
|
border-radius: 16px; padding: 1.4rem 1.5rem;
|
|
text-decoration: none; color: inherit;
|
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
position: relative; overflow: hidden;
|
|
opacity: 0; transform: translateY(16px);
|
|
animation: fadeUp 0.5s forwards;
|
|
}
|
|
.game { animation-delay: calc(0.05s * var(--i)); }
|
|
|
|
@keyframes fadeUp {
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.game::before {
|
|
content: ''; position: absolute; inset: 0;
|
|
background: radial-gradient(circle at var(--mx, 50%) var(--my, 50%), rgba(139,92,246,0.08), transparent 60%);
|
|
opacity: 0; transition: opacity 0.4s;
|
|
}
|
|
.game:hover::before { opacity: 1; }
|
|
.game:hover {
|
|
transform: translateY(-6px);
|
|
border-color: rgba(139,92,246,0.25);
|
|
box-shadow: 0 24px 48px rgba(0,0,0,0.35), 0 0 0 1px rgba(139,92,246,0.1);
|
|
}
|
|
|
|
.game-top { display: flex; align-items: flex-start; gap: 0.9rem; margin-bottom: 0.8rem; }
|
|
|
|
.game-icon {
|
|
width: 52px; height: 52px; border-radius: 14px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 1.6rem; flex-shrink: 0;
|
|
position: relative;
|
|
}
|
|
.game-icon::after {
|
|
content: ''; position: absolute; inset: 0; border-radius: 14px;
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.icon-purple { background: rgba(139,92,246,0.12); }
|
|
.icon-blue { background: rgba(59,130,246,0.12); }
|
|
.icon-pink { background: rgba(236,72,153,0.12); }
|
|
.icon-red { background: rgba(239,68,68,0.12); }
|
|
.icon-amber { background: rgba(245,158,11,0.12); }
|
|
.icon-emerald { background: rgba(16,185,129,0.12); }
|
|
|
|
.game-info { flex: 1; min-width: 0; }
|
|
.game h2 { font-size: 1.1rem; font-weight: 700; color: #f0f0f0; line-height: 1.3; }
|
|
|
|
.game-meta {
|
|
display: flex; align-items: center; gap: 0.6rem;
|
|
margin-top: 0.25rem; flex-wrap: wrap;
|
|
}
|
|
|
|
.health {
|
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
|
font-size: 0.7rem; font-weight: 500;
|
|
}
|
|
.health-dot {
|
|
width: 6px; height: 6px; border-radius: 50%;
|
|
background: #333; transition: all 0.4s;
|
|
}
|
|
.health-dot.online {
|
|
background: #22c55e;
|
|
box-shadow: 0 0 6px rgba(34,197,94,0.6);
|
|
animation: glow 2.5s ease-in-out infinite;
|
|
}
|
|
.health-dot.offline {
|
|
background: #ef4444;
|
|
box-shadow: 0 0 4px rgba(239,68,68,0.4);
|
|
}
|
|
.health-dot.checking {
|
|
background: #666;
|
|
animation: blink 0.8s ease-in-out infinite;
|
|
}
|
|
.health-label { color: #666; }
|
|
.health-label.online { color: #4ade80; }
|
|
.health-label.offline { color: #f87171; }
|
|
|
|
@keyframes glow {
|
|
0%, 100% { box-shadow: 0 0 4px rgba(34,197,94,0.4); }
|
|
50% { box-shadow: 0 0 10px rgba(34,197,94,0.7); }
|
|
}
|
|
@keyframes blink {
|
|
0%, 100% { opacity: 0.3; }
|
|
50% { opacity: 1; }
|
|
}
|
|
|
|
.players-badge {
|
|
font-size: 0.68rem; color: #555; font-weight: 500;
|
|
}
|
|
|
|
.tag {
|
|
font-size: 0.6rem; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: 0.6px; padding: 2px 7px; border-radius: 5px;
|
|
}
|
|
.tag-team { background: rgba(139,92,246,0.1); color: #a78bfa; }
|
|
.tag-party { background: rgba(236,72,153,0.1); color: #f472b6; }
|
|
.tag-strategy { background: rgba(239,68,68,0.1); color: #f87171; }
|
|
.tag-quiz { background: rgba(245,158,11,0.1); color: #fbbf24; }
|
|
.tag-board { background: rgba(16,185,129,0.1); color: #34d399; }
|
|
|
|
.game p {
|
|
font-size: 0.82rem; color: #777; line-height: 1.55;
|
|
position: relative;
|
|
}
|
|
|
|
.game-arrow {
|
|
position: absolute; bottom: 1.2rem; right: 1.4rem;
|
|
color: #333; font-size: 1.1rem; transition: all 0.3s;
|
|
}
|
|
.game:hover .game-arrow { color: #8b5cf6; transform: translateX(3px); }
|
|
|
|
footer {
|
|
text-align: center; margin-top: 3rem;
|
|
font-size: 0.72rem; color: #333;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.logo { font-size: 2.4rem; }
|
|
h1 { font-size: 1.8rem; }
|
|
.games { grid-template-columns: 1fr; }
|
|
.container { padding: 2rem 1rem 3rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="bg"></div>
|
|
<div class="container">
|
|
<header>
|
|
<div class="logo">🎮</div>
|
|
<h1>Game Night</h1>
|
|
<p class="subtitle">Wähle ein Spiel und lade deine Freunde ein</p>
|
|
<div class="status-bar">
|
|
<span class="dot" id="summary-dot"></span>
|
|
<span id="summary-text">Services werden geprüft...</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="games" id="games-grid"></div>
|
|
|
|
<footer>Hosted on joshuahirsig.xyz</footer>
|
|
</div>
|
|
|
|
<script>
|
|
var DEFAULT_SETTINGS = {
|
|
healthChecks: true,
|
|
healthTimeoutMs: 6000
|
|
};
|
|
|
|
var DEFAULT_GAMES = [
|
|
{ id: "codenames", name: "Codenames", icon: "\uD83D\uDD75\uFE0F", iconClass: "icon-purple", description: "Finde die Agenten deines Teams anhand von Hinweisen!", players: "4+ Spieler", tag: "Teamspiel", tagClass: "tag-team", url: "https://codenames.joshuahirsig.xyz" },
|
|
{ id: "scribble", name: "Scribble", icon: "\uD83C\uDFA8", iconClass: "icon-pink", description: "Zeichne und errate Begriffe \u2013 wie Skribbl.io!", players: "3+ Spieler", tag: "Party", tagClass: "tag-party", url: "https://scribble.joshuahirsig.xyz" },
|
|
{ id: "cards", name: "Massive Decks", icon: "\uD83C\uDCCF", iconClass: "icon-blue", description: "Cards Against Humanity \u2013 die besten schlechten Antworten gewinnen.", players: "3+ Spieler", tag: "Party", tagClass: "tag-party", url: "https://cards.joshuahirsig.xyz" },
|
|
{ id: "trivia", name: "Trivia", icon: "\uD83E\uDDE0", iconClass: "icon-amber", description: "Quiz-Spiel wie Kahoot \u2013 erstelle eigene Fragen!", players: "2+ Spieler", tag: "Quiz", tagClass: "tag-quiz", url: "https://trivia.joshuahirsig.xyz" },
|
|
{ id: "tabletop", name: "Virtual Tabletop", icon: "\uD83C\uDFB2", iconClass: "icon-emerald", description: "300+ Brettspiele, Kartenspiele und mehr.", players: "2+ Spieler", tag: "Brettspiel", tagClass: "tag-board", url: "https://tabletop.joshuahirsig.xyz" },
|
|
{ id: "tosios", name: "TOSIOS", icon: "\uD83D\uDD2B", iconClass: "icon-red", description: "Browser IO-Shooter \u2013 schnelle Multiplayer-Matches!", players: "2+ Spieler", tag: "Action", tagClass: "tag-strategy", url: "https://shooter.joshuahirsig.xyz" },
|
|
{ id: "geoquiz", name: "Le Grand GeoQuiz", icon: "\uD83C\uDF0D", iconClass: "icon-emerald", description: "Strategisches Geografie-Spiel \u2013 8 L\u00e4nder, 8 Kategorien, niedrigste Punktzahl gewinnt!", players: "1 Spieler", tag: "Quiz", tagClass: "tag-quiz", url: "https://geoquiz.joshuahirsig.xyz" }
|
|
];
|
|
|
|
var YAML_LOADER_URL = "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js";
|
|
var yamlLoaderPromise = null;
|
|
var grid = document.getElementById("games-grid");
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function slugify(value) {
|
|
var slug = String(value || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return slug || "game";
|
|
}
|
|
|
|
function loadScript(src) {
|
|
return new Promise(function(resolve, reject) {
|
|
var script = document.createElement("script");
|
|
script.src = src;
|
|
script.onload = resolve;
|
|
script.onerror = function() { reject(new Error("Script konnte nicht geladen werden: " + src)); };
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function ensureYamlParser() {
|
|
if (window.jsyaml && typeof window.jsyaml.load === "function") {
|
|
return Promise.resolve(window.jsyaml);
|
|
}
|
|
if (!yamlLoaderPromise) {
|
|
yamlLoaderPromise = loadScript(YAML_LOADER_URL).then(function() {
|
|
if (!window.jsyaml || typeof window.jsyaml.load !== "function") {
|
|
throw new Error("YAML parser nicht verf\u00fcgbar");
|
|
}
|
|
return window.jsyaml;
|
|
});
|
|
}
|
|
return yamlLoaderPromise;
|
|
}
|
|
|
|
function normalizeGame(rawGame, index, usedIds) {
|
|
var game = rawGame && typeof rawGame === "object" ? rawGame : {};
|
|
var name = game.name ? String(game.name) : ("Game " + (index + 1));
|
|
var id = game.id ? String(game.id) : slugify(name);
|
|
|
|
if (usedIds[id]) {
|
|
var suffix = 2;
|
|
while (usedIds[id + "-" + suffix]) {
|
|
suffix++;
|
|
}
|
|
id = id + "-" + suffix;
|
|
}
|
|
usedIds[id] = true;
|
|
|
|
return {
|
|
id: id,
|
|
name: name,
|
|
icon: game.icon ? String(game.icon) : "\uD83C\uDFAE",
|
|
iconClass: game.iconClass ? String(game.iconClass) : "icon-blue",
|
|
iconSvg: game.iconSvg ? String(game.iconSvg) : "",
|
|
description: String(game.description || game.desc || ""),
|
|
players: String(game.players || ""),
|
|
tag: String(game.tag || ""),
|
|
tagClass: String(game.tagClass || "tag-team"),
|
|
url: String(game.url || "#"),
|
|
healthUrl: String(game.healthUrl || game.health_url || ""),
|
|
healthEnabled: game.healthEnabled !== false
|
|
};
|
|
}
|
|
|
|
function normalizeConfig(rawConfig) {
|
|
var configObject = Array.isArray(rawConfig)
|
|
? { games: rawConfig }
|
|
: (rawConfig && typeof rawConfig === "object" ? rawConfig : {});
|
|
|
|
var settings = Object.assign({}, DEFAULT_SETTINGS, configObject.settings || {});
|
|
var timeoutMs = Number(settings.healthTimeoutMs);
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 1000) {
|
|
timeoutMs = DEFAULT_SETTINGS.healthTimeoutMs;
|
|
}
|
|
settings.healthChecks = settings.healthChecks !== false;
|
|
settings.healthTimeoutMs = timeoutMs;
|
|
|
|
var sourceGames = Array.isArray(configObject.games) ? configObject.games : [];
|
|
if (!sourceGames.length) {
|
|
sourceGames = DEFAULT_GAMES;
|
|
}
|
|
|
|
var usedIds = {};
|
|
var normalizedGames = sourceGames.map(function(game, index) {
|
|
return normalizeGame(game, index, usedIds);
|
|
});
|
|
|
|
return {
|
|
settings: settings,
|
|
games: normalizedGames
|
|
};
|
|
}
|
|
|
|
async function loadExternalConfig() {
|
|
var sources = [
|
|
{ path: "/games.json", format: "json" },
|
|
{ path: "/games.yaml", format: "yaml" },
|
|
{ path: "/games.yml", format: "yaml" }
|
|
];
|
|
|
|
for (var i = 0; i < sources.length; i++) {
|
|
var source = sources[i];
|
|
try {
|
|
var response = await fetch(source.path, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
continue;
|
|
}
|
|
|
|
var parsed;
|
|
if (source.format === "json") {
|
|
parsed = await response.json();
|
|
} else {
|
|
var yaml = await ensureYamlParser();
|
|
parsed = yaml.load(await response.text());
|
|
}
|
|
|
|
console.info("Config geladen aus", source.path);
|
|
return normalizeConfig(parsed);
|
|
} catch (error) {
|
|
console.warn("Config konnte nicht gelesen werden:", source.path, error);
|
|
}
|
|
}
|
|
|
|
console.warn("Keine externe Config gefunden. Fallback wird verwendet.");
|
|
return normalizeConfig({ games: DEFAULT_GAMES, settings: DEFAULT_SETTINGS });
|
|
}
|
|
|
|
function buildMeta(game) {
|
|
var parts = [
|
|
'<span class="health" id="health-' + escapeHtml(game.id) + '">' +
|
|
'<span class="health-dot checking"></span>' +
|
|
'<span class="health-label">Pr\u00fcfe...</span>' +
|
|
'</span>'
|
|
];
|
|
if (game.players) {
|
|
parts.push('<span class="players-badge">' + escapeHtml(game.players) + '</span>');
|
|
}
|
|
if (game.tag) {
|
|
parts.push('<span class="tag ' + escapeHtml(game.tagClass) + '">' + escapeHtml(game.tag) + '</span>');
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
function renderGames(games) {
|
|
grid.innerHTML = "";
|
|
games.forEach(function(game, index) {
|
|
var card = document.createElement("a");
|
|
card.className = "game";
|
|
card.href = game.url;
|
|
card.innerHTML =
|
|
'<div class="game-top">' +
|
|
'<div class="game-icon ' + escapeHtml(game.iconClass) + '">' + (game.iconSvg ? '<img src="' + escapeHtml(game.iconSvg) + '"' + ' style="width:32px;height:32px" alt="">' : escapeHtml(game.icon)) + '</div>' +
|
|
'<div class="game-info">' +
|
|
'<h2>' + escapeHtml(game.name) + '</h2>' +
|
|
'<div class="game-meta">' + buildMeta(game) + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<p>' + escapeHtml(game.description) + '</p>' +
|
|
'<span class="game-arrow">\u2192</span>';
|
|
|
|
card.addEventListener("mousemove", function(event) {
|
|
var rect = card.getBoundingClientRect();
|
|
card.style.setProperty("--mx", ((event.clientX - rect.left) / rect.width * 100) + "%");
|
|
card.style.setProperty("--my", ((event.clientY - rect.top) / rect.height * 100) + "%");
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function checkHealth(game, settings) {
|
|
var el = document.getElementById("health-" + game.id);
|
|
if (!el) {
|
|
return Promise.resolve({ checked: false, online: false });
|
|
}
|
|
|
|
if (!settings.healthChecks || game.healthEnabled === false) {
|
|
el.innerHTML = '<span class="health-dot"></span><span class="health-label">Kein Check</span>';
|
|
return Promise.resolve({ checked: false, online: false });
|
|
}
|
|
|
|
var targetUrl = game.healthUrl || game.url;
|
|
if (!targetUrl || targetUrl === "#") {
|
|
el.innerHTML = '<span class="health-dot"></span><span class="health-label">Kein Check</span>';
|
|
return Promise.resolve({ checked: false, online: false });
|
|
}
|
|
|
|
var ctrl = new AbortController();
|
|
var timer = setTimeout(function() { ctrl.abort(); }, settings.healthTimeoutMs);
|
|
|
|
return fetch(targetUrl, { mode: "no-cors", cache: "no-store", signal: ctrl.signal })
|
|
.then(function() {
|
|
clearTimeout(timer);
|
|
el.innerHTML = '<span class="health-dot online"></span><span class="health-label online">Online</span>';
|
|
return { checked: true, online: true };
|
|
})
|
|
.catch(function() {
|
|
clearTimeout(timer);
|
|
el.innerHTML = '<span class="health-dot offline"></span><span class="health-label offline">Offline</span>';
|
|
return { checked: true, online: false };
|
|
});
|
|
}
|
|
|
|
function updateSummary(results, totalGames) {
|
|
var dot = document.getElementById("summary-dot");
|
|
var txt = document.getElementById("summary-text");
|
|
var checked = 0;
|
|
var online = 0;
|
|
|
|
results.forEach(function(result) {
|
|
if (result.checked) {
|
|
checked++;
|
|
if (result.online) {
|
|
online++;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!totalGames) {
|
|
dot.className = "dot";
|
|
txt.textContent = "Keine Spiele konfiguriert";
|
|
return;
|
|
}
|
|
|
|
if (!checked) {
|
|
dot.className = "dot";
|
|
txt.textContent = "Health-Checks deaktiviert";
|
|
return;
|
|
}
|
|
|
|
if (online === checked) {
|
|
dot.className = "dot all-good";
|
|
txt.textContent = "Alle " + online + " Services online";
|
|
} else {
|
|
dot.className = "dot some-down";
|
|
txt.textContent = online + "/" + checked + " Services online";
|
|
}
|
|
|
|
if (checked < totalGames) {
|
|
txt.textContent += " (" + (totalGames - checked) + " ohne Check)";
|
|
}
|
|
}
|
|
|
|
(async function init() {
|
|
var config = await loadExternalConfig();
|
|
renderGames(config.games);
|
|
var healthResults = await Promise.all(
|
|
config.games.map(function(game) {
|
|
return checkHealth(game, config.settings);
|
|
})
|
|
);
|
|
updateSummary(healthResults, config.games.length);
|
|
})().catch(function(error) {
|
|
console.error("Landing konnte nicht initialisiert werden:", error);
|
|
document.getElementById("summary-text").textContent = "Fehler beim Laden";
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|