Files
punktfunk/web/src/sections/Store/index.tsx
T
enricobuehler 3be7d1d4f8 feat(web): the console asks its own questions
Follow-up to a85e8452, closing the three items that sweep flagged and left.

SIXTEEN BROWSER DIALOGS, GONE. Every destructive action in an otherwise fully
branded console handed off to `window.confirm` — a grey OS box with the page's
URL in it, no brand, no red on a delete, and untouchable by any story or
screenshot, which is part of why it survived this long.

They are replaced by one promise-based surface (components/dialogs.tsx) rather
than a dialog per call site. The native calls were EXPRESSIONS — `if
(!confirm(…)) return;` — threaded through mutation handlers; rewriting each into
"hold the pending action in state, render a dialog, run it from onConfirm" would
have put dialog machinery in every section file and turned each linear handler
inside out. Returning a promise keeps them the shape they already were, and it
is what let the navigation guard come along too: TanStack's `shouldBlockFn`
accepts `Promise<boolean>`. `beforeunload` necessarily stays native — a reload
is the browser's dialog to draw, and it will not wait on ours.

No warning copy was rewritten. Each message was SPLIT at its existing sentence
boundary: the question becomes the dialog's title, the consequence its body,
and "Continue?" is dropped where the affirmative button now carries the verb
("Delete", "Uninstall", "Unpair", "Stop every session"). 16 new keys, en and de
in parity at 629.

Verified by driving the real dialogs in a headless browser — all seven contract
checks pass, including the two that would be invisible until they bit: Escape
SETTLES the promise (an unsettled one would hang a mutation handler forever with
no error), and a cancelled prompt resolves null rather than "", so a caller can
still tell "backed out" from "cleared the field".

FOUR OF THE SEVEN NUMERIC FIELDS became InputNumber; three deliberately did not,
and now say why in place. The layout X/Y pair had a real defect: a screen left
of the origin has a negative coordinate, and `Number("-") || 0` rewrote the lone
minus sign to "0" before the digits could be typed. Measured on the built page:
the field can now be emptied to retype instead of snapping to its floor, and 900
in a 1..=16 field clamps to 16. The three left alone cannot take it — the grace
seconds field writes to the HOST on blur (InputNumber commits while typing, so
its clamp would race the apply), and the library's year/players are OPTIONAL,
where `value: number` has no way to say "unset" and would invent a year for
every entry without one.

The select's highlighted row moves off @unom/ui's neutral grey onto the brand
wash the nav and the preset cards already use.

The Displays story earned its keep immediately: adding `useDialogs` to that page
broke it in Storybook, because the provider was mounted in __root and nowhere
else. It belongs beside the other app-level providers in .storybook/preview.
2026-08-07 22:56:46 +02:00

203 lines
6.5 KiB
TypeScript

