feat: scope + un-bundle the plugin; @unom/ui SPA; custom-emulator editor
Reliability + correctness pass on the packaging and UI. Packaging — drop the self-contained bundle: - Rename to `@punktfunk/plugin-rom-manager` (scoped). An unscoped package can't be split across registries, which is the only reason bundling effect + the SDK into every plugin seemed necessary. Scoped names resolve from one scope-map, so the plugin depends on `@punktfunk/host` + `effect` as SHARED (hoisted) deps — no duplication, no ~1 MB effect copy per plugin. Build is back to plain `tsc`. (Runner-side discovery of `@punktfunk/plugin-*` shipped in @punktfunk/host 0.1.1.) UI — use the console's design system so it reads as family: - SPA rebuilt on @unom/style tokens + Tailwind v4 + the console's exact theme (violet chrome, Geist, card/button vocabulary). Build-time only → static assets in dist/ui, no @unom runtime dep. Skips @unom/ui's AnimatedButton/Card, which statically import ~7 MB of UI sound assets — same look, 416 KB dist/ui. - Add a custom-emulator editor (Emulators page) writing config.emulators — you can now add a launcher Punktfunk doesn't ship a definition for. - Fix brand capitalization to "Punktfunk" in all UI copy + docs. Install docs + CI updated for the scoped `bun add @punktfunk/plugin-rom-manager` flow. 48 engine tests green; backend + UI typecheck + biome clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-12
@@ -1,5 +1,6 @@
|
||||
# CI for punktfunk-plugin-rom-manager (Gitea Actions). `bun install` resolves `@punktfunk/host` from
|
||||
# the Gitea npm registry (scope mapping in bunfig.toml). Publish runs on a `v*` tag.
|
||||
# CI for @punktfunk/plugin-rom-manager (Gitea Actions). Installs resolve `@punktfunk/*` and `@unom/*`
|
||||
# (the SPA's design system) from the Gitea npm registry via the bunfig scope maps; `effect` comes from
|
||||
# npm. The GITEA_NPM_TOKEN secret authenticates registry reads. Publish runs on a `v*` tag.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
@@ -16,6 +17,8 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Registry auth
|
||||
run: echo "//git.unom.io/api/packages/unom/npm/:_authToken=${{ secrets.GITEA_NPM_TOKEN }}" > ~/.npmrc
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Lint & format
|
||||
@@ -24,14 +27,12 @@ jobs:
|
||||
run: bunx tsc --noEmit
|
||||
- name: Test (engine)
|
||||
run: bun test
|
||||
- name: Build backend
|
||||
run: bun run build
|
||||
- name: Build SPA
|
||||
run: bun run build:ui
|
||||
- name: Build backend + SPA
|
||||
run: bun run build:all
|
||||
- name: Typecheck (UI)
|
||||
working-directory: ui
|
||||
run: bunx tsc --noEmit
|
||||
- name: Bundle sanity — plugin default export is a valid PluginDef
|
||||
- name: Sanity — plugin default export is a valid PluginDef
|
||||
run: |
|
||||
bun -e 'import p from "./dist/index.js"; const d = p.default; if (d?.name !== "rom-manager" || typeof d?.main !== "function") { console.error("bad default export", d); process.exit(1); } console.log("ok:", d.name)'
|
||||
|
||||
@@ -44,13 +45,11 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Registry auth
|
||||
run: echo "//git.unom.io/api/packages/unom/npm/:_authToken=${{ secrets.GITEA_NPM_TOKEN }}" > ~/.npmrc
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build (backend + SPA)
|
||||
run: bun run build:all
|
||||
- name: Publish to the Gitea npm registry
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }}
|
||||
run: |
|
||||
npm config set //git.unom.io/api/packages/unom/npm/:_authToken "$NPM_TOKEN"
|
||||
npm publish --registry https://git.unom.io/api/packages/unom/npm/
|
||||
run: npm publish --registry https://git.unom.io/api/packages/unom/npm/
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
# punktfunk-plugin-rom-manager
|
||||
# @punktfunk/plugin-rom-manager
|
||||
|
||||
A [punktfunk](https://git.unom.io/unom/punktfunk) plugin that scans your ROM folders, maps them to
|
||||
A [Punktfunk](https://git.unom.io/unom/punktfunk) plugin that scans your ROM folders, maps them to
|
||||
emulators, fetches box art, and reconciles everything into the host game library — so your retro games
|
||||
appear automatically in the punktfunk web console, native clients, and Moonlight `/applist`, each
|
||||
launchable and covered in art. It ships a web UI that lives **inside the punktfunk console** (no second
|
||||
appear automatically in the Punktfunk web console, native clients, and Moonlight `/applist`, each
|
||||
launchable and covered in art. It ships a web UI that lives **inside the Punktfunk console** (no second
|
||||
password, no second port).
|
||||
|
||||
Think of it as [Steam ROM Manager](https://github.com/SteamGridDB/steam-rom-manager) for punktfunk: it
|
||||
Think of it as [Steam ROM Manager](https://github.com/SteamGridDB/steam-rom-manager) for Punktfunk: it
|
||||
even uses the same art source (SteamGridDB) when you give it an API key.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- A punktfunk host (Linux or Windows) with the **scripting runner** (`punktfunk-scripting`) enabled.
|
||||
- A Punktfunk host (Linux or Windows) with the **scripting runner** (`punktfunk-scripting`) enabled.
|
||||
- [Bun](https://bun.sh) (the runner is Bun).
|
||||
- `@punktfunk/host` **with the plugin-UI surface** (`servePluginUi`). This is what powers the
|
||||
console-hosted UI; the standalone fallback works without it.
|
||||
- `@punktfunk/host` **≥ 0.1.1** — it provides the plugin-UI surface (`servePluginUi`) and discovers
|
||||
scoped `@punktfunk/plugin-*` packages.
|
||||
|
||||
## Install
|
||||
|
||||
The plugin is scoped under `@punktfunk`, so one bunfig line points that scope at the Punktfunk registry
|
||||
while everything else (like `effect`) resolves from npm — no bundling, and `@punktfunk/host` + `effect`
|
||||
are shared across all your plugins:
|
||||
|
||||
```sh
|
||||
cd "$(punktfunk config-dir)/plugins" # e.g. ~/.config/punktfunk/plugins
|
||||
bun add punktfunk-plugin-rom-manager --registry https://git.unom.io/api/packages/unom/npm/
|
||||
mkdir -p "$(punktfunk config-dir)/plugins" && cd "$(punktfunk config-dir)/plugins"
|
||||
cat > bunfig.toml <<'EOF'
|
||||
[install.scopes]
|
||||
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
|
||||
EOF
|
||||
bun add @punktfunk/plugin-rom-manager
|
||||
|
||||
# then enable the runner (opt-in):
|
||||
systemctl --user enable --now punktfunk-scripting # Linux
|
||||
Enable-ScheduledTask PunktfunkScripting # Windows
|
||||
```
|
||||
|
||||
Open the punktfunk console — a **ROM Manager** entry appears in the nav. That's it.
|
||||
Open the Punktfunk console — a **ROM Manager** entry appears in the nav. That's it.
|
||||
|
||||
> **Headless / host-only boxes** without the console can use the [standalone UI](#standalone-ui) or
|
||||
> just drop a `config.json` (below) — the sync engine is fully functional from the file alone.
|
||||
@@ -119,14 +128,21 @@ bunx punktfunk-plugin-rom-manager set-password 'your-password'
|
||||
## Development
|
||||
|
||||
```sh
|
||||
bun install # resolves @punktfunk/host from the Gitea npm registry
|
||||
bun install # backend deps (@punktfunk/host + effect) from the Gitea registry
|
||||
bun test # engine unit tests (pure core: quoting, scanner, reconcile, art)
|
||||
bun run typecheck
|
||||
bun run build:all # backend → dist/, SPA → dist/ui/
|
||||
bun run build:all # backend → dist/ (tsc), SPA → dist/ui/ (Vite)
|
||||
cd ui && bun run dev # SPA dev server (point it at a running standalone instance)
|
||||
```
|
||||
|
||||
This plugin requires `@punktfunk/host` ≥ 0.1.0 (the version that introduced `servePluginUi`).
|
||||
- The backend is plain `tsc` output — it depends on `@punktfunk/host` + `effect` at runtime (shared
|
||||
with the runner and other plugins, not bundled).
|
||||
- The **SPA** (`ui/`) uses the console's design system (`@unom/ui` + `@unom/style` + Tailwind v4) so it
|
||||
reads as family. Those are **build-time only** — the SPA compiles to static assets in `dist/ui`, so
|
||||
the published plugin carries no `@unom` runtime dependency. Installing them needs the `@unom` scope in
|
||||
your `~/.npmrc` (with the Gitea token); CI needs the same token as a secret.
|
||||
|
||||
Requires `@punktfunk/host` ≥ 0.1.1.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+11
@@ -13,6 +13,11 @@
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
@@ -29,6 +34,12 @@
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noLabelWithoutControl": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "punktfunk-plugin-rom-manager",
|
||||
"name": "@punktfunk/plugin-rom-manager",
|
||||
"dependencies": {
|
||||
"@punktfunk/host": "^0.1.0",
|
||||
"effect": "^4.0.0-beta.98",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.2",
|
||||
"@punktfunk/host": "^0.1.0",
|
||||
"@types/bun": "^1.3.0",
|
||||
"effect": "^4.0.0-beta.98",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
|
||||
+9
-7
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "punktfunk-plugin-rom-manager",
|
||||
"version": "0.1.1",
|
||||
"name": "@punktfunk/plugin-rom-manager",
|
||||
"version": "0.2.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "punktfunk plugin: scans ROM directories, maps them to emulators, and reconciles them into the host game library as a provider — with a console-hosted web UI.",
|
||||
"description": "Punktfunk plugin: scans ROM directories, maps them to emulators, and reconciles them into the host game library as a provider — with a console-hosted web UI.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
|
||||
"repository": {
|
||||
@@ -20,6 +20,7 @@
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"punktfunk-plugin-rom-manager": "./dist/cli.js"
|
||||
},
|
||||
@@ -32,7 +33,7 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"build": "rm -rf dist && bun build src/index.ts src/cli.ts --target=bun --outdir dist --external undici",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"build:ui": "cd ui && bun install --silent && bun run build",
|
||||
"build:all": "bun run build && bun run build:ui",
|
||||
"sync": "bun src/cli.ts sync",
|
||||
@@ -40,12 +41,13 @@
|
||||
"detect": "bun src/cli.ts detect",
|
||||
"prepublishOnly": "bun run build:all"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"@punktfunk/host": "^0.1.0",
|
||||
"effect": "^4.0.0-beta.98"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.2",
|
||||
"@punktfunk/host": "^0.1.0",
|
||||
"@types/bun": "^1.3.0",
|
||||
"effect": "^4.0.0-beta.98",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
+1116
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
# The @unom design-system packages (@unom/ui, @unom/style) live on the Gitea registry. Auth comes
|
||||
# from your ~/.npmrc (`//git.unom.io/api/packages/unom/npm/:_authToken`); CI needs the same token as a
|
||||
# secret. These are BUILD-time only — the SPA compiles to static assets in ../dist/ui, so the published
|
||||
# plugin has no @unom runtime dependency.
|
||||
[install.scopes]
|
||||
"@unom" = "https://git.unom.io/api/packages/unom/npm/"
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
+14
-2
@@ -2,20 +2,32 @@
|
||||
"name": "punktfunk-plugin-rom-manager-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "The ROM Manager plugin SPA — built into ../dist/ui and served by servePluginUi.",
|
||||
"description": "The ROM Manager plugin SPA — built into ../dist/ui and served by servePluginUi. Uses @unom/ui so it reads as family with the punktfunk console.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"motion": "^12.42.2",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
|
||||
+35
-23
@@ -1,5 +1,6 @@
|
||||
import { Gamepad2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ToastHost } from "./components.js";
|
||||
import { cn } from "./lib/utils.js";
|
||||
import { Emulators } from "./pages/Emulators.js";
|
||||
import { Games } from "./pages/Games.js";
|
||||
import { Setup } from "./pages/Setup.js";
|
||||
@@ -42,29 +43,40 @@ export const App = () => {
|
||||
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 className="mx-auto max-w-5xl px-6 pb-16 pt-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<span className="grid size-9 place-items-center rounded-card bg-primary/15 text-primary">
|
||||
<Gamepad2 className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">ROM Manager</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan ROMs into your Punktfunk library
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="mt-6 flex flex-wrap gap-1 border-b border-border">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => go(t.id)}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors",
|
||||
t.id === tab
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="mt-6 flex flex-col gap-6">
|
||||
<Page />
|
||||
</div>
|
||||
</ToastHost>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface EmulatorDef {
|
||||
template: string;
|
||||
supportsArchives: boolean;
|
||||
contested?: boolean;
|
||||
detect?: { linux: string[]; windows: string[] };
|
||||
}
|
||||
export interface Detected {
|
||||
id: string;
|
||||
@@ -46,6 +47,8 @@ export interface Config {
|
||||
{ emulator: string; core?: string; extraArgs?: string }
|
||||
>;
|
||||
gameOverrides: Record<string, GameOverride>;
|
||||
/** Operator-added / overriding emulator defs (custom launchers). */
|
||||
emulators?: EmulatorDef[];
|
||||
sync: {
|
||||
pollMinutes: number;
|
||||
watch: boolean;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// A small shadcn-style badge on the shared tokens (pill, brand/muted/state variants).
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-secondary text-secondary-foreground",
|
||||
brand: "border-transparent bg-primary/15 text-primary",
|
||||
outline: "text-muted-foreground",
|
||||
success: "border-success/40 text-success",
|
||||
warn: "border-amber-500/40 text-amber-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export const Badge = ({ className, variant, ...props }: BadgeProps) => (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Button — the console's exact variant/size vocabulary + brand tokens (pill shape, `--primary` fill),
|
||||
// but a plain <button> rather than @unom/ui's AnimatedButton: the animated one statically imports ~7 MB
|
||||
// of UI click/hover sound assets (fine for the full console, absurd for an embedded iframe). Same look,
|
||||
// no audio. `buttonVariants` here mirrors @unom/ui's so it reads identically.
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-secondary hover:text-secondary-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-secondary hover:text-secondary-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
lg: "h-10 px-8",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = ({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonProps) => (
|
||||
<button
|
||||
type={type}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Card — the console's card surface (bg-card, brand-violet ring, rounded-card) as a plain element set,
|
||||
// on the shared @unom tokens. We skip @unom/ui's AnimatedCard (motion + material) to keep the bundle
|
||||
// lean; the look matches because the tokens (colour/radius/border) are identical.
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
export const Card = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-card border border-border bg-card text-card-foreground shadow-sm ring-1 ring-accent/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CardHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<h2
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CardDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardContent = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
// shadcn-style input + select on the shared tokens, so form controls match the console's inputs.
|
||||
import type { InputHTMLAttributes, SelectHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
const base =
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50";
|
||||
|
||||
export const Input = ({
|
||||
className,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement>) => (
|
||||
<input className={cn(base, className)} {...props} />
|
||||
);
|
||||
|
||||
export const Select = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectHTMLAttributes<HTMLSelectElement>) => (
|
||||
<select className={cn(base, "cursor-pointer", className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts (same as the console). */
|
||||
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs));
|
||||
@@ -1,3 +1,5 @@
|
||||
import "@fontsource-variable/geist";
|
||||
import { Toaster } from "@unom/ui/toast";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
@@ -6,5 +8,6 @@ import "./styles.css";
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Toaster />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
+316
-135
@@ -1,3 +1,5 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type Detected,
|
||||
@@ -7,7 +9,16 @@ import {
|
||||
type Platform,
|
||||
runDetect,
|
||||
} from "../api.js";
|
||||
import { Badge, Button, Card, useToast } from "../components.js";
|
||||
import { Badge } from "../components/ui/badge.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.js";
|
||||
import { Input, Select } from "../components/ui/input.js";
|
||||
import { useConfig } from "../hooks.js";
|
||||
|
||||
export const Emulators = () => {
|
||||
@@ -16,36 +27,43 @@ export const Emulators = () => {
|
||||
const [detected, setDetected] = useState<Detected[]>([]);
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const load = () => {
|
||||
const load = () =>
|
||||
getEmulators().then((e) => {
|
||||
setDefs(e.defs);
|
||||
setDetected(e.detected);
|
||||
});
|
||||
};
|
||||
useEffect(load, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
getPlatforms()
|
||||
.then(setPlatforms)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const detectedById = new Map(detected.map((d) => [d.id, d]));
|
||||
const onSave = async (next = config) => {
|
||||
if (next && (await save(next))) toast.success("Saved — syncing library");
|
||||
};
|
||||
|
||||
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={
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Detected emulators</CardTitle>
|
||||
<CardDescription>
|
||||
Best-effort detection: PATH, Flatpak, then known install paths.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={detecting}
|
||||
onClick={async () => {
|
||||
setDetecting(true);
|
||||
try {
|
||||
setDetected(await runDetect());
|
||||
toast("Re-detected emulators");
|
||||
toast.success("Re-detected emulators");
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
@@ -53,148 +71,311 @@ export const Emulators = () => {
|
||||
>
|
||||
{detecting ? "Detecting…" : "Re-detect"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Emulator</th>
|
||||
<th>Status</th>
|
||||
<th>Cores</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border">
|
||||
{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>
|
||||
<div
|
||||
key={def.id}
|
||||
className="flex items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">{def.name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{def.id}
|
||||
</span>
|
||||
{def.contested && <Badge variant="warn">contested</Badge>}
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
{d?.cores?.length ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{d.cores.length} cores
|
||||
</span>
|
||||
) : null}
|
||||
{d ? (
|
||||
<Badge tone="ok">detected · {d.via}</Badge>
|
||||
<Badge variant="success">detected · {d.via}</Badge>
|
||||
) : (
|
||||
<Badge>not found</Badge>
|
||||
<Badge variant="outline">not found</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>{d?.cores?.length ? d.cores.length : "—"}</td>
|
||||
<td>
|
||||
{def.contested && <Badge tone="warn">contested</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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");
|
||||
}}
|
||||
>
|
||||
<CustomEmulators
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
reloadDefs={load}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Per-platform emulator</CardTitle>
|
||||
<CardDescription>
|
||||
The default emulator + core for each platform. Override to
|
||||
prefer a different emulator (including a custom one) or
|
||||
RetroArch core.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" disabled={saving} onClick={() => onSave()}>
|
||||
{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,
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[420px] overflow-auto">
|
||||
<div className="divide-y divide-border">
|
||||
{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;
|
||||
}) =>
|
||||
setConfig({
|
||||
...config,
|
||||
platformLaunch: {
|
||||
...config.platformLaunch,
|
||||
[p.id]: {
|
||||
emulator: patch.emulator ?? emulator,
|
||||
core: patch.core ?? core,
|
||||
},
|
||||
});
|
||||
};
|
||||
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>
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="grid grid-cols-[1fr_180px_180px] items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
<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>
|
||||
{emulator === "retroarch" && detCores.length ? (
|
||||
<Select
|
||||
value={core}
|
||||
onChange={(e) => setLaunch({ core: e.target.value })}
|
||||
>
|
||||
{!detCores.includes(core) && (
|
||||
<option value={core}>{core || "—"}</option>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{detCores.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : emulator === "retroarch" ? (
|
||||
<Input
|
||||
value={core}
|
||||
placeholder="snes9x"
|
||||
onChange={(e) => setLaunch({ core: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Custom emulator editor (design §6 escape hatch) ────────────────────────────────────────────
|
||||
interface CustomProps {
|
||||
config: import("../api.js").Config;
|
||||
setConfig: (c: import("../api.js").Config) => void;
|
||||
onSave: (c?: import("../api.js").Config) => Promise<void>;
|
||||
saving: boolean;
|
||||
reloadDefs: () => Promise<void>;
|
||||
}
|
||||
|
||||
const blankDraft = () => ({
|
||||
id: "",
|
||||
name: "",
|
||||
template: "{exe} {rom}",
|
||||
supportsArchives: false,
|
||||
detectLinux: "",
|
||||
detectWindows: "",
|
||||
});
|
||||
|
||||
const CustomEmulators = ({
|
||||
config,
|
||||
setConfig,
|
||||
onSave,
|
||||
saving,
|
||||
reloadDefs,
|
||||
}: CustomProps) => {
|
||||
const emulators = config.emulators ?? [];
|
||||
const [draft, setDraft] = useState(blankDraft());
|
||||
|
||||
const add = async () => {
|
||||
const id = draft.id.trim();
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(id)) {
|
||||
toast.error("Emulator id must be kebab-case (e.g. custom-dosbox)");
|
||||
return;
|
||||
}
|
||||
if (emulators.some((e) => e.id === id)) {
|
||||
toast.error(`An emulator with id "${id}" already exists`);
|
||||
return;
|
||||
}
|
||||
if (!draft.template.includes("{rom}")) {
|
||||
toast.error("Template must include {rom}");
|
||||
return;
|
||||
}
|
||||
const def: EmulatorDef = {
|
||||
id,
|
||||
name: draft.name.trim() || id,
|
||||
template: draft.template.trim(),
|
||||
supportsArchives: draft.supportsArchives,
|
||||
detect: {
|
||||
linux: draft.detectLinux
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
windows: draft.detectWindows
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
};
|
||||
const next = { ...config, emulators: [...emulators, def] };
|
||||
setConfig(next);
|
||||
await onSave(next);
|
||||
await reloadDefs();
|
||||
setDraft(blankDraft());
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
const next = { ...config, emulators: emulators.filter((e) => e.id !== id) };
|
||||
setConfig(next);
|
||||
await onSave(next);
|
||||
await reloadDefs();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1.5">
|
||||
<CardTitle>Custom emulators</CardTitle>
|
||||
<CardDescription>
|
||||
Add an emulator Punktfunk doesn't ship a definition for. The template
|
||||
is trusted, run as the host user — use{" "}
|
||||
<code className="font-mono">{"{exe}"}</code>,{" "}
|
||||
<code className="font-mono">{"{rom}"}</code>, optionally{" "}
|
||||
<code className="font-mono">{"{core}"}</code>. Leave detection blank
|
||||
and bake the executable into the template if it isn't on PATH.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{emulators.length > 0 && (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{emulators.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-3 p-2.5 text-sm">
|
||||
<span className="font-medium">{e.name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{e.id}
|
||||
</span>
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
{e.template}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto shrink-0"
|
||||
onClick={() => remove(e.id)}
|
||||
aria-label={`Remove ${e.id}`}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 rounded-md border border-dashed border-border p-3">
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
id
|
||||
<Input
|
||||
value={draft.id}
|
||||
placeholder="custom-dosbox"
|
||||
onChange={(e) => setDraft({ ...draft, id: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
Name
|
||||
<Input
|
||||
value={draft.name}
|
||||
placeholder="DOSBox"
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 space-y-1 text-xs text-muted-foreground">
|
||||
Template
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={draft.template}
|
||||
placeholder="dosbox {rom} -fullscreen"
|
||||
onChange={(e) => setDraft({ ...draft, template: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
Detect · Linux (comma)
|
||||
<Input
|
||||
value={draft.detectLinux}
|
||||
placeholder="dosbox, flatpak:io.dosbox"
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, detectLinux: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
Detect · Windows (comma)
|
||||
<Input
|
||||
value={draft.detectWindows}
|
||||
placeholder="C:\DOSBox\dosbox.exe"
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, detectWindows: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.supportsArchives}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, supportsArchives: e.target.checked })
|
||||
}
|
||||
/>
|
||||
Can load .zip / .7z archives directly
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<Button size="sm" disabled={saving} onClick={add}>
|
||||
<Plus className="size-4" /> Add emulator
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
+139
-106
@@ -1,24 +1,32 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getConfig, getPreview, type Preview, putConfig } from "../api.js";
|
||||
import { Badge, Button, Card, useToast } from "../components.js";
|
||||
import { Badge } from "../components/ui/badge.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.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)))
|
||||
.catch((e) => toast.error(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 };
|
||||
@@ -30,121 +38,99 @@ export const Games = () => {
|
||||
else delete overrides[externalId];
|
||||
}
|
||||
await putConfig({ ...config, gameOverrides: overrides });
|
||||
toast(exclude ? "Excluded" : "Included");
|
||||
toast.success(exclude ? "Excluded" : "Included");
|
||||
refresh();
|
||||
};
|
||||
|
||||
if (!preview) return <Card>{loading ? "Scanning…" : "Loading…"}</Card>;
|
||||
if (!preview) {
|
||||
return (
|
||||
<Card className="p-6 text-muted-foreground">
|
||||
{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"}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Games ({entries.length})</CardTitle>
|
||||
<CardDescription>
|
||||
What will be reconciled into the library. Untick a title to
|
||||
exclude it; covers come from your art provider.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={refresh}
|
||||
>
|
||||
<RefreshCw className="size-4" /> {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>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 && excluded.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No games found. Add ROM roots in Setup, then rescan.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[520px] space-y-1 overflow-auto">
|
||||
{entries.map((e) => (
|
||||
<GameRow
|
||||
key={e.external_id}
|
||||
cover={e.art?.portrait ?? undefined}
|
||||
title={e.title}
|
||||
platform={platformOf(e.external_id)}
|
||||
detail={e.launch?.value}
|
||||
included
|
||||
onToggle={() => setExcluded(e.external_id, true)}
|
||||
/>
|
||||
))}
|
||||
{excluded.map((x) => (
|
||||
<GameRow
|
||||
key={x.external_id}
|
||||
title={x.title}
|
||||
platform={platformOf(x.external_id)}
|
||||
detail="excluded"
|
||||
included={false}
|
||||
muted
|
||||
onToggle={() => setExcluded(x.external_id, false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{report.skipped.length > 0 && (
|
||||
<Card
|
||||
title={`Skipped (${report.skipped.length})`}
|
||||
right={
|
||||
<Button onClick={() => setShowSkipped((s) => !s)}>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>Skipped ({report.skipped.length})</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowSkipped((s) => !s)}
|
||||
>
|
||||
{showSkipped ? "Hide" : "Show"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
</CardHeader>
|
||||
{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>
|
||||
<CardContent className="max-h-[320px] space-y-1 overflow-auto text-sm">
|
||||
{report.skipped.map((s) => (
|
||||
<div
|
||||
key={s.external_id}
|
||||
className="flex justify-between gap-4 border-b border-border py-1.5"
|
||||
>
|
||||
<span>{s.title}</span>
|
||||
<span className="text-muted-foreground">{s.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
@@ -152,5 +138,52 @@ export const Games = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const truncate = (s: string, n: number): string =>
|
||||
s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
const GameRow = ({
|
||||
cover,
|
||||
title,
|
||||
platform,
|
||||
detail,
|
||||
included,
|
||||
muted,
|
||||
onToggle,
|
||||
}: {
|
||||
cover?: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
detail?: string;
|
||||
included: boolean;
|
||||
muted?: boolean;
|
||||
onToggle: () => void;
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-secondary/40 ${muted ? "opacity-55" : ""}`}
|
||||
>
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt={`${title} cover`}
|
||||
loading="lazy"
|
||||
className="h-14 w-10 shrink-0 rounded object-cover ring-1 ring-border"
|
||||
/>
|
||||
) : (
|
||||
<span className="h-14 w-10 shrink-0 rounded bg-secondary ring-1 ring-border" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{title}</span>
|
||||
<span
|
||||
className="block truncate font-mono text-xs text-muted-foreground"
|
||||
title={detail}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant="outline">{platform}</Badge>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ml-1"
|
||||
checked={included}
|
||||
onChange={onToggle}
|
||||
aria-label={included ? "Exclude" : "Include"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+86
-88
@@ -1,12 +1,21 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPlatforms, type Platform, type RomRoot } from "../api.js";
|
||||
import { Button, Card, useToast } from "../components.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.js";
|
||||
import { Input, Select } from "../components/ui/input.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()
|
||||
@@ -14,104 +23,93 @@ export const Setup = () => {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!config) return <Card>Loading…</Card>;
|
||||
if (!config)
|
||||
return <Card className="p-6 text-muted-foreground">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)));
|
||||
|
||||
const onSave = async () => {
|
||||
if (await save(config)) toast.success("Saved — syncing library");
|
||||
else toast.error("Save failed");
|
||||
};
|
||||
|
||||
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");
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>ROM roots</CardTitle>
|
||||
<CardDescription>
|
||||
Point each folder at the console platform its files belong to.
|
||||
Shared extensions (.iso, .bin) are resolved by the folder's
|
||||
platform.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" disabled={saving} onClick={onSave}>
|
||||
{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>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{roots.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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>
|
||||
)}
|
||||
{roots.map((root, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[1fr_180px_1fr_auto] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={root.dir}
|
||||
placeholder="/home/you/roms/snes"
|
||||
onChange={(e) => update(i, { dir: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={root.platform}
|
||||
onChange={(e) => update(i, { platform: e.target.value })}
|
||||
>
|
||||
<option value="">— platform —</option>
|
||||
{platforms.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
value={(root.excludes ?? []).join(", ")}
|
||||
placeholder="*.sav, bios/**"
|
||||
onChange={(e) =>
|
||||
update(i, {
|
||||
excludes: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRoots(roots.filter((_, j) => j !== i))}
|
||||
aria-label="Remove root"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button onClick={() => setRoots([...roots, { dir: "", platform: "" }])}>
|
||||
+ Add root
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRoots([...roots, { dir: "", platform: "" }])}
|
||||
>
|
||||
<Plus className="size-4" /> Add root
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
+143
-119
@@ -1,90 +1,99 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { type Config, runSync, useStatusStream } from "../api.js";
|
||||
import { Badge, Button, Card, useToast } from "../components.js";
|
||||
import { Badge } from "../components/ui/badge.js";
|
||||
import { Button } from "../components/ui/button.js";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card.js";
|
||||
import { Input, Select } from "../components/ui/input.js";
|
||||
import { useConfig } from "../hooks.js";
|
||||
|
||||
const Stat = ({ n, label }: { n: ReactNode; label: string }) => (
|
||||
<div className="rounded-card bg-secondary/40 px-4 py-3">
|
||||
<div className="text-2xl font-semibold">{n}</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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={
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Status</CardTitle>
|
||||
<CardDescription>
|
||||
{status?.lastSync
|
||||
? `Last sync ${new Date(status.lastSync.at).toLocaleString()} · ${status.os}`
|
||||
: "Not synced yet."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
size="sm"
|
||||
disabled={syncing || status?.syncing}
|
||||
onClick={async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await runSync();
|
||||
toast("Synced");
|
||||
toast.success("Synced");
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
toast.error(String(e));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat n={status?.rootsConfigured ?? "—"} label="ROM roots" />
|
||||
<Stat n={status?.lastSync?.count ?? "—"} label="in library" />
|
||||
<Stat n={report?.skipped.length ?? "—"} label="skipped" />
|
||||
<Stat
|
||||
n={
|
||||
status?.artProvider ? (
|
||||
<Badge variant="brand">{status.artProvider}</Badge>
|
||||
) : (
|
||||
<span className="text-base text-muted-foreground">off</span>
|
||||
)
|
||||
}
|
||||
label="art provider"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
{report?.overWarn && (
|
||||
<p className="text-sm text-amber-400">
|
||||
Large library ({report.included} entries) — reconcile is one
|
||||
full-body PUT; watch host memory.
|
||||
</p>
|
||||
)}
|
||||
{report && report.warnings.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">
|
||||
{report.warnings.length} warning(s)
|
||||
</summary>
|
||||
<ul className="mt-1 list-disc pl-5 text-xs text-muted-foreground">
|
||||
{report.warnings.slice(0, 20).map((w) => (
|
||||
<li key={w}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{config && (
|
||||
@@ -102,9 +111,15 @@ export const Sync = () => {
|
||||
saving={saving}
|
||||
/>
|
||||
{status && (
|
||||
<Card title="Files">
|
||||
<p className="mono">{status.paths.config}</p>
|
||||
<p className="mono">{status.paths.cache}</p>
|
||||
<Card>
|
||||
<CardHeader className="space-y-1.5">
|
||||
<CardTitle>Files</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{status.paths.config}
|
||||
<br />
|
||||
{status.paths.cache}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
@@ -120,37 +135,48 @@ interface CardProps {
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
const Field = ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<label className="space-y-1 text-xs text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
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={
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Box art</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={async () => (await save(config)) && toast("Saved")}
|
||||
onClick={async () => (await save(config)) && toast.success("Saved")}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="row wrap" style={{ gap: 16 }}>
|
||||
<label className="field">
|
||||
<span>Enabled</span>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={art.enabled}
|
||||
onChange={(e) => set({ enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
<Field label="Provider">
|
||||
<Select
|
||||
className="w-56"
|
||||
value={art.provider}
|
||||
onChange={(e) =>
|
||||
set({ provider: e.target.value as Config["art"]["provider"] })
|
||||
@@ -159,84 +185,82 @@ const ArtCard = ({ config, setConfig, save, saving }: CardProps) => {
|
||||
<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
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="SteamGridDB API key">
|
||||
<Input
|
||||
className="w-80"
|
||||
type="password"
|
||||
value={art.steamGridDbKey ?? ""}
|
||||
placeholder="from steamgriddb.com › profile › preferences"
|
||||
placeholder="steamgriddb.com › profile › preferences"
|
||||
onChange={(e) =>
|
||||
set({ steamGridDbKey: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</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={
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<CardTitle>Sync options</CardTitle>
|
||||
<Button
|
||||
primary
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={async () => (await save(config)) && toast("Saved — syncing")}
|
||||
onClick={async () =>
|
||||
(await save(config)) && toast.success("Saved — syncing")
|
||||
}
|
||||
>
|
||||
{saving ? "Saving…" : "Save & sync"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="row wrap" style={{ gap: 16 }}>
|
||||
<label className="field">
|
||||
<span>Poll (minutes)</span>
|
||||
<input
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-4">
|
||||
<Field label="Poll (minutes)">
|
||||
<Input
|
||||
className="w-24"
|
||||
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
|
||||
</Field>
|
||||
<Field label="Max entries">
|
||||
<Input
|
||||
className="w-28"
|
||||
type="number"
|
||||
min={1}
|
||||
style={{ width: 110 }}
|
||||
value={sync.maxEntries}
|
||||
onChange={(e) =>
|
||||
set({ maxEntries: Number(e.target.value) || 5000 })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 pb-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sync.watch}
|
||||
onChange={(e) => set({ watch: e.target.checked })}
|
||||
/>
|
||||
Watch filesystem
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Close emulator on stream end</span>
|
||||
<label className="flex items-center gap-2 pb-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sync.closeOnEnd}
|
||||
onChange={(e) => set({ closeOnEnd: e.target.checked })}
|
||||
/>
|
||||
Close emulator on stream end
|
||||
</label>
|
||||
<label className="field grow">
|
||||
<span>Region-dedupe platforms (comma-separated ids)</span>
|
||||
<input
|
||||
<Field label="Region-dedupe platforms (comma ids)">
|
||||
<Input
|
||||
className="w-64"
|
||||
value={sync.dedupeRegions.join(", ")}
|
||||
placeholder="snes, genesis"
|
||||
onChange={(e) =>
|
||||
@@ -248,8 +272,8 @@ const SyncOptionsCard = ({ config, setConfig, save, saving }: CardProps) => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
+177
-257
@@ -1,267 +1,187 @@
|
||||
/* 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. */
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./timing-functions.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Pull @unom/ui's compiled component classes (bg-neutral, rounded-card, p-padding-card, ring-accent,
|
||||
h-input-height, material…) into the Tailwind 4 scan so their utilities aren't purged. */
|
||||
@source "../node_modules/@unom/ui/dist/**/*.{js,mjs}";
|
||||
|
||||
/* ── punktfunk brand · violet product chrome (copied from the console so the plugin reads as family) ──
|
||||
LIGHT (:root) + DARK (.dark) on one violet identity. The plugin pins <html class="dark"> since it is
|
||||
embedded on the console's dark canvas. The token set feeds BOTH @unom/ui's semantic contract
|
||||
(--brand/--primary/--accent/--neutral/--main…) and the shadcn-style tokens. */
|
||||
: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;
|
||||
--radius: 0.625rem;
|
||||
|
||||
--pf-brand: #6c5bf3;
|
||||
--pf-brand-light: #a79ff8;
|
||||
--pf-highlight: #d2c9fb;
|
||||
|
||||
--background: #ffffff;
|
||||
--foreground: #1b1430;
|
||||
--card: #f6f2ff;
|
||||
--card-foreground: #1b1430;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1b1430;
|
||||
--muted: #f1ecfd;
|
||||
--muted-foreground: #6f6a86;
|
||||
--secondary: #ece6fb;
|
||||
--secondary-foreground: #1b1430;
|
||||
--accent: var(--pf-brand);
|
||||
--accent-foreground: #ffffff;
|
||||
--border: #e4dcf7;
|
||||
--input: #e4dcf7;
|
||||
--ring: var(--pf-brand);
|
||||
|
||||
--primary: var(--pf-brand);
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--success: oklch(0.6 0.14 160);
|
||||
--destructive: oklch(0.55 0.22 18);
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--main: var(--foreground);
|
||||
--brand: var(--pf-brand);
|
||||
--brand-light: var(--pf-brand-light);
|
||||
--highlight: var(--pf-highlight);
|
||||
--neutral: var(--card);
|
||||
--neutral-accent: var(--secondary);
|
||||
--neutral-highlight: var(--border);
|
||||
--error: var(--destructive);
|
||||
|
||||
--font-display: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--radius-card-min: var(--radius);
|
||||
}
|
||||
|
||||
* {
|
||||
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;
|
||||
.dark {
|
||||
--background: #141019;
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: #1c1530;
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: #1c1530;
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--muted: #1f1830;
|
||||
--muted-foreground: oklch(0.728 0.03 286);
|
||||
--secondary: #241c3d;
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--border: #2a2148;
|
||||
--input: #2a2148;
|
||||
--ring: var(--pf-brand-light);
|
||||
|
||||
--primary: var(--pf-brand-light);
|
||||
--primary-foreground: #141019;
|
||||
|
||||
--success: oklch(0.7 0.15 160);
|
||||
--destructive: oklch(0.62 0.21 18);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
}
|
||||
|
||||
.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);
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-button: 9999px;
|
||||
--radius-card: calc(var(--radius) * 2);
|
||||
--radius-main: calc(var(--radius) * 2);
|
||||
|
||||
--spacing-input-height: 3rem;
|
||||
--spacing-padding-card: 1.25rem;
|
||||
--spacing-card: 1.5rem;
|
||||
--spacing-main: 15px;
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-display: var(--font-display);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-main: var(--main);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-light: var(--brand-light);
|
||||
--color-neutral: var(--neutral);
|
||||
--color-neutral-accent: var(--neutral-accent);
|
||||
--color-neutral-highlight: var(--neutral-highlight);
|
||||
--color-highlight: var(--highlight);
|
||||
--color-error: var(--error);
|
||||
}
|
||||
|
||||
.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;
|
||||
@theme {
|
||||
--animate-accordion-down: accordion-down 0.4s var(--ease-out-quart);
|
||||
--animate-accordion-up: accordion-up 0.4s var(--ease-out-quart);
|
||||
--animate-collapsible-down: collapsible-down 0.4s var(--ease-out-quart);
|
||||
--animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart);
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@keyframes collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes collapsible-up {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* Penner easing tokens — shared with the punktfunk console + @unom/ui.
|
||||
@unom/ui's accordion/collapsible/material animations resolve these by name. */
|
||||
@theme {
|
||||
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
|
||||
--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
|
||||
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
|
||||
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
|
||||
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
|
||||
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
|
||||
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
|
||||
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
|
||||
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
|
||||
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
|
||||
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
|
||||
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
|
||||
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
||||
}
|
||||
+4
-3
@@ -1,12 +1,13 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
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.
|
||||
// mounted under `/plugin-ui/rom-manager/` behind the console proxy. Tailwind v4 + @unom/ui give it the
|
||||
// same look as the console. Built into `../dist/ui`, which `servePluginUi`'s staticDir points at.
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "../dist/ui",
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user