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.
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user