app store panel keyed (553 -> 544)
StatusBadge's aria-labels (installing, failed, disabled, installed) are keyed too. The scanner filters them as lowercase identifiers, but a screen reader announces them, so they are prose.
This commit is contained in:
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { Loader2, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// One entry: what it is, how to install it, and what to do about it once installed.
|
||||
//
|
||||
@@ -41,6 +42,7 @@ function Field({ field, value, onChange }: { field: ConfigField; value: string;
|
||||
}
|
||||
|
||||
export const AppStoreDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const id = params.get('selected');
|
||||
const { items, install, setEnabled, uninstall } = useAppStore();
|
||||
@@ -51,7 +53,11 @@ export const AppStoreDetail = () => {
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
if (!item) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select an app</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t('appStore.selectApp')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
|
||||
@@ -82,13 +88,13 @@ export const AppStoreDetail = () => {
|
||||
person. `completeAt` links to the page that mints the token. */}
|
||||
{item.install.status === 'blocked' && item.install.lastError && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<div className="font-medium">Needs you</div>
|
||||
<div className="font-medium">{t('appStore.needsYou')}</div>
|
||||
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.install.status === 'failed' && item.install.lastError && (
|
||||
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
|
||||
<div className="font-medium">Install failed</div>
|
||||
<div className="font-medium">{t('appStore.installFailed')}</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -97,7 +103,7 @@ export const AppStoreDetail = () => {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
|
||||
{item.install.enabled ? 'enabled' : 'disabled'}
|
||||
{item.install.enabled ? t('appStore.enabled') : t('appStore.disabled')}
|
||||
{item.processStatus && ` · process ${item.processStatus}`}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -106,17 +112,14 @@ export const AppStoreDetail = () => {
|
||||
disabled={setEnabled.isPending}
|
||||
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
|
||||
>
|
||||
{item.install.enabled ? 'Disable' : 'Enable'}
|
||||
{item.install.enabled ? t('appStore.disable') : t('appStore.enable')}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
|
||||
Uninstall
|
||||
{t('appStore.uninstall')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data,
|
||||
configuration and this app’s tables are kept either way.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('appStore.removalNote')}</p>
|
||||
</div>
|
||||
) : (
|
||||
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
|
||||
import { useAppStore, type StoreItem } from './useAppStore';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// The list of everything installable, with what has happened to each.
|
||||
//
|
||||
@@ -10,26 +11,31 @@ import { useAppStore, type StoreItem } from './useAppStore';
|
||||
|
||||
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
|
||||
function StatusBadge({ item }: { item: StoreItem }) {
|
||||
const { t } = useTranslation();
|
||||
const { status, enabled } = item.install;
|
||||
|
||||
if (status === 'not-installed') return null;
|
||||
if (status === 'installing') {
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label="installing" />;
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label={t('appStore.installing')} />;
|
||||
}
|
||||
if (status === 'failed') return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label="failed" />;
|
||||
if (status === 'failed')
|
||||
return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label={t('appStore.failed')} />;
|
||||
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
|
||||
if (status === 'blocked') return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label="needs you" />;
|
||||
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />;
|
||||
if (status === 'blocked')
|
||||
return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label={t('appStore.needsYouShort')} />;
|
||||
if (!enabled)
|
||||
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label={t('appStore.disabled')} />;
|
||||
|
||||
// Installed and enabled, but the process is not running — the case worth surfacing rather than
|
||||
// showing a tick that lies.
|
||||
if (item.processStatus && item.processStatus !== 'online') {
|
||||
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
|
||||
}
|
||||
return <Check className="h-3.5 w-3.5 text-success" aria-label="installed" />;
|
||||
return <Check className="h-3.5 w-3.5 text-success" aria-label={t('appStore.installedIcon')} />;
|
||||
}
|
||||
|
||||
export const AppStoreList = () => {
|
||||
const { t } = useTranslation();
|
||||
const { items, isLoading, error } = useAppStore();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
@@ -37,14 +43,14 @@ export const AppStoreList = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('appStore.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
// Distinguished from "nothing installable", which would otherwise look identical and is not a
|
||||
// problem the user can act on.
|
||||
return <div className="p-4 text-sm text-destructive">Could not load the app store.</div>;
|
||||
return <div className="p-4 text-sm text-destructive">{t('appStore.loadFailed')}</div>;
|
||||
}
|
||||
|
||||
const installed = items.filter((i) => i.install.status !== 'not-installed');
|
||||
@@ -66,13 +72,17 @@ export const AppStoreList = () => {
|
||||
<div className="flex h-full flex-col overflow-y-auto p-2">
|
||||
{installed.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Installed</div>
|
||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('appStore.installed')}
|
||||
</div>
|
||||
{installed.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</div>
|
||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('appStore.available')}
|
||||
</div>
|
||||
{available.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
|
||||
@@ -32,5 +32,24 @@
|
||||
"noShells": "No shells running.",
|
||||
"refresh": "Refresh",
|
||||
"kill": "Kill this shell"
|
||||
},
|
||||
"appStore": {
|
||||
"loading": "Loading",
|
||||
"loadFailed": "Could not load the app store.",
|
||||
"installed": "Installed",
|
||||
"available": "Available",
|
||||
"needsYou": "Needs you",
|
||||
"needsYouShort": "needs you",
|
||||
"selectApp": "Select an app",
|
||||
"installFailed": "Install failed",
|
||||
"uninstall": "Uninstall",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enabled": "enabled",
|
||||
"disabled": "disabled",
|
||||
"removalNote": "Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data, configuration and this app’s tables are kept either way.",
|
||||
"installing": "installing",
|
||||
"failed": "failed",
|
||||
"installedIcon": "installed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,5 +32,24 @@
|
||||
"noShells": "Nenhuma shell em execução.",
|
||||
"refresh": "Atualizar",
|
||||
"kill": "Terminar esta shell"
|
||||
},
|
||||
"appStore": {
|
||||
"loading": "A carregar",
|
||||
"loadFailed": "Não foi possível carregar a loja de aplicações.",
|
||||
"installed": "Instaladas",
|
||||
"available": "Disponíveis",
|
||||
"needsYou": "Precisa de si",
|
||||
"needsYouShort": "precisa de si",
|
||||
"selectApp": "Selecione uma aplicação",
|
||||
"installFailed": "A instalação falhou",
|
||||
"uninstall": "Desinstalar",
|
||||
"enable": "Ativar",
|
||||
"disable": "Desativar",
|
||||
"enabled": "ativada",
|
||||
"disabled": "desativada",
|
||||
"removalNote": "Desativar para o sidecar e o seu contentor. Desinstalar remove também os contentores — os seus dados, a configuração e as tabelas desta aplicação são mantidos em qualquer dos casos.",
|
||||
"installing": "a instalar",
|
||||
"failed": "falhou",
|
||||
"installedIcon": "instalada"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user