Desktop: custom context menus everywhere, never the browser's menu

One ContextMenu in the design system (keyboard, type-ahead, submenus, check items,
disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The
native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all.
Menus on sidebar and portfolio apps, app hero, releases, store art, live art,
reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
This commit is contained in:
CRTkafa 2026-10-23 08:59:14 +08:00
parent 944b24fe59
commit dece83c3e8
23 changed files with 839 additions and 28 deletions

View file

@ -3,6 +3,8 @@
// issuer id, .p8). Keys live in the machine's credential profiles (~/.storebound, shared with the CLI);
// the app keeps a label, the studio and the profile name only. Per account: a health
// check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see).
import { useContextMenu } from '../ui/ContextMenu';
import { copyToast } from '../ui/app-menu';
import { type FormEvent, useEffect, useState } from 'react';
import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api';
import { type Key, t } from '../i18n';
@ -54,8 +56,22 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
};
const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); await d.reload(); setEdit(false); }); };
const check = h?.check;
const cm = useContextMenu();
return (
<div className={`acct${ignored ? ' is-ignored' : ''}`}>
<div className={`acct${ignored ? ' is-ignored' : ''}`} {...cm(() => [
{ label: t('acct.test'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => run(`ck-${a.id}`, () => d.checkAccount(a.id)) },
{ label: t('acct.import'), icon: 'download', disabled: busy || d.importBusy ? t('cm.busy') : false, onSelect: () => run(`im-${a.id}`, () => d.importApps(a.id)) },
{ label: t('acct.edit'), icon: 'edit', onSelect: () => setEdit(true) },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
{ label: t('cm.copyName'), icon: 'text', onSelect: () => copyToast(d, a.label) },
check?.principal && { label: t('cm.copyPrincipal'), icon: 'key', shortcut: check.principal, onSelect: () => copyToast(d, check.principal!) },
a.profile && { label: t('cm.copyProfile'), icon: 'folder', shortcut: a.profile, onSelect: () => copyToast(d, a.profile!) },
] },
'sep',
{ label: ignored ? t('acct.unignore') : t('acct.ignore'), icon: ignored ? 'eye' : 'eyeOff', onSelect: () => { void d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], !ignored)); } },
{ label: t('accounts.remove'), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => run(`rm-${a.id}`, async () => { await api.removeAccount(a.id); await d.reload(); }) },
])}>
<div className="acct-row">
<span className={`acct-glyph ${a.kind}`}><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} /></span>
<div className="acct-main">