games-landing/index.html
joshii 5aeaf567a7
Some checks failed
Build games-landing / build (push) Failing after 13s
Initial commit: Game Night landing + Minecraft cheat sheet
Includes:
- Game Night landing page with health checks
- FTB StoneBlock 4 server info & command reference
- Dockerfile for nginx deployment
- Forgejo CI/CD workflow
2026-03-17 23:31:54 +01:00

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">&#127918;</div>
<h1>Game Night</h1>
<p class="subtitle">W&auml;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&uuml;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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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>