feat: initial ROM & emulator manager plugin (M0–M5)
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled

A punktfunk-plugin-* package that scans ROM directories, maps them to
emulators, fetches box art, and reconciles them into the host game library
under provider id `rom-manager` — with a console-hosted web UI.

Engine (pure, unit-tested core):
- Table-driven platform registry (~25 consoles) + emulator registry with
  best-effort per-OS detection (PATH / Flatpak / known paths) and RetroArch
  core discovery.
- Scanner with disc folding (m3u/cue/gdi), archive gating, excludes.
- No-Intro title parsing + optional per-platform region dedupe.
- Security-critical quoting seam: POSIX single-quote + Windows double-quote
  with hostile-name refusal; ROM filenames never reach a shell un-quoted.
- Pure desired-state reconcile (stable external_ids, scale guard, fingerprint
  skip) → full-replace PUT /library/provider/rom-manager.

Box art (like Steam ROM Manager): SteamGridDB primary (portrait/hero/logo/
header, fuzzy match, operator API key) behind a provider seam, with keyless
libretro-thumbnails as the zero-setup fallback (`auto` default).

UI: console-hosted via the SDK `servePluginUi` (zero plugin-side auth) with a
plugin-local REST/SSE API and a self-contained React SPA (Setup / Emulators /
Games / Sync). Standalone password-gated fallback for host-only installs.

CLI: scan / detect / preview / sync / uninstall / set-password.

