mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 08:00:21 +02:00
Initial PatchDL web UI
This commit is contained in:
commit
9c2822f367
5 files changed
+1478
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user