diff --git a/.impeccable/design.json b/.impeccable/design.json index 6075a0a..5a3ca16 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-10-02T16:54:39.012163+00:00", + "generatedAt": "2026-10-08T14:03:22.221536+00:00", "title": "Design System: PS5 RetroArch WebUI", "extensions": { "colorMeta": { @@ -506,6 +506,61 @@ "name": "intermediate-max", "value": "1300px" } + ], + "icons": { + "registry": "webui/icons.js", + "contract": "Role-specific colorful artwork; preserve authored colors and identities.", + "semantic": { + "family": "Fluent Color", + "rendering": "local SVG image assets with authored fills and gradients", + "navigationSize": "27px", + "source": "webui/assets/fluent/SOURCE.json", + "license": "webui/assets/fluent/LICENSE" + }, + "regions": { + "family": "flag-icons", + "rendering": "accurate local regional SVGs", + "size": "26px \u00d7 20px", + "source": "webui/assets/flags/SOURCE.json", + "license": "webui/assets/flags/LICENSE" + }, + "systems": { + "family": "Linear ES-DE hardware imagery", + "registry": "SYSTEM_ICONS", + "recognizedPlatformCount": 50, + "rendering": "local WebP images cropped above source logos", + "frame": "42px \u00d7 30px", + "imageSize": "42px \u00d7 42px", + "selectorFrame": "56px \u00d7 39px", + "selectorImageSize": "56px \u00d7 56px", + "fallback": "library artwork for unknown custom systems only", + "source": "webui/assets/systems/SOURCE.json", + "license": "webui/assets/systems/LICENSE" + }, + "media": { + "style": "colorful recognizable media artwork, including authored geometric 3D box and disc", + "source": "MEDIA_SYMBOLS in webui/icons.js" + }, + "identity": { + "brand": "actual RetroArch logo", + "providers": "original colored provider badges" + }, + "utility": { + "family": "Lucide", + "roles": "simple controls including arrows, close and search", + "viewBox": "0 0 24 24", + "strokeWidth": 1.75, + "fill": "none", + "stroke": "currentColor", + "strokeLinecap": "round", + "strokeLinejoin": "round", + "source": "webui/assets/lucide/SOURCE.txt", + "license": "webui/assets/lucide/LICENSE" + }, + "accessibility": "Decorative images have empty alt text and aria-hidden=true; decorative SVGs are hidden and not focusable. Labels or accessible control names carry meaning." + }, + "implementationDrift": [ + "Pre-existing game-dialog shadow, blurred backdrop and entrance animation, plus selection outlines implemented as shadows, differ from the flat contract. Retained without adopting new global tokens or repairing them in this update." ] }, "components": [ @@ -546,8 +601,8 @@ "kind": "nav", "refersTo": "nav-current", "description": "The solid purple current-page tab inside the dark rail.", - "html": "", - "css": ".ds-nav{background:var(--rail,#37184a);padding:0.722222rem}.ds-nav a{font:1rem/1.45 Arial,Helvetica,sans-serif;display:flex;align-items:center;gap:1.55556rem;min-height:3.11111rem;padding:0.666667rem 1.22222rem;border-radius:0.666667rem;text-decoration:none;color:#fff;font-size:1.11111rem}.ds-nav a[aria-current=page]{background:#6c3b9d}.ds-nav a:hover{background:#62378d}.ds-nav a:focus-visible{outline:0.166667rem solid var(--focus,#a566ef);outline-offset:0.222222rem}.ds-nav svg{width:1.55556rem;height:1.55556rem;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}" + "html": "", + "css": ".ds-nav{background:var(--rail,#37184a);padding:0.722222rem}.ds-nav a{font:1rem/1.45 Arial,Helvetica,sans-serif;display:flex;align-items:center;gap:1.55556rem;min-height:3.11111rem;padding:0.666667rem 1.22222rem;border-radius:0.666667rem;text-decoration:none;color:#fff;font-size:1.11111rem}.ds-nav a[aria-current=page]{background:#6c3b9d}.ds-nav a:hover{background:#62378d}.ds-nav a:focus-visible{outline:0.166667rem solid var(--focus,#a566ef);outline-offset:0.222222rem}.ds-nav svg{width:27px;height:27px}" }, { "name": "Working panel", @@ -562,8 +617,8 @@ "kind": "custom", "refersTo": "dropzone", "description": "Dashed file target; use alongside the primary browse action.", - "html": "", - "css": ".ds-drop{font:1rem/1.45 Arial,Helvetica,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;min-height:7.88889rem;background:transparent;border:0.111111rem dashed #b3b5ce;border-radius:0.444444rem;padding:0.833333rem;color:var(--muted,#6c6f90);cursor:pointer}.ds-drop svg{width:2.11111rem;height:2.11111rem;margin-bottom:0.555556rem;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}.ds-drop strong{font-size:1.11111rem;color:var(--ink,#2d1447)}.ds-drop:hover,.ds-drop.ds-dragging{border-color:var(--accent,#7544b7);background:var(--ground,#ede7f6)}.ds-drop:focus-visible{outline:0.166667rem solid var(--focus,#a566ef);outline-offset:0.222222rem}.ds-drop:disabled{opacity:.55;cursor:not-allowed}@media(min-width:769px){.ds-drop{min-height:7rem}}" + "html": "", + "css": ".ds-drop{font:1rem/1.45 Arial,Helvetica,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;min-height:7.88889rem;background:transparent;border:0.111111rem dashed #b3b5ce;border-radius:0.444444rem;padding:0.833333rem;color:var(--muted,#6c6f90);cursor:pointer}.ds-drop svg{width:2.11111rem;height:2.11111rem;margin-bottom:0.555556rem}.ds-drop strong{font-size:1.11111rem;color:var(--ink,#2d1447)}.ds-drop:hover,.ds-drop.ds-dragging{border-color:var(--accent,#7544b7);background:var(--ground,#ede7f6)}.ds-drop:focus-visible{outline:0.166667rem solid var(--focus,#a566ef);outline-offset:0.222222rem}.ds-drop:disabled{opacity:.55;cursor:not-allowed}@media(min-width:769px){.ds-drop{min-height:7rem}}" }, { "name": "Settings value row", @@ -607,14 +662,19 @@ }, { "name": "The Flat Surface Rule", - "body": "Separate regions with tone, spacing and thin borders; the implemented interface has no shadows.", + "body": "Separate shell regions with tone, spacing and thin borders; do not turn the shading inside icon artwork into a new surface treatment.", "section": "elevation" + }, + { + "name": "The Recognizable Artwork Rule", + "body": "Preserve authored color and identity: Fluent Color for interface meaning, accurate flags for regions, hardware imagery for recognized systems, original brand/provider marks and simple outlines for utility controls.", + "section": "components" } ], "dos": [ "Do preserve the aubergine navigation, lavender ground and purple action hierarchy.", "Do keep labels beside icons and status text beside status indicators.", - "Do use the local heading font, inline SVG icons and the discreet credited portrait.", + "Do use the local heading font, colorful semantic artwork, recognizable hardware and brand marks, and the discreet credited portrait.", "Do retain visible keyboard focus and stable Cancel controls while progress changes.", "Do stack working panels and keep release information visible on narrow screens.", "Do keep settings scope, mode, search and save status explicitly labeled; show explanations in Guided and exact configuration keys in Advanced.", @@ -622,7 +682,8 @@ ], "donts": [ "Don't present a failed release check or an unversioned development build as up to date.", - "Don't introduce external UI fonts, icon packages or framework dependencies into this offline interface.", + "Don't introduce external UI fonts, runtime icon packages or framework dependencies into this offline interface.", + "Don't flatten colorful semantic artwork, flags, hardware or brand identities into one monochrome icon family.", "Don't expand the credit portrait into a hero image or replace useful content with fabricated metrics.", "Don't remove the reduced-motion override when extending transfer feedback." ] diff --git a/evidence/webui-add-game/capture.json b/evidence/webui-add-game/capture.json new file mode 100644 index 0000000..b93681f --- /dev/null +++ b/evidence/webui-add-game/capture.json @@ -0,0 +1,127 @@ +{ + "step": "webui-add-game", + "recorded": "2026-10-08T15:27:03.133715+00:00", + "revision": "working tree on 28bfcc9", + "command": "PS5_FRONTENDS='es-de picker' PS5_VULKAN_TEMPLATE=\"$PWD/build/picker-template\" bash tools/verify.sh format unit build integration evidence; python3 tools/deploy-title.py; python3 build/check-add-game-console.py", + "console_run": "Base PS5, idle or at Picker before restart, Picker after. Disposable synthetic backup, state and PNG fixture only; no game launched. Public LaunchBox database lookup.", + "raw_capture": "klog/webui-add-game/console-check.log", + "raw_lines": 8, + "lines": [ + "== format", + " PASS (9s)", + "== unit", + "Ran 185 tests in 301.651s", + " PASS (302s)", + "== build", + " PASS (89s)", + "== integration", + "Ran 185 tests in 237.645s", + " PASS (245s)", + "== evidence", + "108 capture(s) replayed, 0 failed", + " PASS (0s)", + "verify: PASS (format unit build integration evidence)", + "Running console daemon matches staged build: ef4668acefa32f93654097f3e956aed03b741dfd75ad9fcc3701e9eba3a1a4f7", + "Running daemon and all changed WebUI assets match staged build", + "Add Game registers backup in library; duplicate refused; original bytes preserved", + "Manual metadata and partial-edit preservation round trip", + "Manual cover upload succeeds", + "Savestate sibling screenshot is listed and preview bytes match", + "Replacing state invalidates old screenshot; unrelated preview rejected", + "Console LaunchBox database search puts the exact title first", + "All disposable probe files removed", + "RetroArch and frontend configuration bytes unchanged", + "Browser progress, edited metadata and source-isolation regressions passed in the current full unit/integration runs", + "build/webui-add-exact-title.log: PASS", + "licenses/components.json 1,134,157 bytes 3830b4ca6128e72b ok", + "manifest.sha256 1,806,518 bytes 21ac53bff7933ab2 ok", + "webui/ps5-retroarch-webui.elf 3,372,016 bytes bcee7775b5aebb43 ok", + "webui/version.json 134 bytes 7bf61199110a2938 ok", + "eboot.bin 39,449,916 bytes stored; all 4 of this build's markers present ok", + "==> [deploy] 13830 unchanged files skipped (already verified on this console; --all uploads everything)", + "==> [deploy] published what changed; everything else was verified by an earlier deployment" + ], + "staged_artifacts": { + "webui/index.html": { + "bytes": 29950, + "sha256": "4c629ea9966c3fd827d1cfda98891ad5b47a519ebcba9a804be0b5f850cc2ecc" + }, + "webui/app.css": { + "bytes": 72179, + "sha256": "a5f28171b22482d071962326f8d376d3b53a9ffd7da09a9f8f73e644552c486b" + }, + "webui/app.js": { + "bytes": 123336, + "sha256": "3a37ee0a0e19adf026135e362b434ca41beb0266386ca497b5cf9d638bf68546" + }, + "webui/add-game.js": { + "bytes": 23629, + "sha256": "37d829643603a778bf4fd9b669c877c12c94d2c84f2b6781edfdecdeb5b6dbd8" + }, + "webui/icons.js": { + "bytes": 14549, + "sha256": "477e03808157333b6d5798194d837441586b7e2173428aec68ae0fb1b54e8e73" + }, + "webui/assets/ICON-CREDITS.txt": { + "bytes": 6715, + "sha256": "5751b7acc272db65320e88194dc86ef8e99e07f83f0bba13ad9193545fc61e7f" + }, + "webui/assets/fluent/SOURCE.json": { + "bytes": 1222, + "sha256": "0b55f838bf5c746e3cc601758c165bc63d9d7a15e6e6ba0bd488e78e90c398b3" + }, + "webui/assets/flags/SOURCE.json": { + "bytes": 283, + "sha256": "74211ac5113e15d3aad8921dd9de535aac715430074780ad351fc19a62817936" + }, + "webui/assets/systems/SOURCE.json": { + "bytes": 6463, + "sha256": "6b3fb5163cbb0b7fd34d8f7639ac853689b0f112bb1c86f9b632764b88656b94" + }, + "webui/version.json": { + "bytes": 134, + "sha256": "7bf61199110a2938e73bc18d8a8a1d380c14fabac27ad499b51253c0d78a80d8" + }, + "webui/ps5-retroarch-webui.elf": { + "bytes": 3372016, + "sha256": "bcee7775b5aebb438bf98abf9474aea95bd60e9d7c70926c16b82b4fb40898e3" + }, + "eboot.bin": { + "bytes": 38866604, + "sha256": "89760cc5a1ea6a37cf1ace7d950c4476ef1edeeac42a01bfd9284e3a290bcf6c" + }, + "manifest.sha256": { + "bytes": 1806518, + "sha256": "21ac53bff7933ab2976ff33d71a8b19ce030b51e62f470fc8b517e854083dafb" + } + }, + "config_sha256": { + "retroarch.cfg": "a0b19a85a5d1af2ad85a50a403dd30913df9e3ee4dc9ac42fd84078f29ca2f8e", + "frontend.cfg": "4b46b4c945f76786ddb024e4e4da5f3153a1a50484e2c23a88dc874ffa9633ab" + }, + "browser_captures": [ + ".impeccable/review/colorful-progress/artwork-desktop.png", + ".impeccable/review/colorful-progress/media-mobile.png", + ".impeccable/review/colorful-progress/progress-desktop.png", + ".impeccable/review/colorful-progress/progress-mobile.png", + "klog/webui-add-game/add-game-console.jpg" + ], + "resolved_failures": [ + "Initial full unit run: automatic scraped names changed subsequent matching/cache behavior (three scraper tests failed). Restrict title override to explicitly edited name; final focused/full runs pass.", + "LaunchBox title lookup initially missed aliases; index lookup includes alternate titles.", + "Browser assertions raced refresh/metadata loading or counted both dialog thumbnail sets; wait for intended content and scope to game dialog.", + "Add-first sign-in had no Download Media state: update the account independently; browser test covers it.", + "User rejected monochrome Lucide pictograms. Replaced semantic icons with approved colorful artwork, accurate flags and all 50 recognized system images. Utility controls retain simple strokes; RetroArch branding restored.", + "Backend review found accidental LaunchBox-ID use in ScreenScraper and blank IDs bypassing ambiguity resolution. Fixed and covered with source-isolation/selection tests.", + "Live console browsing exposed exact titles crowded out by 20 variants. A later console assertion also found parenthesized enhanced editions sharing the normalized exact-title key. Literal case-insensitive titles now rank before exact aliases/normalized editions, then partials; the regression includes 30 variants, an enhanced edition and an exact original.", + "Deployment eboot read-back initially differed; standard deployment retried and verified all four build markers.", + "Add Game opened before downloads finished: prominent download CTA and live per-game job view now wait for terminal state and fetch fresh media before opening the game. Browser test covers no-reload artwork.", + "New regression exposed edited fields parsed as media kinds, discarding the manual-name marker and prior metadata edit markers. Separate comma parsing preserves fields and exact matching; browser and metadata tests pass.", + "Initial preflight HTTP was refused because console had no title running; process control confirmed idle. No gameplay interrupted." + ], + "limits": [ + "Add Game accepts one supported backup file; complete multi-file discs and folder-based games use Content.", + "Preview needs a RetroArch-generated PNG sidecar. Importing a state invalidates its old screenshot. Test PNGs are synthetic; no in-game save compatibility or screenshot creation claimed.", + "Console media upload and database lookup verified; actual multi-source download paths verified with host HTTP fixtures." + ] +} diff --git a/evidence/webui-add-game/expectation.json b/evidence/webui-add-game/expectation.json new file mode 100644 index 0000000..e14dc7a --- /dev/null +++ b/evidence/webui-add-game/expectation.json @@ -0,0 +1,23 @@ +{ + "step": "webui-add-game", + "note": "Add Game, savestate screenshot previews and approved colorful icons, accurate region flags and 50 system images. Full gates, source matching isolation, browser flow, and real console disposable-file validation.", + "must_contain": [ + "Ran 185 tests", + "verify: PASS (format unit build integration evidence)", + "Running console daemon matches staged build: ef4668acefa32f93654097f3e956aed03b741dfd75ad9fcc3701e9eba3a1a4f7", + "Running daemon and all changed WebUI assets match staged build", + "Add Game registers backup in library; duplicate refused; original bytes preserved", + "Manual metadata and partial-edit preservation round trip", + "Manual cover upload succeeds", + "Savestate sibling screenshot is listed and preview bytes match", + "Replacing state invalidates old screenshot; unrelated preview rejected", + "Console LaunchBox database search puts the exact title first", + "All disposable probe files removed", + "RetroArch and frontend configuration bytes unchanged" + ], + "must_not_contain": [ + "Traceback", + "FAILED" + ], + "human_check": "Fresh full review matched the approved desktop/phone artwork and CTA/progress scope; its documentation fix was scored resolved. User approved the new visual direction. Synthetic state fixture previews visibly render. Live console Add Game reviewed." +} diff --git a/src/scraper.cpp b/src/scraper.cpp index 6b9cb5e..62681d0 100644 --- a/src/scraper.cpp +++ b/src/scraper.cpp @@ -501,7 +501,7 @@ std::string kinds_text(const std::vector &kinds) out += (out.empty() ? "" : ",") + kind; return out; } -std::vector kinds_from(const std::string &text) +std::vector comma_values(const std::string &text) { std::vector out; size_t start = 0; @@ -511,12 +511,20 @@ std::vector kinds_from(const std::string &text) if (end == std::string::npos) end = text.size(); const std::string kind = text.substr(start, end - start); - if (kind_folders.count(kind) && std::find(out.begin(), out.end(), kind) == out.end()) + if (!kind.empty() && std::find(out.begin(), out.end(), kind) == out.end()) out.push_back(kind); start = end + 1; } return out; } +std::vector kinds_from(const std::string &text) +{ + auto out = comma_values(text); + out.erase(std::remove_if(out.begin(), out.end(), + [](const std::string &kind) { return !kind_folders.count(kind); }), + out.end()); + return out; +} /* An ambiguous item's candidates in the job file, separated by the unit separator. */ std::string candidates_text(const std::vector &candidates) { @@ -1813,7 +1821,8 @@ struct XmlRecords } }; /* Reads one file of the zip through the reader; false when it is not there. */ -bool read_zipped(const std::string &zip, const char *name, XmlRecords &reader) +bool read_zipped(const std::string &zip, const char *name, XmlRecords &reader, + const std::atomic &cancel) { unzFile file = unzOpen(zip.c_str()); if (!file) @@ -1824,7 +1833,7 @@ bool read_zipped(const std::string &zip, const char *name, XmlRecords &reader) std::vector buffer(1 << 20); int got; while ((got = unzReadCurrentFile(file, buffer.data(), unsigned(buffer.size()))) > 0 && - !cancelling) + !cancel) reader.feed(buffer.data(), size_t(got)); ok = got == 0; unzCloseCurrentFile(file); @@ -1835,7 +1844,8 @@ bool read_zipped(const std::string &zip, const char *name, XmlRecords &reader) std::mutex launchbox_lock; /* The index, built from a fresh database when it is missing or a month old. */ -bool launchbox_prepare(Http &http, Job &job, std::string &why) +bool launchbox_prepare(Http &http, Job &job, std::string &why, + const std::atomic &cancel = cancelling) { std::lock_guard guard(launchbox_lock); const std::string folder = launchbox_folder(), done = folder + "/index.done"; @@ -1862,7 +1872,7 @@ bool launchbox_prepare(Http &http, Job &job, std::string &why) why = "The console could not write to its storage."; return false; } - const Response response = http.get(launchbox_metadata, uint64_t(1) << 30, &cancelling, fd); + const Response response = http.get(launchbox_metadata, uint64_t(1) << 30, &cancel, fd); const bool complete = response.status == 200 && response.error.empty() && !response.cancelled && fsync(fd) == 0; close(fd); @@ -1935,7 +1945,7 @@ bool launchbox_prepare(Http &http, Job &job, std::string &why) } } }; - bool ok = read_zipped(zip, "Metadata.xml", games); + bool ok = read_zipped(zip, "Metadata.xml", games, cancel); for (auto &file : files) if (file.second) ok = std::fclose(file.second) == 0 && ok; @@ -1947,13 +1957,13 @@ bool launchbox_prepare(Http &http, Job &job, std::string &why) if (record == "MameFile" && names && f.count("FileName") && f.count("Name")) line(names, {f.at("FileName"), f.at("Name")}); }; - ok = names && read_zipped(zip, "Mame.xml", mame) && ok; + ok = names && read_zipped(zip, "Mame.xml", mame, cancel) && ok; if (names) ok = std::fclose(names) == 0 && ok; unlink(zip.c_str()); - if (!ok || cancelling) + if (!ok || cancel) { - why = cancelling ? "Cancelled." : "The LaunchBox database could not be read."; + why = cancel ? "Cancelled." : "The LaunchBox database could not be read."; return false; } for (const auto &file : files) @@ -2150,7 +2160,10 @@ void process_launchbox(Http &http, Job &job, unsigned index, const Options &opti } } }; - const std::string chosen = candidate_id(item.matched); + const Fields stored = read_fields(meta_path(item.game.system, item.game.key)); + const std::string chosen = stored.count("launchbox_id") && !stored.at("launchbox_id").empty() + ? stored.at("launchbox_id") + : candidate_id(item.matched); if (!chosen.empty()) find_id(chosen); else @@ -2792,9 +2805,13 @@ bool edit_game(const std::string &system, const std::string &key, const std::str } std::lock_guard guard(metadata_lock); Fields meta = read_fields(meta_path(system, key)); + auto edited = comma_values(meta["edited"]); + const bool partial = input["partial"].str() == "true"; for (const char *field : {"name", "description", "developer", "publisher", "genre", "players", "rating", "released"}) { + if (partial && input[field].kind == Json::null) + continue; if (input[field].kind != Json::string) { why = "Every detail must be text."; @@ -2820,8 +2837,20 @@ bool edit_game(const std::string &system, const std::string &key, const std::str } } meta[field] = value; + if (!holds(edited, field)) + edited.push_back(field); } - meta["edited"] = "name,description,developer,publisher,genre,players,rating,released"; + if (input["launchbox_id"].kind == Json::string) + { + const auto id = input["launchbox_id"].str(); + if (id.size() > 20 || id.find_first_not_of("0123456789") != std::string::npos) + { + why = "Choose a valid LaunchBox result."; + return false; + } + meta["launchbox_id"] = id; + } + meta["edited"] = kinds_text(edited); make_folders(library_root() + '/' + system + "/metadata"); if (write_atomic(meta_path(system, key), fields_text(meta))) return true; @@ -2879,6 +2908,162 @@ void uploaded(const std::string &system, const std::string &key, const std::stri write_atomic(meta_path(system, key), fields_text(meta)); } +namespace +{ +std::map add_systems() +{ + ps5_library lib{}; + ps5_library_load(&lib, (root_path + "/playlists").c_str(), (root_path + "/info").c_str(), + (root_path + "/cores").c_str()); + std::map out; + for (size_t i = 0; i < lib.core_count; ++i) + { + const auto &core = lib.cores[i]; + const std::string databases = core.databases; + for (size_t at = 0; at < databases.size();) + { + auto end = databases.find('|', at); + const auto database = databases.substr(at, end - at); + const char *id = ps5_library_platform(database.c_str()); + if (id) + { + auto &entry = out[id]; + entry["name"] = ps5_library_platform_database(id); + if (entry["extensions"].empty()) + entry["extensions"] = core.extensions; + else + entry["extensions"] += '|' + std::string(core.extensions); + } + if (end == std::string::npos) + break; + at = end + 1; + } + } + ps5_library_free(&lib); + return out; +} +} // namespace +std::string add_systems_json() +{ + std::string out = "{\"systems\":["; + for (const auto &system : add_systems()) + { + if (out.back() != '[') + out += ','; + out += "{\"id\":" + quote(system.first) + ",\"name\":" + quote(system.second.at("name")) + + ",\"extensions\":" + quote(system.second.at("extensions")) + '}'; + } + return out + "]}"; +} +bool add_target(const std::string &system, const std::string &filename, std::string &relative, + std::string &key, std::string &why) +{ + const auto systems = add_systems(); + auto selected = systems.find(system); + if (selected == systems.end() || filename.empty() || filename.size() > 240 || + filename.front() == '.' || filename.find_first_of("/\\#\t\r\n") != std::string::npos || + filename.find('\0') != std::string::npos) + { + why = "Choose an installed system and a valid game filename."; + return false; + } + const auto dot = filename.find_last_of('.'); + std::string ext = dot == std::string::npos ? "" : filename.substr(dot + 1); + for (char &c : ext) + c = char(std::tolower((unsigned char)c)); + if (ext.empty() || + ("|" + selected->second.at("extensions") + "|").find('|' + ext + '|') == std::string::npos) + { + why = "This file type is not supported by the installed cores for this system."; + return false; + } + relative = selected->second.at("name") + '/' + filename; + key = game_key("/app0/content/" + relative); + for (const auto &game : load_games()) + if (game.system == system && game.key == key) + { + why = "This game filename is already in your library. Open its overlay to replace it, " + "or rename the new file."; + return false; + } + return true; +} +std::string launchbox_lookup(const std::string &system, const std::string &query, std::string &why) +{ + if (!launchbox_platforms.count(system) || query.size() < 2 || query.size() > 160) + { + why = "Choose a supported system and enter at least two letters."; + return ""; + } + Http http("PS5-RetroArch"); + Job lookup; + if (!launchbox_prepare(http, lookup, why, stopping)) + return ""; + std::vector candidates; + const auto key = title_key(query); + std::vector> matches; + for (const auto &platform : launchbox_platforms.at(system)) + { + const auto index = launchbox_index(platform); + for (const auto &title : index->by_title) + { + if (title.first.find(key) == std::string::npos) + continue; + for (const auto &id : title.second) + { + const auto found = index->by_id.find(id); + if (found == index->by_id.end()) + continue; + const auto &game = index->games[found->second]; + const auto candidate = game.name + " [" + id + ']'; + /* The normalized index strips parenthesized edition tags. A literal + * title must outrank an enhanced edition sharing that index key. */ + const bool exact = game.name.size() == query.size() && + std::equal(game.name.begin(), game.name.end(), query.begin(), + [](unsigned char a, unsigned char b) + { return std::tolower(a) == std::tolower(b); }); + matches.emplace_back((exact ? "0" + : title.first == key ? "1" + : "2") + + title.first, + candidate); + } + } + } + std::sort(matches.begin(), matches.end()); + for (const auto &match : matches) + if (!holds(candidates, match.second) && candidates.size() < 20) + candidates.push_back(match.second); + for (const auto &candidate : launchbox_search(system, query, "wor")) + if (!holds(candidates, candidate) && candidates.size() < 20) + candidates.push_back(candidate); + std::string out = "{\"results\":["; + for (const auto &candidate : candidates) + { + const auto id = candidate_id(candidate); + for (const auto &platform : launchbox_platforms.at(system)) + { + auto index = launchbox_index(platform); + auto hit = index->by_id.find(id); + if (hit == index->by_id.end()) + continue; + const auto &g = index->games[hit->second]; + if (out.back() != '[') + out += ','; + out += + "{\"id\":" + quote(g.id) + ",\"name\":" + quote(g.name) + + ",\"description\":" + quote(g.overview) + ",\"developer\":" + quote(g.developer) + + ",\"publisher\":" + quote(g.publisher) + ",\"released\":" + quote(g.released) + + ",\"genre\":" + quote(g.genres) + ",\"players\":" + quote(g.players) + + ",\"rating\":" + + quote(g.rating.empty() ? "" : std::to_string(std::atof(g.rating.c_str()) / 5.0)) + + '}'; + break; + } + } + return out + "]}"; +} + std::string library_json() { const auto games = load_games(); @@ -2930,7 +3115,8 @@ std::string settings_json() for (const auto &kind : kind_catalog) catalog += std::string(catalog.empty() ? "" : ",") + "{\"id\":" + quote(kind.id) + ",\"name\":" + quote(kind.name) + ",\"description\":" + quote(kind.description) + - ",\"folder\":" + quote(kind.folder) + '}'; + ",\"folder\":" + quote(kind.folder) + + ",\"accepts\":" + list(accepted_types(kind.id)) + '}'; /* The sources: libretro now; the others are named so the page can say where each * media type will come from (they need accounts or keys, to be added). */ struct SourceInfo @@ -3171,6 +3357,11 @@ bool start(const Options &requested, const std::vector &selection, st { Item item; item.game = game; + const auto metadata = read_fields(meta_path(game.system, game.key)); + if (metadata.count("edited") && + holds(comma_values(metadata.at("edited")), "name") && metadata.count("name") && + !metadata.at("name").empty()) + item.game.label = metadata.at("name"); job->items.push_back(item); } if (job->items.empty()) diff --git a/src/scraper.h b/src/scraper.h index 9e935bb..8bbb571 100644 --- a/src/scraper.h +++ b/src/scraper.h @@ -68,6 +68,10 @@ bool busy(); /* The library with what the store holds for each game, as JSON for the WebUI. */ std::string library_json(); +std::string add_systems_json(); +bool add_target(const std::string &system, const std::string &filename, std::string &relative, + std::string &key, std::string &why); +std::string launchbox_lookup(const std::string &system, const std::string &query, std::string &why); /* The remembered choices (method, media, region), as JSON; and saving them. */ std::string settings_json(); bool save_settings(const Options &options); diff --git a/src/webui_ps5.cpp b/src/webui_ps5.cpp index 1f3041c..e5a8fc0 100644 --- a/src/webui_ps5.cpp +++ b/src/webui_ps5.cpp @@ -79,6 +79,7 @@ enum class Transfer part, // PUT /api/upload/part scraped, // PUT /api/scraper/pc/media: a file the PC helper downloaded game_file, // PUT /api/library/file + game_add, // PUT /api/library/add media // PUT /api/library/media: a media file the user chose for a game }; struct Request @@ -1357,7 +1358,8 @@ std::string game_files_json(const GameFiles &g) const std::string shown = path.rfind(root_path + '/', 0) == 0 ? "/app0" + path.substr(root_path.size()) : path; return "{\"path\":" + quote(path) + ",\"name\":" + quote(leaf_name(path)) + - ",\"display\":" + quote(shown) + ",\"bytes\":" + std::to_string(st.st_size) + '}'; + ",\"display\":" + quote(shown) + ",\"bytes\":" + std::to_string(st.st_size) + + ",\"preview\":" + (game_safe_path(path + ".png") ? "true" : "false") + '}'; }; auto list = [&](const std::vector &files) { @@ -1395,6 +1397,44 @@ bool game_file_target(MHD_Connection *c, const GameFiles &g, bool writing, std:: return false; return game_safe_path(target, writing, writing); } +void prepare_game_add(MHD_Connection *c, Request &r) +{ + std::string relative; + r.media_system = arg(c, "system"); + if (!ps5_scraper::add_target(r.media_system, arg(c, "filename"), relative, r.media_game, + r.media_why)) + { + r.error = 400; + r.message = r.media_why.c_str(); + return; + } + if (!content_parents(relative, r.destination) || !game_safe_path(r.destination, false, true)) + { + r.error = 400; + r.message = "Choose a safe game location."; + return; + } + if (exists(r.destination)) + { + r.error = 409; + r.message = "This backup already exists. Open the game to replace it."; + return; + } + if (!content_length(c, r.expected) || !r.expected || r.expected > upload_limit) + { + r.error = 413; + r.message = "Choose a non-empty game backup of at most 64 GiB."; + return; + } + r.transfer = Transfer::game_add; + r.temporary = + r.destination.substr(0, r.destination.find_last_of('/') + 1) + ".upload-" + nonce(); + if (!r.writer.create(r.temporary)) + { + r.error = 507; + r.message = "Cannot store this game. Check free space."; + } +} void prepare_game_file(MHD_Connection *c, Request &r) { GameFiles game; @@ -1631,6 +1671,39 @@ MHD_Result scraper_route(MHD_Connection *c, const std::string &url, const std::s } return o; }; + if (method == "GET" && url == "/api/library/add-options") + return respond(c, 200, ps5_scraper::add_systems_json()); + if (method == "POST" && url == "/api/library/launchbox") + { + const auto result = ps5_scraper::launchbox_lookup(arg(c, "system"), arg(c, "q"), why); + return result.empty() ? error(c, 400, why.c_str()) : respond(c, 200, result); + } + if (method == "PUT" && url == "/api/library/add") + { + if (r.transfer != Transfer::game_add || r.received != r.expected || !r.writer.flush()) + return error(c, 400, "The upload was incomplete. Try again."); + const int fd = r.writer.release(); + const auto result = ps5_transfer::commit(fd, r.temporary, r.destination, false, true); + if (fd >= 0) + close(fd); + if (result != ps5_transfer::Commit::done) + return error(c, result == ps5_transfer::Commit::exists ? 409 : 507, + "Could not add this game. No existing backup was replaced."); + chmod(r.destination.c_str(), 0777); + r.temporary.clear(); + const auto path = "/app0" + r.destination.substr(root_path.size()); + std::string core; + if (!ps5_scraper::game_identity(r.media_system, r.media_game, path, core)) + { + unlink(r.destination.c_str()); + return error(c, 415, + "This file cannot be listed as a game. For disc sets or folder-based " + "games, use Content to upload the complete folder."); + } + return respond(c, 201, + "{\"system\":" + quote(r.media_system) + ",\"key\":" + quote(r.media_game) + + ",\"path\":" + quote(path) + "}"); + } if (url == "/api/library/files" || url == "/api/library/file") { GameFiles game; @@ -1642,7 +1715,15 @@ MHD_Result scraper_route(MHD_Connection *c, const std::string &url, const std::s if (!game_file_target(c, game, method == "PUT", target)) return error(c, 400, "Choose a file belonging to this game."); if (method == "GET") + { + if (std::strcmp(arg(c, "preview"), "1") == 0) + { + if (std::strcmp(arg(c, "kind"), "state") != 0 || !game_safe_path(target + ".png")) + return error(c, 404, "No screenshot was saved for this slot."); + return serve_file(c, target + ".png", "image/png", false); + } return serve_file(c, target, "application/octet-stream", true); + } if (r.transfer != Transfer::game_file || target != r.destination) return error(c, 409, "The game location changed. Refresh and try again."); if (r.received != r.expected || !r.writer.flush()) @@ -1655,6 +1736,9 @@ MHD_Result scraper_route(MHD_Connection *c, const std::string &url, const std::s return error(c, result == ps5_transfer::Commit::exists ? 409 : 507, "Could not finish the upload; the original file was kept."); chmod(target.c_str(), 0777); + // A screenshot belongs to the old state; never show it for an imported one. + if (std::strcmp(arg(c, "kind"), "state") == 0 && game_safe_path(target + ".png")) + unlink((target + ".png").c_str()); r.temporary.clear(); return respond(c, 201, "{\"stored\":true}"); } @@ -2028,6 +2112,109 @@ MHD_Result route(MHD_Connection *c, const std::string &url, const std::string &m {"/index.html", "text/html; charset=utf-8"}, {"/app.css", "text/css; charset=utf-8"}, {"/app.js", "text/javascript; charset=utf-8"}, + {"/icons.js", "text/javascript; charset=utf-8"}, + {"/add-game.js", "text/javascript; charset=utf-8"}, + {"/assets/fluent/LICENSE", "text/plain; charset=utf-8"}, + {"/assets/fluent/arrow-square-down.svg", "image/svg+xml"}, + {"/assets/fluent/arrow-sync.svg", "image/svg+xml"}, + {"/assets/fluent/board.svg", "image/svg+xml"}, + {"/assets/fluent/book-open.svg", "image/svg+xml"}, + {"/assets/fluent/book.svg", "image/svg+xml"}, + {"/assets/fluent/briefcase.svg", "image/svg+xml"}, + {"/assets/fluent/building.svg", "image/svg+xml"}, + {"/assets/fluent/checkmark-circle.svg", "image/svg+xml"}, + {"/assets/fluent/cloud.svg", "image/svg+xml"}, + {"/assets/fluent/coin-multiple.svg", "image/svg+xml"}, + {"/assets/fluent/content-view.svg", "image/svg+xml"}, + {"/assets/fluent/database.svg", "image/svg+xml"}, + {"/assets/fluent/document-folder.svg", "image/svg+xml"}, + {"/assets/fluent/document-text.svg", "image/svg+xml"}, + {"/assets/fluent/edit.svg", "image/svg+xml"}, + {"/assets/fluent/flag.svg", "image/svg+xml"}, + {"/assets/fluent/game-chat.svg", "image/svg+xml"}, + {"/assets/fluent/globe.svg", "image/svg+xml"}, + {"/assets/fluent/headphones.svg", "image/svg+xml"}, + {"/assets/fluent/home.svg", "image/svg+xml"}, + {"/assets/fluent/image.svg", "image/svg+xml"}, + {"/assets/fluent/laptop.svg", "image/svg+xml"}, + {"/assets/fluent/library.svg", "image/svg+xml"}, + {"/assets/fluent/question-circle.svg", "image/svg+xml"}, + {"/assets/fluent/settings.svg", "image/svg+xml"}, + {"/assets/fluent/text-bullet-list-square.svg", "image/svg+xml"}, + {"/assets/fluent/text-edit-style.svg", "image/svg+xml"}, + {"/assets/fluent/vault.svg", "image/svg+xml"}, + {"/assets/fluent/video.svg", "image/svg+xml"}, + {"/assets/fluent/warning.svg", "image/svg+xml"}, + {"/assets/fluent/weather-sunny-low.svg", "image/svg+xml"}, + {"/assets/flags/LICENSE", "text/plain; charset=utf-8"}, + {"/assets/flags/au.svg", "image/svg+xml"}, + {"/assets/flags/br.svg", "image/svg+xml"}, + {"/assets/flags/ca.svg", "image/svg+xml"}, + {"/assets/flags/cn.svg", "image/svg+xml"}, + {"/assets/flags/de.svg", "image/svg+xml"}, + {"/assets/flags/es.svg", "image/svg+xml"}, + {"/assets/flags/eu.svg", "image/svg+xml"}, + {"/assets/flags/fr.svg", "image/svg+xml"}, + {"/assets/flags/gb.svg", "image/svg+xml"}, + {"/assets/flags/it.svg", "image/svg+xml"}, + {"/assets/flags/jp.svg", "image/svg+xml"}, + {"/assets/flags/kr.svg", "image/svg+xml"}, + {"/assets/flags/ru.svg", "image/svg+xml"}, + {"/assets/flags/tw.svg", "image/svg+xml"}, + {"/assets/flags/us.svg", "image/svg+xml"}, + {"/assets/systems/3do.webp", "image/webp"}, + {"/assets/systems/LICENSE", "text/plain; charset=utf-8"}, + {"/assets/systems/amiga.webp", "image/webp"}, + {"/assets/systems/arcade.webp", "image/webp"}, + {"/assets/systems/atari2600.webp", "image/webp"}, + {"/assets/systems/atari5200.webp", "image/webp"}, + {"/assets/systems/atari7800.webp", "image/webp"}, + {"/assets/systems/atarijaguar.webp", "image/webp"}, + {"/assets/systems/atarilynx.webp", "image/webp"}, + {"/assets/systems/atomiswave.webp", "image/webp"}, + {"/assets/systems/c64.webp", "image/webp"}, + {"/assets/systems/cps.webp", "image/webp"}, + {"/assets/systems/dos.webp", "image/webp"}, + {"/assets/systems/dreamcast.webp", "image/webp"}, + {"/assets/systems/fbneo.webp", "image/webp"}, + {"/assets/systems/fds.webp", "image/webp"}, + {"/assets/systems/gamegear.webp", "image/webp"}, + {"/assets/systems/gb.webp", "image/webp"}, + {"/assets/systems/gba.webp", "image/webp"}, + {"/assets/systems/gbc.webp", "image/webp"}, + {"/assets/systems/gc.webp", "image/webp"}, + {"/assets/systems/genesis.webp", "image/webp"}, + {"/assets/systems/mame.webp", "image/webp"}, + {"/assets/systems/mastersystem.webp", "image/webp"}, + {"/assets/systems/msx.webp", "image/webp"}, + {"/assets/systems/n3ds.webp", "image/webp"}, + {"/assets/systems/n64.webp", "image/webp"}, + {"/assets/systems/naomi.webp", "image/webp"}, + {"/assets/systems/nds.webp", "image/webp"}, + {"/assets/systems/neogeo.webp", "image/webp"}, + {"/assets/systems/neogeocd.webp", "image/webp"}, + {"/assets/systems/nes.webp", "image/webp"}, + {"/assets/systems/ngp.webp", "image/webp"}, + {"/assets/systems/ngpc.webp", "image/webp"}, + {"/assets/systems/pcengine.webp", "image/webp"}, + {"/assets/systems/pcfx.webp", "image/webp"}, + {"/assets/systems/pokemini.webp", "image/webp"}, + {"/assets/systems/ps2.webp", "image/webp"}, + {"/assets/systems/psp.webp", "image/webp"}, + {"/assets/systems/psx.webp", "image/webp"}, + {"/assets/systems/saturn.webp", "image/webp"}, + {"/assets/systems/scummvm.webp", "image/webp"}, + {"/assets/systems/sega32x.webp", "image/webp"}, + {"/assets/systems/segacd.webp", "image/webp"}, + {"/assets/systems/sg-1000.webp", "image/webp"}, + {"/assets/systems/snes.webp", "image/webp"}, + {"/assets/systems/supergrafx.webp", "image/webp"}, + {"/assets/systems/virtualboy.webp", "image/webp"}, + {"/assets/systems/wii.webp", "image/webp"}, + {"/assets/systems/wonderswan.webp", "image/webp"}, + {"/assets/systems/wonderswancolor.webp", "image/webp"}, + {"/assets/ICON-CREDITS.txt", "text/plain; charset=utf-8"}, + {"/assets/lucide/LICENSE", "text/plain; charset=utf-8"}, {"/settings-guide.js", "text/javascript; charset=utf-8"}, {"/version.json", "application/json"}, {"/assets/mihawk.png", "image/png"}, @@ -2068,6 +2255,8 @@ MHD_Result handle(void *, MHD_Connection *c, const char *url, const char *method prepare_scraped(c, *r); else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/library/media") == 0) prepare_media(c, *r); + else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/library/add") == 0) + prepare_game_add(c, *r); else if (std::strcmp(method, "PUT") == 0 && std::strcmp(url, "/api/library/file") == 0) prepare_game_file(c, *r); if (std::strcmp(url, "/api/scraper/start") == 0) diff --git a/tests/test_scraper.py b/tests/test_scraper.py index 92ab297..ac96c8b 100644 --- a/tests/test_scraper.py +++ b/tests/test_scraper.py @@ -452,6 +452,38 @@ class Scraper(unittest.TestCase): self.assertEqual({field: game[field] for field in fields}, fields) self.assertTrue(any('released' not in game for game in games)) + def test_add_game_and_savestate_preview(self): + options = json.loads(self.request('GET', '/api/library/add-options')[1]) + self.assertIn('snes', [s['id'] for s in options['systems']]) + route = '/api/library/add?' + urllib.parse.urlencode({'system': 'snes', 'filename': 'New Homebrew.sfc'}) + status, body = self.request('PUT', route, b'synthetic backup') + self.assertEqual(status, 201, body) + game = json.loads(body) + query = urllib.parse.urlencode({'system': 'snes', 'game': game['key'], 'path': game['path']}) + self.assertEqual(self.request('PUT', route, b'overwrite')[0], 400) + for name in ['../escape.sfc', 'invalid.exe', '.hidden.sfc']: + self.assertEqual(self.request('PUT', '/api/library/add?' + urllib.parse.urlencode({'system': 'snes', 'filename': name}), b'x')[0], 400) + self.assertEqual(self.request('GET', '/api/library/file?' + query + '&kind=rom')[1], b'synthetic backup') + self.assertEqual(self.request('POST', '/api/library/game?' + query, json.dumps({'partial': 'true', 'name': 'My Homebrew', 'launchbox_id': '42'}).encode())[0], 200) + self.assertEqual(self.request('POST', '/api/library/game?' + query, json.dumps({'partial': 'true', 'developer': 'Homebrew author'}).encode())[0], 200) + meta = (self.root / 'library/snes/metadata/New Homebrew.meta').read_text() + self.assertIn('edited = "name,developer"', meta) + self.assertNotIn('description =', meta) + folder = self.root / 'savestates/Snes9x' + folder.mkdir(parents=True) + state = folder / 'New Homebrew.state3'; state.write_bytes(b'state') + screenshot = folder / 'New Homebrew.state3.png'; screenshot.write_bytes(b'PNG fixture') + files = json.loads(self.request('GET', '/api/library/files?' + query)[1]) + self.assertTrue(files['state'][0]['preview']) + export = '/api/library/file?' + query + '&' + urllib.parse.urlencode({'kind': 'state', 'file': str(state)}) + self.assertEqual(self.request('GET', export + '&preview=1')[1], b'PNG fixture') + self.assertEqual(self.request('PUT', export + '&existing=replace', b'new state')[0], 201) + self.assertFalse(screenshot.exists(), 'An imported state must not keep the old screenshot') + self.assertEqual(self.request('GET', export + '&preview=1')[0], 404) + screenshot.symlink_to(state) + self.assertFalse(json.loads(self.request('GET', '/api/library/files?' + query)[1])['state'][0]['preview']) + self.assertEqual(self.request('GET', export + '&preview=1')[0], 404) + def test_game_files_and_edited_metadata(self): path = next(iter(self.games.values())) rom = self.root / path.removeprefix('/app0/') @@ -539,6 +571,11 @@ class Scraper(unittest.TestCase): import base64 from unittest.mock import patch png = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==') + state_folder = self.root / 'savestates/Snes9x' + state_folder.mkdir(parents=True) + key = Path(next(path for label, path in self.games.items() if 'Donkey Kong' in label)).stem + (state_folder / (key + '.state5')).write_bytes(b'preview state') + (state_folder / (key + '.state5.png')).write_bytes(png) with patch(__name__ + '.image', return_value=png): run = subprocess.run(['node', 'tests/webui_scraper_browser.cjs'], cwd=ROOT, capture_output=True, text=True, timeout=300, env={**os.environ, 'PLAYWRIGHT_PATH': playwright, 'WEBUI_TEST_URL': f'http://127.0.0.1:{self.port}'}) diff --git a/tests/test_scraper_launchbox.py b/tests/test_scraper_launchbox.py index 19b9c0f..ff14a88 100644 --- a/tests/test_scraper_launchbox.py +++ b/tests/test_scraper_launchbox.py @@ -50,9 +50,9 @@ def picture(name): return f'IMAGE {name}'.encode() * 30 -def metadata_zip(): +def metadata_zip(entries=GAMES): games = ''.join(f'{n}{i}{p}{x or ""}' - for i, n, p, x in GAMES) + for i, n, p, x in entries) alternate = ''.join(f'{n}{i}' for i, n in ALTERNATE) images = ''.join(f'{i}{f}{t}' @@ -186,8 +186,39 @@ class LaunchBox(unittest.TestCase): with self.source.lock: return [p for p in self.source.log if p.startswith(prefix)] + def test_lookup_details_before_game_upload(self): + original = self.source.zip + self.addCleanup(setattr, self.source, 'zip', original) + self.source.zip = metadata_zip(GAMES + [('4000', 'Super Metroid', SNES, ''), ('4001', 'Super Metroid (MSU-1 Enhanced)', SNES, '')] + [(str(5000 + i), f'Super Metroid Variant {i:02}', SNES, '') for i in range(30)]) + status, body = self.request('POST', '/api/library/launchbox?system=snes&q=Super%20Metroid') + self.assertEqual(status, 200, body) + results = json.loads(body)['results'] + self.assertTrue(results) + self.assertEqual(results[0]['id'], '4000') # Literal title beats an edition with the same normalized key. + self.assertEqual(results[1]['id'], '2002') # Exact alias still beats partial/variant names. + self.assertEqual(json.loads(self.request('POST', '/api/library/launchbox?system=snes&q=super%20metroid')[1])['results'][0]['id'], '4000') + self.assertEqual(len(results), 20) + self.assertIn('description', results[0]) + self.assertIn('id', results[0]) + self.assertEqual(self.request('POST', '/api/library/launchbox?system=unknown&q=Metroid')[0], 400) + status, body = self.request('PUT', '/api/library/add?system=snes&filename=My%20Custom%20Backup.sfc', b'synthetic game') + self.assertEqual(status, 201, body) + game = json.loads(body) + query = urllib.parse.urlencode({'system': 'snes', 'game': game['key']}) + self.assertEqual(self.request('POST', '/api/library/game?' + query, json.dumps({'partial': 'true', 'name': 'A custom display name', 'launchbox_id': '2002'}).encode())[0], 200) + job = self.start(kinds='cover', details=True, lines=('snes\t' + game['path'],)) + self.wait(job) + info = json.loads(self.request('GET', '/api/library/game?' + query)[1]) + self.assertEqual(info['details']['name'], 'A custom display name') + self.assertTrue(next(k for k in info['kinds'] if k['id'] == 'cover')['present']) + + def test_ps5_mode_titles_alternate_names_romsets_regions_and_details(self): self.assertEqual({s['id']: s['available'] for s in json.loads(self.request('GET', '/api/scraper/settings')[1])['sources']}['launchbox'], True) + # Add Game without a database choice still permits later ambiguity resolution. + query = urllib.parse.urlencode({'system': 'snes', 'game': 'Chrono Trigger SE'}) + self.assertEqual(self.request('POST', '/api/library/game?' + query, + json.dumps({'partial': 'true', 'launchbox_id': ''}).encode())[0], 200) job_id = self.start() job = self.wait(job_id) self.assertEqual(job['state'], 'done', job) diff --git a/tests/test_scraper_screenscraper.py b/tests/test_scraper_screenscraper.py index 825c5da..052d241 100644 --- a/tests/test_scraper_screenscraper.py +++ b/tests/test_scraper_screenscraper.py @@ -271,12 +271,18 @@ class ScreenScraper(unittest.TestCase): def test_ps5_mode_checksum_name_search_details_and_thread_limit(self): self.assertEqual(self.sign_in()[0], 200) + # Database IDs belong to their source; a LaunchBox choice is not a ScreenScraper ID. + key = self.key('Super Metroid (Japan, USA) (En,Ja)') + query = urllib.parse.urlencode({'system': 'snes', 'game': key}) + self.assertEqual(self.request('POST', '/api/library/game?' + query, + json.dumps({'partial': 'true', 'launchbox_id': '2002'}).encode())[0], 200) job_id = self.start() job = self.wait(job_id) self.assertEqual(job['state'], 'done', job) self.assertEqual(job['counts'], {'ambiguous': 1, 'done': 3, 'unmatched': 1}) self.assertLessEqual(self.source.most_busy, 2 + 3) # two games at once (and their media downloads) infos = [q for e, q in self.source.calls if e == 'jeuInfos.php'] + self.assertFalse(any(q.get('gameid') == '2002' for q in infos)) self.assertTrue(any(q.get('crc') == 'D63ED5F8' and q.get('romtaille') == '300' for q in infos)) library = self.root / 'library/snes' metroid = self.key('Super Metroid (Japan, USA) (En,Ja)') diff --git a/tests/webui_scraper_browser.cjs b/tests/webui_scraper_browser.cjs index 0236fee..6e7e2dc 100644 --- a/tests/webui_scraper_browser.cjs +++ b/tests/webui_scraper_browser.cjs @@ -9,7 +9,40 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright'); try { const page = await browser.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); - await page.route('https://api.github.com/**', route => route.fulfill({ json: [] })); + let releaseResponse = []; + await page.route('https://api.github.com/**', route => route.fulfill({ json: releaseResponse })); + // Sign in from Add Game before the Download Media page initializes its state. + await page.route('**/api/scraper/settings', async route => { + const response = await route.fetch(), settings = await response.json(); + settings.sources.find(s => s.id === 'screenscraper').available = true; + await route.fulfill({json: settings}); + }); + await page.route('**/api/scraper/account?source=screenscraper', route => route.fulfill({json:{signed_in:true,user:'browser fixture'}})); + await page.route('**/version.json', async route => { + const response = await route.fetch(), version = await response.json(); + await route.fulfill({json:{...version,release:'v0.6.7-alpha.6'}}); + }); + await page.goto(process.env.WEBUI_TEST_URL + '/#games'); + for (const [tag,state,icon] of [['v0.6.7-alpha.6','current','check'],['v1.0.0-beta.1','update','info']]) { + releaseResponse = [{tag_name:tag,published_at:'2026-10-01T13:00:00Z',draft:false}]; + await page.locator('#check-release').click(); + await page.waitForFunction(state => document.querySelector('.release-bar').dataset.state === state, state); + assert.equal(await page.locator('.release-icon').evaluate((node,name) => node.classList.contains('color-icon') && node.getAttribute('src') === uiIcon(name).getAttribute('src'), icon),true); + } + releaseResponse = []; await page.unroute('**/version.json'); + await page.locator('#add-game-open').click(); + await page.locator('[data-add-tab=media]').click(); + await page.locator('#add-download').click(); + await page.locator('.add-source', {hasText:'ScreenScraper'}).getByRole('button', {name:'Sign in',exact:true}).click(); + await page.locator('#sign-in-user').fill('browser fixture'); + await page.locator('#sign-in-password').fill('fixture'); + await page.locator('#sign-in-submit').click(); + await page.locator('#sign-in-submit').filter({hasText:'Done'}).waitFor(); + assert.equal(await page.locator('#sign-in-error').innerText(),''); + await page.locator('#sign-in-submit').click(); + await page.locator('#add-cancel').click(); + await page.unroute('**/api/scraper/settings'); + await page.unroute('**/api/scraper/account?source=screenscraper'); // Opened straight on the tab (the state it reads is declared before it runs). await page.goto(process.env.WEBUI_TEST_URL + '/#media'); await page.waitForFunction(() => document.querySelectorAll('.kind-tile').length === 10); @@ -17,7 +50,7 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright'); assert.match(await page.locator('#restart-notice').innerText(), /Close PS5 RetroArch on the console/); await page.goto(process.env.WEBUI_TEST_URL + '/#games'); await page.waitForFunction(() => document.querySelectorAll('.game-card').length === 5); - await page.locator('#scrape-open').click(); + await page.locator('a[data-page=media]').click(); await page.waitForFunction(() => location.hash === '#media' && document.querySelectorAll('.kind-tile').length === 10); assert.match(await page.locator('#scrape-method-hint').innerText(), /This PC → PS5: this PC downloads, then transfers to the PS5/); assert.equal(await page.locator('#scrape-start').isDisabled(), true, 'no start until a method is chosen'); @@ -66,19 +99,19 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright'); assert.equal(await page.locator('.recap-game .kind-pill').count(), 2, 'one kind shown when one is picked'); await page.goto(process.env.WEBUI_TEST_URL + '/#games'); await page.waitForFunction(() => { - const covers = [...document.querySelectorAll('.game-cover img')]; + const covers = [...document.querySelectorAll('.game-cover img:not(.color-icon)')]; return covers.length === 4 && covers.every(img => img.complete && img.naturalWidth > 0); }); // A card opens the game: one row a kind of media, missing ones greyed, each replaceable. await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-cover').click(); const sheet = page.locator('#game-sheet'); await sheet.waitFor({ state: 'visible' }); - await page.waitForFunction(() => document.querySelectorAll('.sheet-thumb').length === 10); + await page.waitForFunction(() => document.querySelectorAll('#game-sheet .sheet-thumb').length === 10); assert.equal(await page.locator('.media-row-item:not(.missing)').count(), 1); assert.equal(await page.locator('.media-row-item').count(), 1); assert.equal(await page.locator('.media-row-item[data-kind="cover"]:not(.missing) .media-stage').count(), 1); assert.match(await page.locator('#sheet-media-count').innerText(), /3 of 10 types/); - await page.locator('.sheet-thumb', { hasText: 'Fan art' }).click(); + await page.locator('#game-sheet .sheet-thumb', { hasText: 'Fan art' }).click(); const fanart = page.locator('.media-row-item[data-kind="fanart"]'); assert.equal(await fanart.locator('button').innerText(), 'Add…'); // A type no frontend shows is refused on the page; a PNG is sent and shown at once. @@ -105,6 +138,10 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright'); const downloadUrl = await saveGroup.locator('a').getAttribute('href'); assert.equal(await (await page.request.get(process.env.WEBUI_TEST_URL + downloadUrl)).text(), 'native save fixture'); const stateGroup = page.locator('.sheet-file-group[data-kind="state"]'); + await page.waitForFunction(() => document.querySelector('.state-preview img')?.naturalWidth > 0); + await stateGroup.locator('.state-preview-image').click(); + await page.locator('.lightbox').waitFor(); await page.keyboard.press('Escape'); + await stateGroup.locator('select').selectOption(''); await stateGroup.locator('input[type="number"]').fill('3'); page.once('dialog', dialog => dialog.accept()); await stateGroup.locator('input[type="file"]').setInputFiles({ name: 'progress.state', mimeType: 'application/octet-stream', buffer: Buffer.from('native state fixture') }); @@ -123,13 +160,71 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright'); assert.equal(await page.locator('input[name="games-view"][value="box3d"]').isChecked(), true); await page.locator('label:has(input[name="games-view"][value="cover"])').click(); await page.waitForFunction(() => { - const covers = [...document.querySelectorAll('.game-cover img')]; + const covers = [...document.querySelectorAll('.game-cover img:not(.color-icon)')]; return covers.length === 4 && covers.every(img => img.complete && img.naturalWidth > 0); }); // The checkbox still selects without opening. await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-select').click(); assert.equal(await sheet.isVisible(), false); await page.locator('.game-card', { hasText: 'Donkey Kong Country 2' }).locator('.game-select').click(); + await page.locator('#add-game-open').click(); + await page.locator('#add-system').selectOption('snes'); + await page.locator('#add-file').setInputFiles({name:'Browser Homebrew.sfc', mimeType:'application/octet-stream', buffer:Buffer.from('synthetic game')}); + await page.locator('[data-add-tab=details]').click(); + await page.locator('#add-name').fill('My Browser Homebrew'); + await page.locator('#add-developer').fill('Homebrew author'); + await page.locator('[data-add-tab=media]').click(); + await page.locator('#add-media-file').setInputFiles({name:'cover.png', mimeType:'image/png', buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==','base64')}); + await page.locator('[data-add-tab=details]').click(); + assert.equal(await page.locator('#add-developer').inputValue(),'Homebrew author'); + await page.locator('#add-submit').click(); + await page.locator('#add-game').waitFor({state:'hidden'}); + await page.locator('#sheet-title').filter({hasText:'My Browser Homebrew'}).waitFor(); + await page.locator('.media-row-item:not(.missing)').waitFor(); + assert.equal(await page.locator('.media-row-item:not(.missing)').count(),1); + await page.locator('#sheet-close').click(); + assert.equal(await page.locator('.game-card').count(),6); + assert.ok(await page.locator('.sidebar nav .color-icon').count() >= 6); + // Color artwork uses bundled assets; real flags and every known system resolve. + assert.equal(await page.locator('.game-platform .system-icon img').count(),6); + const artwork = await page.evaluate(() => ({ systems:Object.values(SYSTEM_ICONS), flags:['us','eu','jp','kr'].map(f => iconNode('flag-'+f).getAttribute('src')) })); + assert.equal(artwork.systems.length,50); + for (const src of [...artwork.systems,...artwork.flags]) assert.equal((await page.request.get(process.env.WEBUI_TEST_URL+'/'+src)).status(),200,src); + assert.equal((await page.request.get(process.env.WEBUI_TEST_URL+'/assets/systems/../../retroarch.cfg')).status(),404); + // Adding with downloads stays in a live progress view and opens fresh artwork. + await page.locator('#add-game-open').click(); + await page.locator('#add-system').selectOption('snes'); + await page.locator('#add-file').setInputFiles({name:'Super Metroid Download Check.sfc',mimeType:'application/octet-stream',buffer:Buffer.from('synthetic download-flow backup')}); + await page.locator('[data-add-tab=details]').click(); + await page.locator('#add-name').fill('Super Metroid (Japan, USA) (En,Ja)'); + await page.locator('[data-add-tab=media]').click(); + await page.locator('#add-download').click(); + assert.match(await page.locator('#add-submit').innerText(),/Add game & download media/); + // Keep the running phase visible even on a fast local fixture. + let polls = 0, lastAddJob; + await page.route('**/api/scraper/job?id=*',async route => { + const response = await route.fetch(), data = await response.json(); lastAddJob = data.job; + if (data.job && polls++ < 2) data.job.state = 'running'; + await route.fulfill({json:data}); + }); + await page.locator('#add-submit').click(); + await page.locator('#add-activity').waitFor({state:'visible'}); + assert.equal(await page.locator('#game-sheet').isVisible(),false); + await page.locator('#add-submit').filter({hasText:'Open game'}).waitFor({timeout:30000}); + assert.ok(await page.locator('#add-live-media .add-live-item').count() > 0,JSON.stringify(lastAddJob)); + if (process.env.WEBUI_ADD_SCREENSHOTS) { + await page.screenshot({path:process.env.WEBUI_ADD_SCREENSHOTS+'-desktop.png'}); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:process.env.WEBUI_ADD_SCREENSHOTS+'-mobile.png'}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),true); + await page.setViewportSize({width:1280,height:720}); + } + await page.locator('#add-submit').click(); + await page.locator('#add-game').waitFor({state:'hidden'}); + await page.locator('#sheet-title').filter({hasText:'Super Metroid'}).waitFor(); + assert.ok(await page.locator('.media-row-item:not(.missing)').count() > 0,'downloaded artwork is visible without reloading'); + await page.locator('#sheet-close').click(); + await page.unroute('**/api/scraper/job?id=*'); // Sorting/filtering the complete library, across systems, before the 300-card limit. const game = (name, details = {}) => ({ name, label: name, key: name, path: `/app0/content/${name}.zip`, media: [], ...details }); const library = { systems: [ @@ -139,7 +234,7 @@ const { chromium } = require(process.env.PLAYWRIGHT_PATH || 'playwright'); const libraryRoute = route => route.fulfill({ json: library }); await page.route('**/api/library', libraryRoute); await page.locator('#refresh-games').click(); - await page.waitForFunction(() => document.querySelectorAll('.game-card').length === 6); + await page.locator('.game-info strong').filter({hasText: '!Unknown'}).waitFor(); const titles = () => page.locator('.game-info strong').allTextContents(); assert.deepEqual(await titles(), ['!Unknown', '1942', 'Alpha 2', 'Alpha 10', 'Éclair', 'Zelda']); await page.locator('.game-card', { hasText: 'Alpha 2' }).locator('.game-select').check(); diff --git a/tooling/notices/components.json b/tooling/notices/components.json index 872db3a..5d94f93 100644 --- a/tooling/notices/components.json +++ b/tooling/notices/components.json @@ -11,6 +11,106 @@ ], "schema": 1, "components": [ + { + "id": "webui-fluent", + "name": "Microsoft Fluent UI color icons", + "kind": "asset", + "artifacts": [ + "webui/assets/fluent/*" + ], + "licence": "MIT", + "copyright": [ + "Copyright (c) Microsoft Corporation." + ], + "texts": [ + { + "from": "webui/assets/fluent/LICENSE", + "to": "LICENSE" + } + ], + "source": { + "kind": "fixed", + "revision": "cd332ec1f90cda2fad16f86754f8da52f1e9ab30", + "remote": "https://github.com/microsoft/fluentui-system-icons" + }, + "modifications": "Bundled artwork; original colors preserved. System artwork is framed with CSS to show the hardware." + }, + { + "id": "webui-flags", + "name": "Region flags by flag-icons", + "kind": "asset", + "artifacts": [ + "webui/assets/flags/*" + ], + "licence": "MIT", + "copyright": [ + "Copyright (c) 2013-2026 Panayiotis Lipiridis" + ], + "texts": [ + { + "from": "webui/assets/flags/LICENSE", + "to": "LICENSE" + } + ], + "source": { + "kind": "fixed", + "revision": "086f7e97d657358203916dbe84f61c2bccaa81eb", + "remote": "https://github.com/lipis/flag-icons" + }, + "modifications": "Bundled artwork; original colors preserved. System artwork is framed with CSS to show the hardware." + }, + { + "id": "webui-systems", + "name": "Linear ES-DE system artwork", + "kind": "asset", + "artifacts": [ + "webui/assets/systems/*" + ], + "licence": "MIT", + "copyright": [ + "Copyright (c) 2024-2026 Northwestern Software AB", + "Copyright (c) 2024 Leon Styhre", + "Copyright (c) 2023 Weestuarty" + ], + "texts": [ + { + "from": "webui/assets/systems/LICENSE", + "to": "LICENSE" + } + ], + "source": { + "kind": "fixed", + "revision": "bundled source snapshot; original SHA-256 manifest in webui/assets/systems/SOURCE.json", + "remote": "https://gitlab.com/es-de/themes/linear-es-de" + }, + "modifications": "Bundled artwork; original colors preserved. System artwork is framed with CSS to show the hardware." + }, + { + "id": "webui-lucide", + "name": "Lucide WebUI icons (including Feather-derived icons)", + "kind": "asset", + "artifacts": [ + "webui/icons.js", + "webui/assets/lucide/*" + ], + "licence": "ISC AND MIT", + "copyright": [ + "Lucide Icons and Contributors", + "Copyright (c) 2013-present Cole Bemis (Feather)" + ], + "texts": [ + { + "from": "webui/assets/lucide/LICENSE", + "to": "LICENSE" + } + ], + "source": { + "kind": "fixed", + "revision": "a04f228cd01185e09c188b7227b9600c08c565ec", + "remote": "https://github.com/lucide-icons/lucide" + }, + "modifications": "Unmodified SVG geometry bundled locally in icons.js, rendered at a consistent 1.75 stroke width." + }, { "id": "ps5-retroarch", "name": "PS5 RetroArch (this port: title code, platform integration, build scripts and patches)", diff --git a/webui/add-game.js b/webui/add-game.js new file mode 100644 index 0000000..59b7295 --- /dev/null +++ b/webui/add-game.js @@ -0,0 +1,261 @@ +/* Add Game shares the library, metadata, media store and scraper used by every frontend. */ +'use strict'; +let addGame = null; +const ADD_FIELDS = [['name','Game name'],['developer','Developer'],['publisher','Publisher'],['released','Release date'],['genre','Genre'],['players','Players'],['rating','Rating (%)'],['description','Description']]; +function addTab(name) { + if (addGame?.busy) return; + $$('[data-add-tab]').forEach(b => b.setAttribute('aria-pressed', b.dataset.addTab === name)); + $$('[data-add-panel]').forEach(p => { p.hidden = p.dataset.addPanel !== name; }); +} +function addDownloadsEnabled() { return $('#add-download').getAttribute('aria-pressed') === 'true'; } +function setAddDownloads(enabled) { + $('#add-download').setAttribute('aria-pressed', enabled); $('#add-download-state').textContent = enabled ? 'Enabled' : 'Enable'; + $('#add-scrapers').hidden = !enabled; + $('#add-submit').replaceChildren(uiIcon(enabled ? 'download' : 'plus'), document.createTextNode(enabled ? 'Add game & download media' : 'Add to library')); +} +function addMessage(text, error = false) { $('#add-status').textContent = text; $('#add-status').classList.toggle('inline-error', error); } +function closeAdd() { + if (!addGame || addGame.busy) return; + if ((addGame.file || addGame.dirty) && !confirm(addGame.uploaded ? 'The game backup is already in your library. Close without finishing the remaining details and media?' : 'Discard this new game draft?')) return; + disposeAdd(); $('#add-game').close(); +} +function disposeAdd() { if (addGame) { for (const item of addGame.media.values()) URL.revokeObjectURL(item.url); addGame.searchController?.abort(); } addGame = null; } +function addFile(file) { + if (!file || !addGame || addGame.busy || addGame.uploaded) return; + if (!file.size || file.size > uploadLimit) { addMessage('Choose a non-empty backup of at most ' + bytes(uploadLimit) + '.', true); return; } + addGame.file = file; + $('#add-file-name').textContent = file.name; $('#add-file-hint').textContent = bytes(file.size) + ' · click to choose another file'; + if (!$('#add-name').value || $('#add-name').value === addGame.suggestedName) { + addGame.suggestedName = file.name.replace(/\.[^.]+$/, '').replace(/[_]+/g, ' '); $('#add-name').value = addGame.suggestedName; + } + $('#add-search').value = $('#add-name').value; addDestination(); addMessage('Ready to add. Details and media are optional.'); +} +function addDestination() { + const system = addGame.systems.find(s => s.id === $('#add-system').value); + $('#add-system-icon').replaceChildren(systemIcon(system?.id)); + $('#add-file').accept = system ? [...new Set(system.extensions.split('|'))].map(x => '.' + x).join(',') : ''; + $('#add-destination').textContent = system && addGame.file ? 'Destination: /app0/content/' + system.name + '/' + addGame.file.name : 'Select the system this game belongs to.'; +} +async function openAddGame() { + if (addGame) return; + addGame = {file:null, media:new Map(), selected:'cover', sources:['libretro'], systems:[], settings:null, busy:false, dirty:false, uploaded:null, launchboxId:''}; + const context = addGame; + $('#add-game').showModal(); addTab('file'); $('#add-results').replaceChildren(); $('#add-match').textContent = ''; $('#add-search-status').textContent = ''; $('#add-search').value = ''; $('#add-search-button').disabled = false; + $('#add-file-name').textContent = 'Choose a game backup'; $('#add-file-hint').textContent = 'Drop one file here, or browse your device'; + setAddDownloads(false); $('#add-layout').hidden = false; $('#add-activity').hidden = true; $('#add-submit').hidden = false; $('#add-progress').hidden = true; $('#add-submit').disabled = true; + $('#add-system').disabled = $('#add-drop').disabled = false; $('#add-fetch-details').checked = true; + $('#add-submit').replaceChildren(uiIcon('plus'), document.createTextNode('Add to library')); addMessage('Loading your installed systems…'); + $('#add-fields').replaceChildren(...ADD_FIELDS.map(([key, title]) => { + const label = element('label', title, key === 'name' || key === 'description' ? 'wide-field' : ''); + const input = document.createElement(key === 'description' ? 'textarea' : 'input'); input.id = 'add-' + key; + input.maxLength = key === 'description' ? 12000 : 512; input.required = key === 'name'; + if (key === 'rating') { input.type = 'number'; input.min = 0; input.max = 100; input.step = .1; } + if (key === 'released') input.placeholder = 'YYYY-MM-DD'; + input.addEventListener('input', () => { addGame.dirty = true; }); label.append(input); return label; + })); + try { + const [options, settings] = await Promise.all([api('/api/library/add-options'), api('/api/scraper/settings')]); + if (addGame !== context) return; + context.systems = options.systems; context.settings = settings; + $('#add-system').replaceChildren(new Option('Choose a system', ''), ...options.systems.map(s => new Option(s.name,s.id))); + if (options.systems.some(s => s.id === $('#games-system').value)) $('#add-system').value = $('#games-system').value; + $('#add-region').value = settings.region || 'us'; $('#add-language').value = settings.language || 'en'; + if (!$('#add-language').value) $('#add-language').value = 'en'; + drawAddMedia(); drawAddSources(); addDestination(); $('#add-submit').disabled = !options.systems.length; + addMessage(options.systems.length ? 'Choose a system and a game backup to begin.' : 'No supported cores are installed. Install a core before adding a game.'); + } catch (error) { if (addGame === context) addMessage(error.message + ' Close and reopen Add Game to retry.', true); } +} +function drawAddSources() { + const context = addGame, box = $('#add-sources'); box.replaceChildren(); + const ordered = [...context.sources.map(id => context.settings.sources.find(s => s.id === id)), ...context.settings.sources.filter(s => !context.sources.includes(s.id))].filter(Boolean); + for (const source of ordered) { + const row = element('div',undefined,'add-source'); row.append(iconNode('source-' + source.id)); + const label = element('label'), check = document.createElement('input'); check.type = 'checkbox'; check.checked = context.sources.includes(source.id); + check.disabled = !source.available || (source.account && !source.signed_in); label.append(check, document.createTextNode(source.name)); row.append(label); + check.addEventListener('change', () => { context.sources = check.checked ? [...context.sources,source.id] : context.sources.filter(id => id !== source.id); drawAddSources(); }); + if (source.available && source.account && !source.signed_in) { + const login = element('button','Sign in','text-button'); login.type = 'button'; login.addEventListener('click', () => openSignIn(source)); row.append(login); + } else if (!source.available) row.append(element('small','Not available yet','muted')); + if (check.checked) for (const direction of [-1,1]) { + const move = element('button',undefined,'icon-button'); move.type = 'button'; move.append(uiIcon(direction < 0 ? 'chevron-up' : 'chevron-down')); + const index = context.sources.indexOf(source.id); move.disabled = index + direction < 0 || index + direction >= context.sources.length; + move.setAttribute('aria-label', 'Ask ' + source.name + (direction < 0 ? ' earlier' : ' later')); + move.addEventListener('click', () => { [context.sources[index],context.sources[index+direction]] = [context.sources[index+direction],context.sources[index]]; drawAddSources(); }); row.append(move); + } + box.append(row); + } +} +function drawAddMedia() { + const context = addGame, kind = context.settings.catalog.find(k => k.id === context.selected), chosen = context.media.get(kind.id); + const preview = $('#add-media-preview'); preview.replaceChildren(); + const stage = element('div',undefined,'add-artwork'); + if (chosen && !['video','manual'].includes(kind.id)) { const img = document.createElement('img'); img.src = chosen.url; img.alt = kind.name; stage.append(img); } + else stage.append(uiIcon(KIND_ICONS[kind.id])); + const side = element('div'); side.append(element('h4',kind.name),element('p',chosen ? chosen.file.name + ' · ' + bytes(chosen.file.size) : kind.description,'muted sheet-hint')); + const choose = element('button',chosen ? 'Replace file…' : 'Choose file…','secondary'); choose.type = 'button'; + choose.addEventListener('click', () => { $('#add-media-file').accept = kind.accepts.map(x => '.' + x).join(','); $('#add-media-file').click(); }); + const actions = element('div',undefined,'sheet-actions'); actions.append(choose); + if (chosen) { const remove = element('button','Remove','text-button'); remove.type = 'button'; remove.addEventListener('click', () => { URL.revokeObjectURL(chosen.url); context.media.delete(kind.id); drawAddMedia(); }); actions.append(remove); } + side.append(actions); preview.append(stage,side); + stage.addEventListener('dragover',e => e.preventDefault()); stage.addEventListener('drop',e => { e.preventDefault(); chooseAddMedia(e.dataTransfer.files[0]); }); + $('#add-media-types').replaceChildren(...context.settings.catalog.map(k => { + const button = element('button',undefined,'sheet-thumb'); button.type = 'button'; button.setAttribute('aria-pressed',k.id === context.selected); + button.append(uiIcon(KIND_ICONS[k.id]), element('span',k.name), element('small',context.media.has(k.id) ? 'Ready' : 'Optional')); + button.addEventListener('click', () => { context.selected = k.id; drawAddMedia(); }); return button; + })); +} +function chooseAddMedia(file) { + if (!file || addGame.busy) return; + const kind = addGame.settings.catalog.find(k => k.id === addGame.selected), ext = file.name.split('.').pop().toLowerCase(); + if (!file.size || file.size > 1024 ** 3 || !kind.accepts.includes(ext)) { addMessage('Choose ' + kind.accepts.join(', ').toUpperCase() + ' media of at most 1 GiB.',true); return; } + const old = addGame.media.get(kind.id); if (old) URL.revokeObjectURL(old.url); + addGame.media.set(kind.id,{file,url:URL.createObjectURL(file)}); addGame.dirty = true; drawAddMedia(); +} +$('#add-search-form').addEventListener('submit', async e => { + e.preventDefault(); const context = addGame, system = $('#add-system').value, query = $('#add-search').value.trim(); + if (!system || query.length < 2) { $('#add-search-status').textContent = 'Choose a system and enter at least two letters.'; return; } + context.searchController?.abort(); context.searchController = new AbortController(); + const controller = context.searchController; $('#add-search-button').disabled = true; + $('#add-search-status').textContent = 'Searching LaunchBox… The first search downloads its database to your console and may take a few minutes.'; $('#add-results').replaceChildren(); + try { + const data = await api('/api/library/launchbox?' + new URLSearchParams({system,q:query}), {method:'POST',signal:controller.signal}); + if (addGame !== context || controller !== context.searchController || $('#add-system').value !== system) return; + $('#add-search-status').textContent = data.results.length ? 'Choose a match to fill the details below.' : 'No matches. Try another title, or enter your details below.'; + for (const match of data.results) { + const row = element('div',undefined,'add-result'), text = element('div'); + text.append(element('strong',match.name),element('small',[match.released,match.developer].filter(Boolean).join(' · ')),element('p',match.description,'add-result-description')); + const choose = element('button','Use these details','secondary'); choose.type = 'button'; + choose.addEventListener('click', () => { + if (context.dirty && !confirm('Replace the current details with this LaunchBox match? You can edit them afterward.')) return; + for (const [key] of ADD_FIELDS) $('#add-' + key).value = key === 'rating' && match[key] ? Math.round(Number(match[key])*1000)/10 : match[key] || ''; + context.launchboxId = match.id; context.dirty = true; $('#add-match').textContent = 'LaunchBox match: ' + match.name; + $('#add-results').replaceChildren(); $('#add-search-status').textContent = ''; $('#add-name').focus(); + }); row.append(text,choose); $('#add-results').append(row); + } + } catch (error) { if (addGame === context && controller === context.searchController) $('#add-search-status').textContent = error.name === 'AbortError' ? 'Search cancelled.' : error.message; } + finally { if (addGame === context && controller === context.searchController) $('#add-search-button').disabled = false; } +}); +function uploadNewGameFile(url, file, context) { + return new Promise((resolve,reject) => { + const request = new XMLHttpRequest(); context.upload = request; request.open('PUT',url); request.setRequestHeader('X-RetroArch-Token',token); + request.upload.addEventListener('progress',e => { if (e.lengthComputable) { $('#add-progress').value = e.loaded/e.total*100; addMessage('Sending ' + file.name + ' · ' + Math.round(e.loaded/e.total*100) + '%'); } }); + request.addEventListener('load', () => { let data = {}; try { data = JSON.parse(request.responseText); } catch {} request.status === 201 ? resolve(data) : reject(new Error(data.error || 'Upload failed. Check the library before retrying.')); }); + request.addEventListener('error', () => reject(new Error('Connection lost. Check the library before retrying; the upload may have completed.'))); + request.addEventListener('abort', () => reject(new Error('Upload cancelled. Check the library if it had already reached 100%.'))); request.send(file); + }); +} +async function watchAddDownloads(context, query) { + context.watching = true; context.detached = false; + $('#add-layout').hidden = true; $('#add-activity').hidden = false; $('#add-submit').hidden = true; + $('#add-progress').hidden = true; $('#add-cancel').disabled = false; $('#add-cancel').textContent = 'Continue in Download Media'; + $('#add-activity-game').textContent = $('#add-name').value; + $('#add-live-media').replaceChildren(); $('#add-activity-progress').removeAttribute('value'); + let job = context.downloadJob, mediaStamp = '', mediaCount = 0; + try { + for (;;) { + if (context.detached) return 'downloads'; + const running = job.state === 'running'; + const sources = (job.sources || job.source).split(','), source = sources[job.pass || 0]; + const sourceName = context.settings.sources.find(s => s.id === source)?.name || source; + $('#add-activity-title').textContent = running ? 'Downloading your game’s media' : job.state === 'done' ? 'Your game has been added' : 'Your game is saved; downloads need attention'; + $('#add-activity-detail').textContent = `${sourceName} · ${job.downloaded.files} files downloaded · ${bytes(job.downloaded.bytes)}` + (job.message ? ' · ' + job.message : ''); + addMessage(running ? 'Finding and downloading media…' : 'Choose what to do next.'); + try { + context.pollController = new AbortController(); + const signal = AbortSignal.any([context.pollController.signal, AbortSignal.timeout(10000)]); + const info = await api('/api/library/game?' + query, {signal}); + const present = info.kinds.filter(k => k.present); mediaCount = present.length; + const stamp = present.map(k => k.id + ':' + k.changed).join(','); + if (stamp !== mediaStamp) { + mediaStamp = stamp; $('#add-live-media').replaceChildren(...present.map(kind => { + const item = element('div',undefined,'add-live-item'); + if (['video','manual'].includes(kind.id)) item.append(uiIcon(KIND_ICONS[kind.id])); + else { const image = document.createElement('img'); image.src = '/api/library/media?' + query + '&kind=' + kind.id + '&v=' + kind.changed; image.alt = kind.name; image.addEventListener('error', () => image.replaceWith(uiIcon(KIND_ICONS[kind.id]))); item.append(image); } + item.append(element('span',kind.name)); return item; + })); + } + if (!running) break; + await new Promise(resolve => setTimeout(resolve,1000)); + if (context.detached) return 'downloads'; + job = (await api('/api/scraper/job?id=' + encodeURIComponent(job.id), {signal})).job; + if (!job) throw new Error('Download job unavailable.'); + context.downloadJob = job; + } catch (error) { + if (context.detached) return 'downloads'; + $('#add-activity-detail').textContent = 'Connection interrupted. Reconnecting… Your game is already saved.'; + await new Promise(resolve => setTimeout(resolve,2000)); + try { job = (await api('/api/scraper/job?id=' + encodeURIComponent(context.downloadJob.id), {signal:AbortSignal.timeout(10000)})).job || context.downloadJob; } catch {} + } + } + $('#add-activity-progress').value = 100; + const attention = job.state !== 'done' || count(job,'ambiguous','unmatched','failed'); + $('#add-activity-hint').textContent = attention ? 'Some downloads need your help. Review matches or retry in Download Media. Your game and any downloaded artwork are saved.' : mediaCount ? `${mediaCount} media types are ready. Open your game to see them—no page refresh needed.` : 'No media was found. You can review the download or add your own artwork from the game overlay.'; + $('#add-submit').hidden = false; $('#add-submit').disabled = false; $('#add-submit').replaceChildren(uiIcon('gamepad-2'),document.createTextNode('Open game')); + $('#add-cancel').textContent = attention ? 'Review downloads' : 'Download details'; + return await new Promise(resolve => { context.finishChoice = resolve; }); + } finally { context.watching = false; context.finishChoice = null; context.pollController = null; } +} +$('#add-submit').addEventListener('click', async () => { + const context = addGame; if (context?.finishChoice) { context.finishChoice('game'); return; } if (!context || context.busy) return; + const system = context.systems.find(s => s.id === $('#add-system').value); + if (!system || !context.file) { addTab('file'); addMessage('Choose a system and a game backup.',true); return; } + for (const [key] of ADD_FIELDS) if (!$('#add-' + key).checkValidity()) { addTab('details'); $('#add-' + key).reportValidity(); return; } + if (!$('#add-name').value.trim()) { addTab('details'); $('#add-name').focus(); addMessage('Give this game a name.',true); return; } + const download = addDownloadsEnabled(); + if (download && !context.sources.length) { addTab('media'); addMessage('Choose at least one download source.',true); return; } + const metadata = {partial:'true',launchbox_id:context.launchboxId}; + for (const [key] of ADD_FIELDS) { const value = $('#add-' + key).value.trim(); if (value) metadata[key] = key === 'rating' ? String(Number(value)/100) : value; } + context.busy = true; context.searchController?.abort(); + for (const control of $$('#add-game input, #add-game select, #add-game textarea, #add-game button')) { control.dataset.wasDisabled = control.disabled; control.disabled = true; } + $('#add-cancel').disabled = false; $('#add-cancel').textContent = 'Cancel upload'; $('#add-progress').hidden = false; + try { + if (!context.uploaded) context.uploaded = await uploadNewGameFile('/api/library/add?' + new URLSearchParams({system:system.id,filename:context.file.name}),context.file,context); + const game = context.uploaded, query = new URLSearchParams({system:game.system,game:game.key,path:game.path}); + context.upload = null; $('#add-cancel').disabled = true; addMessage('Saving game details…'); + await api('/api/library/game?' + query,{method:'POST',body:JSON.stringify(metadata)}); + for (const [kind,item] of context.media) if (!item.stored) { + $('#add-cancel').disabled = false; + await uploadNewGameFile('/api/library/media?' + query + '&' + new URLSearchParams({kind,type:item.file.name.split('.').pop().toLowerCase()}),item.file,context); item.stored = true; + } + context.upload = null; $('#add-cancel').disabled = true; + let destination = 'game'; + if (download) { + addMessage('Starting media downloads…'); + const kinds = [...new Set(context.settings.sources.filter(s => context.sources.includes(s.id)).flatMap(s => s.kinds))]; + const started = context.downloadJob ? {job:context.downloadJob} : await api('/api/scraper/start?' + new URLSearchParams({mode:'ps5',sources:context.sources.join(','),kinds:kinds.join(','),details:$('#add-fetch-details').checked ? '1':'0',region:$('#add-region').value,language:$('#add-language').value,overwrite:'0'}),{method:'POST',body:game.system + '\t' + game.path,signal:AbortSignal.timeout(60000)}); + context.downloadJob = started.job; + destination = await watchAddDownloads(context, query); + } + disposeAdd(); $('#add-game').close(); await loadGames(); + const addedSystem = library.systems.find(s => s.id === game.system), added = addedSystem?.games.find(g => g.path === game.path); + if (destination === 'downloads') { location.hash = 'media'; return; } + if (added) await openGame(addedSystem,added); + announce('Game added.'); + + } catch (error) { + $('#add-layout').hidden = false; $('#add-activity').hidden = true; $('#add-submit').hidden = false; + addMessage((context.uploaded ? 'Game backup added. ' : '') + error.message + (context.uploaded ? ' Retry to finish the remaining setup.' : ''),true); + } finally { + context.busy = false; context.upload = null; + for (const control of $$('#add-game [data-was-disabled]')) { control.disabled = control.dataset.wasDisabled === 'true'; delete control.dataset.wasDisabled; } + $('#add-cancel').textContent = 'Cancel'; $('#add-progress').hidden = true; + if (addGame === context && context.uploaded) { $('#add-system').disabled = $('#add-drop').disabled = true; $('#add-submit').textContent = 'Finish setup'; } + } +}); +$('#add-game-open').addEventListener('click',openAddGame); +$$('[data-add-tab]').forEach(b => b.addEventListener('click', () => addTab(b.dataset.addTab))); +$('#add-close').addEventListener('click',closeAdd); +$('#add-cancel').addEventListener('click', () => { + if (addGame?.finishChoice) addGame.finishChoice('downloads'); + else if (addGame?.watching) { addGame.detached = true; addGame.pollController?.abort(); } + else if (addGame?.busy) addGame.upload?.abort(); else closeAdd(); +}); +$('#add-game').addEventListener('cancel',e => { e.preventDefault(); closeAdd(); }); +$('#add-drop').addEventListener('click', () => $('#add-file').click()); +$('#add-drop').addEventListener('dragover',e => e.preventDefault()); +$('#add-drop').addEventListener('drop',e => { e.preventDefault(); if (e.dataTransfer.files.length !== 1) addMessage('Choose one game backup. Use Content for complete disc sets or folders.',true); else addFile(e.dataTransfer.files[0]); }); +$('#add-file').addEventListener('change',e => { addFile(e.target.files[0]); e.target.value = ''; }); +$('#add-media-file').addEventListener('change',e => { chooseAddMedia(e.target.files[0]); e.target.value = ''; }); +$('#add-system').addEventListener('change', () => { addGame.launchboxId = ''; addGame.searchController?.abort(); $('#add-match').textContent = ''; $('#add-results').replaceChildren(); addDestination(); }); +$('#add-download').addEventListener('click', () => setAddDownloads(!addDownloadsEnabled())); +$('#sign-in').addEventListener('close',async () => { const context = addGame; if (context) { try { const settings = await api('/api/scraper/settings'); if (addGame === context) { context.settings = settings; drawAddSources(); } } catch (error) { addMessage(error.message,true); } } }); diff --git a/webui/app.css b/webui/app.css index a025453..9cf814d 100644 --- a/webui/app.css +++ b/webui/app.css @@ -2,9 +2,9 @@ :root{color-scheme:light;--rail:#37184a;--ground:#ede7f6;--surface:#fcfaff;--ink:#2d1447;--muted:#6c6f90;--accent:#7544b7;--accent-hover:#63339f;--line:#e0d6ef;--control:#cbcbe0;--green:#19af68;--sidebar-width:15.6667rem;--radius:0.722222rem;--r-page-title-font:2.77778rem;font:1rem/1.45 Arial,Helvetica,sans-serif;color:var(--ink);background:var(--ground);accent-color:var(--accent);scrollbar-color:#b09acb var(--ground)} *{box-sizing:border-box}body{margin:0}button,input,select{font:inherit;color:inherit}button,a,input,select{touch-action:manipulation}button,a{ -webkit-tap-highlight-color:transparent}button{cursor:pointer}a{color:var(--accent);text-underline-offset:0.222222rem}button:disabled,input:disabled,select:disabled{cursor:not-allowed;opacity:.55}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:0.166667rem solid #a566ef;outline-offset:0.222222rem}::selection{background:#d4b5fb;color:#27133e}input{caret-color:var(--accent)}[hidden]{display:none!important}.icon-definitions{position:absolute;width:0;height:0;overflow:hidden}.icon{width:1.44444rem;height:1.44444rem;flex:none;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}h1,h2,p{margin:0}h1{font-family:'Encode Sans Condensed',Arial,sans-serif;font-size:var(--r-page-title-font);font-weight:700;line-height:1.15;letter-spacing:-.025em}h2{font-size:1.38889rem;line-height:1.3;letter-spacing:-.025em}p{color:var(--muted)}.skip-link{position:fixed;left:0.666667rem;top:-80px;z-index:10;padding:0.666667rem;background:var(--surface)}.skip-link:focus{top:0.666667rem} .sidebar{position:fixed;inset:0 auto 0 0;width:var(--sidebar-width);background:var(--rail);color:#fff;display:flex;flex-direction:column;padding:5.05556rem 0.722222rem 1.27778rem}.brand{display:flex;gap:0.833333rem;align-items:center;margin:0 0.666667rem 2rem;color:#fff;text-decoration:none;white-space:nowrap}.brand img{width:3.55556rem;height:2.38889rem;object-fit:contain}.brand strong{font-size:1.83333rem;line-height:1.1;letter-spacing:-.025em}.brand small{display:block;font-size:1.11111rem;color:#d5c4ec;line-height:1.4;margin-top:0.111111rem}.sidebar nav{display:grid;gap:0.277778rem}.sidebar nav a{display:flex;align-items:center;gap:1.55556rem;min-height:3.11111rem;padding:0.666667rem 1.22222rem;border-radius:0.666667rem;text-decoration:none;color:#fff;font-size:1.11111rem}.sidebar nav .icon{width:1.55556rem;height:1.55556rem}.sidebar nav a[aria-current=page]{background:#6c3b9d}.sidebar nav a:hover{background:#62378d}.credit{margin-top:auto;display:flex;align-items:flex-end;gap:0.777778rem;color:#d5c4ec;font-size:0.888889rem;text-decoration:none;padding:0 0.333333rem;white-space:nowrap}.credit img{width:4.27778rem;height:4.44444rem;object-fit:contain;border-radius:0.222222rem}.credit span{padding-bottom:0.388889rem} -.workspace{margin-left:var(--sidebar-width);min-height:100vh}.release-bar{min-height:3.44444rem;background:var(--surface);border-bottom:0.0555556rem solid #d8c8ef;display:flex;align-items:center;gap:1.11111rem;padding:0.444444rem 1.66667rem;font-size:0.944444rem}.release-icon{color:var(--accent);width:1.44444rem;height:1.44444rem}.release-status{display:flex;gap:1rem;align-items:center;flex:1;min-width:0}.release-status strong{white-space:nowrap}.release-status span{color:#55337d;border-left:0.0555556rem solid #b799df;padding-left:1rem}.repository{display:flex;align-items:center;gap:0.555556rem;margin-left:0.555556rem;white-space:nowrap}.repository .icon{width:1.11111rem;height:1.11111rem}.primary{display:inline-flex;align-items:center;justify-content:center;gap:0.722222rem;border:0;border-radius:0.444444rem;background:var(--accent);color:white;text-decoration:none;min-height:2.77778rem;padding:0.666667rem 1.22222rem;font-size:1rem}.primary:hover{background:var(--accent-hover)}.compact{min-height:2.22222rem;padding:0.444444rem 1.27778rem;font-size:0.888889rem;white-space:nowrap}.primary .icon{width:1.27778rem;height:1.27778rem}main{padding:0 1.66667rem 1.38889rem}.page-heading{min-height:5.72222rem;display:flex;align-items:center;justify-content:space-between;gap:1.11111rem}.connection{display:flex;gap:0.777778rem;align-items:center;font-size:1.11111rem;color:var(--ink)}.status-dot{width:1rem;height:1rem;background:var(--green);border-radius:50%;flex:none}.overview-top{display:grid;grid-template-columns:minmax(0,.486fr) minmax(0,.514fr);gap:0.944444rem}.panel{background:var(--surface);border:0.0555556rem solid var(--line);border-radius:var(--radius);padding:1rem 1.05556rem 1.22222rem}.panel-heading{display:flex;align-items:flex-start;gap:1.05556rem;min-height:3.27778rem}.section-icon{width:2.88889rem;height:2.88889rem;color:var(--accent);margin-top:0.166667rem;stroke-width:1.9}.panel-heading p{font-size:1rem;margin-top:0.166667rem}.upload-panel,.library-panel{min-height:23.3889rem}.destination-label{display:block;font-size:0.944444rem;margin-top:1.05556rem;margin-bottom:0.388889rem}select{width:100%;min-height:2.5rem;padding:0.388889rem 0.833333rem;background:var(--surface);border:0.0555556rem solid var(--control);border-radius:0.333333rem}.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;min-height:7.88889rem;background:transparent;border:0.111111rem dashed #b3b5ce;border-radius:0.444444rem;margin-top:1rem;padding:0.833333rem;color:var(--muted)}.dropzone .icon{width:2.11111rem;height:2.11111rem;margin-bottom:0.555556rem}.dropzone strong{font-size:1.11111rem;color:var(--ink)}.dropzone span{font-size:1rem}.dropzone:hover,.dropzone.dragging{border-color:var(--accent);background:#f2eafb}.browse-files{width:100%;margin-top:1rem}.library-folders{margin-top:1.16667rem;border:0.0555556rem solid var(--line);border-radius:0.666667rem;overflow:hidden}.folder-row{display:flex;align-items:center;gap:1.38889rem;min-height:4.33333rem;padding:0.888889rem 1.05556rem;text-decoration:none;border-bottom:0.0555556rem solid var(--line)}.folder-row:last-child{border-bottom:0}.folder-row>.icon:first-child{width:2.27778rem;height:2.27778rem;fill:var(--accent);stroke-width:1.5}.folder-row strong{font-size:1.05556rem;color:var(--ink);flex:1;min-width:0;overflow-wrap:anywhere}.folder-row span{font-size:0.888889rem;font-weight:600;white-space:nowrap}.folder-row>.icon:last-child{width:1.05556rem;height:1.05556rem;margin-left:-12px}.folder-row:hover{background:#f2eafb}.transfers-panel{margin-top:1.11111rem;min-height:12.2222rem}.transfers-panel .section-icon{color:var(--ink);width:2.77778rem;height:2.77778rem}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:0.888889rem;min-height:7.5rem;color:var(--muted)}.empty-state>.icon{width:2.38889rem;height:2.38889rem}.empty-state p{font-size:1.05556rem}.quick-panel{margin-top:1.11111rem;min-height:9.66667rem;padding-bottom:1rem}.quick-panel .panel-heading{min-height:2.88889rem}.quick-panel .section-icon{width:2.77778rem;height:2.77778rem}.quick-settings{display:grid;grid-template-columns:1.15fr 1.1fr 1fr auto;gap:1.5rem;margin-top:0.666667rem;align-items:end}.quick-field{min-width:0}.quick-field label{display:flex;align-items:center;gap:0.833333rem;font-size:0.888889rem;margin-bottom:0.277778rem;white-space:nowrap}.quick-field label .icon{width:1.5rem;height:1.5rem}.range-field{display:flex;align-items:center;gap:0.944444rem;padding-left:2.38889rem;min-height:2.05556rem}.range-field input{width:100%;min-width:0;margin:0;height:1.44444rem}.range-field output{font-size:0.888889rem;color:var(--muted);min-width:2.44444rem;font-variant-numeric:tabular-nums}.quick-field select{margin-left:2.33333rem;width:calc(100% - 2.33333rem);min-height:2.38889rem;font-size:0.888889rem}.settings-open{min-height:3.11111rem;font-size:0.888889rem;gap:0.777778rem;padding:0.777778rem 1.38889rem} -h2{line-height:1.2}.destination-label{line-height:1.2}.library-panel .section-icon{fill:#d5b6f7;margin-left:0.277778rem}.transfers-panel .empty-state{min-height:6.5rem}.quick-panel{padding:0.666667rem 1.05556rem 0.777778rem}.quick-panel .panel-heading p{font-size:0.888889rem}.quick-panel .section-icon{fill:var(--accent)}.quick-panel .section-icon circle{fill:var(--surface)}.settings-open{min-width:11.3889rem}.settings-open .icon{fill:currentColor}.settings-open .icon circle{fill:var(--accent)} -.quick-panel .section-icon{padding:0.277778rem;margin-top:0}.quick-panel .section-icon circle{stroke:none} +.workspace{margin-left:var(--sidebar-width);min-height:100vh}.release-bar{min-height:3.44444rem;background:var(--surface);border-bottom:0.0555556rem solid #d8c8ef;display:flex;align-items:center;gap:1.11111rem;padding:0.444444rem 1.66667rem;font-size:0.944444rem}.release-icon{color:var(--accent);width:1.44444rem;height:1.44444rem}.release-status{display:flex;gap:1rem;align-items:center;flex:1;min-width:0}.release-status strong{white-space:nowrap}.release-status span{color:#55337d;border-left:0.0555556rem solid #b799df;padding-left:1rem}.repository{display:flex;align-items:center;gap:0.555556rem;margin-left:0.555556rem;white-space:nowrap}.repository .icon{width:1.11111rem;height:1.11111rem}.primary{display:inline-flex;align-items:center;justify-content:center;gap:0.722222rem;border:0;border-radius:0.444444rem;background:var(--accent);color:white;text-decoration:none;min-height:2.77778rem;padding:0.666667rem 1.22222rem;font-size:1rem}.primary:hover{background:var(--accent-hover)}.compact{min-height:2.22222rem;padding:0.444444rem 1.27778rem;font-size:0.888889rem;white-space:nowrap}.primary .icon{width:1.27778rem;height:1.27778rem}main{padding:0 1.66667rem 1.38889rem}.page-heading{min-height:5.72222rem;display:flex;align-items:center;justify-content:space-between;gap:1.11111rem}.connection{display:flex;gap:0.777778rem;align-items:center;font-size:1.11111rem;color:var(--ink)}.status-dot{width:1rem;height:1rem;background:var(--green);border-radius:50%;flex:none}.overview-top{display:grid;grid-template-columns:minmax(0,.486fr) minmax(0,.514fr);gap:0.944444rem}.panel{background:var(--surface);border:0.0555556rem solid var(--line);border-radius:var(--radius);padding:1rem 1.05556rem 1.22222rem}.panel-heading{display:flex;align-items:flex-start;gap:1.05556rem;min-height:3.27778rem}.section-icon{width:2.88889rem;height:2.88889rem;color:var(--accent);margin-top:0.166667rem;stroke-width:1.9}.panel-heading p{font-size:1rem;margin-top:0.166667rem}.upload-panel,.library-panel{min-height:23.3889rem}.destination-label{display:block;font-size:0.944444rem;margin-top:1.05556rem;margin-bottom:0.388889rem}select{width:100%;min-height:2.5rem;padding:0.388889rem 0.833333rem;background:var(--surface);border:0.0555556rem solid var(--control);border-radius:0.333333rem}.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;min-height:7.88889rem;background:transparent;border:0.111111rem dashed #b3b5ce;border-radius:0.444444rem;margin-top:1rem;padding:0.833333rem;color:var(--muted)}.dropzone .icon{width:2.11111rem;height:2.11111rem;margin-bottom:0.555556rem}.dropzone strong{font-size:1.11111rem;color:var(--ink)}.dropzone span{font-size:1rem}.dropzone:hover,.dropzone.dragging{border-color:var(--accent);background:#f2eafb}.browse-files{width:100%;margin-top:1rem}.library-folders{margin-top:1.16667rem;border:0.0555556rem solid var(--line);border-radius:0.666667rem;overflow:hidden}.folder-row{display:flex;align-items:center;gap:1.38889rem;min-height:4.33333rem;padding:0.888889rem 1.05556rem;text-decoration:none;border-bottom:0.0555556rem solid var(--line)}.folder-row:last-child{border-bottom:0}.folder-row>.icon:first-child{width:2.27778rem;height:2.27778rem}.folder-row strong{font-size:1.05556rem;color:var(--ink);flex:1;min-width:0;overflow-wrap:anywhere}.folder-row span{font-size:0.888889rem;font-weight:600;white-space:nowrap}.folder-row>.icon:last-child{width:1.05556rem;height:1.05556rem;margin-left:-12px}.folder-row:hover{background:#f2eafb}.transfers-panel{margin-top:1.11111rem;min-height:12.2222rem}.transfers-panel .section-icon{color:var(--ink);width:2.77778rem;height:2.77778rem}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:0.888889rem;min-height:7.5rem;color:var(--muted)}.empty-state>.icon{width:2.38889rem;height:2.38889rem}.empty-state p{font-size:1.05556rem}.quick-panel{margin-top:1.11111rem;min-height:9.66667rem;padding-bottom:1rem}.quick-panel .panel-heading{min-height:2.88889rem}.quick-panel .section-icon{width:2.77778rem;height:2.77778rem}.quick-settings{display:grid;grid-template-columns:1.15fr 1.1fr 1fr auto;gap:1.5rem;margin-top:0.666667rem;align-items:end}.quick-field{min-width:0}.quick-field label{display:flex;align-items:center;gap:0.833333rem;font-size:0.888889rem;margin-bottom:0.277778rem;white-space:nowrap}.quick-field label .icon{width:1.5rem;height:1.5rem}.range-field{display:flex;align-items:center;gap:0.944444rem;padding-left:2.38889rem;min-height:2.05556rem}.range-field input{width:100%;min-width:0;margin:0;height:1.44444rem}.range-field output{font-size:0.888889rem;color:var(--muted);min-width:2.44444rem;font-variant-numeric:tabular-nums}.quick-field select{margin-left:2.33333rem;width:calc(100% - 2.33333rem);min-height:2.38889rem;font-size:0.888889rem}.settings-open{min-height:3.11111rem;font-size:0.888889rem;gap:0.777778rem;padding:0.777778rem 1.38889rem} +h2{line-height:1.2}.destination-label{line-height:1.2}.library-panel .section-icon{fill:#d5b6f7;margin-left:0.277778rem}.transfers-panel .empty-state{min-height:6.5rem}.quick-panel{padding:0.666667rem 1.05556rem 0.777778rem}.quick-panel .panel-heading p{font-size:0.888889rem}.settings-open{min-width:11.3889rem}.settings-open .icon{fill:currentColor} +.quick-panel .section-icon{padding:0.277778rem;margin-top:0} :root[data-theme=dark]{color-scheme:dark;--rail:#21112f;--ground:#291d35;--surface:#33243f;--ink:#f3eafa;--muted:#c4b7d1;--accent:#ae80ea;--accent-hover:#9e6cde;--line:#51405f;--control:#715d80;--green:#59d394}.release-bar{flex-wrap:wrap;gap:0.666667rem 1.11111rem}.release-status{flex-wrap:wrap;gap:0.444444rem 1rem}.release-status span{color:var(--ink);font-size:0.888889rem}.release-bar[data-state=current] .release-icon{color:#15834f}.release-bar[data-state=update]{background:#fff5d8}.release-bar[data-state=update] .release-icon{color:#9a6800}.release-bar[data-state=update] .release-status{color:#583b00}.release-bar[data-state=update] .release-status span{color:#583b00}:root[data-theme=dark] .release-bar[data-state=update]{background:#4d3b1c}:root[data-theme=dark] .release-bar[data-state=update] .release-status,:root[data-theme=dark] .release-bar[data-state=update] .release-status span{color:#ffe09d}.connection.offline .status-dot{background:#987caa}.connection.offline{color:var(--muted)}.secondary,.text-button{border:0.0555556rem solid var(--control);background:var(--surface);color:var(--accent);border-radius:0.388889rem;padding:0.444444rem 0.777778rem;min-height:2.33333rem;font-size:0.888889rem}.secondary:hover{border-color:var(--accent);background:var(--ground)}.text-button{border:0;background:transparent;padding:0.222222rem 0;white-space:nowrap;text-decoration:underline;text-underline-offset:0.222222rem}.text-button:hover{color:var(--accent-hover)}.text-button:disabled{opacity:1;color:var(--ink);text-decoration:none}.announcement:empty{display:none}.announcement{padding:0.777778rem 0;color:var(--ink);font-size:0.888889rem}.error,.inline-error{color:#a32c51}.notice{margin:1.11111rem 0 0;border:0.0555556rem solid var(--line);border-radius:0.444444rem;background:var(--surface);padding:0.777778rem 1rem;display:flex;gap:0.888889rem;align-items:center;justify-content:space-between}.notice span{font-size:0.888889rem}.list-message{display:block;padding:1.11111rem;font-size:0.888889rem}.list-message p{margin-bottom:0.666667rem}.library-folders .list-message{min-height:4.22222rem}.library-folders .list-message:last-child{border:0}.section-toolbar{display:flex;align-items:center;justify-content:space-between;gap:1.11111rem;margin-bottom:1.22222rem}.section-toolbar p{margin-top:0.333333rem;font-size:0.888889rem}.content-tools{display:flex;gap:1.11111rem;align-items:end;margin:1.11111rem 0}.search-field{flex:1;max-width:30.5556rem;font-size:0.888889rem}.search-field input{display:block;width:100%;margin-top:0.333333rem}.content-tools input,.folder-form input,.setting-row input[type=number]{min-height:2.44444rem;padding:0.444444rem 0.666667rem;border:0.0555556rem solid var(--control);border-radius:0.333333rem;background:var(--surface)}#breadcrumbs{display:flex;gap:0.666667rem;flex-wrap:wrap;padding-bottom:0.888889rem;border-bottom:0.0555556rem solid var(--line)}#breadcrumbs button+button:before{content:'/';display:inline-block;margin-right:0.666667rem;color:var(--muted);text-decoration:none}.folder-form{display:flex;align-items:center;gap:0.666667rem;flex-wrap:wrap;margin:1.33333rem 0;font-size:0.888889rem}.folder-form input{min-width:0;width:14.4444rem}.muted{color:var(--muted)}#folder-caption{font-size:0.833333rem;margin:0.888889rem 0 0.333333rem}.content-list{border-top:0.0555556rem solid var(--line)}.content-row{display:grid;grid-template-columns:1.55556rem minmax(0,1fr) 5rem 5rem;align-items:center;gap:1rem;padding:0.944444rem 0;border-bottom:0.0555556rem solid var(--line);font-size:0.888889rem}.content-row .icon{color:var(--accent);width:1.44444rem;height:1.44444rem}.file-name{border:0;background:transparent;text-align:left;text-decoration:none;padding:0;min-width:0;overflow-wrap:anywhere;font-size:0.888889rem;color:var(--ink)}.file-name:hover{text-decoration:underline}.file-size{font-size:0.777778rem;text-align:right;color:var(--muted);font-variant-numeric:tabular-nums}.content-row>a:last-child{text-align:right}.help-text{font-size:0.888889rem;margin-bottom:1.33333rem;max-width:75ch}.transfer-row{display:flex;align-items:center;gap:1.11111rem;padding:0.777778rem 0;border-bottom:0.0555556rem solid var(--line);animation:transfer-added .35s ease-out}.transfer-row:last-child{border-bottom:0}.transfer-details{flex:1;min-width:0}.transfer-details strong,.transfer-details span{display:block;overflow-wrap:anywhere}.transfer-details strong{font-size:0.888889rem}.transfer-details span{font-size:0.777778rem;margin-top:0.222222rem}.transfer-details progress{width:100%;height:0.388889rem;margin-top:0.555556rem;border:0;border-radius:0.222222rem;overflow:hidden;accent-color:var(--accent)}progress::-webkit-progress-bar{background:var(--line)}progress::-webkit-progress-value{background:var(--accent)}progress::-moz-progress-bar{background:var(--accent)}.settings-intro{font-size:0.888889rem;max-width:75ch;margin:0.666667rem 0 1.55556rem}fieldset{padding:0;margin:0;border:0}.setting-row{display:grid;grid-template-columns:minmax(0,1fr) 10.5556rem;align-items:center;gap:0.444444rem 1.33333rem;padding:1rem 0;border-bottom:0.0555556rem solid var(--line);font-size:0.944444rem}.setting-row input[type=checkbox]{justify-self:end;width:1.22222rem;height:1.22222rem;margin:0}.setting-row small{grid-column:1/-1;color:var(--muted);font-size:0.777778rem}.setting-row input[type=number]{width:100%;text-align:right}.form-actions{display:flex;align-items:center;gap:1.11111rem;margin-top:1.38889rem;font-size:0.888889rem}.release-details{margin-top:1.33333rem}.release-details pre{font:0.888889rem/1.7 Arial,Helvetica,sans-serif;white-space:pre-wrap;overflow-wrap:anywhere;max-width:85ch;margin:1.11111rem 0;color:var(--ink)}#release-date{font-size:0.833333rem}.range-field output{white-space:nowrap;min-width:2.77778rem}.visually-hidden{position:absolute;width:0.0555556rem;height:0.0555556rem;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.primary:disabled:hover{background:var(--accent)}.folder-row:hover,.dropzone:hover,.dropzone.dragging{background:var(--ground)}:root[data-theme=dark] .primary{color:#251434}:root[data-theme=dark] .error,:root[data-theme=dark] .inline-error{color:#ff9db7}:root[data-theme=dark] .release-bar[data-state=current] .release-icon{color:#59d394} @keyframes transfer-added{from{background:var(--ground)}to{background:transparent}} @media(min-width:769px) and (max-width:1300px){:root{--sidebar-width:12.7778rem}.sidebar{padding-top:3.88889rem}.brand{gap:0.555556rem;margin-left:0.277778rem;margin-right:0}.brand strong{font-size:1.5rem}.brand img{width:2.66667rem}.sidebar nav a{gap:1.11111rem;padding-left:0.944444rem}.credit{gap:0.555556rem;font-size:0.777778rem}.credit img{width:2.77778rem;height:3rem}.panel-heading{gap:0.666667rem}.panel-heading p{font-size:0.888889rem}.panel-heading h2{font-size:1.27778rem}.section-icon{width:2.44444rem;height:2.44444rem}.overview-top{grid-template-columns:1fr 1fr}.release-status{min-width:15rem}.repository{font-size:0.833333rem}.quick-settings{grid-template-columns:1fr 1fr;gap:1.11111rem}.quick-panel{padding:1.11111rem}.settings-open{min-width:0}.folder-row{gap:0.833333rem;padding:0.888889rem 0.666667rem}.folder-row span{font-size:0.777778rem}.folder-row>.icon:first-child{width:1.66667rem}.release-bar,main{padding-left:1.33333rem;padding-right:1.33333rem}} @@ -98,3 +98,17 @@ main{max-width:100rem;margin-inline:auto} .sheet-fields{display:grid;grid-template-columns:1fr 1fr;gap:.8rem 1rem}.sheet-fields label,.sheet-slot{display:grid;gap:.3rem;font-size:.76rem;color:var(--muted)}.sheet-fields input,.sheet-fields textarea,.sheet-slot input{width:100%;min-width:0;border:1px solid var(--control);border-radius:.35rem;padding:.55rem .65rem;background:var(--surface);color:var(--ink);font:inherit;font-size:.86rem}.sheet-fields textarea{min-height:7rem;resize:vertical}.sheet-fields .wide-field{grid-column:1/-1}.sheet-actions{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;margin-top:.7rem}.sheet-actions a{text-decoration:none}.sheet-actions [aria-disabled=true]{opacity:.5;cursor:default}.sheet-status{font-size:.8rem;margin-top:.6rem;overflow-wrap:anywhere}.sheet-status:empty{display:none}.sheet-file-group{padding:1rem 0;border-bottom:1px solid var(--line)}.sheet-file-group h4{font-size:.95rem;margin:0}.sheet-file-group select{font-size:.76rem;min-width:0;max-width:100%}.sheet-file-group progress{width:100%;margin-top:.6rem}.sheet-path{font-size:.68rem;overflow-wrap:anywhere;margin-top:.4rem}.sheet-slot{margin-top:.6rem;max-width:16rem}.sheet-slot[hidden]{display:none}.sheet-editor h3,#sheet-files h3{font-size:1.02rem;margin:0 0 .6rem}.game-sheet [hidden]{display:none!important} @media(max-width:650px){.game-sheet{width:calc(100vw - 1rem);max-height:calc(100dvh - 1rem)}.sheet-head{grid-template-columns:3.2rem minmax(0,1fr) 2.2rem;padding:.85rem;gap:.65rem}.sheet-heading h2{font-size:1.2rem}.sheet-head #sheet-edit{grid-column:2;grid-row:2;justify-self:start;margin:0}.sheet-head .sheet-close{grid-column:3;grid-row:1}.sheet-layout{grid-template-columns:1fr;min-height:0;overflow:auto}.sheet-nav{display:flex;gap:.3rem;padding:.4rem .65rem;border-right:0;border-bottom:1px solid var(--line)}.sheet-nav button{margin:0;text-align:center}.sheet-nav p{display:none}.sheet-body{padding:.85rem;overflow:visible}.sheet-media .media-stage,.sheet-media .media-row-item.missing .media-stage{height:11rem}.sheet-media .media-row-item,.sheet-media .media-row-item.missing{gap:.7rem}.sheet-media .media-side .actions button{flex:auto}.sheet-fields{grid-template-columns:1fr}.sheet-fields input,.sheet-fields textarea,.sheet-slot input{font-size:16px}.sheet-thumbs{gap:.2rem}.sheet-thumb{font-size:.66rem}.sheet-thumb small{font-size:.61rem}.sheet-media .media-side p{font-size:.73rem}} @media(pointer:coarse){.sheet-nav button,.sheet-thumb,.sheet-actions button,.sheet-actions a{min-height:44px}} + +/* One local Lucide family across navigation, sources, media and actions. */ +.icon.lucide{fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.brand .brand-icon{width:2.5rem;height:2.5rem}.icon-credit{font-size:.65rem;color:inherit;opacity:.85;text-align:center;text-decoration:none;margin-top:.65rem}.source-logo.lucide{padding:.15rem;color:var(--accent);width:2.15rem;height:2.15rem}.device.lucide{width:1.2rem;height:1.2rem}.signed-check .lucide,.tick .lucide{width:1rem;height:1rem}.pass-number .lucide{width:.9rem;height:.9rem}.games-tools .add-game-cta{min-height:3.1rem;padding:.75rem 1.3rem;font-size:1rem;gap:.5rem;white-space:nowrap}.version-tag.flags{gap:.3rem}.version-tag.flags .lucide{width:.85rem;height:.85rem}.state-preview{margin:.65rem 0;display:flex;align-items:center;gap:.65rem}.state-preview p{font-size:.76rem;max-width:48ch}.state-preview-image{background:var(--ground);border:0;border-radius:.5rem;padding:.4rem;cursor:zoom-in}.state-preview-image img{display:block;max-width:100%;max-height:11rem;object-fit:contain} +.add-game-sheet .sheet-head{grid-template-columns:3rem minmax(0,1fr) 2.2rem}.add-symbol{background:var(--ground);color:var(--accent);width:3rem;height:3rem;border-radius:.65rem;display:grid;place-items:center}.add-symbol .icon{width:1.5rem;height:1.5rem}.add-game-sheet .sheet-body h3{font-size:1.1rem;margin:0 0 .7rem}.add-game-sheet .sheet-body{max-height:65dvh}.add-field{display:grid;gap:.5rem;font-size:.85rem;margin:1.1rem 0}.add-drop{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;width:100%;min-height:12rem;background:var(--ground);border:1px dashed var(--accent);border-radius:.65rem;color:var(--ink);padding:1.3rem;overflow-wrap:anywhere}.add-drop:hover{background:var(--surface)}.add-drop>.icon{width:2rem;height:2rem;color:var(--accent)}.add-drop span{font-size:.8rem}.add-search{display:flex;gap:.5rem;align-items:end;margin:1.1rem 0 .75rem}.add-search label{flex:1;display:grid;gap:.35rem;font-size:.8rem}.add-search input,.add-download-options select{width:100%;min-width:0}.add-results{max-height:19rem;overflow:auto;margin-bottom:1rem}.add-result{padding:.9rem 0;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:1rem}.add-result>div{flex:1;min-width:0}.add-result strong,.add-result small{display:block}.add-result small{font-size:.75rem;color:var(--muted);margin:.25rem 0}.add-result-description{font-size:.76rem;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.add-result button{flex:none}.add-footer{display:flex;align-items:center;gap:.65rem;border-top:1px solid var(--line);padding:.9rem 1.25rem;background:var(--surface);flex-shrink:0}.add-progress{flex:1;min-width:0}.add-progress p{font-size:.77rem;margin:0}.add-progress progress{width:100%;height:.3rem;margin-top:.45rem}.add-media-preview{display:grid;grid-template-columns:11rem minmax(0,1fr);gap:1.1rem;margin:1rem 0;align-items:center}.add-artwork{height:10rem;background:var(--ground);display:grid;place-items:center;border-radius:.6rem;padding:.65rem}.add-artwork img{max-height:100%;max-width:100%;object-fit:contain}.add-artwork>.icon{width:2rem;height:2rem;color:var(--accent)}.add-media-preview h4{margin:0;font-size:1rem}.add-download{display:flex;gap:.6rem;align-items:center;margin:1.1rem 0;font-size:.86rem;font-weight:500}.add-download input,.add-source input{accent-color:var(--accent)}.add-source{display:flex;align-items:center;gap:.6rem;padding:.7rem 0;border-bottom:1px solid var(--line);font-size:.82rem}.add-source label{display:flex;gap:.55rem;align-items:center;flex:1}.add-source .icon-button{background:none;border:0;padding:.3rem;color:var(--accent)}.add-source .icon-button:disabled{opacity:.3}.add-download-options{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:1rem}.add-download-options label{display:grid;gap:.3rem;font-size:.8rem} +@media(max-width:650px){.add-game-sheet .sheet-body{max-height:none}.add-footer{flex-wrap:wrap;padding:.8rem}.add-progress{flex-basis:100%}.add-footer>.primary{flex:1}.add-drop{min-height:10rem}.add-media-preview{grid-template-columns:7rem minmax(0,1fr);gap:.75rem}.add-artwork{height:8rem}.add-result{align-items:start;flex-direction:column;gap:.5rem}.add-search input{font-size:16px}.add-source{flex-wrap:wrap}.add-source label{min-width:9rem}.icon-credit{display:none}.add-game-sheet .sheet-head .sheet-close{grid-column:3}.games-tools .add-game-cta{width:100%;justify-content:center}} + +.add-search input{min-height:2.75rem;padding:.6rem .7rem;border:1px solid var(--control);border-radius:.4rem;background:var(--surface)}.add-search button{display:inline-flex;align-items:center;gap:.4rem;min-height:2.75rem}.add-search label{min-width:0} + +.add-download-cta{width:100%;display:flex;align-items:center;gap:.85rem;text-align:left;border:1px solid var(--accent);background:var(--ground);color:var(--ink);padding:1rem;border-radius:.65rem;margin:1.1rem 0}.add-download-cta>.icon{width:1.7rem;height:1.7rem;color:var(--accent)}.add-download-cta>span:nth-child(2){flex:1}.add-download-cta strong,.add-download-cta small{display:block}.add-download-cta strong{font-size:1rem}.add-download-cta small{font-size:.8rem;color:var(--muted);margin-top:.25rem;line-height:1.45}#add-download-state{color:var(--accent);font-weight:600;font-size:.85rem}.add-download-cta[aria-pressed=true]{background:var(--accent);color:#fff}.add-download-cta[aria-pressed=true] small,.add-download-cta[aria-pressed=true]>.icon,.add-download-cta[aria-pressed=true] #add-download-state{color:#fff}.add-activity{padding:1.8rem;overflow:auto;min-height:22rem;max-height:65dvh}.add-activity-heading{display:flex;gap:1rem;align-items:center;margin-bottom:1.5rem}.add-activity-heading>.icon{width:2.5rem;height:2.5rem;color:var(--accent)}.add-activity h3{margin:0 0 .4rem;font-size:1.3rem}.add-activity p{font-size:.88rem;line-height:1.55;overflow-wrap:anywhere}.add-activity progress{width:100%;height:.5rem;margin:1rem 0;accent-color:var(--accent)}.add-live-media{display:grid;grid-template-columns:repeat(auto-fill,minmax(7rem,1fr));gap:.8rem;margin:.8rem 0 1.4rem}.add-live-item{min-width:0;display:flex;flex-direction:column;gap:.5rem;align-items:center;padding:.6rem;background:var(--ground);border-radius:.5rem}.add-live-item img{height:7rem;max-width:100%;object-fit:contain}.add-live-item>.icon{height:7rem;width:2rem}.add-live-item span{font-size:.72rem;text-align:center}@media(max-width:700px){.add-activity{padding:1rem;min-height:18rem}.add-download-cta{gap:.6rem;padding:.8rem}.add-footer .primary{white-space:normal}.add-game-sheet:has(#add-layout[hidden]) .add-footer{flex-wrap:wrap}} + +/* Color artwork retains its authored palette; platform artwork frames the hardware above its logo. */ +.icon.color-icon{object-fit:contain;flex-shrink:0;stroke:none;fill:none}.sidebar nav .color-icon{width:27px;height:27px}.system-icon{display:inline-block;position:relative;width:42px;height:30px;overflow:hidden;flex:0 0 42px;vertical-align:middle}.system-icon>img{position:absolute;width:42px;height:42px;max-width:none;top:0;left:0;object-fit:contain}.system-icon>.icon:not(img){width:28px;height:28px}.game-platform,#sheet-sub{display:flex;align-items:center;gap:6px}.game-platform{line-height:1.3}.system-select{display:flex;align-items:center;gap:8px}.system-select select{min-width:0;flex:1}.system-select .system-icon{width:56px;height:39px;flex-basis:56px}.system-select .system-icon>img{width:56px;height:56px}.flag.color-icon{width:26px;height:20px;object-fit:contain}.source-logo{flex-shrink:0}.device{color:#5575b7} + +.source-logo[src$="retroarch.svg"]{background:var(--rail);border-radius:4px;padding:2px} diff --git a/webui/app.js b/webui/app.js index c3bad2e..55406f9 100644 --- a/webui/app.js +++ b/webui/app.js @@ -19,12 +19,7 @@ function element(tag, text, className) { if (className) node.className = className; return node; } -function icon(kind) { - const selectors = { folder: '.library-panel .section-icon', file: '.dropzone .icon', check: '.release-icon' }; - const node = $(selectors[kind] || selectors.file).cloneNode(true); - node.setAttribute('class', 'icon'); - return node; -} +function icon(kind) { return uiIcon({folder:'folder', file:'file-text', check:'check'}[kind] || kind); } function bytes(n) { if (n === null || !Number.isFinite(n)) return 'Unknown'; const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; @@ -727,7 +722,7 @@ async function checkRelease() { if ((comparison === null || comparison < 0) && assetNames.has(archive) && assetNames.has(archive + '.sha256')) latestUpdate = latest.tag_name; bar.dataset.state = comparison === null ? 'development' : comparison < 0 ? 'update' : 'current'; $('#release-summary').textContent = comparison === null ? `Latest release: ${latest.tag_name}` : comparison < 0 ? `Update available · ${latest.tag_name}` : 'You’re up to date'; - $('.release-icon').innerHTML = comparison !== null && comparison >= 0 ? '' : ''; + $('.release-icon').replaceWith(uiIcon(comparison !== null && comparison >= 0 ? 'check' : 'info', 'release-icon')); $('#notes-title').textContent = `What’s new in ${latest.tag_name}`; $('#release-date').textContent = new Date(latest.published_at).toLocaleDateString(undefined, { dateStyle: 'long' }); $('#release-notes').textContent = latest.body || 'No release notes were provided for this version.'; @@ -748,17 +743,8 @@ setInterval(() => { if (!document.hidden) reconnect(); }, 10000); navigate(); drawTransfers(); reconnect(); checkRelease(); -// The WebUI's drawn icons (inline SVG, no image requests): flags for regions, the -// console and the PC, and a badge for each media source. The sources' badges are -// original drawings, not their owners' logos. -const EU_STARS = Array.from({ length: 12 }, (_, i) => { const a = i * Math.PI / 6; return ``; }).join(''); -const US_STRIPES = Array.from({ length: 7 }, (_, i) => ``).join(''); -const US_STARS = Array.from({ length: 12 }, (_, i) => ``).join(''); +// Colorful provider badges, console silhouettes and accurate region flags. const ICONS = { - 'flag-us': ``, - 'flag-eu': ``, - 'flag-jp': '', - 'flag-kr': '', globe: '', ps5: '', pc: '', @@ -768,12 +754,13 @@ const ICONS = { 'source-launchbox': '', 'source-emumovies': '' }; function iconNode(name) { - const template = document.createElement('template'); - template.innerHTML = ICONS[name] || ''; - return template.content.firstElementChild; + if (name.startsWith('flag-')) return assetIcon('assets/flags/' + name.slice(5) + '.svg','flag'); + if (name === 'pc') return uiIcon('monitor','device'); + if (name === 'globe') return uiIcon('globe','flag'); + const template = document.createElement('template'); template.innerHTML = ICONS[name] || ''; return template.content.firstElementChild || uiIcon(name); } // A region tag of a game's name, as a flag ("USA", "Europe", "USA, Europe"...). -const REGION_FLAGS = { USA: 'flag-us', Europe: 'flag-eu', Japan: 'flag-jp', Korea: 'flag-kr', World: 'globe' }; +const REGION_FLAGS = { USA: 'flag-us', Europe: 'flag-eu', Japan: 'flag-jp', Korea: 'flag-kr', World: 'globe', UK:'flag-gb', France:'flag-fr', Germany:'flag-de', Spain:'flag-es', Italy:'flag-it', Australia:'flag-au', Canada:'flag-ca', Brazil:'flag-br', China:'flag-cn', Taiwan:'flag-tw', Russia:'flag-ru' }; function tagNode(tag) { const node = element('span', undefined, 'version-tag'); const flags = tag.split(/,\s*/).map(part => REGION_FLAGS[part]).filter(Boolean); @@ -800,6 +787,7 @@ function gameTags(game) { return tags.filter(Boolean); } function visibleGames() { + $('#games-system-icon').replaceChildren(systemIcon($('#games-system').value)); const system = $('#games-system').value, words = $('#games-search').value.trim().toLowerCase(), missing = $('#games-missing').checked; const letter = $('#games-letters [aria-pressed="true"]')?.dataset.letter || ''; const sort = $('#games-sort').value, direction = $('#games-direction').dataset.direction === 'desc' ? -1 : 1; @@ -866,7 +854,8 @@ function drawGames() { const tags = element('div', undefined, 'version-tags'); for (const tag of gameTags(game)) tags.append(tagNode(tag)); card.title = `${game.path.split('/').pop()} · open to see and change its media`; - info.append(element('strong', gameName(game)), tags, element('small', system.name), badges); + const platform = element('small', undefined, 'game-platform'); platform.append(systemIcon(system.id),document.createTextNode(system.name)); + info.append(element('strong', gameName(game)), tags, platform, badges); // The card opens the game's media; its checkbox still selects it for a download. card.tabIndex = 0; card.setAttribute('aria-haspopup', 'dialog'); card.addEventListener('click', event => { if (event.target !== pick) openGame(system, game); }); @@ -906,7 +895,7 @@ for (const letter of ['', '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ']) { } function setGamesDirection(direction) { $('#games-direction').dataset.direction = direction; - $('#games-direction').textContent = direction === 'desc' ? '↓ Descending' : '↑ Ascending'; + $('#games-direction').replaceChildren(uiIcon(direction === 'desc' ? 'arrow-down' : 'arrow-up'), document.createTextNode(direction === 'desc' ? 'Descending' : 'Ascending')); } try { const sort = localStorage.getItem('ps5-games-sort'); @@ -936,7 +925,7 @@ async function openGame(system, game) { const dialog = $('#game-sheet'); $('#sheet-title').textContent = gameName(game); $('#sheet-tags').replaceChildren(...gameTags(game).map(tagNode)); - $('#sheet-sub').textContent = `${system.name} · ${game.path.split('/').pop()}`; + $('#sheet-sub').replaceChildren(systemIcon(system.id), document.createTextNode(`${system.name} · ${game.path.split('/').pop()}`)); $('#sheet-details').hidden = true; $('#sheet-media-panel').hidden = false; $('#sheet-files').hidden = true; $('#sheet-thumbs').replaceChildren(); @@ -1050,6 +1039,7 @@ async function loadSheetFiles() { const slotLabel = element('label', 'Slot (0 is the default, -1 is automatic)', 'sheet-slot'), slot = document.createElement('input'); slot.type = 'number'; slot.min = -1; slot.max = 999999; slot.value = 0; slotLabel.append(slot); const destination = element('p', '', 'muted sheet-path'); + const preview = element('div', undefined, 'state-preview'); preview.hidden = kind !== 'state'; const actions = element('div', undefined, 'sheet-actions'), upload = element('button', kind === 'rom' ? 'Replace…' : 'Import…', 'secondary'), download = element('a', 'Export', 'secondary'); upload.type = 'button'; const input = document.createElement('input'); input.type = 'file'; input.hidden = true; if (kind === 'rom') input.accept = '.' + data.rom.name.split('.').pop(); @@ -1058,6 +1048,16 @@ async function loadSheetFiles() { const refresh = () => { const file = list.find(f => f.path === select.value); slotLabel.hidden = kind !== 'state' || !!file; + if (kind === 'state') { + preview.replaceChildren(); + if (file?.preview) { + const image = document.createElement('img'); image.alt = 'Screenshot of ' + file.name; + image.src = `/api/library/file?${sheetQuery(context)}&kind=state&file=${encodeURIComponent(file.path)}&preview=1`; + const enlarge = element('button', undefined, 'state-preview-image'); enlarge.type = 'button'; enlarge.setAttribute('aria-label', 'Enlarge savestate screenshot'); enlarge.append(image); + enlarge.addEventListener('click', () => openLightbox(image.src, {name:'Savestate screenshot'})); preview.append(enlarge); + image.addEventListener('error', () => preview.replaceChildren(element('p', 'Screenshot unavailable. Refresh to try again.', 'muted'))); + } else preview.append(uiIcon('image'), element('p', file ? 'No screenshot for this slot. Enable Savestate Thumbnails in RetroArch and save the state again.' : 'A screenshot appears here when a saved slot has a thumbnail.', 'muted')); + } destination.textContent = file ? file.display : data[kind + 'Folder'] ? `Destination: ${data[kind + 'Folder']}` : 'Launch this game with its core and save once, then refresh to locate its files.'; upload.disabled = !file && !data[kind + 'Folder']; if (file) { download.href = `/api/library/file?${sheetQuery(context)}&kind=${kind}&file=${encodeURIComponent(file.path)}`; download.removeAttribute('aria-disabled'); } @@ -1098,7 +1098,7 @@ async function loadSheetFiles() { } catch (error) { status.textContent = error.message; status.classList.add('inline-error'); } finally { sheetBusy = false; select.disabled = false; refresh(); progress.remove(); cancel.remove(); } }); - actions.append(upload, download, input); group.append(select, destination, slotLabel, actions, status); box.append(group); + actions.append(upload, download, input); group.append(select, destination, preview, slotLabel, actions, status); box.append(group); } box.append(element('p', 'File transfers preserve bytes; they do not convert save formats. Cores with private save directories and multi-file disc sets may need the Content browser or FTP.', 'muted sheet-hint')); } catch (error) { if (sheetGame === context) box.replaceChildren(element('p', error.message, 'inline-error')); } @@ -1112,14 +1112,14 @@ function mediaRow(kind) { stage.classList.add('video'); const video = document.createElement('video'); video.src = sheetMediaUrl(kind.id); video.preload = 'metadata'; video.playsInline = true; video.controls = true; const play = element('button', undefined, 'play'); play.type = 'button'; play.setAttribute('aria-label', 'Play the video'); - play.innerHTML = ''; + play.append(uiIcon('video')); play.addEventListener('click', () => { video.play(); }); video.addEventListener('play', () => { play.hidden = true; }); video.addEventListener('pause', () => { play.hidden = false; }); stage.append(video, play); } else if (kind.type === 'pdf') { const a = element('a', undefined, 'pdf'); a.href = sheetMediaUrl(kind.id); a.target = '_blank'; a.rel = 'noopener'; - a.innerHTML = ''; a.append(element('span', 'Open the manual (PDF)')); + a.append(uiIcon('book-open')); a.append(element('span', 'Open the manual (PDF)')); stage.append(a); } else { const img = document.createElement('img'); img.alt = kind.name; img.src = sheetMediaUrl(kind.id); @@ -1206,18 +1206,13 @@ const METHOD_HINTS = { pc: 'Downloads on this PC first, then transfers each file to your PS5. A small helper program on this PC does both; keep it running until the job is done (this page can close).', '': 'PS5: the console downloads directly. This PC → PS5: this PC downloads, then transfers to the PS5. Your choice is remembered on this console.' }; const RECOMMENDED = ['cover', 'screenshot', 'title']; -const KIND_ICONS = { cover: 'M5 3h14v18H5Z M8 7h8 M8 11h8', backcover: 'M5 3h14v18H5Z M8 14h8 M8 17h5', box3d: 'M4 7l8-4 8 4v10l-8 4-8-4Z M4 7l8 4 8-4 M12 11v10', - screenshot: 'M3 5h18v14H3Z M7 15l3-4 3 3 2-2 3 3', title: 'M3 5h18v14H3Z M7 10h10 M9 14h6', logo: 'M4 12c4-8 12-8 16 0-4 8-12 8-16 0Z M9 12h6', - physical: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z M12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', fanart: 'M3 5h18v14H3Z M3 15l5-5 4 4 3-3 6 6', - manual: 'M5 4h10l4 4v12H5Z M8 10h8 M8 14h8', video: 'M3 6h13v12H3Z M16 10l5-3v10l-5-3', - details: 'M4 5h16v14H4Z M8 9h8 M8 12h8 M8 15h5' }; +const KIND_ICONS = {cover:'book-image', backcover:'book-open', box3d:'box', screenshot:'image', title:'panels-top-left', logo:'type', physical:'disc-3', fanart:'wallpaper', manual:'book-open', video:'video', details:'list'}; const DETAILS_TEXT = 'Description, developer, publisher, release date, genre, players and rating, shown in EmulationStation and on each game here.'; function detailsOn() { return scrapeDetails && !!chosenSource()?.details; } function drawDetails() { const source = chosenSource(), tile = element('button', undefined, 'details-tile'); tile.type = 'button'; tile.setAttribute('aria-pressed', detailsOn()); tile.disabled = !source.details; - const tick = element('span', undefined, 'tick'), check = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); check.setAttribute('viewBox', '0 0 24 24'); - const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', 'M5 12.5l4.5 4.5L19 7.5'); check.append(p); tick.append(check); + const tick = element('span', undefined, 'tick'); tick.append(uiIcon('check')); tile.append(svgIcon(KIND_ICONS.details), element('strong', 'Game details'), source.details ? tick : element('em', `Not from ${source.name}`, 'soon-tag'), element('small', DETAILS_TEXT)); const hint = source.details ? `Game details: ${DETAILS_TEXT} Text only, so it costs almost nothing; details you edited yourself are never replaced.` : `Game details: ${source.name} has pictures only. ScreenScraper has details.`; tile.title = hint; @@ -1235,10 +1230,7 @@ function chosenSource() { } function sourcesFor(kind) { return chainSources().filter(s => kind === 'details' ? (s.details ?? s.id !== 'libretro') : s.kinds.includes(kind)).map(s => s.name); } function pcAllowed() { const ids = chosenSource().ids; return ids.length <= 1 && !ids.includes('screenscraper'); } -function svgIcon(path) { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('class', 'icon'); svg.setAttribute('aria-hidden', 'true'); - const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', path); svg.append(p); return svg; -} +function svgIcon(name) { return uiIcon(name); } // The Games tab's view choices get their drawings (KIND_ICONS is set by now). for (const span of $$('[data-view-icon]')) span.prepend(svgIcon(KIND_ICONS[span.dataset.viewIcon])); function drawMethod() { @@ -1252,7 +1244,6 @@ function drawMethod() { function drawSources() { const box = $('#scrape-sources'), ordered = [...chainSources(), ...scraperSettings.sources.filter(s => !scrapeChain.includes(s.id))]; box.replaceChildren(); - const arrow = d => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', d); svg.append(p); return svg; }; const move = (id, by) => { const i = scrapeChain.indexOf(id), j = i + by; if (i < 0 || j < 0 || j >= scrapeChain.length) return; [scrapeChain[i], scrapeChain[j]] = [scrapeChain[j], scrapeChain[i]]; drawSources(); drawKinds(); }; for (const source of ordered) { const on = scrapeChain.includes(source.id), order = scrapeChain.indexOf(source.id); @@ -1277,7 +1268,7 @@ function drawSources() { } const moves = element('div', undefined, 'source-move'); const up = element('button'), down = element('button'); up.type = down.type = 'button'; - up.append(arrow('M6 15l6-6 6 6')); down.append(arrow('M6 9l6 6 6-6')); + up.append(uiIcon('chevron-up')); down.append(uiIcon('chevron-down')); up.setAttribute('aria-label', `Ask ${source.name} earlier`); down.setAttribute('aria-label', `Ask ${source.name} later`); up.disabled = !on || order === 0; down.disabled = !on || order === scrapeChain.length - 1; up.addEventListener('click', () => move(source.id, -1)); down.addEventListener('click', () => move(source.id, 1)); @@ -1316,9 +1307,7 @@ function drawSources() { drawMethod(); } function checkNode() { - const badge = element('span', undefined, 'signed-check'); badge.setAttribute('role', 'img'); badge.setAttribute('aria-label', 'Signed in'); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); - const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p.setAttribute('d', 'M5 12.5l4.5 4.5L19 7.5'); svg.append(p); badge.append(svg); return badge; + const badge = element('span', undefined, 'signed-check'); badge.setAttribute('role', 'img'); badge.setAttribute('aria-label', 'Signed in'); badge.append(uiIcon('check')); return badge; } // Sign-in: the name and password go to the console in a request body (never in an @@ -1349,6 +1338,8 @@ async function openSignIn(source) { } else $('#sign-in-user').focus(); } function signedIn(state) { + signInSource.signed_in = state; + if (!scraperSettings) return; const source = scraperSettings.sources.find(s => s.id === signInSource.id); source.signed_in = signInSource.signed_in = state; if (state && !scrapeChain.includes(source.id)) scrapeChain.push(source.id); @@ -1440,7 +1431,7 @@ async function openMedia() { if (selectedGames.size) $('input[name="scrape-scope"][value="selected"]').checked = true; drawMethod(); drawSources(); drawKinds(); loadJob(); } -$('#scrape-open').addEventListener('click', () => { location.hash = 'media'; }); + for (const node of $$('input[name="scrape-method"], input[name="scrape-scope"]')) node.addEventListener('change', () => { drawMethod(); }); $('#scrape-system').addEventListener('change', drawSummary); const EXISTING_HINTS = { @@ -1575,7 +1566,7 @@ function drawRecap() { const details = element('details', undefined, 'recap-system'); details.dataset.system = system.id; if (open.has(system.id) || recap.systems.length === 1) details.open = true; const got = games.filter(g => g.got.length).length, missing = games.filter(g => g.missed.length).length; - const summary = element('summary'); summary.append(element('strong', system.name), element('span', `${games.length.toLocaleString()} game${games.length === 1 ? '' : 's'} · ${got.toLocaleString()} with new media · ${missing.toLocaleString()} missing some`)); + const summary = element('summary'); summary.append(systemIcon(system.id), element('strong', system.name), element('span', `${games.length.toLocaleString()} game${games.length === 1 ? '' : 's'} · ${got.toLocaleString()} with new media · ${missing.toLocaleString()} missing some`)); const list = element('div', undefined, 'recap-games'), limit = recapLimit[system.id] || 100; const draw = () => { list.replaceChildren(...games.slice(0, limit).map(recapRow)); @@ -1620,7 +1611,7 @@ function drawPasses(job, running) { const state = !running || i < job.pass ? 'done' : i === job.pass ? 'running' : 'waiting'; const step = element('li', undefined, 'job-pass'); step.dataset.state = state; const head = element('div', undefined, 'pass-head'), name = scraperSettings?.sources.find(s => s.id === pass.source)?.name || pass.source; - head.append(element('span', state === 'done' ? '✓' : String(i + 1), 'pass-number'), element('span', name)); + const number = element('span', state === 'done' ? '' : String(i + 1), 'pass-number'); if (state === 'done') number.append(uiIcon('check')); head.append(number, element('span', name)); step.append(head); const gave = pass.files ? `${pass.files.toLocaleString()} found for ${pass.games.toLocaleString()} game${pass.games === 1 ? '' : 's'}` : 'nothing missing it has'; if (state === 'running') { diff --git a/webui/assets/ICON-CREDITS.txt b/webui/assets/ICON-CREDITS.txt new file mode 100644 index 0000000..b1a251b --- /dev/null +++ b/webui/assets/ICON-CREDITS.txt @@ -0,0 +1,120 @@ +WebUI artwork credits + +Microsoft Fluent UI System Icons — Microsoft Corporation (MIT) +MIT License + +Copyright (c) 2020 Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +flag-icons — Panayiotis Lipiridis (MIT) +The MIT License (MIT) + +Copyright (c) 2013 Panayiotis Lipiridis + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Linear ES-DE system artwork (MIT) +Copyright (c) 2024-2026 Northwestern Software AB +Copyright (c) 2024 Leon Styhre +Copyright (c) 2023 Weestuarty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +Lucide utility controls (ISC / MIT) +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/webui/assets/flags/LICENSE b/webui/assets/flags/LICENSE new file mode 100644 index 0000000..ee959dc --- /dev/null +++ b/webui/assets/flags/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Panayiotis Lipiridis + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/webui/assets/flags/SOURCE.json b/webui/assets/flags/SOURCE.json new file mode 100644 index 0000000..d0ba362 --- /dev/null +++ b/webui/assets/flags/SOURCE.json @@ -0,0 +1,21 @@ +{ + "repository": "https://github.com/lipis/flag-icons", + "revision": "086f7e97d657358203916dbe84f61c2bccaa81eb", + "icons": [ + "us", + "eu", + "jp", + "kr", + "gb", + "fr", + "de", + "es", + "it", + "au", + "ca", + "br", + "cn", + "tw", + "ru" + ] +} diff --git a/webui/assets/flags/au.svg b/webui/assets/flags/au.svg new file mode 100644 index 0000000..96e8076 --- /dev/null +++ b/webui/assets/flags/au.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/webui/assets/flags/br.svg b/webui/assets/flags/br.svg new file mode 100644 index 0000000..719a763 --- /dev/null +++ b/webui/assets/flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/flags/ca.svg b/webui/assets/flags/ca.svg new file mode 100644 index 0000000..c9b23b4 --- /dev/null +++ b/webui/assets/flags/ca.svg @@ -0,0 +1,4 @@ + + + + diff --git a/webui/assets/flags/cn.svg b/webui/assets/flags/cn.svg new file mode 100644 index 0000000..10d3489 --- /dev/null +++ b/webui/assets/flags/cn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/webui/assets/flags/de.svg b/webui/assets/flags/de.svg new file mode 100644 index 0000000..71aa2d2 --- /dev/null +++ b/webui/assets/flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/webui/assets/flags/es.svg b/webui/assets/flags/es.svg new file mode 100644 index 0000000..a296ebf --- /dev/null +++ b/webui/assets/flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/flags/eu.svg b/webui/assets/flags/eu.svg new file mode 100644 index 0000000..b0874c1 --- /dev/null +++ b/webui/assets/flags/eu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/flags/fr.svg b/webui/assets/flags/fr.svg new file mode 100644 index 0000000..e682b90 --- /dev/null +++ b/webui/assets/flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/webui/assets/flags/gb.svg b/webui/assets/flags/gb.svg new file mode 100644 index 0000000..7991383 --- /dev/null +++ b/webui/assets/flags/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webui/assets/flags/it.svg b/webui/assets/flags/it.svg new file mode 100644 index 0000000..20a8bfd --- /dev/null +++ b/webui/assets/flags/it.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webui/assets/flags/jp.svg b/webui/assets/flags/jp.svg new file mode 100644 index 0000000..cc1c181 --- /dev/null +++ b/webui/assets/flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/webui/assets/flags/kr.svg b/webui/assets/flags/kr.svg new file mode 100644 index 0000000..6947eab --- /dev/null +++ b/webui/assets/flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/flags/ru.svg b/webui/assets/flags/ru.svg new file mode 100644 index 0000000..cf24301 --- /dev/null +++ b/webui/assets/flags/ru.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/webui/assets/flags/tw.svg b/webui/assets/flags/tw.svg new file mode 100644 index 0000000..57fd98b --- /dev/null +++ b/webui/assets/flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/flags/us.svg b/webui/assets/flags/us.svg new file mode 100644 index 0000000..9cfd0c9 --- /dev/null +++ b/webui/assets/flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/webui/assets/fluent/LICENSE b/webui/assets/fluent/LICENSE new file mode 100644 index 0000000..bc9c36b --- /dev/null +++ b/webui/assets/fluent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/webui/assets/fluent/SOURCE.json b/webui/assets/fluent/SOURCE.json new file mode 100644 index 0000000..8256446 --- /dev/null +++ b/webui/assets/fluent/SOURCE.json @@ -0,0 +1,42 @@ +{ + "repository": "https://github.com/microsoft/fluentui-system-icons", + "revision": "cd332ec1f90cda2fad16f86754f8da52f1e9ab30", + "icons": { + "layout-dashboard": "Home", + "folder-open": "Document Folder", + "folder": "Document Folder", + "library-big": "Library", + "download": "Arrow Square Down", + "arrow-up-down": "Arrow Sync", + "settings-2": "Settings", + "cloud-upload": "Cloud", + "book-image": "Book", + "book-open": "Book Open", + "box": "Briefcase", + "image": "Image", + "images": "Content View", + "panels-top-left": "Board", + "type": "Text Edit Style", + "disc-3": "Coin Multiple", + "wallpaper": "Image", + "video": "Video", + "clapperboard": "Video", + "database": "Database", + "file-text": "Document Text", + "list": "Text Bullet List Square", + "monitor": "Laptop", + "globe": "Globe", + "pencil": "Edit", + "save": "Vault", + "gamepad-2": "Game Chat", + "hard-drive": "Database", + "circle-alert": "Warning", + "info": "Question Circle", + "check": "Checkmark Circle", + "sun": "Weather Sunny Low", + "volume-2": "Headphones", + "gallery-horizontal-end": "Content View", + "tower-control": "Building", + "flag": "Flag" + } +} diff --git a/webui/assets/fluent/arrow-square-down.svg b/webui/assets/fluent/arrow-square-down.svg new file mode 100644 index 0000000..8cd0263 --- /dev/null +++ b/webui/assets/fluent/arrow-square-down.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/arrow-sync.svg b/webui/assets/fluent/arrow-sync.svg new file mode 100644 index 0000000..7d38d90 --- /dev/null +++ b/webui/assets/fluent/arrow-sync.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/webui/assets/fluent/board.svg b/webui/assets/fluent/board.svg new file mode 100644 index 0000000..5701d2d --- /dev/null +++ b/webui/assets/fluent/board.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/book-open.svg b/webui/assets/fluent/book-open.svg new file mode 100644 index 0000000..d3024ae --- /dev/null +++ b/webui/assets/fluent/book-open.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/book.svg b/webui/assets/fluent/book.svg new file mode 100644 index 0000000..6022eaa --- /dev/null +++ b/webui/assets/fluent/book.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/briefcase.svg b/webui/assets/fluent/briefcase.svg new file mode 100644 index 0000000..0138946 --- /dev/null +++ b/webui/assets/fluent/briefcase.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/building.svg b/webui/assets/fluent/building.svg new file mode 100644 index 0000000..9df1d90 --- /dev/null +++ b/webui/assets/fluent/building.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/checkmark-circle.svg b/webui/assets/fluent/checkmark-circle.svg new file mode 100644 index 0000000..3af83e2 --- /dev/null +++ b/webui/assets/fluent/checkmark-circle.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/cloud.svg b/webui/assets/fluent/cloud.svg new file mode 100644 index 0000000..a71b5f3 --- /dev/null +++ b/webui/assets/fluent/cloud.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/coin-multiple.svg b/webui/assets/fluent/coin-multiple.svg new file mode 100644 index 0000000..3ee7b13 --- /dev/null +++ b/webui/assets/fluent/coin-multiple.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/content-view.svg b/webui/assets/fluent/content-view.svg new file mode 100644 index 0000000..3f8ee2f --- /dev/null +++ b/webui/assets/fluent/content-view.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/database.svg b/webui/assets/fluent/database.svg new file mode 100644 index 0000000..2739747 --- /dev/null +++ b/webui/assets/fluent/database.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/document-folder.svg b/webui/assets/fluent/document-folder.svg new file mode 100644 index 0000000..9b53db1 --- /dev/null +++ b/webui/assets/fluent/document-folder.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/document-text.svg b/webui/assets/fluent/document-text.svg new file mode 100644 index 0000000..21d3cab --- /dev/null +++ b/webui/assets/fluent/document-text.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/edit.svg b/webui/assets/fluent/edit.svg new file mode 100644 index 0000000..c90d004 --- /dev/null +++ b/webui/assets/fluent/edit.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/flag.svg b/webui/assets/fluent/flag.svg new file mode 100644 index 0000000..5fecae6 --- /dev/null +++ b/webui/assets/fluent/flag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/game-chat.svg b/webui/assets/fluent/game-chat.svg new file mode 100644 index 0000000..1e92eec --- /dev/null +++ b/webui/assets/fluent/game-chat.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/globe.svg b/webui/assets/fluent/globe.svg new file mode 100644 index 0000000..7f1c21c --- /dev/null +++ b/webui/assets/fluent/globe.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/headphones.svg b/webui/assets/fluent/headphones.svg new file mode 100644 index 0000000..88bc2f0 --- /dev/null +++ b/webui/assets/fluent/headphones.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/home.svg b/webui/assets/fluent/home.svg new file mode 100644 index 0000000..3853581 --- /dev/null +++ b/webui/assets/fluent/home.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/image.svg b/webui/assets/fluent/image.svg new file mode 100644 index 0000000..ad832f7 --- /dev/null +++ b/webui/assets/fluent/image.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/laptop.svg b/webui/assets/fluent/laptop.svg new file mode 100644 index 0000000..9092434 --- /dev/null +++ b/webui/assets/fluent/laptop.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/library.svg b/webui/assets/fluent/library.svg new file mode 100644 index 0000000..bc040da --- /dev/null +++ b/webui/assets/fluent/library.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/question-circle.svg b/webui/assets/fluent/question-circle.svg new file mode 100644 index 0000000..1bf4bc2 --- /dev/null +++ b/webui/assets/fluent/question-circle.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/settings.svg b/webui/assets/fluent/settings.svg new file mode 100644 index 0000000..e9cdc51 --- /dev/null +++ b/webui/assets/fluent/settings.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/webui/assets/fluent/text-bullet-list-square.svg b/webui/assets/fluent/text-bullet-list-square.svg new file mode 100644 index 0000000..aa462f1 --- /dev/null +++ b/webui/assets/fluent/text-bullet-list-square.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/text-edit-style.svg b/webui/assets/fluent/text-edit-style.svg new file mode 100644 index 0000000..a1fefd0 --- /dev/null +++ b/webui/assets/fluent/text-edit-style.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/vault.svg b/webui/assets/fluent/vault.svg new file mode 100644 index 0000000..f61539c --- /dev/null +++ b/webui/assets/fluent/vault.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/video.svg b/webui/assets/fluent/video.svg new file mode 100644 index 0000000..759bd93 --- /dev/null +++ b/webui/assets/fluent/video.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/warning.svg b/webui/assets/fluent/warning.svg new file mode 100644 index 0000000..5c594bd --- /dev/null +++ b/webui/assets/fluent/warning.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/webui/assets/fluent/weather-sunny-low.svg b/webui/assets/fluent/weather-sunny-low.svg new file mode 100644 index 0000000..7aa7a8a --- /dev/null +++ b/webui/assets/fluent/weather-sunny-low.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/assets/lucide/LICENSE b/webui/assets/lucide/LICENSE new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/webui/assets/lucide/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/webui/assets/lucide/SOURCE.txt b/webui/assets/lucide/SOURCE.txt new file mode 100644 index 0000000..57507c2 --- /dev/null +++ b/webui/assets/lucide/SOURCE.txt @@ -0,0 +1,3 @@ +Lucide icons: https://lucide.dev +SVG source: https://github.com/lucide-icons/lucide/tree/a04f228cd01185e09c188b7227b9600c08c565ec/icons +ISC and Feather MIT notices: LICENSE diff --git a/webui/assets/lucide/arrow-down.svg b/webui/assets/lucide/arrow-down.svg new file mode 100644 index 0000000..3244b63 --- /dev/null +++ b/webui/assets/lucide/arrow-down.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/arrow-right.svg b/webui/assets/lucide/arrow-right.svg new file mode 100644 index 0000000..433ded3 --- /dev/null +++ b/webui/assets/lucide/arrow-right.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/arrow-up-down.svg b/webui/assets/lucide/arrow-up-down.svg new file mode 100644 index 0000000..c8d7f2f --- /dev/null +++ b/webui/assets/lucide/arrow-up-down.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/lucide/arrow-up.svg b/webui/assets/lucide/arrow-up.svg new file mode 100644 index 0000000..50a81cb --- /dev/null +++ b/webui/assets/lucide/arrow-up.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/book-image.svg b/webui/assets/lucide/book-image.svg new file mode 100644 index 0000000..65631e0 --- /dev/null +++ b/webui/assets/lucide/book-image.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/book-open.svg b/webui/assets/lucide/book-open.svg new file mode 100644 index 0000000..9e0ace5 --- /dev/null +++ b/webui/assets/lucide/book-open.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/box.svg b/webui/assets/lucide/box.svg new file mode 100644 index 0000000..114e737 --- /dev/null +++ b/webui/assets/lucide/box.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/check.svg b/webui/assets/lucide/check.svg new file mode 100644 index 0000000..5abe152 --- /dev/null +++ b/webui/assets/lucide/check.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/chevron-down.svg b/webui/assets/lucide/chevron-down.svg new file mode 100644 index 0000000..e70d30b --- /dev/null +++ b/webui/assets/lucide/chevron-down.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/chevron-left.svg b/webui/assets/lucide/chevron-left.svg new file mode 100644 index 0000000..056d5ab --- /dev/null +++ b/webui/assets/lucide/chevron-left.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/chevron-right.svg b/webui/assets/lucide/chevron-right.svg new file mode 100644 index 0000000..db62a0c --- /dev/null +++ b/webui/assets/lucide/chevron-right.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/chevron-up.svg b/webui/assets/lucide/chevron-up.svg new file mode 100644 index 0000000..b011f77 --- /dev/null +++ b/webui/assets/lucide/chevron-up.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/circle-alert.svg b/webui/assets/lucide/circle-alert.svg new file mode 100644 index 0000000..578d25c --- /dev/null +++ b/webui/assets/lucide/circle-alert.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/clapperboard.svg b/webui/assets/lucide/clapperboard.svg new file mode 100644 index 0000000..99c818e --- /dev/null +++ b/webui/assets/lucide/clapperboard.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/lucide/cloud-upload.svg b/webui/assets/lucide/cloud-upload.svg new file mode 100644 index 0000000..c3b6413 --- /dev/null +++ b/webui/assets/lucide/cloud-upload.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/database.svg b/webui/assets/lucide/database.svg new file mode 100644 index 0000000..f95db0e --- /dev/null +++ b/webui/assets/lucide/database.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/disc-3.svg b/webui/assets/lucide/disc-3.svg new file mode 100644 index 0000000..538f576 --- /dev/null +++ b/webui/assets/lucide/disc-3.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/lucide/download.svg b/webui/assets/lucide/download.svg new file mode 100644 index 0000000..5c75527 --- /dev/null +++ b/webui/assets/lucide/download.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/external-link.svg b/webui/assets/lucide/external-link.svg new file mode 100644 index 0000000..a73a9ba --- /dev/null +++ b/webui/assets/lucide/external-link.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/file-text.svg b/webui/assets/lucide/file-text.svg new file mode 100644 index 0000000..67dc5b9 --- /dev/null +++ b/webui/assets/lucide/file-text.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/webui/assets/lucide/flag.svg b/webui/assets/lucide/flag.svg new file mode 100644 index 0000000..7a15001 --- /dev/null +++ b/webui/assets/lucide/flag.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/folder-open.svg b/webui/assets/lucide/folder-open.svg new file mode 100644 index 0000000..b01f60f --- /dev/null +++ b/webui/assets/lucide/folder-open.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/folder.svg b/webui/assets/lucide/folder.svg new file mode 100644 index 0000000..ef69b34 --- /dev/null +++ b/webui/assets/lucide/folder.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/gallery-horizontal-end.svg b/webui/assets/lucide/gallery-horizontal-end.svg new file mode 100644 index 0000000..4f5496b --- /dev/null +++ b/webui/assets/lucide/gallery-horizontal-end.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/gamepad-2.svg b/webui/assets/lucide/gamepad-2.svg new file mode 100644 index 0000000..bc7f08c --- /dev/null +++ b/webui/assets/lucide/gamepad-2.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/webui/assets/lucide/globe.svg b/webui/assets/lucide/globe.svg new file mode 100644 index 0000000..53c80ee --- /dev/null +++ b/webui/assets/lucide/globe.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/hard-drive.svg b/webui/assets/lucide/hard-drive.svg new file mode 100644 index 0000000..4a99fab --- /dev/null +++ b/webui/assets/lucide/hard-drive.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/lucide/image.svg b/webui/assets/lucide/image.svg new file mode 100644 index 0000000..3241134 --- /dev/null +++ b/webui/assets/lucide/image.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/images.svg b/webui/assets/lucide/images.svg new file mode 100644 index 0000000..27eeb3e --- /dev/null +++ b/webui/assets/lucide/images.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/lucide/info.svg b/webui/assets/lucide/info.svg new file mode 100644 index 0000000..2a46eac --- /dev/null +++ b/webui/assets/lucide/info.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/layout-dashboard.svg b/webui/assets/lucide/layout-dashboard.svg new file mode 100644 index 0000000..8d60654 --- /dev/null +++ b/webui/assets/lucide/layout-dashboard.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/lucide/library-big.svg b/webui/assets/lucide/library-big.svg new file mode 100644 index 0000000..5375b94 --- /dev/null +++ b/webui/assets/lucide/library-big.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/list.svg b/webui/assets/lucide/list.svg new file mode 100644 index 0000000..0d88fb1 --- /dev/null +++ b/webui/assets/lucide/list.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/webui/assets/lucide/monitor.svg b/webui/assets/lucide/monitor.svg new file mode 100644 index 0000000..e869f2f --- /dev/null +++ b/webui/assets/lucide/monitor.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/moon.svg b/webui/assets/lucide/moon.svg new file mode 100644 index 0000000..3c4bc31 --- /dev/null +++ b/webui/assets/lucide/moon.svg @@ -0,0 +1,13 @@ + + + diff --git a/webui/assets/lucide/panels-top-left.svg b/webui/assets/lucide/panels-top-left.svg new file mode 100644 index 0000000..574e4de --- /dev/null +++ b/webui/assets/lucide/panels-top-left.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/pencil.svg b/webui/assets/lucide/pencil.svg new file mode 100644 index 0000000..7384498 --- /dev/null +++ b/webui/assets/lucide/pencil.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/plus.svg b/webui/assets/lucide/plus.svg new file mode 100644 index 0000000..41f92bb --- /dev/null +++ b/webui/assets/lucide/plus.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/save.svg b/webui/assets/lucide/save.svg new file mode 100644 index 0000000..07af070 --- /dev/null +++ b/webui/assets/lucide/save.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/search.svg b/webui/assets/lucide/search.svg new file mode 100644 index 0000000..03e442a --- /dev/null +++ b/webui/assets/lucide/search.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/settings-2.svg b/webui/assets/lucide/settings-2.svg new file mode 100644 index 0000000..0a9d29d --- /dev/null +++ b/webui/assets/lucide/settings-2.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/lucide/sun.svg b/webui/assets/lucide/sun.svg new file mode 100644 index 0000000..3ecadd6 --- /dev/null +++ b/webui/assets/lucide/sun.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/webui/assets/lucide/tower-control.svg b/webui/assets/lucide/tower-control.svg new file mode 100644 index 0000000..1a79f02 --- /dev/null +++ b/webui/assets/lucide/tower-control.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/webui/assets/lucide/type.svg b/webui/assets/lucide/type.svg new file mode 100644 index 0000000..8f269e7 --- /dev/null +++ b/webui/assets/lucide/type.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/upload.svg b/webui/assets/lucide/upload.svg new file mode 100644 index 0000000..ce35507 --- /dev/null +++ b/webui/assets/lucide/upload.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/video.svg b/webui/assets/lucide/video.svg new file mode 100644 index 0000000..c198f49 --- /dev/null +++ b/webui/assets/lucide/video.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/volume-2.svg b/webui/assets/lucide/volume-2.svg new file mode 100644 index 0000000..519e6cb --- /dev/null +++ b/webui/assets/lucide/volume-2.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/webui/assets/lucide/wallpaper.svg b/webui/assets/lucide/wallpaper.svg new file mode 100644 index 0000000..a45be5f --- /dev/null +++ b/webui/assets/lucide/wallpaper.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/webui/assets/lucide/x.svg b/webui/assets/lucide/x.svg new file mode 100644 index 0000000..e317943 --- /dev/null +++ b/webui/assets/lucide/x.svg @@ -0,0 +1,14 @@ + + + + diff --git a/webui/assets/lucide/zoom-in.svg b/webui/assets/lucide/zoom-in.svg new file mode 100644 index 0000000..11173c0 --- /dev/null +++ b/webui/assets/lucide/zoom-in.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/webui/assets/systems/3do.webp b/webui/assets/systems/3do.webp new file mode 100644 index 0000000..7da3a13 Binary files /dev/null and b/webui/assets/systems/3do.webp differ diff --git a/webui/assets/systems/3do.webp.json b/webui/assets/systems/3do.webp.json new file mode 100644 index 0000000..6af013a --- /dev/null +++ b/webui/assets/systems/3do.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file 3do.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.628Z" +} \ No newline at end of file diff --git a/webui/assets/systems/LICENSE b/webui/assets/systems/LICENSE new file mode 100644 index 0000000..a00ad5d --- /dev/null +++ b/webui/assets/systems/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2024-2026 Northwestern Software AB +Copyright (c) 2024 Leon Styhre +Copyright (c) 2023 Weestuarty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/webui/assets/systems/SOURCE.json b/webui/assets/systems/SOURCE.json new file mode 100644 index 0000000..b0f1970 --- /dev/null +++ b/webui/assets/systems/SOURCE.json @@ -0,0 +1,108 @@ +{ + "source": "https://gitlab.com/es-de/themes/linear-es-de", + "bundled_source": ".deps/es-de/themes/linear-es-de/system/systemart", + "systems": { + "gba": "assets/systems/gba.webp", + "gbc": "assets/systems/gbc.webp", + "gb": "assets/systems/gb.webp", + "n3ds": "assets/systems/n3ds.webp", + "nds": "assets/systems/nds.webp", + "n64": "assets/systems/n64.webp", + "snes": "assets/systems/snes.webp", + "fds": "assets/systems/fds.webp", + "nes": "assets/systems/nes.webp", + "gc": "assets/systems/gc.webp", + "wii": "assets/systems/wii.webp", + "ps2": "assets/systems/ps2.webp", + "psp": "assets/systems/psp.webp", + "psx": "assets/systems/psx.webp", + "virtualboy": "assets/systems/virtualboy.webp", + "pokemini": "assets/systems/pokemini.webp", + "segacd": "assets/systems/segacd.webp", + "sega32x": "assets/systems/sega32x.webp", + "genesis": "assets/systems/genesis.webp", + "mastersystem": "assets/systems/mastersystem.webp", + "gamegear": "assets/systems/gamegear.webp", + "sg-1000": "assets/systems/sg-1000.webp", + "saturn": "assets/systems/saturn.webp", + "dreamcast": "assets/systems/dreamcast.webp", + "naomi": "assets/systems/naomi.webp", + "atomiswave": "assets/systems/atomiswave.webp", + "c64": "assets/systems/c64.webp", + "amiga": "assets/systems/amiga.webp", + "atari2600": "assets/systems/atari2600.webp", + "atari5200": "assets/systems/atari5200.webp", + "atari7800": "assets/systems/atari7800.webp", + "atarijaguar": "assets/systems/atarijaguar.webp", + "atarilynx": "assets/systems/atarilynx.webp", + "supergrafx": "assets/systems/supergrafx.webp", + "pcfx": "assets/systems/pcfx.webp", + "pcengine": "assets/systems/pcengine.webp", + "ngpc": "assets/systems/ngpc.webp", + "ngp": "assets/systems/ngp.webp", + "wonderswancolor": "assets/systems/wonderswancolor.webp", + "wonderswan": "assets/systems/wonderswan.webp", + "3do": "assets/systems/3do.webp", + "dos": "assets/systems/dos.webp", + "scummvm": "assets/systems/scummvm.webp", + "neogeocd": "assets/systems/neogeocd.webp", + "msx": "assets/systems/msx.webp", + "neogeo": "assets/systems/neogeo.webp", + "cps": "assets/systems/cps.webp", + "fbneo": "assets/systems/fbneo.webp", + "mame": "assets/systems/mame.webp", + "arcade": "assets/systems/arcade.webp" + }, + "original_sha256": { + "gba": "8d46273be41cf663547ec44a55546a667d67450e7da08a12b9e17911a299e6b2", + "gbc": "b6ba4ee42b50ed1f127700d06f7555e8956c3b3626fd0e285a4b08906bcc5a93", + "gb": "092094cf5768a4e0a52a1aad2f88b1690d4d272ba33b89d45368367a60a4ccea", + "n3ds": "3dbdc06b1e90718c21ebfa63e7e0ca0b4977b4d2c2c99d7017eb8ca9af20c739", + "nds": "2007fff484247df9cb18764f4dec5c1cb0ef5de4afdcd558d65b6c62f023d8f8", + "n64": "a245322b8519293244923a3b40b476126982fe0565951d1b16a3eb7534e0dbaf", + "snes": "2f2528a821d4c30c7cf9915916b8b1a398fdc5f1131ba3c3dcf4144f2cc87e79", + "fds": "396988d0304247334bd0e62a436b3348f22cbac8dc5b2312dac22e7c3adbf917", + "nes": "27f0eb438f7e71bc88dbffe6453218b46e7a977eef470386f6de6f82b31e7463", + "gc": "76be30756445b2ececc00575acb8368ae1e5ce0816b84e4ae41edff8a2ae324a", + "wii": "5214e3c528ff1b5e1dec150c99d5cb8119c4ecff465036117a3485222d64ed1f", + "ps2": "c97788cde1681b99402b1d74a58d642c9886bd82af50720a2b125e81485b6714", + "psp": "e23f121689b8514bca18f760e583a69a47c01ad0222617a358f530da8786a698", + "psx": "e877e3e44378499386c9645412569b9b1808d90d6edb2f49b5da6d714a5067e4", + "virtualboy": "06b109888f17f6657812062cabf6ea4b5d9fa95edb5ee6cad18c4161d934e936", + "pokemini": "f7e64f7cbc85ceb3f9e3affca59205095cfe354d4f40d7e5f874d25b6069c5aa", + "segacd": "0659b769bda55a02ab529d95f4abef2deac8a2d328580f9c5d6c56f9df85040e", + "sega32x": "fcfc38697bed46b7f466ee539c2e0850f428f2ae147fe716222e6d67d068d8a7", + "genesis": "2d350343a48f7c1ce4cb577281b46692c3295b1d0dd6f1206376d840a71ff5a0", + "mastersystem": "7c2596ddadf01cdcdf686a6f04cad1b2920280390416877ebb7982b6634e5c35", + "gamegear": "a66abd5c4412ae96487fd5c9d5652976584d944bf4bc55f88e069c4dcabd8176", + "sg-1000": "8584a3a88c560852a28098c28a1f1961ac9ec0e88851a4bb66b114cfc0c96c37", + "saturn": "e32b0a1169148e69a08ddf1f1924c2fd5d16e97699054cf25f138bf7c465e1e1", + "dreamcast": "47327077da9ddd40f97c6658abcbaaf8be66d8b6b37226d11f66bcbf6acaf686", + "naomi": "07dd791036dbb63bdfdf4db51e38d583102e6e73e1ebd2fc53d53a0eff09dc50", + "atomiswave": "1df2b1a844543c54e7ce515d18e6ab08b3556d856188ba73a58dce7aa65c40fd", + "c64": "02bb8f7c9b8f7777fd15e6699c554d10856a8b17bdffa198d7eaef009c228e1e", + "amiga": "2de78ab77def46d16863517e55ce38dd6bbb6912ec7d9925519a04819ada89a2", + "atari2600": "92ebae64785e358ce158bdd3544b2930b6a2aac2d1de54b462d6f7f84d56416e", + "atari5200": "d5f6ed1384b960d677364acd2d5a73a1cb0f334b4ac64783e14a3707bdc514a4", + "atari7800": "51be1f174320e9ff0d7078297c98bfe6ddc536a19d31ee538f3469b90c150ae6", + "atarijaguar": "d3dfae06b66e9376ef6c4901e1c4ec93fff69ed7896316cff40c375757b684ee", + "atarilynx": "f628ffc8fc65196a2f8a9071cad483169032709f4fb73bb8a103102d07e797c6", + "supergrafx": "e9bfa8b679e9bb5a4ba43745e0a8cc6d322952e497bf8d5d5b90946ba36419a6", + "pcfx": "5dbfa92b31c70aed5f265d912c9a663a82c453a47f0b92c0c1fbd1f98b1f4208", + "pcengine": "2ff7b889e8342e9d37a6c10507e676c3cf8aec59b7ed62a8cc59bb314cc61d53", + "ngpc": "8b42b4adfa6e1c790ecd9c2d34760f8fe534772eb877f3d0a377b75f6a3e6e0b", + "ngp": "53048dec1aa464a1655789d7a5a3e552b0636171ed5c1a4eece958453bdf8a97", + "wonderswancolor": "23819d4a489e4ff447a57670248344448aae7a8002af5f4e4a414f056192c2e1", + "wonderswan": "95e1256bda6c78d57de9d594fb5683b9b7c532d2a3f7f5353e168fcd7ff0e777", + "3do": "5cf56cba1270d1987adc41fec3e74d1d948a5139cd50d3d172d9234a6959d80e", + "dos": "62d6cce7e74320352c0629fb7c6cd035e2687b83b089a19ed86eb3d4fe0b2220", + "scummvm": "ce21d50cdc102498e1ab03b974d3a67abf5d74218ff40a909f209bfe98c68e61", + "neogeocd": "bbbaaeec3b4d092fbbe89f7f771f24cc0072763cfae9d8fe502fbe2e8c1785e5", + "msx": "2ec23c0050ae39ede601c4b4a60eb8e0a5968c2d281e2dc3a7d4c2ddaebb5332", + "neogeo": "0f26739b2c13efd8221e5ef1160d297b102bf0f740f69d7ac46d8b3d57056aca", + "cps": "e8b9113da0feab46f3247793490ba14add13d7728a08692475d70745299521a6", + "fbneo": "2cce724ac30ffa247db4bb37f6f27bc8943b1d7cd290735a32edf63d1fe55982", + "mame": "4dcbbb0947274fc346e75a2f2b2242c1a0cd53df6d2c6f6944890aeccca48a64", + "arcade": "45ccb0c47369973265f4e6fc350b0ba434955c8c3c904c572fe7edd8c440f146" + } +} diff --git a/webui/assets/systems/amiga.webp b/webui/assets/systems/amiga.webp new file mode 100644 index 0000000..26f45dd Binary files /dev/null and b/webui/assets/systems/amiga.webp differ diff --git a/webui/assets/systems/amiga.webp.json b/webui/assets/systems/amiga.webp.json new file mode 100644 index 0000000..616860b --- /dev/null +++ b/webui/assets/systems/amiga.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file amiga.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.450Z" +} \ No newline at end of file diff --git a/webui/assets/systems/arcade.webp b/webui/assets/systems/arcade.webp new file mode 100644 index 0000000..34fbb1d Binary files /dev/null and b/webui/assets/systems/arcade.webp differ diff --git a/webui/assets/systems/arcade.webp.json b/webui/assets/systems/arcade.webp.json new file mode 100644 index 0000000..8dd2afe --- /dev/null +++ b/webui/assets/systems/arcade.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file arcade.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.716Z" +} \ No newline at end of file diff --git a/webui/assets/systems/atari2600.webp b/webui/assets/systems/atari2600.webp new file mode 100644 index 0000000..2730d4d Binary files /dev/null and b/webui/assets/systems/atari2600.webp differ diff --git a/webui/assets/systems/atari2600.webp.json b/webui/assets/systems/atari2600.webp.json new file mode 100644 index 0000000..9bf3159 --- /dev/null +++ b/webui/assets/systems/atari2600.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file atari2600.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.463Z" +} \ No newline at end of file diff --git a/webui/assets/systems/atari5200.webp b/webui/assets/systems/atari5200.webp new file mode 100644 index 0000000..c783ea6 Binary files /dev/null and b/webui/assets/systems/atari5200.webp differ diff --git a/webui/assets/systems/atari5200.webp.json b/webui/assets/systems/atari5200.webp.json new file mode 100644 index 0000000..e2ae456 --- /dev/null +++ b/webui/assets/systems/atari5200.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file atari5200.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.476Z" +} \ No newline at end of file diff --git a/webui/assets/systems/atari7800.webp b/webui/assets/systems/atari7800.webp new file mode 100644 index 0000000..1c8d406 Binary files /dev/null and b/webui/assets/systems/atari7800.webp differ diff --git a/webui/assets/systems/atari7800.webp.json b/webui/assets/systems/atari7800.webp.json new file mode 100644 index 0000000..4d74f91 --- /dev/null +++ b/webui/assets/systems/atari7800.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file atari7800.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.491Z" +} \ No newline at end of file diff --git a/webui/assets/systems/atarijaguar.webp b/webui/assets/systems/atarijaguar.webp new file mode 100644 index 0000000..d87909c Binary files /dev/null and b/webui/assets/systems/atarijaguar.webp differ diff --git a/webui/assets/systems/atarijaguar.webp.json b/webui/assets/systems/atarijaguar.webp.json new file mode 100644 index 0000000..600020a --- /dev/null +++ b/webui/assets/systems/atarijaguar.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file atarijaguar.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.506Z" +} \ No newline at end of file diff --git a/webui/assets/systems/atarilynx.webp b/webui/assets/systems/atarilynx.webp new file mode 100644 index 0000000..9f54aaf Binary files /dev/null and b/webui/assets/systems/atarilynx.webp differ diff --git a/webui/assets/systems/atarilynx.webp.json b/webui/assets/systems/atarilynx.webp.json new file mode 100644 index 0000000..f4e5e8a --- /dev/null +++ b/webui/assets/systems/atarilynx.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file atarilynx.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.521Z" +} \ No newline at end of file diff --git a/webui/assets/systems/atomiswave.webp b/webui/assets/systems/atomiswave.webp new file mode 100644 index 0000000..e5a78f0 Binary files /dev/null and b/webui/assets/systems/atomiswave.webp differ diff --git a/webui/assets/systems/atomiswave.webp.json b/webui/assets/systems/atomiswave.webp.json new file mode 100644 index 0000000..7af7c86 --- /dev/null +++ b/webui/assets/systems/atomiswave.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file atomiswave.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.426Z" +} \ No newline at end of file diff --git a/webui/assets/systems/c64.webp b/webui/assets/systems/c64.webp new file mode 100644 index 0000000..de03fcc Binary files /dev/null and b/webui/assets/systems/c64.webp differ diff --git a/webui/assets/systems/c64.webp.json b/webui/assets/systems/c64.webp.json new file mode 100644 index 0000000..5741d56 --- /dev/null +++ b/webui/assets/systems/c64.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file c64.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.439Z" +} \ No newline at end of file diff --git a/webui/assets/systems/cps.webp b/webui/assets/systems/cps.webp new file mode 100644 index 0000000..9e00b8f Binary files /dev/null and b/webui/assets/systems/cps.webp differ diff --git a/webui/assets/systems/cps.webp.json b/webui/assets/systems/cps.webp.json new file mode 100644 index 0000000..0a81d1f --- /dev/null +++ b/webui/assets/systems/cps.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file cps.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.695Z" +} \ No newline at end of file diff --git a/webui/assets/systems/dos.webp b/webui/assets/systems/dos.webp new file mode 100644 index 0000000..43783c9 Binary files /dev/null and b/webui/assets/systems/dos.webp differ diff --git a/webui/assets/systems/dos.webp.json b/webui/assets/systems/dos.webp.json new file mode 100644 index 0000000..1ad1f16 --- /dev/null +++ b/webui/assets/systems/dos.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file dos.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.637Z" +} \ No newline at end of file diff --git a/webui/assets/systems/dreamcast.webp b/webui/assets/systems/dreamcast.webp new file mode 100644 index 0000000..97ce8e0 Binary files /dev/null and b/webui/assets/systems/dreamcast.webp differ diff --git a/webui/assets/systems/dreamcast.webp.json b/webui/assets/systems/dreamcast.webp.json new file mode 100644 index 0000000..8aeb806 --- /dev/null +++ b/webui/assets/systems/dreamcast.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file dreamcast.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.411Z" +} \ No newline at end of file diff --git a/webui/assets/systems/fbneo.webp b/webui/assets/systems/fbneo.webp new file mode 100644 index 0000000..5ea05dc Binary files /dev/null and b/webui/assets/systems/fbneo.webp differ diff --git a/webui/assets/systems/fbneo.webp.json b/webui/assets/systems/fbneo.webp.json new file mode 100644 index 0000000..cda70e5 --- /dev/null +++ b/webui/assets/systems/fbneo.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file fbneo.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.701Z" +} \ No newline at end of file diff --git a/webui/assets/systems/fds.webp b/webui/assets/systems/fds.webp new file mode 100644 index 0000000..313a849 Binary files /dev/null and b/webui/assets/systems/fds.webp differ diff --git a/webui/assets/systems/fds.webp.json b/webui/assets/systems/fds.webp.json new file mode 100644 index 0000000..e672c02 --- /dev/null +++ b/webui/assets/systems/fds.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file fds.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.285Z" +} \ No newline at end of file diff --git a/webui/assets/systems/gamegear.webp b/webui/assets/systems/gamegear.webp new file mode 100644 index 0000000..f9f4824 Binary files /dev/null and b/webui/assets/systems/gamegear.webp differ diff --git a/webui/assets/systems/gamegear.webp.json b/webui/assets/systems/gamegear.webp.json new file mode 100644 index 0000000..a196d54 --- /dev/null +++ b/webui/assets/systems/gamegear.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file gamegear.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.389Z" +} \ No newline at end of file diff --git a/webui/assets/systems/gb.webp b/webui/assets/systems/gb.webp new file mode 100644 index 0000000..dc5a414 Binary files /dev/null and b/webui/assets/systems/gb.webp differ diff --git a/webui/assets/systems/gb.webp.json b/webui/assets/systems/gb.webp.json new file mode 100644 index 0000000..d5dccbe --- /dev/null +++ b/webui/assets/systems/gb.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file gb.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.244Z" +} \ No newline at end of file diff --git a/webui/assets/systems/gba.webp b/webui/assets/systems/gba.webp new file mode 100644 index 0000000..bff4b8b Binary files /dev/null and b/webui/assets/systems/gba.webp differ diff --git a/webui/assets/systems/gba.webp.json b/webui/assets/systems/gba.webp.json new file mode 100644 index 0000000..bd1638d --- /dev/null +++ b/webui/assets/systems/gba.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file gba.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.228Z" +} \ No newline at end of file diff --git a/webui/assets/systems/gbc.webp b/webui/assets/systems/gbc.webp new file mode 100644 index 0000000..2eace9e Binary files /dev/null and b/webui/assets/systems/gbc.webp differ diff --git a/webui/assets/systems/gbc.webp.json b/webui/assets/systems/gbc.webp.json new file mode 100644 index 0000000..6af645d --- /dev/null +++ b/webui/assets/systems/gbc.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file gbc.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.236Z" +} \ No newline at end of file diff --git a/webui/assets/systems/gc.webp b/webui/assets/systems/gc.webp new file mode 100644 index 0000000..55fc524 Binary files /dev/null and b/webui/assets/systems/gc.webp differ diff --git a/webui/assets/systems/gc.webp.json b/webui/assets/systems/gc.webp.json new file mode 100644 index 0000000..01eb5f5 --- /dev/null +++ b/webui/assets/systems/gc.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file gc.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.303Z" +} \ No newline at end of file diff --git a/webui/assets/systems/genesis.webp b/webui/assets/systems/genesis.webp new file mode 100644 index 0000000..e2bd9ac Binary files /dev/null and b/webui/assets/systems/genesis.webp differ diff --git a/webui/assets/systems/genesis.webp.json b/webui/assets/systems/genesis.webp.json new file mode 100644 index 0000000..541ec33 --- /dev/null +++ b/webui/assets/systems/genesis.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file genesis.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.373Z" +} \ No newline at end of file diff --git a/webui/assets/systems/mame.webp b/webui/assets/systems/mame.webp new file mode 100644 index 0000000..854c9e2 Binary files /dev/null and b/webui/assets/systems/mame.webp differ diff --git a/webui/assets/systems/mame.webp.json b/webui/assets/systems/mame.webp.json new file mode 100644 index 0000000..75e55d3 --- /dev/null +++ b/webui/assets/systems/mame.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file mame.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.709Z" +} \ No newline at end of file diff --git a/webui/assets/systems/mastersystem.webp b/webui/assets/systems/mastersystem.webp new file mode 100644 index 0000000..330fa3a Binary files /dev/null and b/webui/assets/systems/mastersystem.webp differ diff --git a/webui/assets/systems/mastersystem.webp.json b/webui/assets/systems/mastersystem.webp.json new file mode 100644 index 0000000..197b40e --- /dev/null +++ b/webui/assets/systems/mastersystem.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file mastersystem.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.381Z" +} \ No newline at end of file diff --git a/webui/assets/systems/msx.webp b/webui/assets/systems/msx.webp new file mode 100644 index 0000000..98bbd18 Binary files /dev/null and b/webui/assets/systems/msx.webp differ diff --git a/webui/assets/systems/msx.webp.json b/webui/assets/systems/msx.webp.json new file mode 100644 index 0000000..eb81539 --- /dev/null +++ b/webui/assets/systems/msx.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file msx.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.677Z" +} \ No newline at end of file diff --git a/webui/assets/systems/n3ds.webp b/webui/assets/systems/n3ds.webp new file mode 100644 index 0000000..8f18307 Binary files /dev/null and b/webui/assets/systems/n3ds.webp differ diff --git a/webui/assets/systems/n3ds.webp.json b/webui/assets/systems/n3ds.webp.json new file mode 100644 index 0000000..b4e70f6 --- /dev/null +++ b/webui/assets/systems/n3ds.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file n3ds.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.252Z" +} \ No newline at end of file diff --git a/webui/assets/systems/n64.webp b/webui/assets/systems/n64.webp new file mode 100644 index 0000000..7912e16 Binary files /dev/null and b/webui/assets/systems/n64.webp differ diff --git a/webui/assets/systems/n64.webp.json b/webui/assets/systems/n64.webp.json new file mode 100644 index 0000000..7b788ec --- /dev/null +++ b/webui/assets/systems/n64.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file n64.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.269Z" +} \ No newline at end of file diff --git a/webui/assets/systems/naomi.webp b/webui/assets/systems/naomi.webp new file mode 100644 index 0000000..f62357b Binary files /dev/null and b/webui/assets/systems/naomi.webp differ diff --git a/webui/assets/systems/naomi.webp.json b/webui/assets/systems/naomi.webp.json new file mode 100644 index 0000000..eb65419 --- /dev/null +++ b/webui/assets/systems/naomi.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file naomi.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.418Z" +} \ No newline at end of file diff --git a/webui/assets/systems/nds.webp b/webui/assets/systems/nds.webp new file mode 100644 index 0000000..cdb5cfa Binary files /dev/null and b/webui/assets/systems/nds.webp differ diff --git a/webui/assets/systems/nds.webp.json b/webui/assets/systems/nds.webp.json new file mode 100644 index 0000000..a3910fa --- /dev/null +++ b/webui/assets/systems/nds.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file nds.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.260Z" +} \ No newline at end of file diff --git a/webui/assets/systems/neogeo.webp b/webui/assets/systems/neogeo.webp new file mode 100644 index 0000000..76d9278 Binary files /dev/null and b/webui/assets/systems/neogeo.webp differ diff --git a/webui/assets/systems/neogeo.webp.json b/webui/assets/systems/neogeo.webp.json new file mode 100644 index 0000000..3ca1e2f --- /dev/null +++ b/webui/assets/systems/neogeo.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file neogeo.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.687Z" +} \ No newline at end of file diff --git a/webui/assets/systems/neogeocd.webp b/webui/assets/systems/neogeocd.webp new file mode 100644 index 0000000..3c13402 Binary files /dev/null and b/webui/assets/systems/neogeocd.webp differ diff --git a/webui/assets/systems/neogeocd.webp.json b/webui/assets/systems/neogeocd.webp.json new file mode 100644 index 0000000..ad3aedf --- /dev/null +++ b/webui/assets/systems/neogeocd.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file neogeocd.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.669Z" +} \ No newline at end of file diff --git a/webui/assets/systems/nes.webp b/webui/assets/systems/nes.webp new file mode 100644 index 0000000..7ab410f Binary files /dev/null and b/webui/assets/systems/nes.webp differ diff --git a/webui/assets/systems/nes.webp.json b/webui/assets/systems/nes.webp.json new file mode 100644 index 0000000..65475ff --- /dev/null +++ b/webui/assets/systems/nes.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file nes.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.295Z" +} \ No newline at end of file diff --git a/webui/assets/systems/ngp.webp b/webui/assets/systems/ngp.webp new file mode 100644 index 0000000..a202f59 Binary files /dev/null and b/webui/assets/systems/ngp.webp differ diff --git a/webui/assets/systems/ngp.webp.json b/webui/assets/systems/ngp.webp.json new file mode 100644 index 0000000..f222af0 --- /dev/null +++ b/webui/assets/systems/ngp.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file ngp.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.599Z" +} \ No newline at end of file diff --git a/webui/assets/systems/ngpc.webp b/webui/assets/systems/ngpc.webp new file mode 100644 index 0000000..76e2584 Binary files /dev/null and b/webui/assets/systems/ngpc.webp differ diff --git a/webui/assets/systems/ngpc.webp.json b/webui/assets/systems/ngpc.webp.json new file mode 100644 index 0000000..b94a24e --- /dev/null +++ b/webui/assets/systems/ngpc.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file ngpc.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.587Z" +} \ No newline at end of file diff --git a/webui/assets/systems/pcengine.webp b/webui/assets/systems/pcengine.webp new file mode 100644 index 0000000..11cb818 Binary files /dev/null and b/webui/assets/systems/pcengine.webp differ diff --git a/webui/assets/systems/pcengine.webp.json b/webui/assets/systems/pcengine.webp.json new file mode 100644 index 0000000..817a0a6 --- /dev/null +++ b/webui/assets/systems/pcengine.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file pcengine.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.575Z" +} \ No newline at end of file diff --git a/webui/assets/systems/pcfx.webp b/webui/assets/systems/pcfx.webp new file mode 100644 index 0000000..0fec277 Binary files /dev/null and b/webui/assets/systems/pcfx.webp differ diff --git a/webui/assets/systems/pcfx.webp.json b/webui/assets/systems/pcfx.webp.json new file mode 100644 index 0000000..7993ee6 --- /dev/null +++ b/webui/assets/systems/pcfx.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file pcfx.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.557Z" +} \ No newline at end of file diff --git a/webui/assets/systems/pokemini.webp b/webui/assets/systems/pokemini.webp new file mode 100644 index 0000000..d4bd34b Binary files /dev/null and b/webui/assets/systems/pokemini.webp differ diff --git a/webui/assets/systems/pokemini.webp.json b/webui/assets/systems/pokemini.webp.json new file mode 100644 index 0000000..5f64be9 --- /dev/null +++ b/webui/assets/systems/pokemini.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file pokemini.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.349Z" +} \ No newline at end of file diff --git a/webui/assets/systems/ps2.webp b/webui/assets/systems/ps2.webp new file mode 100644 index 0000000..d5d08b6 Binary files /dev/null and b/webui/assets/systems/ps2.webp differ diff --git a/webui/assets/systems/ps2.webp.json b/webui/assets/systems/ps2.webp.json new file mode 100644 index 0000000..051ae9a --- /dev/null +++ b/webui/assets/systems/ps2.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file ps2.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.320Z" +} \ No newline at end of file diff --git a/webui/assets/systems/psp.webp b/webui/assets/systems/psp.webp new file mode 100644 index 0000000..f5ba33b Binary files /dev/null and b/webui/assets/systems/psp.webp differ diff --git a/webui/assets/systems/psp.webp.json b/webui/assets/systems/psp.webp.json new file mode 100644 index 0000000..55d1468 --- /dev/null +++ b/webui/assets/systems/psp.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file psp.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.327Z" +} \ No newline at end of file diff --git a/webui/assets/systems/psx.webp b/webui/assets/systems/psx.webp new file mode 100644 index 0000000..313493c Binary files /dev/null and b/webui/assets/systems/psx.webp differ diff --git a/webui/assets/systems/psx.webp.json b/webui/assets/systems/psx.webp.json new file mode 100644 index 0000000..d6c3aea --- /dev/null +++ b/webui/assets/systems/psx.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file psx.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.334Z" +} \ No newline at end of file diff --git a/webui/assets/systems/saturn.webp b/webui/assets/systems/saturn.webp new file mode 100644 index 0000000..b937d16 Binary files /dev/null and b/webui/assets/systems/saturn.webp differ diff --git a/webui/assets/systems/saturn.webp.json b/webui/assets/systems/saturn.webp.json new file mode 100644 index 0000000..8de3020 --- /dev/null +++ b/webui/assets/systems/saturn.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file saturn.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.403Z" +} \ No newline at end of file diff --git a/webui/assets/systems/scummvm.webp b/webui/assets/systems/scummvm.webp new file mode 100644 index 0000000..867c0f7 Binary files /dev/null and b/webui/assets/systems/scummvm.webp differ diff --git a/webui/assets/systems/scummvm.webp.json b/webui/assets/systems/scummvm.webp.json new file mode 100644 index 0000000..18eebae --- /dev/null +++ b/webui/assets/systems/scummvm.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file scummvm.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.654Z" +} \ No newline at end of file diff --git a/webui/assets/systems/sega32x.webp b/webui/assets/systems/sega32x.webp new file mode 100644 index 0000000..90dfcef Binary files /dev/null and b/webui/assets/systems/sega32x.webp differ diff --git a/webui/assets/systems/sega32x.webp.json b/webui/assets/systems/sega32x.webp.json new file mode 100644 index 0000000..c62670b --- /dev/null +++ b/webui/assets/systems/sega32x.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file sega32x.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.364Z" +} \ No newline at end of file diff --git a/webui/assets/systems/segacd.webp b/webui/assets/systems/segacd.webp new file mode 100644 index 0000000..88674df Binary files /dev/null and b/webui/assets/systems/segacd.webp differ diff --git a/webui/assets/systems/segacd.webp.json b/webui/assets/systems/segacd.webp.json new file mode 100644 index 0000000..e53c78b --- /dev/null +++ b/webui/assets/systems/segacd.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file segacd.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.356Z" +} \ No newline at end of file diff --git a/webui/assets/systems/sg-1000.webp b/webui/assets/systems/sg-1000.webp new file mode 100644 index 0000000..e03f77f Binary files /dev/null and b/webui/assets/systems/sg-1000.webp differ diff --git a/webui/assets/systems/sg-1000.webp.json b/webui/assets/systems/sg-1000.webp.json new file mode 100644 index 0000000..55dd79c --- /dev/null +++ b/webui/assets/systems/sg-1000.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file sg-1000.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.396Z" +} \ No newline at end of file diff --git a/webui/assets/systems/snes.webp b/webui/assets/systems/snes.webp new file mode 100644 index 0000000..1d430e2 Binary files /dev/null and b/webui/assets/systems/snes.webp differ diff --git a/webui/assets/systems/snes.webp.json b/webui/assets/systems/snes.webp.json new file mode 100644 index 0000000..532590d --- /dev/null +++ b/webui/assets/systems/snes.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file snes.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.278Z" +} \ No newline at end of file diff --git a/webui/assets/systems/supergrafx.webp b/webui/assets/systems/supergrafx.webp new file mode 100644 index 0000000..b7e8060 Binary files /dev/null and b/webui/assets/systems/supergrafx.webp differ diff --git a/webui/assets/systems/supergrafx.webp.json b/webui/assets/systems/supergrafx.webp.json new file mode 100644 index 0000000..33cfc69 --- /dev/null +++ b/webui/assets/systems/supergrafx.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file supergrafx.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.537Z" +} \ No newline at end of file diff --git a/webui/assets/systems/virtualboy.webp b/webui/assets/systems/virtualboy.webp new file mode 100644 index 0000000..a7d0f79 Binary files /dev/null and b/webui/assets/systems/virtualboy.webp differ diff --git a/webui/assets/systems/virtualboy.webp.json b/webui/assets/systems/virtualboy.webp.json new file mode 100644 index 0000000..4e99544 --- /dev/null +++ b/webui/assets/systems/virtualboy.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file virtualboy.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.342Z" +} \ No newline at end of file diff --git a/webui/assets/systems/wii.webp b/webui/assets/systems/wii.webp new file mode 100644 index 0000000..3d1b3c7 Binary files /dev/null and b/webui/assets/systems/wii.webp differ diff --git a/webui/assets/systems/wii.webp.json b/webui/assets/systems/wii.webp.json new file mode 100644 index 0000000..edadc7f --- /dev/null +++ b/webui/assets/systems/wii.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file wii.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.312Z" +} \ No newline at end of file diff --git a/webui/assets/systems/wonderswan.webp b/webui/assets/systems/wonderswan.webp new file mode 100644 index 0000000..313641b Binary files /dev/null and b/webui/assets/systems/wonderswan.webp differ diff --git a/webui/assets/systems/wonderswan.webp.json b/webui/assets/systems/wonderswan.webp.json new file mode 100644 index 0000000..1a88d52 --- /dev/null +++ b/webui/assets/systems/wonderswan.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file wonderswan.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.619Z" +} \ No newline at end of file diff --git a/webui/assets/systems/wonderswancolor.webp b/webui/assets/systems/wonderswancolor.webp new file mode 100644 index 0000000..f0218de Binary files /dev/null and b/webui/assets/systems/wonderswancolor.webp differ diff --git a/webui/assets/systems/wonderswancolor.webp.json b/webui/assets/systems/wonderswancolor.webp.json new file mode 100644 index 0000000..06af050 --- /dev/null +++ b/webui/assets/systems/wonderswancolor.webp.json @@ -0,0 +1,4 @@ +{ + "prompt": "Source: Linear ES-DE theme system artwork, bundled in .deps/es-de/themes/linear-es-de/system/systemart; original file wonderswancolor.webp. MIT license in adjacent LICENSE. Original artwork pixels unchanged.", + "createdAt": "2026-10-08T14:36:03.609Z" +} \ No newline at end of file diff --git a/webui/icons.js b/webui/icons.js new file mode 100644 index 0000000..52fc0b9 --- /dev/null +++ b/webui/icons.js @@ -0,0 +1,166 @@ +/* Lucide contributors, ISC; Feather contributors, MIT. See assets/lucide/LICENSE. */ +'use strict'; +const WEBUI_ICONS = { + "arrow-down": "\n ", + "arrow-right": "\n ", + "arrow-up-down": "\n \n \n ", + "arrow-up": "\n ", + "book-image": "\n \n ", + "book-open": "\n ", + "box": "\n \n ", + "check": "", + "chevron-down": "", + "chevron-left": "", + "chevron-right": "", + "chevron-up": "", + "circle-alert": "\n \n ", + "clapperboard": "\n \n \n ", + "cloud-upload": "\n \n ", + "database": "\n \n ", + "disc-3": "\n \n \n ", + "download": "\n \n ", + "external-link": "\n \n ", + "file-text": "\n \n \n \n ", + "flag": "", + "folder-open": "", + "folder": "", + "gallery-horizontal-end": "\n \n ", + "gamepad-2": "\n \n \n \n ", + "globe": "\n \n ", + "hard-drive": "\n \n \n ", + "image": "\n \n ", + "images": "\n \n \n ", + "info": "\n \n ", + "layout-dashboard": "\n \n \n ", + "library-big": "\n \n ", + "list": "\n \n \n \n \n ", + "monitor": "\n \n ", + "moon": "", + "panels-top-left": "\n \n ", + "pencil": "\n ", + "plus": "\n ", + "save": "\n \n ", + "search": "\n ", + "settings-2": "\n \n \n ", + "sun": "\n \n \n \n \n \n \n \n ", + "tower-control": "\n \n \n \n \n \n ", + "type": "\n \n ", + "upload": "\n \n ", + "video": "\n ", + "volume-2": "\n \n ", + "wallpaper": "\n \n \n \n ", + "x": "\n ", + "zoom-in": "\n \n \n " +}; +const MEDIA_SYMBOLS = { + 'box': '', + 'disc-3': '' +}; +const COLOR_ICONS = { + "layout-dashboard": "assets/fluent/home.svg", + "folder-open": "assets/fluent/document-folder.svg", + "folder": "assets/fluent/document-folder.svg", + "library-big": "assets/fluent/library.svg", + "download": "assets/fluent/arrow-square-down.svg", + "arrow-up-down": "assets/fluent/arrow-sync.svg", + "settings-2": "assets/fluent/settings.svg", + "cloud-upload": "assets/fluent/cloud.svg", + "book-image": "assets/fluent/book.svg", + "book-open": "assets/fluent/book-open.svg", + "box": "assets/fluent/briefcase.svg", + "image": "assets/fluent/image.svg", + "images": "assets/fluent/content-view.svg", + "panels-top-left": "assets/fluent/board.svg", + "type": "assets/fluent/text-edit-style.svg", + "disc-3": "assets/fluent/coin-multiple.svg", + "wallpaper": "assets/fluent/image.svg", + "video": "assets/fluent/video.svg", + "clapperboard": "assets/fluent/video.svg", + "database": "assets/fluent/database.svg", + "file-text": "assets/fluent/document-text.svg", + "list": "assets/fluent/text-bullet-list-square.svg", + "monitor": "assets/fluent/laptop.svg", + "globe": "assets/fluent/globe.svg", + "pencil": "assets/fluent/edit.svg", + "save": "assets/fluent/vault.svg", + "gamepad-2": "assets/fluent/game-chat.svg", + "hard-drive": "assets/fluent/database.svg", + "circle-alert": "assets/fluent/warning.svg", + "info": "assets/fluent/question-circle.svg", + "check": "assets/fluent/checkmark-circle.svg", + "sun": "assets/fluent/weather-sunny-low.svg", + "volume-2": "assets/fluent/headphones.svg", + "gallery-horizontal-end": "assets/fluent/content-view.svg", + "tower-control": "assets/fluent/building.svg", + "flag": "assets/fluent/flag.svg" +}; +function assetIcon(src, className = '') { + const img = document.createElement('img'); img.src = src; img.alt = ''; img.className = 'icon color-icon ' + className; img.setAttribute('aria-hidden','true'); return img; +} +function uiIcon(name, className = '') { + if (MEDIA_SYMBOLS[name]) return assetIcon('data:image/svg+xml,' + encodeURIComponent(MEDIA_SYMBOLS[name]),className); + if (COLOR_ICONS[name]) return assetIcon(COLOR_ICONS[name],className); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + for (const [key, value] of Object.entries({viewBox:'0 0 24 24', fill:'none', stroke:'currentColor', 'stroke-width':'1.75', 'stroke-linecap':'round', 'stroke-linejoin':'round', 'aria-hidden':'true', focusable:'false', class:'icon lucide ' + className})) svg.setAttribute(key, value); + svg.innerHTML = WEBUI_ICONS[name] || WEBUI_ICONS['file-text']; + return svg; +} +for (const node of document.querySelectorAll('[data-ui-icon]')) node.replaceWith(uiIcon(node.dataset.uiIcon, node.className)); + +const SYSTEM_ICONS = { + "gba": "assets/systems/gba.webp", + "gbc": "assets/systems/gbc.webp", + "gb": "assets/systems/gb.webp", + "n3ds": "assets/systems/n3ds.webp", + "nds": "assets/systems/nds.webp", + "n64": "assets/systems/n64.webp", + "snes": "assets/systems/snes.webp", + "fds": "assets/systems/fds.webp", + "nes": "assets/systems/nes.webp", + "gc": "assets/systems/gc.webp", + "wii": "assets/systems/wii.webp", + "ps2": "assets/systems/ps2.webp", + "psp": "assets/systems/psp.webp", + "psx": "assets/systems/psx.webp", + "virtualboy": "assets/systems/virtualboy.webp", + "pokemini": "assets/systems/pokemini.webp", + "segacd": "assets/systems/segacd.webp", + "sega32x": "assets/systems/sega32x.webp", + "genesis": "assets/systems/genesis.webp", + "mastersystem": "assets/systems/mastersystem.webp", + "gamegear": "assets/systems/gamegear.webp", + "sg-1000": "assets/systems/sg-1000.webp", + "saturn": "assets/systems/saturn.webp", + "dreamcast": "assets/systems/dreamcast.webp", + "naomi": "assets/systems/naomi.webp", + "atomiswave": "assets/systems/atomiswave.webp", + "c64": "assets/systems/c64.webp", + "amiga": "assets/systems/amiga.webp", + "atari2600": "assets/systems/atari2600.webp", + "atari5200": "assets/systems/atari5200.webp", + "atari7800": "assets/systems/atari7800.webp", + "atarijaguar": "assets/systems/atarijaguar.webp", + "atarilynx": "assets/systems/atarilynx.webp", + "supergrafx": "assets/systems/supergrafx.webp", + "pcfx": "assets/systems/pcfx.webp", + "pcengine": "assets/systems/pcengine.webp", + "ngpc": "assets/systems/ngpc.webp", + "ngp": "assets/systems/ngp.webp", + "wonderswancolor": "assets/systems/wonderswancolor.webp", + "wonderswan": "assets/systems/wonderswan.webp", + "3do": "assets/systems/3do.webp", + "dos": "assets/systems/dos.webp", + "scummvm": "assets/systems/scummvm.webp", + "neogeocd": "assets/systems/neogeocd.webp", + "msx": "assets/systems/msx.webp", + "neogeo": "assets/systems/neogeo.webp", + "cps": "assets/systems/cps.webp", + "fbneo": "assets/systems/fbneo.webp", + "mame": "assets/systems/mame.webp", + "arcade": "assets/systems/arcade.webp" +}; +function systemIcon(id) { + const frame = document.createElement('span'); frame.className = 'system-icon'; frame.setAttribute('aria-hidden','true'); + if (SYSTEM_ICONS[id]) { const img = assetIcon(SYSTEM_ICONS[id]); img.loading = 'lazy'; frame.append(img); } else frame.append(uiIcon('library-big')); + return frame; +} diff --git a/webui/index.html b/webui/index.html index 3cd983d..d218459 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1,28 +1,28 @@ -RetroArch · PS5 +RetroArch · PS5 - -
Development buildChecking releases…
View repository
+ +
Development buildChecking releases…
View repository

Overview

Connecting to RetroArch…

-

Alerts

Checking required BIOS and system files…

Upload content

Add games, ROMs or other files to your PS5

+

Alerts

Checking required BIOS and system files…

Upload content

Add games, ROMs or other files to your PS5

-
-

Your library

Browse and manage content on your PS5

Loading your library…

-

Recent transfers

Track transfers from this browser session

Your transfers will appear here.

-

Settings

Saved changes apply the next time you open RetroArch

—
—
Open settings
+
+

Your library

Browse and manage content on your PS5

Loading your library…

+

Recent transfers

Track transfers from this browser session

Your transfers will appear here.

+

Settings

Saved changes apply the next time you open RetroArch

—
—
Open settings
-

+ +

Add Game

Your game, ready for every frontend.

+
+

Start with your game

Add a legally obtained backup of a game you own. Your existing games stay untouched.

The system’s installed core is detected automatically. For multi-file discs or folder-based games, upload the complete folder in Content.

+ + +

Choose a system and a game backup to begin.

+ +

Media

Choose a media type to preview, enlarge or replace it. Your uploaded media is kept when downloading again.