Files
punktfunk-plugin-playnite/ui/src/pages/settings.tsx
T
enricobuehler e8acf922de
CI / build (push) Successful in 31s
CI / exporter (push) Successful in 12s
CI / publish (push) Successful in 22s
feat(ui): three-page console SPA on @unom/ui + the kit's react helpers
Overview (exporter health, Playnite version, counts, sync, live SSE activity,
and a first-run panel that names the exact ingest path the .pext writes to),
Library (covers + per-game include toggles + why-not-synced), Settings
(filters, art delivery, sync cadence, paths).

Data layer is AtomHttpApi atoms derived from the shared contract; the whole
thing runs with zero host against an in-browser mock transport with four
scenarios, and Storybook renders the real pages on the same fixtures. CI now
asserts the production bundle carries no fixture strings.

Also: CI rebuilt for the workspace layout (one root install, per-package
typecheck, publish from plugin/ on a v* tag) with the exporter job and
`publish: needs [build, exporter]` unchanged; README rewritten around the three
workspaces, with the ingest inbox explained as the load-bearing mechanism it is.
2026-07-20 21:05:04 +02:00

302 lines
9.0 KiB
TypeScript

// Settings — subscribes the config draft (raw edits, resolved display values) +
// liveStatusAtom (the Files paths); mutates saveConfig via the sticky SaveBar.
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import type { ArtMode, RawConfig } from "@playnite/contract";
import { PathListEditor } from "@unom/app-ui/path-list-editor";
import { SettingsGroup, SettingsRow } from "@unom/app-ui/settings";
import { CodeBlock } from "@unom/ui/code-block";
import { InputNumber } from "@unom/ui/form/input-number";
import { InputText } from "@unom/ui/form/input-text";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unom/ui/form/select";
import { Switch } from "@unom/ui/form/switch";
import { Skeleton } from "@unom/ui/skeleton";
import { Gate } from "@/components/gate";
import { SaveBar } from "@/components/save-bar";
import { configAtom, liveStatusAtom, statusAtom } from "@/data/atoms";
import { useConfigDraft } from "@/data/drafts";
import type { PageProps } from "@/routes";
type ArtRaw = NonNullable<RawConfig["art"]>;
type SyncRaw = NonNullable<RawConfig["sync"]>;
type FilterRaw = NonNullable<RawConfig["filter"]>;
const PageSkeleton = () => (
<div className="space-y-6">
<Skeleton className="h-72" />
<Skeleton className="h-64" />
<Skeleton className="h-52" />
<Skeleton className="h-40" />
</div>
);
const ART_MODES: ReadonlyArray<{
readonly value: ArtMode;
readonly label: string;
readonly hint: string;
}> = [
{
value: "host",
label: "Served by the host",
hint: "Send each cover's local path and let the host serve the bytes. Scales to any library size — the recommended mode.",
},
{
value: "dataurl",
label: "Inlined in the payload",
hint: "Embed each cover in the library payload. Self-contained, but only fit for small libraries.",
},
{ value: "off", label: "No cover art", hint: "Sync titles with no art." },
];
/** A string list editor over one of the source filters (`sources` / `excludeSources`). */
const SourceList = ({
values,
onChange,
placeholder,
addLabel,
}: {
values: ReadonlyArray<string>;
onChange: (next: ReadonlyArray<string>) => void;
placeholder: string;
addLabel: string;
}) => (
<PathListEditor<string>
items={values}
onChange={onChange}
path={{ get: (s) => s, set: (_, value) => value, placeholder }}
makeItem={(s) => s}
addLabel={addLabel}
className="w-full"
/>
);
export const SettingsPage = (_: PageProps) => {
const config = useAtomValue(configAtom);
const retry = useAtomRefresh(configAtom);
const status = useAtomValue(liveStatusAtom);
const retryStatus = useAtomRefresh(statusAtom);
const draft = useConfigDraft();
const { resolved } = draft;
const patchArt = (patch: Partial<ArtRaw>) =>
draft.patch({ art: { ...(draft.draft.art ?? {}), ...patch } });
const patchSync = (patch: Partial<SyncRaw>) =>
draft.patch({ sync: { ...(draft.draft.sync ?? {}), ...patch } });
const patchFilter = (patch: Partial<FilterRaw>) =>
draft.patch({ filter: { ...(draft.draft.filter ?? {}), ...patch } });
const artMode = resolved?.art.mode ?? "host";
return (
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
{() => (
<div className="space-y-6">
<SettingsGroup
title="Which games sync"
description="Playnite exports your whole library; these filters decide what reaches the Punktfunk library."
>
<SettingsRow
label="Only installed games"
description="Skip titles Playnite knows about but hasn't installed."
>
<Switch
checked={resolved?.filter.installedOnly ?? true}
onCheckedChange={(checked) =>
patchFilter({ installedOnly: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Include hidden games"
description="Titles you flagged Hidden in Playnite."
>
<Switch
checked={resolved?.filter.includeHidden ?? false}
onCheckedChange={(checked) =>
patchFilter({ includeHidden: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Only these sources"
description='Leave empty for every source. Names as Playnite shows them ("Steam", "GOG", "Epic"…); matching ignores case.'
vertical
>
<SourceList
values={resolved?.filter.sources ?? []}
onChange={(sources) => patchFilter({ sources: [...sources] })}
placeholder="Steam"
addLabel="Add source"
/>
</SettingsRow>
<SettingsRow
label="Never these sources"
description="Applied after the list above."
vertical
>
<SourceList
values={resolved?.filter.excludeSources ?? []}
onChange={(excludeSources) =>
patchFilter({ excludeSources: [...excludeSources] })
}
placeholder="Xbox"
addLabel="Exclude source"
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Cover art"
description="Playnite keeps cover art as files on this machine; this decides how it reaches your clients."
>
<SettingsRow
label="Delivery"
description={
ART_MODES.find((m) => m.value === artMode)?.hint ?? ""
}
>
<Select
value={artMode}
onValueChange={(value) => patchArt({ mode: value as ArtMode })}
>
<SelectTrigger size="sm" className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ART_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
{artMode !== "off" ? (
<SettingsRow
label="Also send backgrounds"
description="Playnite's background image becomes hero art. Usually large."
>
<Switch
checked={resolved?.art.includeBackground ?? false}
onCheckedChange={(checked) =>
patchArt({ includeBackground: checked === true })
}
/>
</SettingsRow>
) : null}
{artMode === "dataurl" ? (
<SettingsRow
label="Size cap per image"
description="Bytes. A cover larger than this is dropped rather than inlined."
>
<InputNumber
className="w-36"
min={1}
step={100_000}
value={resolved?.art.maxBytes ?? 1_500_000}
onChange={(maxBytes) => patchArt({ maxBytes })}
/>
</SettingsRow>
) : null}
</SettingsGroup>
<SettingsGroup
title="Sync"
description="When the plugin re-reads Playnite's export and reconciles the library."
>
<SettingsRow
label="Watch for changes"
description="React to a new export immediately (polling stays on as the fallback)."
>
<Switch
checked={resolved?.sync.watch ?? true}
onCheckedChange={(checked) =>
patchSync({ watch: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Poll interval"
description="Minutes between re-reads."
>
<InputNumber
className="w-28"
min={1}
value={resolved?.sync.pollMinutes ?? 5}
onChange={(pollMinutes) => patchSync({ pollMinutes })}
/>
</SettingsRow>
<SettingsRow
label="Debounce"
description="Milliseconds to wait after a change before re-reading."
>
<InputNumber
className="w-28"
min={0}
step={100}
value={resolved?.sync.debounceMs ?? 1500}
onChange={(debounceMs) => patchSync({ debounceMs })}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Advanced"
description="Only needed when Playnite isn't where the plugin expects it."
>
<SettingsRow
label="Playnite data directory"
description="Overrides the auto-detected %APPDATA%\Playnite — for a portable install. The host ingest inbox is still read first."
>
<InputText
className="w-72"
spellCheck={false}
value={draft.draft.playniteDir ?? ""}
placeholder="C:\Playnite"
onChange={(event) => {
const dir = event.target.value;
// `playniteDir` is an optionalKey: undefined is dropped by
// JSON.stringify, so the PUT body omits it entirely.
draft.patch({
playniteDir: dir.length > 0 ? dir : undefined,
});
}}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Files"
description="Where this plugin keeps its state, and where Playnite drops the library."
>
<Gate
result={status}
skeleton={<Skeleton className="h-24" />}
retry={retryStatus}
>
{(s) => (
<CodeBlock copy>
{`config: ${s.paths.config}\ncache: ${s.paths.cache}\ninbox: ${s.paths.ingest}\nexport: ${s.location.file ?? "(not written yet)"}`}
</CodeBlock>
)}
</Gate>
</SettingsGroup>
<SaveBar
dirty={draft.dirty}
saving={draft.saving}
onSave={draft.save}
onDiscard={draft.discard}
/>
</div>
)}
</Gate>
);
};