import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type InstallBody,
type InstalledPlugin,
runningJob,
type StoreEntry,
useInstallPlugin,
useStoreCatalog,
useStoreJobs,
useUninstallPlugin,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { BrowseTab } from "./Browse";
import { InstallDialog, SpecInstallDialog } from "./InstallDialogs";
import { InstalledTab } from "./Installed";
import { JobProgressSection } from "./JobProgress";
import { SourcesTab } from "./Sources";
type StoreTab = "browse" | "installed" | "sources";
/**
* The plugin store: browse a catalog, manage what's installed, and choose which catalogs this host
* trusts. Each tab owns its own queries; this container owns only what genuinely spans them — the
* install/uninstall mutations, the confirm dialogs their trust tier dictates, and the job the host
* hands back (which must stay visible whichever tab you switch to while it runs).
*/
export const SectionStore: FC = () => {
useLocale();
const { confirm } = useDialogs();
const [tab, setTab] = useState<StoreTab>("browse");
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
const [specOpen, setSpecOpen] = useState(false);
const [specWrongPassword, setSpecWrongPassword] = useState(false);
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
// finished job's log is the only record of what happened.
const [jobId, setJobId] = useState<string | null>(null);
const catalog = useStoreCatalog();
// Re-attach to a job that was already running when this page loaded — an install survives a
// reload on the host side, and losing sight of it left the Install buttons armed against a host
// that answers 409.
const jobs = useStoreJobs();
const orphan = runningJob(jobs.data);
useEffect(() => {
if (orphan && !jobId) setJobId(orphan.id);
}, [orphan, jobId]);
const install = useInstallPlugin();
const uninstall = useUninstallPlugin();
/** Turn a failed 202-request into a message: 409 means the host is busy, not that we're broken. */
const failed = (e: unknown, fallback: string) =>
toast.error(
e instanceof ApiError && e.status === 409 ? m.store_busy() : fallback,
);
const start = async (body: InstallBody) => {
try {
const { job } = await install.mutateAsync(body);
setJobId(job);
} catch (e) {
failed(e, m.store_install_failed());
}
};
const onConfirmEntry = async (entry: StoreEntry) => {
setTarget(null);
await start({ source: entry.source, id: entry.id });
};
const onConfirmSpec = async (spec: string, password: string) => {
setSpecWrongPassword(false);
try {
const { job } = await install.mutateAsync({
spec,
accept_unverified: true,
password,
});
setSpecOpen(false);
setJobId(job);
} catch (e) {
// A rejected password keeps the dialog open with everything the operator typed still in
// it; anything else is an ordinary install failure.
if (e instanceof ApiError && e.status === 401) {
setSpecWrongPassword(true);
return;
}
setSpecOpen(false);
failed(e, m.store_install_failed());
}
};
// An update from the Installed tab installs the CATALOG version — so it goes through the very
// same tier-appropriate dialog a fresh install would, warning included.
//
// Resolve by the entry the plugin was actually installed FROM (source + entry id) before falling
// back to the package name: two sources may carry the same `pkg`, and matching on the name alone
// could offer a row badged "verified" an entry from somebody else's source at a different version.
const onUpdate = (plugin: InstalledPlugin) => {
const entries = catalog.data?.plugins ?? [];
const entry =
(plugin.source && plugin.entry_id
? entries.find(
(e) => e.source === plugin.source && e.id === plugin.entry_id,
)
: undefined) ??
(plugin.source
? entries.find(
(e) => e.source === plugin.source && e.pkg === plugin.pkg,
)
: undefined) ??
entries.find((e) => e.pkg === plugin.pkg);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
}
setTarget(entry);
};
const onUninstall = async (plugin: InstalledPlugin) => {
const ok = await confirm({
title: m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }),
description: m.store_uninstall_body(),
confirmLabel: m.store_uninstall(),
destructive: true,
});
if (!ok) return;
try {
const { job } = await uninstall.mutateAsync(plugin.pkg);
setJobId(job);
} catch (e) {
failed(e, m.store_uninstall_failed());
}
};
return (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.store_title()}</h1>
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
</div>
{jobId && (
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
<TabsList>
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
<TabsTrigger value="installed">
{m.store_tab_installed()}
</TabsTrigger>
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
</TabsList>
<TabsContent value="browse">
<BrowseTab
onInstall={setTarget}
onInstallSpec={() => setSpecOpen(true)}
/>
</TabsContent>
<TabsContent value="installed">
<InstalledTab
onUpdate={onUpdate}
onUninstall={onUninstall}
busyPkg={
uninstall.isPending ? (uninstall.variables ?? null) : null
}
/>
</TabsContent>
<TabsContent value="sources">
<SourcesTab />
</TabsContent>
</Tabs>
<InstallDialog
entry={target}
isPending={install.isPending}
onCancel={() => setTarget(null)}
onConfirm={onConfirmEntry}
/>
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
wrongPassword={specWrongPassword}
onCancel={() => {
setSpecOpen(false);
setSpecWrongPassword(false);
}}
onConfirm={onConfirmSpec}
/>
</div>
</Section>
);
};