) =>
- setConfig((c) => (c ? { ...c, ...p } : c));
-
- const save = async () => {
- if (!config) return;
- setSaving(true);
- setMsg(undefined);
- try {
- setConfig(await putConfig(config));
- setMsg("Saved — re-syncing with the new settings.");
- } catch (e) {
- setMsg(String(e));
- } finally {
- setSaving(false);
- }
- };
-
- const sync = async () => {
- setSyncing(true);
- setMsg(undefined);
- try {
- await runSync();
- setMsg("Sync requested.");
- } catch (e) {
- setMsg(String(e));
- } finally {
- setSyncing(false);
- }
- };
-
- return (
-
-
-
- {status ? (
-
-
-
- {config && (
-
- )}
-
- ) : (
-
Loading…
- )}
-
- {msg && (
-
- {msg}
-
- )}
-
- );
-}
-
-function Connection({ status }: { status: Status }) {
- const found = Boolean(status.location.file);
- return (
- }>
- {found ? (
-
-
-
-
- Exporter connected — updated{" "}
-
- {relTime(status.location.mtime ?? undefined)}
-
- .
-
-
- {status.location.file}
-
-
-
- ) : (
-
-
-
-
- No exporter output found. Install the{" "}
- Punktfunk Sync extension in
- Playnite (then restart Playnite).
-
- {status.exportError && (
-
{status.exportError}
- )}
-
- Looked under:{" "}
- {status.location.dir ?? "no Playnite data dir found"}
-
-
-
- )}
-
- );
-}
-
-function LibrarySummary({
- status,
- onSync,
- syncing,
-}: {
- status: Status;
- onSync: () => void;
- syncing: boolean;
-}) {
- const report = status.lastReport;
- const busy = syncing || status.syncing;
- const sources = report
- ? Object.entries(report.perSource).sort((a, b) => b[1] - a[1])
- : [];
- return (
- }
- right={
-
- }
- >
-
-
- {status.lastSync?.count ?? 0}
-
-
- title(s) synced · last {relTime(status.lastSync?.at)}
-
-
- {sources.length > 0 && (
-
- {sources.map(([src, n]) => (
-
- {src} {n}
-
- ))}
-
- )}
- {report && report.skipped.length > 0 && (
-
- {report.skipped.length} skipped (e.g. {report.skipped[0]?.title}:{" "}
- {report.skipped[0]?.reason})
-
- )}
- {report?.warnings.map((w) => (
-
- {w}
-
- ))}
-
- );
-}
-
-function Settings({
- config,
- patch,
- onSave,
- saving,
-}: {
- config: Config;
- patch: (p: Partial) => void;
- onSave: () => void;
- saving: boolean;
-}) {
- const csv = (a: string[]) => a.join(", ");
- const parseCsv = (s: string) =>
- s
- .split(",")
- .map((x) => x.trim())
- .filter(Boolean);
-
- return (
- }
- right={
-
- }
- >
-
-
- patch({ filter: { ...config.filter, installedOnly: v } })
- }
- />
-
- patch({ filter: { ...config.filter, includeHidden: v } })
- }
- />
-
- patch({ art: { ...config.art, mode: v ? "dataurl" : "off" } })
- }
- />
-
- patch({ art: { ...config.art, includeBackground: v } })
- }
- />
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/ui/src/api.ts b/ui/src/api.ts
deleted file mode 100644
index 63eb4f9..0000000
--- a/ui/src/api.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-// Typed client for the plugin-local API. Relative paths — the SPA is mounted under the console proxy
-// prefix, so `api/...` resolves to `/plugin-ui/playnite/api/...`. Types mirror `src/engine` +
-// `src/config`.
-import { useEffect, useState } from "react";
-
-export interface Location {
- dir: string | null;
- candidates: string[];
- file: string | null;
- mtime: number | null;
-}
-export interface Skipped {
- id: string;
- title: string;
- reason: string;
-}
-export interface Report {
- generatedAt: string;
- considered: number;
- included: number;
- skipped: Skipped[];
- warnings: string[];
- perSource: Record;
-}
-export interface LastSync {
- fingerprint: string;
- count: number;
- at: number;
-}
-export interface Status {
- platform: string;
- location: Location;
- exportError?: string;
- lastSync?: LastSync;
- lastReport?: Report;
- paths: { dir: string; config: string; cache: string; relConfig: string };
- syncing: boolean;
-}
-export interface Config {
- playniteDir?: string;
- sync: { pollMinutes: number; watch: boolean; debounceMs: number };
- filter: {
- installedOnly: boolean;
- includeHidden: boolean;
- sources: string[];
- excludeSources: string[];
- };
- art: {
- mode: "dataurl" | "off";
- maxBytes: number;
- includeBackground: boolean;
- };
- ui: {
- standalone: boolean;
- port: number;
- bind: string;
- passwordHash?: string;
- };
- devEntry: boolean;
-}
-export interface Preview {
- location: Location;
- report?: Report;
- error?: string;
-}
-
-const api = async (path: string, opts?: RequestInit): Promise => {
- 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("api/status");
-export const getConfig = () => api("api/config");
-export const putConfig = (config: Config) =>
- api("api/config", { method: "PUT", body: JSON.stringify(config) });
-export const getPreview = () => api("api/preview");
-export const runSync = () => api("api/sync", { method: "POST" });
-
-/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
-export const useStatusStream = (): Status | undefined => {
- const [status, setStatus] = useState();
- 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;
-};
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
deleted file mode 100644
index 15f25a8..0000000
--- a/ui/src/main.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import { App } from "./App.js";
-import "./styles.css";
-
-createRoot(document.getElementById("root")!).render(
-
-
- ,
-);
diff --git a/ui/src/styles.css b/ui/src/styles.css
deleted file mode 100644
index 3d0d178..0000000
--- a/ui/src/styles.css
+++ /dev/null
@@ -1,41 +0,0 @@
-@import "tailwindcss";
-
-@custom-variant dark (&:is(.dark *));
-
-/* Punktfunk violet identity — dark only (the SPA is embedded on the console's dark canvas). The token
- names feed Tailwind v4 utilities directly: `bg-card`, `text-muted`, `bg-brand`, `border-border`… */
-@theme {
- --color-bg: #0b0b0f;
- --color-surface: #131120;
- --color-card: #1a1726;
- --color-elevated: #221d33;
- --color-border: #2a2440;
- --color-fg: #e9e6f3;
- --color-muted: #a39fbb;
- --color-brand: #6c5bf3;
- --color-brand-hover: #7d6ef5;
- --color-brand-fg: #ffffff;
- --color-ok: #34d399;
- --color-warn: #fbbf24;
- --color-err: #f87171;
- --radius: 0.7rem;
-}
-
-html,
-body {
- margin: 0;
- background: var(--color-bg);
- color: var(--color-fg);
- font-family:
- "Geist", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
- -webkit-font-smoothing: antialiased;
-}
-
-*::-webkit-scrollbar {
- width: 10px;
- height: 10px;
-}
-*::-webkit-scrollbar-thumb {
- background: var(--color-border);
- border-radius: 6px;
-}
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
deleted file mode 100644
index 1c02818..0000000
--- a/ui/vite.config.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import tailwindcss from "@tailwindcss/vite";
-import react from "@vitejs/plugin-react";
-import { defineConfig } from "vite";
-
-// `base: "./"` (relative asset URLs) is the plugin-ui-surface contract: the SPA is mounted under
-// `/plugin-ui/playnite/` behind the console proxy. Built into `../dist/ui`, which `servePluginUi`'s
-// staticDir points at.
-export default defineConfig({
- base: "./",
- plugins: [react(), tailwindcss()],
- build: {
- outDir: "../dist/ui",
- emptyOutDir: true,
- },
- server: { port: 5601 },
-});