48 engine tests, typecheck + biome clean, SPA builds. Verified end-to-end:
scan → detect → reconcile PUT, fingerprint idempotence, and the standalone
UI serving SPA + REST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 02:40:59 +02:00
commit 3a6c80558d
59 changed files with 6051 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "punktfunk-plugin-rom-manager-ui",
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.9.3",
"vite": "^7.0.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ROM Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "punktfunk-plugin-rom-manager-ui",
"private": true,
"type": "module",
"description": "The ROM Manager plugin SPA — built into ../dist/ui and served by servePluginUi.",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.9.3",
"vite": "^7.0.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { ToastHost } from "./components.js";
import { Emulators } from "./pages/Emulators.js";
import { Games } from "./pages/Games.js";
import { Setup } from "./pages/Setup.js";
import { Sync } from "./pages/Sync.js";
const TABS = [
{ id: "setup", label: "Setup", Page: Setup },
{ id: "emulators", label: "Emulators", Page: Emulators },
{ id: "games", label: "Games", Page: Games },
{ id: "sync", label: "Sync", Page: Sync },
] as const;
type TabId = (typeof TABS)[number]["id"];
const tabFromHash = (): TabId => {
const h = window.location.hash.replace(/^#\/?/, "");
return (TABS.find((t) => t.id === h)?.id ?? "setup") as TabId;
};
export const App = () => {
const [tab, setTab] = useState<TabId>(tabFromHash);
useEffect(() => {
const onHash = () => setTab(tabFromHash());
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
const go = (id: TabId) => {
window.location.hash = `/${id}`;
setTab(id);
// Best-effort deep-link sync with the console shell (plugin-ui-surface §5; ignored elsewhere).
try {
window.parent?.postMessage({ type: "pf-ui:navigate", path: id }, "*");
} catch {
// not embedded
}
};
const Page = TABS.find((t) => t.id === tab)?.Page ?? Setup;
return (
<ToastHost>
<div className="app">
<div className="topbar">
<span className="logo">🎮</span>
<h1>ROM Manager</h1>
<span className="subtle">
scan ROMs into your punktfunk library
</span>
</div>
<div className="tabs">
{TABS.map((t) => (
<button
type="button"
key={t.id}
className={`tab${t.id === tab ? " active" : ""}`}
onClick={() => go(t.id)}
>
{t.label}
</button>
))}
</div>
<Page />
</div>
</ToastHost>
);
};
+162
View File
@@ -0,0 +1,162 @@
// Typed client for the plugin-local API (relative paths — the SPA is mounted under the console proxy
// prefix, so `api/...` resolves to `/plugin-ui/rom-manager/api/...`). Types mirror the backend.
import { useEffect, useState } from "react";
export interface Platform {
id: string;
name: string;
extensions: string[];
libretroSystem?: string;
defaultLaunch: { emulator: string; core?: string; extraArgs?: string };
disc?: boolean;
}
export interface EmulatorDef {
id: string;
name: string;
template: string;
supportsArchives: boolean;
contested?: boolean;
}
export interface Detected {
id: string;
name: string;
via: "path" | "file" | "flatpak";
exeToken: string;
contested?: boolean;
coresDir?: string;
cores?: string[];
}
export interface RomRoot {
dir: string;
platform: string;
excludes?: string[];
}
export interface GameOverride {
exclude?: boolean;
emulator?: string;
core?: string;
extraArgs?: string;
title?: string;
art?: string;
}
export interface Config {
roots: RomRoot[];
platformLaunch: Record<
string,
{ emulator: string; core?: string; extraArgs?: string }
>;
gameOverrides: Record<string, GameOverride>;
sync: {
pollMinutes: number;
watch: boolean;
debounceMs: number;
dedupeRegions: string[];
regionPriority: string[];
warnEntries: number;
maxEntries: number;
closeOnEnd: boolean;
};
art: {
enabled: boolean;
provider: "auto" | "steamgriddb" | "libretro";
steamGridDbKey?: string;
};
ui: {
standalone: boolean;
port: number;
bind: string;
passwordHash?: string;
};
devEntry: boolean;
}
export interface Artwork {
portrait?: string | null;
hero?: string | null;
logo?: string | null;
header?: string | null;
}
export interface Entry {
external_id: string;
title: string;
launch?: { kind: string; value: string } | null;
art?: Artwork;
prep?: { do: string; undo?: string | null }[];
}
export interface Skipped {
external_id: string;
title: string;
reason: string;
}
export interface Report {
considered: number;
included: number;
skipped: Skipped[];
excluded: { external_id: string; title: string }[];
warnings: string[];
truncated: number;
perPlatform: Record<string, number>;
overWarn: boolean;
}
export interface Preview {
entries: Entry[];
report: Report;
detected: Detected[];
}
export interface Status {
rootsConfigured: number;
os: "linux" | "windows";
artProvider: string | null;
lastSync?: { fingerprint: string; count: number; at: number };
lastReport?: Report;
detected?: { at: number; emulators: Detected[] };
paths: { dir: string; config: string; cache: string; relConfig: string };
syncing: boolean;
}
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
const res = await fetch(path, {
headers: { "content-type": "application/json" },
...opts,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
};
export const getStatus = () => api<Status>("api/status");
export const getConfig = () => api<Config>("api/config");
export const putConfig = (config: Config) =>
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
export const getPreview = () => api<Preview>("api/preview");
export const runDetect = () =>
api<Detected[]>("api/detect", { method: "POST" });
export const runSync = () => api<Report>("api/sync", { method: "POST" });
export const getPlatforms = () => api<Platform[]>("api/platforms");
export const getEmulators = () =>
api<{ defs: EmulatorDef[]; detected: Detected[] }>("api/emulators");
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
export const useStatusStream = (): Status | undefined => {
const [status, setStatus] = useState<Status>();
useEffect(() => {
getStatus()
.then(setStatus)
.catch(() => {});
try {
const es = new EventSource("api/events");
es.addEventListener("status", (e) => {
try {
setStatus(JSON.parse((e as MessageEvent).data));
} catch {
// ignore malformed frame
}
});
return () => es.close();
} catch {
return;
}
}, []);
return status;
};
+61
View File
@@ -0,0 +1,61 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useState } from "react";
export const Card = ({
title,
hint,
right,
children,
}: {
title?: string;
hint?: string;
right?: ReactNode;
children: ReactNode;
}) => (
<div className="card">
{(title || right) && (
<div className="row spread" style={{ marginBottom: hint ? 0 : 8 }}>
{title && <h2>{title}</h2>}
{right}
</div>
)}
{hint && <p className="hint">{hint}</p>}
{children}
</div>
);
export const Button = ({
primary,
children,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { primary?: boolean }) => (
<button type="button" className={`btn${primary ? " primary" : ""}`} {...rest}>
{children}
</button>
);
export const Badge = ({
tone,
children,
}: {
tone?: "ok" | "warn" | "accent";
children: ReactNode;
}) => <span className={`badge${tone ? ` ${tone}` : ""}`}>{children}</span>;
// ── Toasts ────────────────────────────────────────────────────────────────────────────────────
const ToastCtx = createContext<(msg: string) => void>(() => {});
export const useToast = () => useContext(ToastCtx);
export const ToastHost = ({ children }: { children: ReactNode }) => {
const [msg, setMsg] = useState<string>();
const show = useCallback((m: string) => {
setMsg(m);
window.setTimeout(() => setMsg(undefined), 2600);
}, []);
return (
<ToastCtx.Provider value={show}>
{children}
{msg && <div className="toast">{msg}</div>}
</ToastCtx.Provider>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { useCallback, useEffect, useState } from "react";
import { type Config, getConfig, putConfig } from "./api.js";
/** Load + save the plugin config, with a local editable copy. */
export const useConfig = () => {
const [config, setConfig] = useState<Config | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const reload = useCallback(() => {
getConfig()
.then(setConfig)
.catch((e) => setError(String(e)));
}, []);
useEffect(reload, [reload]);
const save = useCallback(async (next: Config): Promise<boolean> => {
setSaving(true);
setError(undefined);
try {
setConfig(await putConfig(next));
return true;
} catch (e) {
setError(String(e));
return false;
} finally {
setSaving(false);
}
}, []);
return { config, setConfig, reload, save, saving, error };
};
/** The platform id out of an `external_id` (`snes/Foo.sfc` → `snes`; `snes/0/Foo.sfc` → `snes`). */
export const platformOf = (externalId: string): string =>
externalId.split("/")[0] ?? "";
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useState } from "react";
import {
type Detected,
type EmulatorDef,
getEmulators,
getPlatforms,
type Platform,
runDetect,
} from "../api.js";
import { Badge, Button, Card, useToast } from "../components.js";
import { useConfig } from "../hooks.js";
export const Emulators = () => {
const { config, setConfig, save, saving } = useConfig();
const [defs, setDefs] = useState<EmulatorDef[]>([]);
const [detected, setDetected] = useState<Detected[]>([]);
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [detecting, setDetecting] = useState(false);
const toast = useToast();
const load = () => {
getEmulators().then((e) => {
setDefs(e.defs);
setDetected(e.detected);
});
};
useEffect(load, []);
useEffect(() => {
getPlatforms()
.then(setPlatforms)
.catch(() => {});
}, []);
const detectedById = new Map(detected.map((d) => [d.id, d]));
return (
<>
<Card
title="Detected emulators"
hint="Best-effort detection: PATH, Flatpak, then known install paths. Missing ones can still be launched via a custom template."
right={
<Button
disabled={detecting}
onClick={async () => {
setDetecting(true);
try {
setDetected(await runDetect());
toast("Re-detected emulators");
} finally {
setDetecting(false);
}
}}
>
{detecting ? "Detecting…" : "Re-detect"}
</Button>
}
>
<table>
<thead>
<tr>
<th>Emulator</th>
<th>Status</th>
<th>Cores</th>
<th />
</tr>
</thead>
<tbody>
{defs.map((def) => {
const d = detectedById.get(def.id);
return (
<tr key={def.id}>
<td>
{def.name} <span className="mono">{def.id}</span>
</td>
<td>
{d ? (
<Badge tone="ok">detected · {d.via}</Badge>
) : (
<Badge>not found</Badge>
)}
</td>
<td>{d?.cores?.length ? d.cores.length : "—"}</td>
<td>
{def.contested && <Badge tone="warn">contested</Badge>}
</td>
</tr>
);
})}
</tbody>
</table>
</Card>
{config && (
<Card
title="Per-platform emulator"
hint="The default emulator + core for each platform. Override here to prefer a different emulator or RetroArch core."
right={
<Button
primary
disabled={saving}
onClick={async () => {
if (await save(config)) toast("Saved — syncing library");
}}
>
{saving ? "Saving…" : "Save & sync"}
</Button>
}
>
<div className="scroll">
<table>
<thead>
<tr>
<th>Platform</th>
<th>Emulator</th>
<th>Core (RetroArch)</th>
</tr>
</thead>
<tbody>
{platforms.map((p) => {
const override = config.platformLaunch[p.id];
const emulator =
override?.emulator ?? p.defaultLaunch.emulator;
const core = override?.core ?? p.defaultLaunch.core ?? "";
const detCores = detectedById.get(emulator)?.cores ?? [];
const setLaunch = (patch: {
emulator?: string;
core?: string;
}) => {
const next = {
emulator: patch.emulator ?? emulator,
core: patch.core ?? core,
};
setConfig({
...config,
platformLaunch: {
...config.platformLaunch,
[p.id]: next,
},
});
};
return (
<tr key={p.id}>
<td>{p.name}</td>
<td>
<select
value={emulator}
onChange={(e) =>
setLaunch({ emulator: e.target.value })
}
>
{defs.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
{detectedById.has(d.id) ? " ✓" : ""}
</option>
))}
</select>
</td>
<td>
{emulator === "retroarch" ? (
detCores.length ? (
<select
value={core}
onChange={(e) =>
setLaunch({ core: e.target.value })
}
>
{!detCores.includes(core) && (
<option value={core}>{core || "—"}</option>
)}
{detCores.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
) : (
<input
value={core}
placeholder="snes9x"
onChange={(e) =>
setLaunch({ core: e.target.value })
}
/>
)
) : (
<span className="subtle"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
</>
);
};
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from "react";
import { getConfig, getPreview, type Preview, putConfig } from "../api.js";
import { Badge, Button, Card, useToast } from "../components.js";
import { platformOf } from "../hooks.js";
export const Games = () => {
const [preview, setPreview] = useState<Preview>();
const [loading, setLoading] = useState(false);
const [showSkipped, setShowSkipped] = useState(false);
const toast = useToast();
const refresh = useCallback(() => {
setLoading(true);
getPreview()
.then(setPreview)
.catch((e) => toast(String(e)))
.finally(() => setLoading(false));
}, [toast]);
useEffect(refresh, [refresh]);
// Toggle a title's exclude override, persist, and refresh the preview.
const setExcluded = async (externalId: string, exclude: boolean) => {
const config = await getConfig();
const overrides = { ...config.gameOverrides };
const current = overrides[externalId] ?? {};
if (exclude) overrides[externalId] = { ...current, exclude: true };
else {
const { exclude: _drop, ...rest } = current;
if (Object.keys(rest).length) overrides[externalId] = rest;
else delete overrides[externalId];
}
await putConfig({ ...config, gameOverrides: overrides });
toast(exclude ? "Excluded" : "Included");
refresh();
};
if (!preview) return <Card>{loading ? "Scanning…" : "Loading…"}</Card>;
const { entries, report } = preview;
const excluded = report.excluded ?? [];
return (
<>
<Card
title={`Games (${entries.length})`}
hint="What will be reconciled into the library. Untick a title to exclude it; covers come from your art provider."
right={
<Button disabled={loading} onClick={refresh}>
{loading ? "Scanning…" : "Rescan"}
</Button>
}
>
{entries.length === 0 && excluded.length === 0 ? (
<div className="empty-state">
No games found. Add ROM roots in <strong>Setup</strong>, then
rescan.
</div>
) : (
<div className="scroll">
<table>
<thead>
<tr>
<th style={{ width: 52 }} />
<th>Title</th>
<th style={{ width: 90 }}>Platform</th>
<th>Launch command</th>
<th style={{ width: 60 }}>Include</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.external_id}>
<td>
{e.art?.portrait ? (
<img
className="cover"
src={e.art.portrait}
alt={`${e.title} cover`}
loading="lazy"
/>
) : (
<span className="cover empty" />
)}
</td>
<td>{e.title}</td>
<td>
<Badge>{platformOf(e.external_id)}</Badge>
</td>
<td className="mono" title={e.launch?.value}>
{truncate(e.launch?.value ?? "", 70)}
</td>
<td>
<input
type="checkbox"
checked
onChange={() => setExcluded(e.external_id, true)}
/>
</td>
</tr>
))}
{excluded.map((x) => (
<tr key={x.external_id} style={{ opacity: 0.55 }}>
<td>
<span className="cover empty" />
</td>
<td>{x.title}</td>
<td>
<Badge>{platformOf(x.external_id)}</Badge>
</td>
<td className="subtle">excluded</td>
<td>
<input
type="checkbox"
checked={false}
onChange={() => setExcluded(x.external_id, false)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{report.skipped.length > 0 && (
<Card
title={`Skipped (${report.skipped.length})`}
right={
<Button onClick={() => setShowSkipped((s) => !s)}>
{showSkipped ? "Hide" : "Show"}
</Button>
}
>
{showSkipped && (
<div className="scroll">
<table>
<tbody>
{report.skipped.map((s) => (
<tr key={s.external_id}>
<td>{s.title}</td>
<td className="subtle">{s.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
</>
);
};
const truncate = (s: string, n: number): string =>
s.length > n ? `${s.slice(0, n)}` : s;
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from "react";
import { getPlatforms, type Platform, type RomRoot } from "../api.js";
import { Button, Card, useToast } from "../components.js";
import { useConfig } from "../hooks.js";
export const Setup = () => {
const { config, setConfig, save, saving } = useConfig();
const [platforms, setPlatforms] = useState<Platform[]>([]);
const toast = useToast();
useEffect(() => {
getPlatforms()
.then(setPlatforms)
.catch(() => {});
}, []);
if (!config) return <Card>Loading</Card>;
const roots = config.roots;
const setRoots = (next: RomRoot[]) => setConfig({ ...config, roots: next });
const update = (i: number, patch: Partial<RomRoot>) =>
setRoots(roots.map((r, j) => (j === i ? { ...r, ...patch } : r)));
return (
<Card
title="ROM roots"
hint="Point each folder at the console platform its files belong to. Shared extensions (.iso, .bin) are resolved by the folder's platform."
right={
<Button
primary
disabled={saving}
onClick={async () => {
if (await save(config)) toast("Saved — syncing library");
}}
>
{saving ? "Saving…" : "Save & sync"}
</Button>
}
>
{roots.length === 0 && (
<p className="subtle" style={{ margin: "8px 0 16px" }}>
No roots yet. Add a folder of ROMs to get started.
</p>
)}
{roots.length > 0 && (
<table>
<thead>
<tr>
<th style={{ width: "45%" }}>Folder</th>
<th style={{ width: "25%" }}>Platform</th>
<th>Excludes (comma-separated globs)</th>
<th />
</tr>
</thead>
<tbody>
{roots.map((root, i) => (
<tr key={i}>
<td>
<input
className="grow"
style={{ width: "100%" }}
value={root.dir}
placeholder="/home/you/roms/snes"
onChange={(e) => update(i, { dir: e.target.value })}
/>
</td>
<td>
<select
value={root.platform}
onChange={(e) => update(i, { platform: e.target.value })}
>
<option value=""> pick </option>
{platforms.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</td>
<td>
<input
style={{ width: "100%" }}
value={(root.excludes ?? []).join(", ")}
placeholder="*.sav, bios/**"
onChange={(e) =>
update(i, {
excludes: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</td>
<td>
<Button
className="icon"
onClick={() => setRoots(roots.filter((_, j) => j !== i))}
>
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{ marginTop: 12 }}>
<Button onClick={() => setRoots([...roots, { dir: "", platform: "" }])}>
+ Add root
</Button>
</div>
</Card>
);
};
+255
View File
@@ -0,0 +1,255 @@
import { useState } from "react";
import { type Config, runSync, useStatusStream } from "../api.js";
import { Badge, Button, Card, useToast } from "../components.js";
import { useConfig } from "../hooks.js";
export const Sync = () => {
const status = useStatusStream();
const { config, setConfig, save, saving } = useConfig();
const [syncing, setSyncing] = useState(false);
const toast = useToast();
const report = status?.lastReport;
return (
<>
<Card
title="Status"
right={
<Button
primary
disabled={syncing || status?.syncing}
onClick={async () => {
setSyncing(true);
try {
await runSync();
toast("Synced");
} catch (e) {
toast(String(e));
} finally {
setSyncing(false);
}
}}
>
{syncing || status?.syncing ? "Syncing…" : "Sync now"}
</Button>
}
>
<div className="stats">
<div className="stat">
<span className="n">{status?.rootsConfigured ?? "—"}</span>
<span className="l">ROM roots</span>
</div>
<div className="stat">
<span className="n">{status?.lastSync?.count ?? "—"}</span>
<span className="l">titles in library</span>
</div>
<div className="stat">
<span className="n">{report?.skipped.length ?? "—"}</span>
<span className="l">skipped</span>
</div>
<div className="stat">
<span className="n" style={{ fontSize: 15, paddingTop: 6 }}>
{status?.artProvider ? (
<Badge tone="accent">{status.artProvider}</Badge>
) : (
"off"
)}
</span>
<span className="l">art provider</span>
</div>
</div>
{status?.lastSync && (
<p className="subtle" style={{ marginTop: 12 }}>
Last sync {new Date(status.lastSync.at).toLocaleString()} · OS{" "}
{status.os}
</p>
)}
{report?.overWarn && (
<p style={{ color: "var(--warn)", marginTop: 8 }}>
Large library ({report.included} entries) reconcile is a single
full-body PUT; watch host memory.
</p>
)}
{report && report.warnings.length > 0 && (
<details style={{ marginTop: 8 }}>
<summary className="subtle">
{report.warnings.length} warning(s)
</summary>
<ul>
{report.warnings.slice(0, 20).map((w) => (
<li key={w} className="subtle">
{w}
</li>
))}
</ul>
</details>
)}
</Card>
{config && (
<>
<ArtCard
config={config}
setConfig={setConfig}
save={save}
saving={saving}
/>
<SyncOptionsCard
config={config}
setConfig={setConfig}
save={save}
saving={saving}
/>
{status && (
<Card title="Files">
<p className="mono">{status.paths.config}</p>
<p className="mono">{status.paths.cache}</p>
</Card>
)}
</>
)}
</>
);
};
interface CardProps {
config: Config;
setConfig: (c: Config) => void;
save: (c: Config) => Promise<boolean>;
saving: boolean;
}
const ArtCard = ({ config, setConfig, save, saving }: CardProps) => {
const toast = useToast();
const art = config.art;
const set = (patch: Partial<Config["art"]>) =>
setConfig({ ...config, art: { ...art, ...patch } });
return (
<Card
title="Box art"
hint="SteamGridDB (like Steam ROM Manager) gives portrait + hero + logo + header with a free API key. Without a key, the keyless libretro-thumbnails fallback provides box art only."
right={
<Button
primary
disabled={saving}
onClick={async () => (await save(config)) && toast("Saved")}
>
{saving ? "Saving…" : "Save"}
</Button>
}
>
<div className="row wrap" style={{ gap: 16 }}>
<label className="field">
<span>Enabled</span>
<input
type="checkbox"
checked={art.enabled}
onChange={(e) => set({ enabled: e.target.checked })}
/>
</label>
<label className="field">
<span>Provider</span>
<select
value={art.provider}
onChange={(e) =>
set({ provider: e.target.value as Config["art"]["provider"] })
}
>
<option value="auto">auto (SteamGridDB if key set)</option>
<option value="steamgriddb">SteamGridDB</option>
<option value="libretro">libretro-thumbnails</option>
</select>
</label>
<label className="field grow">
<span>SteamGridDB API key</span>
<input
type="password"
value={art.steamGridDbKey ?? ""}
placeholder="from steamgriddb.com profile preferences"
onChange={(e) =>
set({ steamGridDbKey: e.target.value || undefined })
}
/>
</label>
</div>
</Card>
);
};
const SyncOptionsCard = ({ config, setConfig, save, saving }: CardProps) => {
const toast = useToast();
const sync = config.sync;
const set = (patch: Partial<Config["sync"]>) =>
setConfig({ ...config, sync: { ...sync, ...patch } });
return (
<Card
title="Sync options"
right={
<Button
primary
disabled={saving}
onClick={async () => (await save(config)) && toast("Saved — syncing")}
>
{saving ? "Saving…" : "Save & sync"}
</Button>
}
>
<div className="row wrap" style={{ gap: 16 }}>
<label className="field">
<span>Poll (minutes)</span>
<input
type="number"
min={1}
style={{ width: 90 }}
value={sync.pollMinutes}
onChange={(e) => set({ pollMinutes: Number(e.target.value) || 15 })}
/>
</label>
<label className="field">
<span>Watch filesystem</span>
<input
type="checkbox"
checked={sync.watch}
onChange={(e) => set({ watch: e.target.checked })}
/>
</label>
<label className="field">
<span>Max entries</span>
<input
type="number"
min={1}
style={{ width: 110 }}
value={sync.maxEntries}
onChange={(e) =>
set({ maxEntries: Number(e.target.value) || 5000 })
}
/>
</label>
<label className="field">
<span>Close emulator on stream end</span>
<input
type="checkbox"
checked={sync.closeOnEnd}
onChange={(e) => set({ closeOnEnd: e.target.checked })}
/>
</label>
<label className="field grow">
<span>Region-dedupe platforms (comma-separated ids)</span>
<input
value={sync.dedupeRegions.join(", ")}
placeholder="snes, genesis"
onChange={(e) =>
set({
dedupeRegions: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</label>
</div>
</Card>
);
};
+267
View File
@@ -0,0 +1,267 @@
/* A self-contained dark theme (the console renders plugin UIs on a dark canvas — plugin-ui-surface
§5). Purple accent to sit alongside the punktfunk console; no external UI library so the SPA builds
anywhere. */
:root {
--bg: #0b0b0f;
--panel: #14141b;
--panel-2: #1b1b24;
--border: #2a2a36;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #7c3aed;
--accent-hover: #6d28d9;
--danger: #ef4444;
--ok: #22c55e;
--warn: #f59e0b;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font:
14px / 1.5 system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
}
a {
color: var(--accent);
}
code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
}
.app {
max-width: 1100px;
margin: 0 auto;
padding: 20px 24px 64px;
}
.topbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.topbar .logo {
font-size: 20px;
}
.subtle {
color: var(--muted);
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin: 18px 0 20px;
flex-wrap: wrap;
}
.tab {
padding: 8px 14px;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
font-size: 14px;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
margin-bottom: 16px;
}
.card h2 {
font-size: 15px;
margin-bottom: 4px;
}
.card .hint {
color: var(--muted);
font-size: 13px;
margin: 2px 0 12px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.row.wrap {
flex-wrap: wrap;
}
.spread {
justify-content: space-between;
}
.grow {
flex: 1;
}
button.btn {
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
cursor: pointer;
font-size: 13px;
}
button.btn:hover {
border-color: var(--accent);
}
button.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.btn.primary:hover {
background: var(--accent-hover);
}
button.btn:disabled {
opacity: 0.5;
cursor: default;
}
button.btn.icon {
padding: 4px 8px;
}
input,
select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #0f0f16;
color: var(--text);
font-size: 13px;
}
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
}
label.field {
display: grid;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
th {
color: var(--muted);
font-weight: 500;
font-size: 12px;
}
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--muted);
}
.badge.ok {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 40%, var(--border));
}
.badge.warn {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
}
.badge.accent {
color: #c4b5fd;
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
}
.cover {
width: 42px;
height: 60px;
object-fit: cover;
border-radius: 4px;
background: var(--panel-2);
border: 1px solid var(--border);
}
.cover.empty {
display: inline-block;
}
.mono {
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
color: var(--muted);
word-break: break-all;
}
.empty-state {
text-align: center;
color: var(--muted);
padding: 32px;
}
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 16px;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.stat {
display: grid;
gap: 2px;
}
.stat .n {
font-size: 22px;
font-weight: 600;
}
.stat .l {
color: var(--muted);
font-size: 12px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.scroll {
max-height: 420px;
overflow: auto;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"noEmit": true,
"types": []
},
"include": ["src", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// `base: "./"` (relative asset URLs) + hash routing is the plugin-ui-surface §6 contract: the SPA is
// mounted under `/plugin-ui/rom-manager/` behind the console proxy, so assets must resolve relative to
// that prefix. Built into `../dist/ui`, which `servePluginUi`'s staticDir points at.
export default defineConfig({
base: "./",
plugins: [react()],
build: {
outDir: "../dist/ui",
emptyOutDir: true,
},
server: { port: 5599 },
});