Add web-based save editor for "Reksio i Czarodzieje"
- Implemented a local web server using http.server to serve the save editor. - Created a user interface with HTML, CSS, and JavaScript for managing game slots and editing save files. - Added API endpoints for retrieving slot information, overview, and raw data for editing. - Implemented functionality for saving changes back to the game files. - Added tests for ARR and DTA formats to ensure data integrity during round-trip serialization. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""Lokalny edytor w przeglądarce. Stdlib http.server — zero zależności.
|
||||
|
||||
ricsave --game "~/Reksio/Reksio i Czarodzieje" serve
|
||||
|
||||
Serwer celowo słucha tylko na 127.0.0.1: daje pełny zapis do katalogu gry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import webbrowser
|
||||
from functools import partial
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from ricsave import catalog, schema, slot as slotmod
|
||||
from ricsave.slot import Game, SaveError
|
||||
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
game: Game
|
||||
|
||||
def __init__(self, *args, game: Game, **kwargs):
|
||||
self.game = game
|
||||
super().__init__(*args, directory=str(STATIC), **kwargs)
|
||||
|
||||
def log_message(self, fmt, *args): # cisza w konsoli
|
||||
pass
|
||||
|
||||
# ---- API ----
|
||||
|
||||
def do_GET(self):
|
||||
url = urlparse(self.path)
|
||||
if not url.path.startswith("/api/"):
|
||||
return super().do_GET()
|
||||
query = parse_qs(url.query)
|
||||
try:
|
||||
payload = self._get(url.path, query)
|
||||
except SaveError as exc:
|
||||
return self._json({"error": str(exc)}, status=400)
|
||||
except KeyError as exc:
|
||||
return self._json({"error": f"nie znaleziono: {exc}"}, status=404)
|
||||
return self._json(payload)
|
||||
|
||||
def do_POST(self):
|
||||
url = urlparse(self.path)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
try:
|
||||
payload = self._post(url.path, body)
|
||||
except SaveError as exc:
|
||||
return self._json({"error": str(exc)}, status=400)
|
||||
except (KeyError, ValueError) as exc:
|
||||
return self._json({"error": str(exc)}, status=400)
|
||||
return self._json(payload)
|
||||
|
||||
def _slot_card(self, number: int) -> dict:
|
||||
s = self.game.slot(number)
|
||||
card = {"number": number, "live": number == catalog.LIVE_SLOT, "exists": s.exists()}
|
||||
if card["exists"]:
|
||||
try:
|
||||
card.update(s.summary())
|
||||
except SaveError as exc:
|
||||
# slot niekompletny — pokazujemy go, ale nie udajemy że da się otworzyć
|
||||
card["exists"] = False
|
||||
card["error"] = str(exc)
|
||||
return card
|
||||
|
||||
def _get(self, path: str, query: dict) -> dict:
|
||||
if path == "/api/slots":
|
||||
return {
|
||||
"root": str(self.game.root),
|
||||
"slots": [
|
||||
self._slot_card(n) for n in (catalog.LIVE_SLOT, *catalog.MENU_SLOTS)
|
||||
],
|
||||
"scenes": list(catalog.SCENES),
|
||||
"characters": list(catalog.CHARACTERS),
|
||||
"minigames": list(catalog.MINIGAMES),
|
||||
}
|
||||
|
||||
slot = self.game.slot(int(query["slot"][0]))
|
||||
|
||||
if path == "/api/overview":
|
||||
spells = slot.spells
|
||||
exp = slot.experience
|
||||
return {
|
||||
"summary": slot.summary(),
|
||||
"problems": slot.validate(),
|
||||
# Osobny blok zamiast grzebania w summary — front nie zna indeksów
|
||||
# SPELLS i nie musi liczyć klatki klepsydry sam.
|
||||
"experience": {
|
||||
"value": exp,
|
||||
"threshold": schema.EXP_PER_LEVEL,
|
||||
"frame": exp // schema.EXP_PER_HOURGLASS_FRAME + 1,
|
||||
"frames": schema.EXP_PER_LEVEL // schema.EXP_PER_HOURGLASS_FRAME + 1,
|
||||
"level": int(spells[schema.LEVEL_INDEX]),
|
||||
"blinking": bool(spells[schema.HOURGLASS_BLINKING_INDEX]),
|
||||
"known": slot.known_spells(),
|
||||
},
|
||||
"gamesets": [
|
||||
{
|
||||
"index": i,
|
||||
"name": col.name,
|
||||
"note": col.note,
|
||||
"value": slot.gamesets[i] if i < len(slot.gamesets) else None,
|
||||
}
|
||||
for i, col in enumerate(schema.GAMESETS)
|
||||
],
|
||||
"spells": [
|
||||
{"index": i, "value": v, "note": schema.describe_spell_index(i)}
|
||||
for i, v in enumerate(slot.spells)
|
||||
],
|
||||
"items": slot.items.rows(),
|
||||
}
|
||||
|
||||
if path == "/api/scene":
|
||||
view = slot.scene(query["name"][0])
|
||||
return {
|
||||
"name": view.name,
|
||||
"columns": [
|
||||
{"name": c.name, "type": c.type, "note": c.note} for c in view.columns
|
||||
],
|
||||
"rows": view.table.rows,
|
||||
"types": {
|
||||
row[2]: schema.describe_object_type(int(row[2]))
|
||||
for row in view.table.rows
|
||||
if row[2].lstrip("-").isdigit()
|
||||
},
|
||||
}
|
||||
|
||||
if path == "/api/raw":
|
||||
key = query["key"][0]
|
||||
entry = catalog.BY_KEY[key]
|
||||
value = slot.raw(key)
|
||||
return {
|
||||
"key": key,
|
||||
"kind": entry.kind,
|
||||
"label": entry.label,
|
||||
"value": value if entry.kind == "arr" else value.rows,
|
||||
}
|
||||
|
||||
if path == "/api/manifest":
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"key": e.key,
|
||||
"kind": e.kind,
|
||||
"label": e.label,
|
||||
"file": e.filename(slot.number),
|
||||
}
|
||||
for e in catalog.MANIFEST
|
||||
]
|
||||
}
|
||||
|
||||
raise KeyError(path)
|
||||
|
||||
def _post(self, path: str, body: dict) -> dict:
|
||||
if path == "/api/write":
|
||||
slot = self.game.slot(int(body["slot"]))
|
||||
key = body["key"]
|
||||
entry = catalog.BY_KEY[key]
|
||||
current = slot.raw(key)
|
||||
if entry.kind == "arr":
|
||||
slot._cache[key] = body["value"]
|
||||
else:
|
||||
rows = [[str(cell) for cell in row] for row in body["value"]]
|
||||
if len(rows) != len(current.rows):
|
||||
raise SaveError(
|
||||
f"liczba wierszy musi zostać {len(current.rows)} — "
|
||||
"skrypty scen adresują wiersze po indeksie"
|
||||
)
|
||||
current.rows = rows
|
||||
slot.touch(key)
|
||||
return {"written": slot.flush()}
|
||||
|
||||
if path == "/api/copy":
|
||||
files = slotmod.copy_slot(self.game, int(body["src"]), int(body["dst"]))
|
||||
return {"copied": len(files)}
|
||||
|
||||
if path == "/api/backup":
|
||||
return {"path": str(slotmod.backup(self.game, body["dest"]))}
|
||||
|
||||
raise KeyError(path)
|
||||
|
||||
def _json(self, payload: dict, status: int = 200) -> None:
|
||||
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
|
||||
def serve(game: Game, host: str = "127.0.0.1", port: int = 8765, open_browser: bool = True) -> None:
|
||||
handler = partial(Handler, game=game)
|
||||
server = HTTPServer((host, port), handler)
|
||||
url = f"http://{host}:{port}/"
|
||||
print(f"edytor: {url}")
|
||||
print(f"gra: {game.common}")
|
||||
print("Ctrl-C kończy.")
|
||||
if open_browser:
|
||||
webbrowser.open(url)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nkoniec")
|
||||
@@ -0,0 +1,248 @@
|
||||
"use strict";
|
||||
|
||||
const state = {
|
||||
slot: null,
|
||||
key: null, // aktualnie otwarty plik z manifestu, null = przegląd
|
||||
manifest: [],
|
||||
edits: new Map(), // "wiersz:kolumna" -> nowa wartość
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const el = (tag, props = {}, kids = []) => {
|
||||
const node = Object.assign(document.createElement(tag), props);
|
||||
for (const kid of [].concat(kids)) {
|
||||
node.append(kid instanceof Node ? kid : document.createTextNode(kid));
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
async function api(path, body) {
|
||||
const res = await fetch(path, body ? {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
} : undefined);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(message, isError = false) {
|
||||
const node = $("#toast");
|
||||
node.textContent = message;
|
||||
node.classList.toggle("err", isError);
|
||||
node.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { node.hidden = true; }, 3500);
|
||||
}
|
||||
|
||||
// ---- sloty ----------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
const data = await api("/api/slots");
|
||||
$("#root-path").textContent = data.root;
|
||||
const nav = $("#slots");
|
||||
nav.replaceChildren();
|
||||
for (const slot of data.slots) {
|
||||
const label = slot.live ? "slot 0 · żywy stan" : `slot ${slot.number}`;
|
||||
const sub = slot.exists ? (slot.location || "?") : "pusty";
|
||||
const button = el("button", { type: "button", disabled: !slot.exists }, [
|
||||
label, el("span", { className: "sub" }, sub),
|
||||
]);
|
||||
button.dataset.slot = String(slot.number);
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.onclick = () => selectSlot(slot.number);
|
||||
nav.append(button);
|
||||
}
|
||||
const first = data.slots.find((s) => s.exists);
|
||||
if (first) selectSlot(first.number);
|
||||
}
|
||||
|
||||
async function selectSlot(number) {
|
||||
state.slot = number;
|
||||
state.key = null;
|
||||
state.edits.clear();
|
||||
document.querySelectorAll("#slots button").forEach((b) => {
|
||||
b.setAttribute("aria-pressed", String(Number(b.dataset.slot) === number));
|
||||
});
|
||||
const { entries } = await api(`/api/manifest?slot=${number}`);
|
||||
state.manifest = entries;
|
||||
renderManifest();
|
||||
renderOverview();
|
||||
}
|
||||
|
||||
function renderManifest() {
|
||||
const needle = $("#filter").value.trim().toLowerCase();
|
||||
const list = $("#manifest");
|
||||
list.replaceChildren();
|
||||
|
||||
const overview = el("li", {}, [el("button", { type: "button" }, "Przegląd slotu")]);
|
||||
overview.firstChild.setAttribute("aria-current", String(state.key === null));
|
||||
overview.firstChild.onclick = () => { state.key = null; renderManifest(); renderOverview(); };
|
||||
list.append(overview);
|
||||
|
||||
for (const entry of state.manifest) {
|
||||
const haystack = `${entry.key} ${entry.label} ${entry.file}`.toLowerCase();
|
||||
if (needle && !haystack.includes(needle)) continue;
|
||||
const button = el("button", { type: "button" }, [
|
||||
entry.label, el("span", { className: "file mono" }, entry.file),
|
||||
]);
|
||||
button.setAttribute("aria-current", String(state.key === entry.key));
|
||||
button.onclick = () => openEntry(entry.key);
|
||||
list.append(el("li", {}, button));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- przegląd -------------------------------------------------------------
|
||||
|
||||
async function renderOverview() {
|
||||
const panel = $("#panel");
|
||||
panel.replaceChildren(el("p", { className: "muted" }, "Wczytuję…"));
|
||||
const data = await api(`/api/overview?slot=${state.slot}`);
|
||||
panel.replaceChildren();
|
||||
|
||||
panel.append(el("h2", {}, "Stan"));
|
||||
if (data.problems.length) {
|
||||
panel.append(el("ul", { className: "problems" },
|
||||
data.problems.map((p) => el("li", {}, p))));
|
||||
} else {
|
||||
panel.append(el("p", { className: "ok" }, "Niezmienniki OK."));
|
||||
}
|
||||
|
||||
panel.append(el("h2", {}, "Zmienne globalne — GAME.ARR"));
|
||||
const grid = el("div", { className: "grid" });
|
||||
for (const field of data.gamesets) {
|
||||
grid.append(el("div", { className: "card" }, [
|
||||
el("div", { className: "k mono" }, field.name),
|
||||
el("div", { className: "v" }, String(field.value ?? "(brak)")),
|
||||
el("div", { className: "k" }, field.note || ""),
|
||||
]));
|
||||
}
|
||||
panel.append(grid);
|
||||
|
||||
panel.append(el("h2", {}, "Ekwipunek"));
|
||||
panel.append(renderReadonlyTable(
|
||||
["GUID", "NAME", "PARENT", "BASE", "EMPTY"],
|
||||
data.items.map((r) => [r.GUID, r.NAME, r.PARENT, r.BASE, r.EMPTY]),
|
||||
));
|
||||
panel.append(el("p", { className: "muted" },
|
||||
"EMPTY = 1 oznacza slot zajęty (tak, odwrotnie niż sugeruje nazwa)."));
|
||||
|
||||
panel.append(el("h2", {}, "Czary i doświadczenie — SPELLS.ARR"));
|
||||
const exp = data.experience;
|
||||
if (exp) {
|
||||
panel.append(el("p", { className: "muted" },
|
||||
`Klepsydra: ${exp.value}/${exp.threshold} doświadczenia `
|
||||
+ `(klatka ${exp.frame}/${exp.frames}), awansów: ${exp.level}`
|
||||
+ (exp.blinking ? " — miga, można nauczyć się czaru." : ".")));
|
||||
panel.append(el("p", { className: "muted" },
|
||||
"Znane czary: " + (exp.known.length ? exp.known.join(", ") : "brak")));
|
||||
} else {
|
||||
panel.append(el("p", { className: "problems" }, staleServerNote()));
|
||||
}
|
||||
panel.append(renderReadonlyTable(
|
||||
["#", "wartość", "znaczenie"],
|
||||
data.spells.map((s) => [String(s.index), String(s.value), s.note || "—"]),
|
||||
));
|
||||
}
|
||||
|
||||
function staleServerNote() {
|
||||
// Strona statyczna odświeża się sama, ale server.py ładuje się przy starcie —
|
||||
// rozjazd wersji objawia się brakiem pól w odpowiedzi. Powiedz to wprost,
|
||||
// zamiast renderować undefined/NaN.
|
||||
return "Serwer odpowiada bez bloku doświadczenia — najpewniej działa na starszym "
|
||||
+ "kodzie niż ta strona. Zrestartuj `ricsave serve`.";
|
||||
}
|
||||
|
||||
function renderReadonlyTable(headers, rows) {
|
||||
const table = el("table", {}, [
|
||||
el("thead", {}, el("tr", {}, headers.map((h) => el("th", {}, h)))),
|
||||
el("tbody", {}, rows.map((row) => el("tr", {}, row.map((c) => el("td", {}, String(c)))))),
|
||||
]);
|
||||
return el("div", { className: "scroll" }, table);
|
||||
}
|
||||
|
||||
// ---- edycja pliku ---------------------------------------------------------
|
||||
|
||||
async function openEntry(key) {
|
||||
state.key = key;
|
||||
state.edits.clear();
|
||||
renderManifest();
|
||||
|
||||
const panel = $("#panel");
|
||||
panel.replaceChildren(el("p", { className: "muted" }, "Wczytuję…"));
|
||||
const data = await api(`/api/raw?slot=${state.slot}&key=${encodeURIComponent(key)}`);
|
||||
panel.replaceChildren();
|
||||
panel.append(el("h2", {}, `${data.label} — ${key}`));
|
||||
|
||||
const isDta = data.kind === "dta";
|
||||
const columns = isDta
|
||||
? (key === "items"
|
||||
? ["GUID", "NAME", "PARENT", "BASE", "EMPTY"]
|
||||
: ["NAME", "IDNAME", "TYPE", "SPARAM0", "SPARAM1", "SPARAM2", "IPARAM0", "IPARAM1", "IPARAM2"])
|
||||
: ["wartość"];
|
||||
const rows = isDta ? data.value : data.value.map((v) => [v]);
|
||||
|
||||
const save = el("button", { type: "button", disabled: true }, "Zapisz na dysk");
|
||||
const revert = el("button", { type: "button", className: "ghost", disabled: true }, "Cofnij zmiany");
|
||||
const counter = el("span", { className: "muted" }, `${rows.length} wierszy`);
|
||||
panel.append(el("div", { className: "actions" }, [save, revert, counter]));
|
||||
|
||||
const body = el("tbody", {});
|
||||
rows.forEach((row, r) => {
|
||||
const cells = [el("td", { className: "idx mono" }, String(r))];
|
||||
row.forEach((value, c) => {
|
||||
const input = el("input", { value: String(value), className: "mono" });
|
||||
input.size = Math.max(4, Math.min(40, String(value).length + 2));
|
||||
input.oninput = () => {
|
||||
const id = `${r}:${c}`;
|
||||
if (input.value === String(value)) state.edits.delete(id);
|
||||
else state.edits.set(id, input.value);
|
||||
input.classList.toggle("changed", state.edits.has(id));
|
||||
save.disabled = revert.disabled = state.edits.size === 0;
|
||||
counter.textContent = state.edits.size
|
||||
? `${rows.length} wierszy · ${state.edits.size} zmian`
|
||||
: `${rows.length} wierszy`;
|
||||
};
|
||||
cells.push(el("td", {}, input));
|
||||
});
|
||||
body.append(el("tr", {}, cells));
|
||||
});
|
||||
|
||||
panel.append(el("div", { className: "scroll" }, el("table", {}, [
|
||||
el("thead", {}, el("tr", {}, [el("th", {}, "#"), ...columns.map((h) => el("th", {}, h))])),
|
||||
body,
|
||||
])));
|
||||
|
||||
if (isDta && key.startsWith("scene:")) {
|
||||
panel.append(el("p", { className: "muted" },
|
||||
"Nie dodawaj ani nie usuwaj wierszy — skrypty scen adresują je po indeksie. "
|
||||
+ "Dla obiektów z TYPE = 2 kolumna IPARAM0 to flaga widoczności (1 = jest, 0 = zabrany)."));
|
||||
}
|
||||
|
||||
revert.onclick = () => openEntry(key);
|
||||
save.onclick = async () => {
|
||||
save.disabled = true;
|
||||
const next = rows.map((row, r) => row.map((value, c) => {
|
||||
const edited = state.edits.get(`${r}:${c}`);
|
||||
if (edited === undefined) return value;
|
||||
return (!isDta && typeof value === "number") ? Number(edited) : edited;
|
||||
}));
|
||||
try {
|
||||
const res = await api("/api/write", {
|
||||
slot: state.slot,
|
||||
key,
|
||||
value: isDta ? next : next.map((row) => row[0]),
|
||||
});
|
||||
toast(`zapisano: ${res.written.join(", ")}`);
|
||||
openEntry(key);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
save.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$("#filter").addEventListener("input", renderManifest);
|
||||
boot().catch((err) => toast(err.message, true));
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Reksio i Czarodzieje — edytor zapisów</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Reksio i Czarodzieje <span class="muted">— edytor zapisów</span></h1>
|
||||
<div id="root-path" class="muted mono"></div>
|
||||
</header>
|
||||
|
||||
<nav id="slots" aria-label="Sloty"></nav>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<input id="filter" type="search" placeholder="filtruj pliki slotu…" autocomplete="off">
|
||||
<ul id="manifest"></ul>
|
||||
</aside>
|
||||
|
||||
<section id="panel">
|
||||
<p class="muted">Wybierz slot.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,161 @@
|
||||
:root {
|
||||
--bg: #16130f;
|
||||
--panel: #201b16;
|
||||
--line: #3a3129;
|
||||
--ink: #ece4d8;
|
||||
--muted: #a2957f;
|
||||
--accent: #d8a13a;
|
||||
--warn: #d4674a;
|
||||
--ok: #7fa650;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.muted { color: var(--muted); font-weight: 400; }
|
||||
|
||||
header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 17px; font-weight: 600; }
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav button {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
nav button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
|
||||
nav button .sub { display: block; font-size: 12px; color: var(--muted); }
|
||||
nav button[disabled] { opacity: .45; cursor: default; }
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 280px) 1fr;
|
||||
gap: 0;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
|
||||
|
||||
aside {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
aside input {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
aside ul { list-style: none; margin: 0; padding: 0; }
|
||||
aside li button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--ink);
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
aside li button:hover { background: var(--panel); }
|
||||
aside li button[aria-current="true"] { background: var(--panel); color: var(--accent); }
|
||||
aside li .file { display: block; font-size: 11px; color: var(--muted); }
|
||||
|
||||
section#panel { padding: 18px 20px 60px; min-width: 0; }
|
||||
h2 { font-size: 15px; margin: 22px 0 10px; }
|
||||
h2:first-child { margin-top: 0; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.card .k { font-size: 12px; color: var(--muted); }
|
||||
.card .v { font-size: 15px; margin-top: 2px; word-break: break-word; }
|
||||
|
||||
.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { padding: 5px 9px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
th { background: var(--panel); position: sticky; top: 0; font-weight: 600; }
|
||||
tbody tr:hover { background: #1c1814; }
|
||||
td input {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
/* szerokość sterowana atrybutem size — inaczej długie ścieżki w NAME są ucinane */
|
||||
min-width: 4ch;
|
||||
}
|
||||
td input:hover { border-color: var(--line); }
|
||||
td input:focus { border-color: var(--accent); outline: none; background: var(--bg); }
|
||||
td input.changed { color: var(--accent); border-color: var(--accent); }
|
||||
td.idx { color: var(--muted); }
|
||||
|
||||
.actions { display: flex; gap: 8px; margin: 14px 0; flex-wrap: wrap; align-items: center; }
|
||||
.actions button {
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
color: #241c0c;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions button.ghost { background: none; border: 1px solid var(--line); color: var(--ink); font-weight: 400; }
|
||||
.actions button[disabled] { opacity: .4; cursor: default; }
|
||||
|
||||
.problems { border-left: 3px solid var(--warn); padding: 8px 12px; background: var(--panel); border-radius: 0 6px 6px 0; }
|
||||
.problems li { margin: 2px 0; }
|
||||
.ok { color: var(--ok); }
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 10px 18px;
|
||||
box-shadow: 0 6px 24px #0008;
|
||||
}
|
||||
#toast.err { border-color: var(--warn); }
|
||||
Reference in New Issue
Block a user