Add web frontend and Docker packaging

Frontend is a single page served from classpath resources with a JSON API
alongside it. Kept separate from the MCP tools because the consumers differ:
a model reads formatted text, a browser needs structures. Only the database
is shared.

The page has three views - catalog with edition details, copies, detected
facts with evidence and a filterable file list; script search with FTS5
snippets and a viewer; and a format breakdown. No build step, no CDN, no
dependencies; light and dark follow the system.

New `serve` command puts the frontend and MCP on one port, one process and
one database connection, which is also what the container runs. `mcp` alone
still works for a headless setup.

Docker is a two-stage build: JDK plus the Rex-EMoolator submodule to compile,
JRE for runtime. installDist rather than build, so check - and with it
verifyCoreVersion - is skipped and git is not needed in the image. The pinned
:core tag is extracted from gradle.properties at build time and passed in by
the entrypoint, otherwise copy.core_version would record "nieznana" and
derived-artifact invalidation would stop working. Properties go through
JAVA_OPTS because Gradle's launcher treats everything after the script name
as application arguments.

Compose mounts the collection read-only and keeps the database and script
cache in a named volume. The port is bound to loopback.

Not verified: the image itself does not build here, the Docker daemon is not
running on this machine. The launcher, JAVA_OPTS handling and resource
packaging were tested against the installDist output directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-08-20 22:34:51 +02:00
co-authored by Claude Opus 5
parent 393bcde6a8
commit 02988297ec
8 changed files with 889 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
# submoduł vendor/ MUSI zostać — composite build kompiluje z niego :core
.git
.gitmodules
.gradle
.idea
build
out
data
collection
vendor/Rex-EMoolator/.git
vendor/Rex-EMoolator/build
vendor/Rex-EMoolator/.gradle
+38
View File
@@ -0,0 +1,38 @@
# Build wymaga submodułu vendor/Rex-EMoolator — composite build kompiluje :core
# ze źródeł. Jeśli katalog jest pusty, settings.gradle przerwie z komunikatem.
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
# najpierw sam opis buildu: zmiana w kodzie nie unieważnia pobranych zależności
COPY gradlew settings.gradle build.gradle gradle.properties ./
COPY gradle ./gradle
COPY vendor ./vendor
RUN ./gradlew --no-daemon dependencies --configuration runtimeClasspath > /dev/null 2>&1 || true
COPY src ./src
# installDist zamiast build: pomija check, a więc i verifyCoreVersion, które
# potrzebowałoby gita w obrazie. Wersję i tak przypina submoduł.
RUN ./gradlew --no-daemon installDist \
&& grep '^coreVersion=' gradle.properties | cut -d= -f2 > /src/core-version
FROM eclipse-temurin:21-jre
WORKDIR /app
# obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen
VOLUME ["/data"]
ENV CATALOG_DATA=/data \
JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8"
COPY --from=build /src/build/install/rex-catalog /app
COPY --from=build /src/core-version /app/core-version
COPY docker-entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh \
&& useradd --system --uid 10001 --create-home katalog \
&& mkdir -p /data /media \
&& chown katalog:katalog /data
USER katalog
EXPOSE 8765
ENTRYPOINT ["/app/entrypoint.sh"]
# domyślnie wstajemy jako serwer; front i MCP dzielą port
CMD ["serve", "--host", "0.0.0.0"]
+17
View File
@@ -0,0 +1,17 @@
services:
katalog:
build: .
image: rex-catalog:latest
ports:
# front pod /, MCP pod /mcp — jeden port, jeden proces
- "127.0.0.1:8765:8765"
volumes:
# kolekcja tylko do odczytu: katalog niczego w niej nie zmienia,
# a obrazy płyt są nieodtwarzalne
- ${COLLECTION:-./collection}:/media:ro
# baza i cache odszyfrowanych skryptów — jedyny stan wart trzymania
- catalog-data:/data
restart: unless-stopped
volumes:
catalog-data:
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Launcher generowany przez Gradle traktuje wszystko po nazwie skryptu jako
# argumenty aplikacji, więc właściwości JVM muszą iść przez JAVA_OPTS.
set -e
# wersja :core jest przypięta submodułem w czasie budowania obrazu; bez niej
# copy.core_version zapisałoby "nieznana" i przestałoby działać unieważnianie
# cache'u pochodnych po bumpie emulatora
CORE_VERSION="${CORE_VERSION:-$(cat /app/core-version 2>/dev/null || echo nieznana)}"
JAVA_OPTS="-Dfile.encoding=UTF-8 -Dcatalog.data=${CATALOG_DATA:-/data} -Dcatalog.coreVersion=${CORE_VERSION} ${JAVA_OPTS}"
export JAVA_OPTS
exec /app/bin/rex-catalog "$@"
@@ -6,10 +6,14 @@ import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.ingest.Ingestor;
import pl.genschu.rexcatalog.ingest.MetadataDetector;
import pl.genschu.rexcatalog.mcp.McpServer;
import pl.genschu.rexcatalog.web.WebServer;
import pl.genschu.rexcatalog.script.ScriptDecoder;
import pl.genschu.rexcatalog.script.ScriptSearch;
import com.sun.net.httpserver.HttpServer;
import java.io.File;
import java.net.InetSocketAddress;
import java.nio.file.Path;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -17,6 +21,7 @@ import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Executors;
/**
* CLI katalogu. Krok 1: indeksowanie obrazów do SQLite.
@@ -46,6 +51,7 @@ public final class Main {
case "set" -> set(dataDir, args);
case "lang" -> lang(dataDir, args);
case "mcp" -> mcp(dataDir, args);
case "serve" -> serve(dataDir, args);
case "find" -> find(dataDir, args);
case "cat" -> cat(dataDir, args);
default -> usage();
@@ -314,6 +320,28 @@ public final class Main {
}
}
/** Front i MCP na jednym porcie — jeden proces, jeden kontener, jedno połączenie z bazą. */
private static void serve(Path dataDir, String[] args) throws Exception {
String host = option(args, "--host", "127.0.0.1");
int port = Integer.parseInt(option(args, "--port", "8765"));
try (Database db = Database.open(dataDir)) {
McpServer mcp = new McpServer(db, dataDir);
HttpServer http = HttpServer.create(new InetSocketAddress(host, port), 0);
mcp.register(http);
new WebServer(db, dataDir, mcp.lock()).register(http);
http.setExecutor(Executors.newFixedThreadPool(4));
http.start();
System.out.printf("Front: http://%s:%d/%n", host, port);
System.out.printf("MCP: http://%s:%d/mcp%n", host, port);
System.out.println("Zatrzymanie: Ctrl+C");
Runtime.getRuntime().addShutdownHook(new Thread(() -> http.stop(0)));
Thread.currentThread().join();
}
}
private static String option(String[] args, String name, String fallback) {
for (int i = 1; i < args.length - 1; i++) {
if (args[i].equals(name)) {
@@ -483,6 +511,7 @@ public final class Main {
cat <ścieżka|sha1> wypisz odszyfrowany skrypt
mcp [--host H] [--port P] serwer MCP (Streamable HTTP, domyślnie 127.0.0.1:8765)
serve [--host H] [--port P] front i MCP na jednym porcie
Baza: -Dcatalog.data=<katalog> (domyślnie ./data)
""");
@@ -52,12 +52,25 @@ public final class McpServer {
public void start(String host, int port) throws IOException {
http = HttpServer.create(new InetSocketAddress(host, port), 0);
http.createContext("/mcp", this::handle);
// pula wątków, ale dostęp do bazy i tak serializujemy — patrz dispatch()
register(http);
// pula wątków, ale dostęp do bazy i tak serializujemy — patrz toolsCall()
http.setExecutor(Executors.newFixedThreadPool(4));
http.start();
}
/** Podpina endpoint MCP do cudzego serwera — front i MCP dzielą jeden port. */
public void register(HttpServer server) {
server.createContext("/mcp", this::handle);
}
/**
* Blokada serializująca dostęp do bazy. Front musi używać tej samej, bo obie
* warstwy siedzą na jednym połączeniu sqlite-jdbc.
*/
public Object lock() {
return lock;
}
public void stop() {
if (http != null) {
http.stop(0);
@@ -0,0 +1,420 @@
package pl.genschu.rexcatalog.web;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.script.ScriptSearch;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Przeglądarkowy front katalogu: statyczna strona z zasobów oraz API JSON.
*
* <p>Osobno od narzędzi MCP, bo odbiorca jest inny — model czyta tekst, przeglądarka
* potrzebuje struktur. Wspólna jest baza, nie warstwa prezentacji.
*/
public final class WebServer {
private final Database db;
private final ScriptSearch search;
private final Object lock;
public WebServer(Database db, Path dataDir, Object lock) {
this.db = db;
this.search = new ScriptSearch(db, dataDir);
this.lock = lock;
}
public void register(HttpServer http) {
http.createContext("/api/", this::handleApi);
http.createContext("/", this::handleStatic);
}
// ---------- statyczne ----------
private void handleStatic(HttpExchange exchange) throws IOException {
try {
String path = exchange.getRequestURI().getPath();
if (path.equals("/") || path.isEmpty()) {
path = "/index.html";
}
// zasoby są wbudowane w jar, ale ścieżka i tak przychodzi z sieci
if (path.contains("..")) {
respond(exchange, 400, "text/plain", "Nieprawidłowa ścieżka".getBytes(UTF8));
return;
}
try (InputStream in = WebServer.class.getResourceAsStream("/web" + path)) {
if (in == null) {
respond(exchange, 404, "text/plain", "Nie ma takiej strony".getBytes(UTF8));
return;
}
respond(exchange, 200, contentType(path), in.readAllBytes());
}
} finally {
exchange.close();
}
}
private static String contentType(String path) {
if (path.endsWith(".html")) {
return "text/html";
}
if (path.endsWith(".css")) {
return "text/css";
}
if (path.endsWith(".js")) {
return "application/javascript";
}
return "application/octet-stream";
}
// ---------- API ----------
private void handleApi(HttpExchange exchange) throws IOException {
try {
String path = exchange.getRequestURI().getPath().substring("/api".length());
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
Object payload;
synchronized (lock) {
payload = route(path, query);
}
if (payload == null) {
respond(exchange, 404, "application/json",
"{\"error\":\"nieznany zasób\"}".getBytes(UTF8));
return;
}
respond(exchange, 200, "application/json", payload.toString().getBytes(UTF8));
} catch (Exception e) {
JsonObject error = new JsonObject();
error.addProperty("error", e.getMessage() == null ? e.toString() : e.getMessage());
respond(exchange, 500, "application/json", error.toString().getBytes(UTF8));
} finally {
exchange.close();
}
}
private Object route(String path, Map<String, String> query) throws Exception {
if (path.equals("/stats")) {
return stats();
}
if (path.equals("/titles")) {
return titles();
}
if (path.equals("/search")) {
return searchScripts(query.getOrDefault("q", ""),
parseInt(query.get("limit"), 30));
}
if (path.startsWith("/editions/")) {
String rest = path.substring("/editions/".length());
if (rest.endsWith("/files")) {
int id = parseInt(rest.substring(0, rest.length() - "/files".length()), -1);
return files(id, query.get("format"), query.get("q"),
parseInt(query.get("limit"), 300));
}
return edition(parseInt(rest, -1));
}
if (path.startsWith("/scripts/")) {
return script(path.substring("/scripts/".length()));
}
return null;
}
private JsonObject stats() throws Exception {
JsonObject out = new JsonObject();
out.addProperty("copies", scalar("SELECT COUNT(*) FROM copy"));
out.addProperty("titles", scalar("SELECT COUNT(*) FROM title"));
out.addProperty("editions", scalar("SELECT COUNT(*) FROM edition"));
out.addProperty("files", scalar("SELECT COUNT(*) FROM file"));
out.addProperty("blobs", scalar("SELECT COUNT(*) FROM blob"));
out.addProperty("scripts", scalar("SELECT COUNT(*) FROM script_fts"));
out.addProperty("bytes", scalar("SELECT COALESCE(SUM(size), 0) FROM blob"));
JsonArray formats = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT format, COUNT(*) n, SUM(size) bytes FROM blob "
+ "GROUP BY format ORDER BY n DESC LIMIT 20");
ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("format", rs.getString("format"));
row.addProperty("n", rs.getInt("n"));
row.addProperty("bytes", rs.getLong("bytes"));
formats.add(row);
}
}
out.add("formats", formats);
return out;
}
private JsonArray titles() throws Exception {
JsonArray out = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT e.id AS edition_id, t.name AS title, t.series, e.label,
COALESCE(e.release_date_override, (
SELECT d.value FROM game_root g
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
WHERE g.edition_id = e.id AND d.field = 'release_date' LIMIT 1)) AS release_date,
COALESCE(e.engine_override, (
SELECT g.engine_detected FROM game_root g
WHERE g.edition_id = e.id LIMIT 1)) AS engine,
(SELECT GROUP_CONCAT(lang, ' ') FROM edition_language
WHERE edition_id = e.id) AS langs,
(SELECT COUNT(*) FROM game_root WHERE edition_id = e.id) AS copies
FROM edition e LEFT JOIN title t ON t.id = e.title_id
ORDER BY t.series IS NULL, t.series, t.name, release_date
""");
ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("edition_id", rs.getInt("edition_id"));
row.addProperty("title", rs.getString("title"));
row.addProperty("series", rs.getString("series"));
row.addProperty("label", rs.getString("label"));
row.addProperty("release_date", rs.getString("release_date"));
row.addProperty("engine", rs.getString("engine"));
row.addProperty("langs", rs.getString("langs"));
row.addProperty("copies", rs.getInt("copies"));
out.add(row);
}
}
return out;
}
private JsonObject edition(int id) throws Exception {
JsonObject out = new JsonObject();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT e.id, t.name AS title, t.series, t.publisher, e.label, e.fingerprint,
e.dll_sha1, e.app_def_sha1, e.distributor, e.notes,
e.engine_override, e.engine_version_override,
e.compiler_override, e.release_date_override
FROM edition e LEFT JOIN title t ON t.id = e.title_id WHERE e.id = ?
""")) {
st.setInt(1, id);
try (ResultSet rs = st.executeQuery()) {
if (!rs.next()) {
return null;
}
for (String column : List.of("title", "series", "publisher", "label",
"fingerprint", "dll_sha1", "app_def_sha1", "distributor", "notes",
"engine_override", "engine_version_override", "compiler_override",
"release_date_override")) {
out.addProperty(column, rs.getString(column));
}
out.addProperty("edition_id", rs.getInt("id"));
}
}
JsonArray languages = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT lang, role FROM edition_language WHERE edition_id = ? ORDER BY lang, role")) {
st.setInt(1, id);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("lang", rs.getString("lang"));
row.addProperty("role", rs.getString("role"));
languages.add(row);
}
}
}
out.add("languages", languages);
JsonArray copies = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT c.id, c.display_name, c.path, c.container, c.fs_type, c.status,
c.status_note, c.file_count, c.size, c.sha256, c.source_kind,
c.source_url, c.source_ref, c.rip_tool, c.acquired_at, c.verified,
c.notes, g.root_path
FROM game_root g JOIN copy c ON c.id = g.copy_id
WHERE g.edition_id = ? ORDER BY c.display_name
""")) {
st.setInt(1, id);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
for (String column : List.of("display_name", "path", "container", "fs_type",
"status", "status_note", "sha256", "source_kind", "source_url",
"source_ref", "rip_tool", "acquired_at", "notes", "root_path")) {
row.addProperty(column, rs.getString(column));
}
row.addProperty("copy_id", rs.getInt("id"));
row.addProperty("file_count", rs.getInt("file_count"));
row.addProperty("size", rs.getLong("size"));
row.addProperty("verified", rs.getInt("verified") == 1);
copies.add(row);
}
}
}
out.add("copies", copies);
JsonArray facts = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT DISTINCT d.field, d.value, d.confidence, d.evidence, d.detector
FROM game_root g
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
WHERE g.edition_id = ? ORDER BY d.detector, d.field
""")) {
st.setInt(1, id);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("field", rs.getString("field"));
row.addProperty("value", rs.getString("value"));
row.addProperty("confidence", rs.getDouble("confidence"));
row.addProperty("evidence", rs.getString("evidence"));
row.addProperty("detector", rs.getString("detector"));
facts.add(row);
}
}
}
out.add("facts", facts);
return out;
}
private JsonArray files(int editionId, String format, String pathContains, int limit)
throws Exception {
StringBuilder sql = new StringBuilder("""
SELECT DISTINCT COALESCE(f.path_raw, f.path) AS path, b.format, b.size,
b.encrypted, f.blob_sha1,
(SELECT 1 FROM artifact a WHERE a.blob_sha1 = f.blob_sha1
AND a.kind = 'script') AS decoded
FROM game_root g
JOIN file f ON f.copy_id = g.copy_id
JOIN blob b ON b.sha1 = f.blob_sha1
WHERE g.edition_id = ?
""");
List<Object> params = new ArrayList<>();
params.add(editionId);
if (format != null && !format.isBlank()) {
sql.append(" AND b.format = ?\n");
params.add(format.toUpperCase(Locale.ROOT));
}
if (pathContains != null && !pathContains.isBlank()) {
sql.append(" AND f.path LIKE ?\n");
params.add("%" + pathContains.toLowerCase(Locale.ROOT) + "%");
}
sql.append("ORDER BY path LIMIT ?");
params.add(Math.min(limit, 2000));
JsonArray out = new JsonArray();
try (PreparedStatement st = db.connection().prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
st.setObject(i + 1, params.get(i));
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
JsonObject row = new JsonObject();
row.addProperty("path", rs.getString("path"));
row.addProperty("format", rs.getString("format"));
row.addProperty("size", rs.getLong("size"));
row.addProperty("encrypted", rs.getInt("encrypted") == 1);
row.addProperty("sha1", rs.getString("blob_sha1"));
row.addProperty("decoded", rs.getInt("decoded") == 1);
out.add(row);
}
}
}
return out;
}
private JsonObject searchScripts(String query, int limit) throws Exception {
JsonObject out = new JsonObject();
if (query.isBlank()) {
out.addProperty("query", "");
out.add("hits", new JsonArray());
return out;
}
ScriptSearch.Result result = search.find(query, Math.min(limit, 200));
out.addProperty("query", result.effectiveQuery());
out.addProperty("quoted", result.quoted());
JsonArray hits = new JsonArray();
for (ScriptSearch.Hit hit : result.hits()) {
JsonObject row = new JsonObject();
row.addProperty("sha1", hit.sha1());
row.addProperty("snippet", hit.snippet());
row.add("paths", toArray(hit.paths()));
row.add("games", toArray(hit.games()));
hits.add(row);
}
out.add("hits", hits);
return out;
}
private JsonObject script(String sha1) throws Exception {
String body = search.body(sha1);
if (body == null) {
return null;
}
JsonObject out = new JsonObject();
out.addProperty("sha1", sha1);
out.addProperty("body", body);
out.add("paths", toArray(search.pathsFor(sha1)));
out.add("games", toArray(search.gamesFor(sha1)));
return out;
}
// ---------- pomocnicze ----------
private static final java.nio.charset.Charset UTF8 = StandardCharsets.UTF_8;
private static JsonArray toArray(List<String> values) {
JsonArray array = new JsonArray();
values.forEach(array::add);
return array;
}
private long scalar(String sql) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(sql);
ResultSet rs = st.executeQuery()) {
return rs.next() ? rs.getLong(1) : 0;
}
}
private static int parseInt(String value, int fallback) {
try {
return value == null ? fallback : Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
private static Map<String, String> parseQuery(String raw) {
Map<String, String> out = new LinkedHashMap<>();
if (raw == null || raw.isBlank()) {
return out;
}
for (String pair : raw.split("&")) {
int eq = pair.indexOf('=');
if (eq < 0) {
continue;
}
out.put(URLDecoder.decode(pair.substring(0, eq), UTF8),
URLDecoder.decode(pair.substring(eq + 1), UTF8));
}
return out;
}
private static void respond(HttpExchange exchange, int status, String contentType, byte[] body)
throws IOException {
exchange.getResponseHeaders().set("Content-Type", contentType + "; charset=utf-8");
exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length);
if (body.length > 0) {
exchange.getResponseBody().write(body);
}
}
}
+344
View File
@@ -0,0 +1,344 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rex-catalog</title>
<style>
:root {
--bg: #f6f5f2; --panel: #fff; --line: #ddd8cf; --ink: #2b2822; --dim: #7a7368;
--accent: #8a5a2b; --hl: #f2e6c9; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1815; --panel: #232019; --line: #3a352c; --ink: #e8e2d6; --dim: #948b7c;
--accent: #d3a05e; --hl: #3d3323;
}
}
* { 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;
}
header {
padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel);
display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap;
}
h1 { margin: 0; font-size: 17px; letter-spacing: .02em; }
h1 span { color: var(--accent); }
#stats { color: var(--dim); font-size: 12.5px; }
nav { margin-left: auto; display: flex; gap: 4px; }
nav button {
border: 1px solid var(--line); background: transparent; color: var(--ink);
padding: 5px 12px; border-radius: 5px; cursor: pointer; font-size: 13px;
}
nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
main { padding: 18px 20px; max-width: 1500px; }
.cols { display: grid; grid-template-columns: minmax(320px, 460px) 1fr; gap: 18px; align-items: start; }
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
padding: 14px 16px; margin-bottom: 14px;
}
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th {
text-align: left; font-weight: 600; color: var(--dim); font-size: 11.5px;
text-transform: uppercase; letter-spacing: .04em;
padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap;
}
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: var(--hl); }
tbody tr.on { background: var(--hl); box-shadow: inset 3px 0 0 var(--accent); }
.series { color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; }
.label { color: var(--dim); font-size: 12.5px; }
.mono { font-family: var(--mono); font-size: 12px; }
.dim { color: var(--dim); }
input[type=search], input[type=text], select {
width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); color: var(--ink); font-size: 13px; font-family: inherit;
}
.row { display: flex; gap: 8px; margin-bottom: 12px; }
.row > * { flex: 1; }
.row > .narrow { flex: 0 0 130px; }
dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; margin: 0; font-size: 13px; }
dt { color: var(--dim); white-space: nowrap; }
dd { margin: 0; overflow-wrap: anywhere; }
pre {
font-family: var(--mono); font-size: 12px; line-height: 1.45; margin: 0;
white-space: pre-wrap; overflow-wrap: anywhere; max-height: 62vh; overflow-y: auto;
}
mark { background: var(--accent); color: #fff; border-radius: 2px; padding: 0 1px; }
.snip { font-family: var(--mono); font-size: 11.5px; color: var(--dim); margin-top: 3px; }
.pill {
display: inline-block; padding: 1px 7px; border: 1px solid var(--line);
border-radius: 10px; font-size: 11px; color: var(--dim); margin-right: 4px;
}
.bar { height: 6px; background: var(--hl); border-radius: 3px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: var(--accent); }
.empty { color: var(--dim); padding: 20px 0; text-align: center; }
h2 { font-size: 14px; margin: 0 0 10px; }
h3 { font-size: 12px; margin: 16px 0 6px; color: var(--dim);
text-transform: uppercase; letter-spacing: .05em; }
a { color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>rex<span>·</span>catalog</h1>
<div id="stats">wczytuję…</div>
<nav>
<button data-view="katalog" class="on">Katalog</button>
<button data-view="skrypty">Skrypty</button>
<button data-view="formaty">Formaty</button>
</nav>
</header>
<main>
<section id="katalog" class="cols">
<div>
<div class="card">
<input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off">
</div>
<div class="card"><table>
<thead><tr><th>Tytuł</th><th>Silnik</th><th>Data</th><th>Kopie</th></tr></thead>
<tbody id="tytuly"></tbody>
</table></div>
</div>
<div id="szczegoly"><div class="card empty">Wybierz tytuł z listy.</div></div>
</section>
<section id="skrypty" hidden class="cols">
<div>
<div class="card">
<input type="search" id="szukaj" placeholder="Szukaj w skryptach, np. CANVAS_OBSERVER"
autocomplete="off">
<div class="dim" style="margin-top:8px;font-size:12px">
Składnia FTS5. Identyfikatory z podkreśleniem są jednym tokenem;
użyj <span class="mono">*</span> do przedrostka.
</div>
</div>
<div class="card" id="wyniki"><div class="empty">Wpisz zapytanie.</div></div>
</div>
<div id="podglad"><div class="card empty">Wybierz trafienie, żeby zobaczyć skrypt.</div></div>
</section>
<section id="formaty" hidden>
<div class="card" style="max-width:620px">
<h2>Rozkład formatów</h2>
<table><thead><tr><th>Format</th><th>Plików</th><th>Rozmiar</th><th></th></tr></thead>
<tbody id="tabformaty"></tbody></table>
</div>
</section>
</main>
<script>
const $ = s => document.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"]/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const mb = b => b >= 1048576 ? (b/1048576).toFixed(1)+' MB'
: b >= 1024 ? (b/1024).toFixed(1)+' kB' : b+' B';
const get = async u => { const r = await fetch(u); if (!r.ok) throw new Error(await r.text());
return r.json(); };
let tytuly = [];
// ---- nawigacja ----
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
['katalog','skrypty','formaty'].forEach(v => $('#'+v).hidden = v !== b.dataset.view);
});
// ---- katalog ----
function rysujTytuly(filtr = '') {
const f = filtr.toLowerCase();
const widoczne = tytuly.filter(t =>
!f || (t.title||'').toLowerCase().includes(f) || (t.label||'').toLowerCase().includes(f)
|| (t.engine||'').toLowerCase().includes(f) || (t.series||'').toLowerCase().includes(f));
if (!widoczne.length) { $('#tytuly').innerHTML =
'<tr><td colspan="4" class="empty">Nic nie pasuje.</td></tr>'; return; }
$('#tytuly').innerHTML = widoczne.map(t => `
<tr data-id="${t.edition_id}">
<td>${t.series ? `<div class="series">${esc(t.series)}</div>` : ''}
${esc(t.title)}
${t.label ? `<div class="label">${esc(t.label)}</div>` : ''}</td>
<td>${esc(t.engine || '—')}</td>
<td class="mono">${esc(t.release_date || '—')}</td>
<td>${t.copies}</td>
</tr>`).join('');
$('#tytuly').querySelectorAll('tr[data-id]').forEach(tr => tr.onclick = () => {
$('#tytuly').querySelectorAll('tr').forEach(x => x.classList.remove('on'));
tr.classList.add('on');
pokazWydanie(tr.dataset.id);
});
}
async function pokazWydanie(id) {
$('#szczegoly').innerHTML = '<div class="card empty">Wczytuję…</div>';
const e = await get('/api/editions/' + id);
const pliki = await get(`/api/editions/${id}/files?limit=400`);
const jezyki = e.languages.length
? e.languages.map(l => `<span class="pill">${esc(l.lang)} · ${esc(l.role)}</span>`).join('')
: '<span class="dim">nie uzupełniono</span>';
const pole = (k, v) => v ? `<dt>${k}</dt><dd>${esc(v)}</dd>` : '';
$('#szczegoly').innerHTML = `
<div class="card">
<h2>${esc(e.title)}${e.label ? ` <span class="label">${esc(e.label)}</span>` : ''}</h2>
<dl>
${pole('Seria', e.series)}${pole('Wydawca', e.publisher)}
${pole('Dystrybutor', e.distributor)}${pole('Notatki', e.notes)}
${pole('Override silnika', e.engine_override)}
${pole('Override wersji', e.engine_version_override)}
${pole('Override kompilera', e.compiler_override)}
${pole('Override daty', e.release_date_override)}
<dt>Języki</dt><dd>${jezyki}</dd>
</dl>
</div>
<div class="card">
<h3>Kopie na dysku</h3>
${e.copies.map(c => `
<dl style="margin-bottom:12px">
<dt>Plik</dt><dd>${esc(c.display_name)}
${c.root_path ? `<span class="dim">(korzeń ${esc(c.root_path)}/)</span>` : ''}</dd>
<dt>Ścieżka</dt><dd class="mono">${esc(c.path)}</dd>
<dt>Nośnik</dt><dd>${esc(c.container)} / ${esc(c.fs_type || '—')},
${c.file_count} plików, ${mb(c.size)}, status ${esc(c.status)}</dd>
<dt>Źródło</dt><dd>${esc(c.source_kind)}${c.rip_tool ? ` · ${esc(c.rip_tool)}` : ''}
${c.verified ? '<span class="pill">zweryfikowane</span>' : ''}
${c.source_url ? `<br><a href="${esc(c.source_url)}">${esc(c.source_url)}</a>` : ''}</dd>
<dt>SHA-256</dt><dd class="mono">${esc(c.sha256 || '—')}</dd>
</dl>`).join('')}
</div>
<div class="card">
<h3>Wykryte fakty</h3>
<table><thead><tr><th>Pole</th><th>Wartość</th><th>Pewność</th></tr></thead><tbody>
${e.facts.map(f => `<tr style="cursor:default">
<td>${esc(f.field)}</td>
<td>${esc(f.value)}<div class="snip">${esc(f.evidence)}</div></td>
<td class="mono">${f.confidence.toFixed(2)}</td></tr>`).join('')}
</tbody></table>
</div>
<div class="card">
<h3>Pliki (${pliki.length}${pliki.length >= 400 ? ', obcięte' : ''})</h3>
<div class="row">
<input type="search" id="filtrpliki" placeholder="Filtruj po ścieżce…">
<select id="filtrformat" class="narrow"></select>
</div>
<table><thead><tr><th>Ścieżka</th><th>Format</th><th>Rozmiar</th></tr></thead>
<tbody id="tabpliki"></tbody></table>
</div>`;
const formaty = [...new Set(pliki.map(p => p.format).filter(Boolean))].sort();
$('#filtrformat').innerHTML = '<option value="">wszystkie</option>' +
formaty.map(f => `<option>${esc(f)}</option>`).join('');
const rysujPliki = () => {
const q = $('#filtrpliki').value.toLowerCase(), fmt = $('#filtrformat').value;
const w = pliki.filter(p => (!q || p.path.toLowerCase().includes(q))
&& (!fmt || p.format === fmt));
$('#tabpliki').innerHTML = w.length ? w.map(p => `
<tr ${p.decoded ? `data-sha1="${p.sha1}"` : 'style="cursor:default"'}>
<td class="mono">${esc(p.path)}
${p.encrypted ? '<span class="pill">szyfr</span>' : ''}
${p.decoded ? '<span class="pill">podgląd</span>' : ''}</td>
<td>${esc(p.format || '—')}</td>
<td class="mono">${mb(p.size)}</td></tr>`).join('')
: '<tr><td colspan="3" class="empty">Nic nie pasuje.</td></tr>';
$('#tabpliki').querySelectorAll('tr[data-sha1]').forEach(tr =>
tr.onclick = () => otworzSkrypt(tr.dataset.sha1));
};
$('#filtrpliki').oninput = rysujPliki;
$('#filtrformat').onchange = rysujPliki;
rysujPliki();
}
// ---- skrypty ----
let czekaj;
$('#szukaj').oninput = e => {
clearTimeout(czekaj);
const q = e.target.value.trim();
czekaj = setTimeout(() => szukajSkryptow(q), 250);
};
async function szukajSkryptow(q) {
if (!q) { $('#wyniki').innerHTML = '<div class="empty">Wpisz zapytanie.</div>'; return; }
$('#wyniki').innerHTML = '<div class="empty">Szukam…</div>';
const r = await get('/api/search?limit=40&q=' + encodeURIComponent(q));
if (!r.hits.length) {
$('#wyniki').innerHTML = `<div class="empty">Brak trafień dla ${esc(r.query)}.</div>`;
return;
}
// snippet przychodzi z FTS5 z własnymi znacznikami » «, zamieniamy je po ucieczce
const podswietl = s => esc(s).replace(/»/g, '<mark>').replace(/«/g, '</mark>');
$('#wyniki').innerHTML =
`<div class="dim" style="margin-bottom:10px">${r.hits.length} trafień
dla <span class="mono">${esc(r.query)}</span>
${r.quoted ? '— potraktowane jako fraza' : ''}</div>` +
r.hits.map(h => `
<div class="wynik" data-sha1="${h.sha1}"
style="padding:8px;border-radius:6px;cursor:pointer">
<div class="mono">${esc(h.paths[0])}</div>
<div class="dim" style="font-size:12px">${esc(h.games.join(', '))}</div>
<div class="snip">${podswietl(h.snippet)}</div>
</div>`).join('');
$('#wyniki').querySelectorAll('.wynik').forEach(d => {
d.onmouseenter = () => d.style.background = 'var(--hl)';
d.onmouseleave = () => d.style.background = '';
d.onclick = () => otworzSkrypt(d.dataset.sha1);
});
}
async function otworzSkrypt(sha1) {
document.querySelector('nav button[data-view=skrypty]').click();
$('#podglad').innerHTML = '<div class="card empty">Wczytuję…</div>';
const s = await get('/api/scripts/' + sha1);
$('#podglad').innerHTML = `
<div class="card">
<h2 class="mono" style="font-size:13px">${esc(s.paths[0])}</h2>
<div class="dim" style="font-size:12px;margin-bottom:4px">
${esc(s.games.join(', '))}</div>
<div class="mono dim" style="font-size:11px">sha1: ${esc(s.sha1)}</div>
${s.paths.length > 1 ? `<div class="dim" style="font-size:12px;margin-top:6px">
Ten sam plik pod ${s.paths.length} ścieżkami:<br>
<span class="mono">${s.paths.map(esc).join('<br>')}</span></div>` : ''}
</div>
<div class="card"><pre>${esc(s.body)}</pre></div>`;
}
// ---- start ----
(async () => {
const s = await get('/api/stats');
$('#stats').textContent =
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów w indeksie `
+ `· ${mb(s.bytes)}`;
const max = Math.max(...s.formats.map(f => f.n));
$('#tabformaty').innerHTML = s.formats.map(f => `
<tr style="cursor:default"><td>${esc(f.format || '—')}</td>
<td class="mono">${f.n.toLocaleString('pl')}</td>
<td class="mono">${mb(f.bytes)}</td>
<td style="width:170px"><div class="bar"><i style="width:${f.n/max*100}%"></i></div></td>
</tr>`).join('');
tytuly = await get('/api/titles');
rysujTytuly();
$('#filtr').oninput = e => rysujTytuly(e.target.value);
})().catch(e => $('#stats').textContent = 'Błąd: ' + e.message);
</script>
</body>
</html>