Panel: area plugin cards with per-feature toggles, the Fixes list, modes (first-run screen and Settings)
- PanelSite.features: the app's feature table; AreaPlugins draws one card per area (switch, count, grouped features, Settings button that runs the area's command in the site tab) and the core flags as Fixes - a feature still backed by an old plugin file is switched through plugin_set_enabled from its card and left out of the JavaScript plugins list - Modes: Onboarding (three cards, recommended preselected; an existing setup may be kept) and ModeSettings with the keep-my-changes question; openOnboardingWhenDue for ShellSite.start - flags are saved sparsely (only what differs from the table's defaults) - PanelSite.appearance.extra; palette commands of an area are listed under its name - the kit's words for feature settings and health notes; English in the framework catalog
This commit is contained in:
parent
365f55f604
commit
d8619c7482
16 changed files with 689 additions and 24 deletions
171
packages/shell/src/panel/Modes.tsx
Normal file
171
packages/shell/src/panel/Modes.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Modes: the presets an app's feature table defines (Basic / Advanced / Power User, or whatever the
|
||||
// app calls them). `Onboarding` is the first-run screen: three cards, one recommended and
|
||||
// preselected; someone who set the app up before modes existed may dismiss it and keep exactly
|
||||
// what they have. `ModeSettings` is the same choice later, in Settings, with the question whether
|
||||
// the owner's own changes stay. A mode is a starting point: choosing one writes flag values
|
||||
// (`features_set`) and remembers the choice (`mode_get/set`); it never takes a switch away.
|
||||
import { useEffect, useState, type ComponentType } from 'react';
|
||||
import { modeById, sparseFlags as sparse, type FeatureTable, type Flags } from '../../../runtime/src/featureTable.ts';
|
||||
import { invoke } from '../frame/bridge.ts';
|
||||
import { useT } from '../frame/hooks.ts';
|
||||
import { tx } from '../frame/i18n.ts';
|
||||
import { shell } from '../frame/state.ts';
|
||||
import { Badge, Button, Row, Section } from './controls.tsx';
|
||||
import { modePlan, type PluginFileState } from './featureModel.ts';
|
||||
import { featureTable, flagsChanged, useFlags } from './PluginFeatures.tsx';
|
||||
import { panelSite, type Notify } from './site.ts';
|
||||
import './plugins.css';
|
||||
|
||||
/** The onboarding state native code keeps (waybetter-host `modes::ModeState`). */
|
||||
export type ModeState = { mode: string | null; onboarded: boolean; existing: boolean };
|
||||
|
||||
const getError = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
const listeners = new Set<(state: ModeState) => void>();
|
||||
let cached: ModeState | null = null;
|
||||
|
||||
/** The mode state, loaded once and shared by the first-run screen and Settings. Null until loaded (or without modes). */
|
||||
export function useModeState(): [ModeState | null, (next: ModeState) => void] {
|
||||
const [state, setState] = useState<ModeState | null>(cached);
|
||||
useEffect(() => {
|
||||
listeners.add(setState);
|
||||
if (!cached && featureTable()?.modes.length) invoke<ModeState>('mode_get').then(value => { cached = value; for (const listener of listeners) listener(value); }).catch(() => {});
|
||||
return () => { listeners.delete(setState); };
|
||||
}, []);
|
||||
return [state, next => { cached = next; for (const listener of listeners) listener(next); }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies mode `to`: saves its flags (over the owner's own changes, or under them with `keep`),
|
||||
* turns off the old plugin files a fresh mode does not have, records the choice and runs the app's
|
||||
* own step for the mode.
|
||||
*/
|
||||
async function applyMode(table: FeatureTable, flags: Flags, from: string | null, to: string, keep: boolean, save: (flags: Flags) => Promise<boolean>): Promise<ModeState | null> {
|
||||
const plugins = await invoke<PluginFileState[]>('plugins_list').catch(() => [] as PluginFileState[]);
|
||||
const plan = modePlan(table, flags, plugins, from, to, keep);
|
||||
if (!await save(plan.flags)) return null;
|
||||
for (const file of plan.disable) await invoke('plugin_set_enabled', { file, enabled: false }).catch(() => {});
|
||||
if (plan.disable.length) window.dispatchEvent(new CustomEvent('bt-plugins-changed'));
|
||||
const state = await invoke<ModeState>('mode_set', { mode: to, onboarded: true });
|
||||
flagsChanged();
|
||||
await panelSite().features?.onMode?.(to);
|
||||
return state;
|
||||
}
|
||||
|
||||
function ModeCards({ table, selected, onSelect, Art }: { table: FeatureTable; selected: string | null; onSelect(id: string): void; Art?: ComponentType<{ mode: string }> }) {
|
||||
const t = useT();
|
||||
return <div className="wbm-cards" role="radiogroup" aria-label={t('wb.modes.section')}>
|
||||
{table.modes.map(mode => <button key={mode.id} type="button" role="radio" aria-checked={selected === mode.id} className={`wbm-card ${selected === mode.id ? 'is-on' : ''}`} onClick={() => onSelect(mode.id)}>
|
||||
<span className="wbm-art" aria-hidden="true">{Art ? <Art mode={mode.id}/> : <ModeBars index={table.modes.indexOf(mode)} count={table.modes.length}/>}</span>
|
||||
<strong>{tx(mode.name, undefined, mode.id)}{mode.recommended && <Badge tone="accent">{t('wb.modes.recommended')}</Badge>}</strong>
|
||||
<span>{tx(mode.description, undefined, '')}</span>
|
||||
</button>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** The default illustration: how much of the page a mode touches, as filled bars. */
|
||||
function ModeBars({ index, count }: { index: number; count: number }) {
|
||||
return <svg viewBox="0 0 96 40" width="96" height="40">
|
||||
{Array.from({ length: count + 2 }, (_, bar) => <rect key={bar} x={8 + bar * (80 / (count + 2))} y={8} width={80 / (count + 2) - 5} height={24} rx={3}
|
||||
fill="currentColor" opacity={bar <= index + 1 ? 0.85 : 0.16}/>)}
|
||||
</svg>;
|
||||
}
|
||||
|
||||
/** The first-run screen. `done` runs once a mode was chosen or the screen was dismissed. */
|
||||
export function Onboarding({ state, done, notify }: { state: ModeState; done(next: ModeState): void; notify: Notify }) {
|
||||
const t = useT();
|
||||
const table = featureTable()!;
|
||||
const panel = panelSite();
|
||||
const [flags] = useFlags(table, notify);
|
||||
const recommended = table.modes.find(mode => mode.recommended)?.id ?? table.modes[0]?.id ?? null;
|
||||
// Someone who set the app up before keeps what they have unless they pick a mode themselves.
|
||||
const [selected, setSelected] = useState<string | null>(state.existing ? null : recommended);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const mode = modeById(table, selected);
|
||||
|
||||
async function finish(next: () => Promise<ModeState | null>) {
|
||||
setBusy(true);
|
||||
try { const result = await next(); if (result) done(result); }
|
||||
catch (err) { notify(getError(err), true); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
|
||||
const start = (keep: boolean) => flags && selected && void finish(() => applyMode(table, flags, state.mode, selected, keep, save));
|
||||
const dismiss = () => void finish(() => invoke<ModeState>('mode_set', { mode: state.mode, onboarded: true }));
|
||||
|
||||
return <div className="wbm-onboarding" role="dialog" aria-labelledby="wbm-title">
|
||||
<img src={panel.brand.logo} alt="" width={44} height={44}/>
|
||||
<h1 id="wbm-title">{t('wb.modes.title', { app: panel.brand.name })}</h1>
|
||||
<p>{t('wb.modes.lede')}</p>
|
||||
<ModeCards table={table} selected={selected} onSelect={setSelected} Art={panel.features?.ModeArt}/>
|
||||
{state.existing && <p className="wbm-note">{t('wb.modes.existing', { app: panel.brand.name })}</p>}
|
||||
<div className="wbm-actions">
|
||||
{state.existing && <Button tone={selected ? 'ghost' : 'primary'} disabled={busy} onClick={dismiss}>{t('wb.modes.keepSetup')}</Button>}
|
||||
{mode && state.existing && <Button disabled={busy || !flags} onClick={() => start(true)}>{t('wb.modes.keep')}</Button>}
|
||||
{mode && <Button tone="primary" disabled={busy || !flags} onClick={() => start(false)}>{t('wb.modes.start', { mode: tx(mode.name, undefined, mode.id) })}</Button>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** Settings: the mode in use and the way to another one, keeping or dropping the owner's changes. */
|
||||
export function ModeSettings({ notify }: { notify: Notify }) {
|
||||
const table = featureTable();
|
||||
return table?.modes.length ? <ModeSettingsOf table={table} notify={notify}/> : null;
|
||||
}
|
||||
|
||||
function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify }) {
|
||||
const t = useT();
|
||||
const [state, setState] = useModeState();
|
||||
const [flags, , reload] = useFlags(table, notify);
|
||||
const [asking, setAsking] = useState<{ to: string; changes: number } | null>(null);
|
||||
if (!state || !flags) return null;
|
||||
const name = (id: string) => tx(modeById(table, id)?.name ?? id, undefined, id);
|
||||
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
|
||||
|
||||
async function apply(to: string, keep: boolean) {
|
||||
setAsking(null);
|
||||
try {
|
||||
const next = await applyMode(table, flags!, state!.mode, to, keep, save);
|
||||
if (next) { setState(next); await reload(); notify(t('wb.modes.applied', { mode: name(to) })); }
|
||||
} catch (err) { notify(getError(err), true); }
|
||||
}
|
||||
async function choose(to: string) {
|
||||
const plugins = await invoke<PluginFileState[]>('plugins_list').catch(() => [] as PluginFileState[]);
|
||||
const changes = modePlan(table, flags!, plugins, state!.mode, to, false).changes;
|
||||
if (changes > 0) setAsking({ to, changes }); else void apply(to, false);
|
||||
}
|
||||
|
||||
return <Section title={t('wb.modes.section')} hint={t('wb.modes.sectionHint')} id="modes">
|
||||
{table.modes.map(mode => {
|
||||
const current = state.mode === mode.id;
|
||||
return <Row key={mode.id} label={name(mode.id)} hint={tx(mode.description, undefined, '')} keywords={`mode preset ${mode.id}`}
|
||||
badge={<>{current && <Badge tone="accent">{t('wb.modes.current')}</Badge>}{mode.recommended && <Badge>{t('wb.modes.recommended')}</Badge>}</>}>
|
||||
{asking?.to === mode.id
|
||||
? <span className="wbf-ask" role="alertdialog">{t('wb.modes.keepQuestion')} <small>{t('wb.modes.keepHint', { count: asking.changes })}</small>
|
||||
<Button size="sm" tone="primary" onClick={() => void apply(mode.id, true)}>{t('wb.modes.keep')}</Button>
|
||||
<Button size="sm" onClick={() => void apply(mode.id, false)}>{t('wb.modes.reset', { mode: name(mode.id) })}</Button>
|
||||
<Button size="sm" tone="ghost" onClick={() => setAsking(null)}>{t('wb.common.cancel')}</Button>
|
||||
</span>
|
||||
: <Button size="sm" onClick={() => void choose(mode.id)}>{t('wb.modes.use')}</Button>}
|
||||
</Row>;
|
||||
})}
|
||||
</Section>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the app's panel (which shows the first-run screen) when the screen has not been answered.
|
||||
* Call it from the app's `ShellSite.start`: it waits until the shell has restored its tabs.
|
||||
*/
|
||||
export function openOnboardingWhenDue(): void {
|
||||
if (!featureTable()?.modes.length) return;
|
||||
invoke<ModeState>('mode_get').then(state => {
|
||||
cached = state;
|
||||
if (state.onboarded) return;
|
||||
let tries = 0;
|
||||
const timer = setInterval(() => {
|
||||
tries += 1;
|
||||
const api = shell.api;
|
||||
if (api && !shell.restoring && api.panels.length > 0) { clearInterval(timer); shell.openPanel(api.activePanel?.id); }
|
||||
else if (tries > 80) clearInterval(timer);
|
||||
}, 250);
|
||||
}).catch(() => {});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue