e8acf922de
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.
302 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
};
|