Initial PatchDL web UI

This commit is contained in:
Knutwurst committed 2026-06-23 07:52:17 +02:00
commit 9c2822f367
5 files changed
+1478

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# PatchDL
PatchDL is a planned standalone PS5 ELF payload for controlled official game
patch downloads.
The target design is deny-by-default:
- Sony domains stay blocked by nanoDNS for the normal PS5 system.
- PatchDL uses its own internal allowlist resolver for Sony CDN hosts.
- Games must be explicitly enabled before PatchDL checks or downloads updates.
- Patch selection is capped to the highest update compatible with the current
firmware.
## Current Contents
```text
web/
Static web UI prototype for the future embedded PatchDL web server.
```
Open `web/index.html` directly for the mock UI, or serve `web/` from a local
HTTP server.
+47
View File
@@ -0,0 +1,47 @@
# PatchDL Web UI
Standalone static UI for the planned PS5 `patchdl.elf` web server.
Open `index.html` directly for the mock UI, or serve this directory from the
ELF web server. The JavaScript first tries the real API and falls back to demo
data when the API is not available.
## Expected API
```text
GET /api/status
GET /api/config
POST /api/config
GET /api/titles
GET /api/downloads
POST /api/titles/:title_id/check
POST /api/titles/:title_id/download
```
## Policy Model
The UI assumes deny-by-default behavior:
```json
{
"default_policy": "deny",
"download_dir": "/mnt/usb0/patches",
"install_after_download": false,
"delete_pkg_after_install": false,
"cdn_allowlist": [
"sgst.prod.dl.playstation.net",
"gst.prod.dl.playstation.net",
"gs2.ww.prod.dl.playstation.net"
]
}
```
Per-title modes:
```text
disabled
download_only
latest_compatible
pin
check_only
```
+483
View File
@@ -0,0 +1,483 @@
const API = {
status: "/api/status",
titles: "/api/titles",
config: "/api/config",
downloads: "/api/downloads",
action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
};
const fallback = {
status: {
firmware: "11.60",
firmware_build: "0x11600000",
dns_guard: "Aktiv",
resolver: "Internal allowlist",
free_space_mb: 64218,
download_dir: "/mnt/usb0/patches",
},
config: {
default_policy: "deny",
download_dir: "/mnt/usb0/patches",
install_after_download: false,
delete_pkg_after_install: false,
cdn_allowlist: [
"sgst.prod.dl.playstation.net",
"gst.prod.dl.playstation.net",
"gs2.ww.prod.dl.playstation.net",
],
},
titles: [
{
title_id: "PPSA01628_00",
name: "Call of Duty Black Ops Cold War",
content_id: "UP0002-PPSA01628_00-CODCWTHEGAME0001",
installed_version: "01.032.000",
compatible_version: "01.041.000",
latest_version: "01.041.000",
latest_required_fw: "11.60",
enabled: true,
mode: "latest_compatible",
queued: false,
status: "available",
},
{
title_id: "PPSA01284_00",
name: "Demon's Souls",
content_id: "UP9000-PPSA01284_00-DEMONSSOULS00001",
installed_version: "01.002.000",
compatible_version: "01.004.000",
latest_version: "01.004.000",
latest_required_fw: "10.01",
enabled: true,
mode: "pin",
max_content_ver: "01.004.000",
queued: true,
status: "queued",
},
{
title_id: "PPSA08329_00",
name: "Example Future Game",
content_id: "EP0000-PPSA08329_00-FUTUREPATCH00001",
installed_version: "01.000.000",
compatible_version: null,
latest_version: "01.012.000",
latest_required_fw: "12.50",
enabled: false,
mode: "disabled",
queued: false,
status: "blocked",
},
],
downloads: [
{
title_id: "PPSA01284_00",
name: "Demon's Souls",
version: "01.004.000",
progress: 64,
detail: "4.8 GB von 7.5 GB",
},
],
logs: [
"[12:41:02] nanoDNS guard detected: Sony domains blocked",
"[12:41:04] Internal resolver ready for 3 CDN hosts",
"[12:41:07] PPSA01284_00 selected: pin 01.004.000",
"[12:41:09] Download started: PPSA01284_00 01.004.000",
],
};
let state = {
status: fallback.status,
config: fallback.config,
titles: fallback.titles,
downloads: fallback.downloads,
logs: fallback.logs,
filter: "all",
query: "",
usingFallback: false,
};
const els = {};
document.addEventListener("DOMContentLoaded", () => {
bindElements();
bindEvents();
loadInitialData();
});
function bindElements() {
Object.assign(els, {
firmwareValue: document.getElementById("firmwareValue"),
firmwareBuild: document.getElementById("firmwareBuild"),
dnsValue: document.getElementById("dnsValue"),
resolverValue: document.getElementById("resolverValue"),
spaceValue: document.getElementById("spaceValue"),
downloadDirValue: document.getElementById("downloadDirValue"),
gameGrid: document.getElementById("gameGrid"),
queueList: document.getElementById("queueList"),
logOutput: document.getElementById("logOutput"),
allowlistHosts: document.getElementById("allowlistHosts"),
searchInput: document.getElementById("searchInput"),
defaultPolicy: document.getElementById("defaultPolicy"),
downloadDir: document.getElementById("downloadDir"),
installAfterDownload: document.getElementById("installAfterDownload"),
deleteAfterInstall: document.getElementById("deleteAfterInstall"),
refreshBtn: document.getElementById("refreshBtn"),
saveBtn: document.getElementById("saveBtn"),
pauseAllBtn: document.getElementById("pauseAllBtn"),
toast: document.getElementById("toast"),
});
}
function bindEvents() {
els.searchInput.addEventListener("input", (event) => {
state.query = event.target.value.trim().toLowerCase();
renderGames();
});
document.querySelectorAll(".segmented button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".segmented button").forEach((item) => item.classList.remove("is-selected"));
button.classList.add("is-selected");
state.filter = button.dataset.filter;
renderGames();
});
});
els.refreshBtn.addEventListener("click", loadInitialData);
els.saveBtn.addEventListener("click", saveConfig);
els.pauseAllBtn.addEventListener("click", () => showToast("Queue wurde pausiert."));
}
async function loadInitialData() {
const [status, config, titles, downloads] = await Promise.all([
getJson(API.status, fallback.status),
getJson(API.config, fallback.config),
getJson(API.titles, fallback.titles),
getJson(API.downloads, fallback.downloads),
]);
state = {
...state,
status,
config,
titles,
downloads,
};
render();
showToast(state.usingFallback ? "Demo-Daten geladen. API noch nicht erreichbar." : "Daten aktualisiert.");
}
async function getJson(url, fallbackValue) {
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
state.usingFallback = true;
return clone(fallbackValue);
}
}
function render() {
renderStatus();
renderSettings();
renderGames();
renderQueue();
renderLogs();
}
function renderStatus() {
els.firmwareValue.textContent = state.status.firmware || "--";
els.firmwareBuild.textContent = state.status.firmware_build || "Systemversion";
els.dnsValue.textContent = state.status.dns_guard || "--";
els.resolverValue.textContent = state.status.resolver || "--";
els.spaceValue.textContent = formatMb(state.status.free_space_mb);
els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download Ziel";
}
function renderSettings() {
els.defaultPolicy.value = state.config.default_policy || "deny";
els.downloadDir.value = state.config.download_dir || "";
els.installAfterDownload.checked = Boolean(state.config.install_after_download);
els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install);
els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => {
const chip = document.createElement("span");
chip.className = "host-chip";
chip.textContent = host;
return chip;
}));
}
function renderGames() {
const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
els.gameGrid.replaceChildren();
if (!visible.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "Keine Titel fuer diesen Filter.";
els.gameGrid.appendChild(empty);
return;
}
visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
}
function matchesFilter(game) {
if (state.filter === "enabled") return game.enabled;
if (state.filter === "available") return game.status === "available";
if (state.filter === "blocked") return game.status === "blocked";
if (state.filter === "queued") return game.queued || game.status === "queued";
return true;
}
function matchesQuery(game) {
if (!state.query) return true;
return [game.name, game.title_id, game.content_id]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(state.query));
}
function createGameCard(game) {
const card = document.createElement("article");
card.className = "game-card";
const title = document.createElement("div");
title.className = "game-title";
title.innerHTML = `
<div class="cover">${initials(game.name)}</div>
<div>
<h3>${escapeHtml(game.name)}</h3>
<div class="subline">
<span>${escapeHtml(game.title_id)}</span>
<span>${escapeHtml(game.content_id || "")}</span>
</div>
<div class="subline" style="margin-top:8px">
${statusPill(game)}
<span class="pill">${game.enabled ? "Aktiv" : "Aus"}</span>
</div>
</div>
`;
const versions = document.createElement("div");
versions.className = "version-grid";
versions.innerHTML = `
${versionBox("Installiert", game.installed_version || "--")}
${versionBox("Kompatibel", game.compatible_version || "Keine")}
${versionBox("Neueste", `${game.latest_version || "--"} / FW ${game.latest_required_fw || "--"}`)}
`;
const controls = document.createElement("div");
controls.className = "controls";
const controlRow = document.createElement("div");
controlRow.className = "control-row";
const mode = document.createElement("select");
mode.title = "Update-Modus";
[
["disabled", "Disabled"],
["download_only", "Download only"],
["latest_compatible", "Latest compatible"],
["pin", "Pin version"],
["check_only", "Check only"],
].forEach(([value, label]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
option.selected = (game.mode || "disabled") === value;
mode.appendChild(option);
});
mode.addEventListener("change", () => updateGame(game.title_id, { mode: mode.value, enabled: mode.value !== "disabled" }));
const pin = document.createElement("input");
pin.title = "Maximale Content-Version";
pin.placeholder = "max ver";
pin.value = game.max_content_ver || "";
pin.addEventListener("change", () => updateGame(game.title_id, { max_content_ver: pin.value.trim() }));
controlRow.append(mode, pin);
const actions = document.createElement("div");
actions.className = "actions-row";
actions.innerHTML = `
<button class="row-button" data-action="check">
<svg><use href="#icon-refresh"></use></svg>
Pruefen
</button>
<button class="row-button is-primary" data-action="download" ${!game.compatible_version ? "disabled" : ""}>
<svg><use href="#icon-download"></use></svg>
Laden
</button>
`;
actions.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", () => runTitleAction(game.title_id, button.dataset.action));
});
controls.append(controlRow, actions);
card.append(title, versions, controls);
return card;
}
function versionBox(label, value) {
return `
<div class="version-box">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`;
}
function statusPill(game) {
if (game.status === "blocked") return `<span class="pill blocked">FW blockiert</span>`;
if (game.status === "queued") return `<span class="pill warn">In Queue</span>`;
if (game.status === "available") return `<span class="pill ok">Update verfuegbar</span>`;
return `<span class="pill">Aktuell</span>`;
}
function renderQueue() {
els.queueList.replaceChildren();
if (!state.downloads.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "Keine aktiven Downloads.";
els.queueList.appendChild(empty);
return;
}
state.downloads.forEach((item) => {
const row = document.createElement("div");
row.className = "queue-item";
row.innerHTML = `
<div>
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")}</span>
<div class="progress"><i style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></i></div>
</div>
<span>${item.progress || 0}%</span>
`;
els.queueList.appendChild(row);
});
}
function renderLogs() {
els.logOutput.textContent = state.logs.join("\n");
}
async function saveConfig() {
const config = {
...state.config,
default_policy: els.defaultPolicy.value,
download_dir: els.downloadDir.value.trim(),
install_after_download: els.installAfterDownload.checked,
delete_pkg_after_install: els.deleteAfterInstall.checked,
};
try {
await postJson(API.config, config);
state.config = config;
showToast("Konfiguration gespeichert.");
} catch (error) {
state.config = config;
showToast("Konfiguration lokal aktualisiert. API noch nicht erreichbar.");
}
renderSettings();
}
async function runTitleAction(titleId, action) {
const game = state.titles.find((item) => item.title_id === titleId);
if (!game) return;
try {
await postJson(API.action(titleId, action), {});
} catch (error) {
// Demo mode keeps the UI interactive before the ELF API exists.
}
if (action === "download" && game.compatible_version) {
game.queued = true;
game.status = "queued";
if (!state.downloads.some((item) => item.title_id === titleId)) {
state.downloads.push({
title_id: game.title_id,
name: game.name,
version: game.compatible_version,
progress: 0,
detail: "Wartet auf Start",
});
}
state.logs.push(`[${timeNow()}] Queued ${game.title_id} ${game.compatible_version}`);
showToast(`${game.title_id} wurde zur Queue hinzugefuegt.`);
} else {
state.logs.push(`[${timeNow()}] Check requested for ${game.title_id}`);
showToast(`${game.title_id} wird geprueft.`);
}
renderGames();
renderQueue();
renderLogs();
}
function updateGame(titleId, patch) {
const game = state.titles.find((item) => item.title_id === titleId);
if (!game) return;
Object.assign(game, patch);
if (patch.mode === "disabled") game.enabled = false;
state.logs.push(`[${timeNow()}] Policy updated for ${titleId}`);
renderGames();
renderLogs();
}
async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json().catch(() => ({}));
}
function initials(name) {
return (name || "PD")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
function formatMb(value) {
if (!Number.isFinite(Number(value))) return "--";
if (value > 1024) return `${(value / 1024).toFixed(1)} GB`;
return `${value} MB`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function timeNow() {
return new Date().toLocaleTimeString("de-DE", { hour12: false });
}
let toastTimer;
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 2600);
}
+210
View File
@@ -0,0 +1,210 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PatchDL</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<svg class="icon-sprite" aria-hidden="true">
<symbol id="icon-shield" viewBox="0 0 24 24">
<path d="M12 3l7 3v5c0 5-3 8-7 10-4-2-7-5-7-10V6l7-3z" />
<path d="M9 12l2 2 4-5" />
</symbol>
<symbol id="icon-download" viewBox="0 0 24 24">
<path d="M12 3v11" />
<path d="M7 10l5 5 5-5" />
<path d="M5 20h14" />
</symbol>
<symbol id="icon-refresh" viewBox="0 0 24 24">
<path d="M20 6v6h-6" />
<path d="M4 18v-6h6" />
<path d="M19 12A7 7 0 0 0 7 7" />
<path d="M5 12a7 7 0 0 0 12 5" />
</symbol>
<symbol id="icon-settings" viewBox="0 0 24 24">
<path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<path d="M4 12h2M18 12h2M12 4v2M12 18v2M6.5 6.5 8 8M16 16l1.5 1.5M17.5 6.5 16 8M8 16l-1.5 1.5" />
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">
<path d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15z" />
<path d="M16 16l5 5" />
</symbol>
<symbol id="icon-save" viewBox="0 0 24 24">
<path d="M5 4h12l2 2v14H5V4z" />
<path d="M8 4v6h8V4" />
<path d="M8 20v-6h8v6" />
</symbol>
<symbol id="icon-pause" viewBox="0 0 24 24">
<path d="M8 5v14" />
<path d="M16 5v14" />
</symbol>
<symbol id="icon-log" viewBox="0 0 24 24">
<path d="M7 4h10l3 3v13H7V4z" />
<path d="M17 4v4h4" />
<path d="M10 12h7M10 16h5" />
</symbol>
</svg>
<div class="app-shell">
<aside class="rail" aria-label="Navigation">
<div class="brand">
<div class="brand-mark">PD</div>
<div>
<strong>PatchDL</strong>
<span>PS5 Patch Control</span>
</div>
</div>
<nav class="nav-list">
<a class="nav-link is-active" href="#games">
<svg><use href="#icon-download"></use></svg>
Spiele
</a>
<a class="nav-link" href="#downloads">
<svg><use href="#icon-refresh"></use></svg>
Queue
</a>
<a class="nav-link" href="#settings">
<svg><use href="#icon-settings"></use></svg>
Settings
</a>
<a class="nav-link" href="#logs">
<svg><use href="#icon-log"></use></svg>
Logs
</a>
</nav>
</aside>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow">Standalone ELF Web UI</p>
<h1>Spielupdates kontrolliert laden</h1>
</div>
<div class="topbar-actions">
<button class="icon-button" id="refreshBtn" title="Status und Spiele neu pruefen">
<svg><use href="#icon-refresh"></use></svg>
</button>
<button class="primary-button" id="saveBtn">
<svg><use href="#icon-save"></use></svg>
Speichern
</button>
</div>
</header>
<section class="status-strip" aria-label="Systemstatus">
<article class="metric">
<span>Firmware</span>
<strong id="firmwareValue">--</strong>
<em id="firmwareBuild">Systemversion</em>
</article>
<article class="metric">
<span>DNS Guard</span>
<strong id="dnsValue">--</strong>
<em>Sony blockiert per nanoDNS</em>
</article>
<article class="metric">
<span>CDN Zugriff</span>
<strong id="resolverValue">--</strong>
<em>Nur PatchDL Resolver</em>
</article>
<article class="metric">
<span>Speicher</span>
<strong id="spaceValue">--</strong>
<em id="downloadDirValue">Download Ziel</em>
</article>
</section>
<section class="toolbar" id="games">
<div class="search-box">
<svg><use href="#icon-search"></use></svg>
<input id="searchInput" type="search" placeholder="Titel, Title ID oder Content ID suchen" />
</div>
<div class="segmented" role="tablist" aria-label="Filter">
<button class="is-selected" data-filter="all">Alle</button>
<button data-filter="enabled">Aktiv</button>
<button data-filter="available">Updates</button>
<button data-filter="blocked">Blockiert</button>
<button data-filter="queued">Queue</button>
</div>
</section>
<section class="game-grid" id="gameGrid" aria-live="polite"></section>
<section class="split-band">
<div class="panel" id="downloads">
<div class="panel-heading">
<div>
<p class="eyebrow">Downloads</p>
<h2>Aktive Queue</h2>
</div>
<button class="ghost-button" id="pauseAllBtn">
<svg><use href="#icon-pause"></use></svg>
Pause
</button>
</div>
<div class="queue-list" id="queueList"></div>
</div>
<div class="panel" id="settings">
<div class="panel-heading">
<div>
<p class="eyebrow">Policy</p>
<h2>Update Regeln</h2>
</div>
</div>
<div class="settings-grid">
<label class="field">
<span>Default Policy</span>
<select id="defaultPolicy">
<option value="deny">Deny by default</option>
<option value="allow">Allow by default</option>
</select>
</label>
<label class="field">
<span>Download Ordner</span>
<input id="downloadDir" type="text" spellcheck="false" />
</label>
<label class="switch-row">
<input id="installAfterDownload" type="checkbox" />
<span>
<strong>Nach Download installieren</strong>
<em>Globaler Default, pro Spiel ueberschreibbar</em>
</span>
</label>
<label class="switch-row">
<input id="deleteAfterInstall" type="checkbox" />
<span>
<strong>PKG nach Installation loeschen</strong>
<em>Nur wenn Installation erfolgreich war</em>
</span>
</label>
</div>
<div class="allowlist">
<span>Interne CDN Allowlist</span>
<div id="allowlistHosts"></div>
</div>
</div>
</section>
<section class="log-panel" id="logs">
<div class="panel-heading">
<div>
<p class="eyebrow">Runtime</p>
<h2>Letzte Ereignisse</h2>
</div>
</div>
<pre id="logOutput" tabindex="0"></pre>
</section>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+715
View File
@@ -0,0 +1,715 @@
:root {
color-scheme: light;
--bg: #f6f7f4;
--surface: #ffffff;
--surface-2: #eeefe9;
--ink: #1f2420;
--muted: #697069;
--border: #d7dbd2;
--green: #247857;
--green-dark: #155f42;
--green-soft: #dff3e9;
--amber: #b87516;
--amber-soft: #fff1d6;
--red: #b54343;
--red-soft: #ffe4e4;
--blue: #2d6d9d;
--blue-soft: #deeff8;
--shadow: 0 18px 50px rgba(31, 36, 32, 0.1);
--radius: 8px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon-sprite {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.rail {
position: sticky;
top: 0;
height: 100vh;
padding: 20px 16px;
background: #1e241f;
color: #f7f8f4;
border-right: 1px solid #111611;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
margin-bottom: 28px;
}
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: #2b6f50;
border-radius: 8px;
font-weight: 800;
}
.brand strong {
display: block;
font-size: 16px;
}
.brand span {
display: block;
margin-top: 2px;
color: #b8c2ba;
font-size: 12px;
}
.nav-list {
display: grid;
gap: 6px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 0 10px;
color: #cbd3cc;
text-decoration: none;
border-radius: 8px;
}
.nav-link:hover,
.nav-link.is-active {
background: rgba(255, 255, 255, 0.09);
color: #ffffff;
}
.main {
min-width: 0;
padding: 28px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--green-dark);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 30px;
line-height: 1.12;
}
h2 {
font-size: 18px;
line-height: 1.2;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.icon-button,
.primary-button,
.ghost-button,
.row-button,
.mini-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
border-radius: 8px;
font-weight: 700;
}
.icon-button {
width: 38px;
padding: 0;
}
.primary-button {
padding: 0 14px;
border-color: var(--green-dark);
background: var(--green);
color: #ffffff;
}
.ghost-button,
.row-button,
.mini-button {
padding: 0 12px;
}
.mini-button {
min-height: 32px;
font-size: 13px;
}
.row-button.is-primary {
border-color: var(--green);
color: var(--green-dark);
background: var(--green-soft);
}
.status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.metric {
min-width: 0;
padding: 16px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
box-shadow: 0 1px 0 rgba(31, 36, 32, 0.04);
}
.metric span,
.metric em {
display: block;
color: var(--muted);
font-size: 12px;
font-style: normal;
}
.metric strong {
display: block;
margin: 7px 0 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 21px;
}
.toolbar {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.search-box {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 0 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 8px;
}
.search-box input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
}
.segmented {
display: flex;
padding: 4px;
border: 1px solid var(--border);
background: var(--surface-2);
border-radius: 8px;
}
.segmented button {
min-height: 32px;
padding: 0 12px;
border: 0;
background: transparent;
color: var(--muted);
border-radius: 6px;
font-weight: 700;
}
.segmented button.is-selected {
background: var(--surface);
color: var(--ink);
box-shadow: 0 1px 2px rgba(31, 36, 32, 0.08);
}
.game-grid {
display: grid;
gap: 12px;
}
.game-card {
display: grid;
grid-template-columns: minmax(250px, 1.2fr) minmax(380px, 1.6fr) minmax(260px, 0.9fr);
gap: 16px;
align-items: center;
padding: 16px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
box-shadow: 0 1px 0 rgba(31, 36, 32, 0.04);
}
.game-title {
display: flex;
gap: 12px;
min-width: 0;
}
.cover {
display: grid;
flex: 0 0 46px;
place-items: center;
width: 46px;
height: 46px;
color: #ffffff;
background: linear-gradient(135deg, #2d6d9d, #247857);
border-radius: 8px;
font-size: 13px;
font-weight: 800;
}
.game-title h3 {
margin: 0 0 5px;
overflow-wrap: anywhere;
font-size: 16px;
letter-spacing: 0;
}
.subline {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.pill.ok {
border-color: #b7dec9;
background: var(--green-soft);
color: var(--green-dark);
}
.pill.warn {
border-color: #f0cd91;
background: var(--amber-soft);
color: #81500b;
}
.pill.blocked {
border-color: #f0b8b8;
background: var(--red-soft);
color: var(--red);
}
.version-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.version-box {
min-width: 0;
padding: 10px;
background: #f8f9f6;
border: 1px solid var(--border);
border-radius: 8px;
}
.version-box span {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.version-box strong {
display: block;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.controls {
display: grid;
gap: 10px;
}
.control-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(86px, 0.65fr);
gap: 8px;
}
.control-row select,
.control-row input {
min-width: 0;
min-height: 36px;
padding: 0 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
border-radius: 8px;
}
.actions-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.split-band {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.82fr);
gap: 12px;
margin-top: 22px;
}
.panel,
.log-panel {
padding: 16px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
}
.queue-list {
display: grid;
gap: 10px;
}
.queue-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 92px;
gap: 12px;
align-items: center;
padding: 12px;
background: #f8f9f6;
border: 1px solid var(--border);
border-radius: 8px;
}
.queue-item strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.queue-item span {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.progress {
height: 8px;
margin-top: 10px;
overflow: hidden;
background: #e3e6de;
border-radius: 999px;
}
.progress > i {
display: block;
width: 0%;
height: 100%;
background: linear-gradient(90deg, var(--green), var(--blue));
}
.settings-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.field span,
.allowlist > span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.field input,
.field select {
min-height: 38px;
padding: 0 10px;
border: 1px solid var(--border);
background: #f8f9f6;
color: var(--ink);
border-radius: 8px;
}
.switch-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
border: 1px solid var(--border);
background: #f8f9f6;
border-radius: 8px;
}
.switch-row input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--green);
}
.switch-row strong,
.switch-row em {
display: block;
}
.switch-row strong {
font-size: 14px;
}
.switch-row em {
margin-top: 2px;
color: var(--muted);
font-size: 12px;
font-style: normal;
}
.allowlist {
display: grid;
gap: 8px;
margin-top: 14px;
}
.allowlist div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.host-chip {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 8px;
background: var(--blue-soft);
color: #1e547a;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.log-panel {
margin-top: 12px;
}
pre {
min-height: 150px;
max-height: 280px;
margin: 0;
padding: 12px;
overflow: auto;
background: #1f2420;
color: #e9eee8;
border-radius: 8px;
font-size: 12px;
line-height: 1.55;
}
.empty-state {
padding: 18px;
border: 1px dashed var(--border);
background: var(--surface);
color: var(--muted);
border-radius: 8px;
text-align: center;
}
.toast {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 10;
max-width: min(420px, calc(100vw - 36px));
padding: 12px 14px;
color: #ffffff;
background: #1f2420;
border-radius: 8px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.rail {
position: static;
height: auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
margin: 0;
}
.nav-list {
display: flex;
flex-wrap: wrap;
}
.game-card {
grid-template-columns: 1fr;
}
.split-band,
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.main {
padding: 18px;
}
.rail {
align-items: flex-start;
flex-direction: column;
gap: 14px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.topbar-actions {
justify-content: flex-start;
}
h1 {
font-size: 24px;
}
.toolbar,
.split-band,
.status-strip,
.version-grid {
grid-template-columns: 1fr;
}
.segmented {
overflow-x: auto;
}
.segmented button {
flex: 0 0 auto;
}
}