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:
parent
944b24fe59
commit
dece83c3e8
23 changed files with 839 additions and 28 deletions
87
apps/desktop/src/ui/app-menu.ts
Normal file
87
apps/desktop/src/ui/app-menu.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// The context menu of one app (sidebar row, portfolio card): open, a tab, the store consoles, the folder, ids,
|
||||
// reads, and the ignore actions the app view and the Ignored screen already have.
|
||||
import { api, errorText } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { copyText } from '../lib/crash';
|
||||
import { withIgnored, withSideIgnored, type UnifiedApp } from '../lib/identity';
|
||||
import { setShowIgnored } from '../lib/show-ignored';
|
||||
import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav';
|
||||
import type { Data } from '../store';
|
||||
import type { MenuInput, MenuItem } from './menu-logic';
|
||||
|
||||
export const ASC_APP = (id: string | null | undefined): string => (id ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(id)}/distribution` : 'https://appstoreconnect.apple.com/apps');
|
||||
export const PLAY_HOME = 'https://play.google.com/console/';
|
||||
|
||||
export function openUrl(d: Data, url: string): void {
|
||||
api.openExternal(url).catch(e => d.toast({ kind: 'bad', title: t('link.openFailed'), detail: errorText(e) }));
|
||||
}
|
||||
export function copyToast(d: Data, text: string): void {
|
||||
void copyText(text).then(ok => d.toast(ok ? { kind: 'ok', title: t('cm.copied') } : { kind: 'bad', title: t('health.copyFailed') }));
|
||||
}
|
||||
|
||||
export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: boolean }): MenuInput {
|
||||
const ios = app.ios?.app ?? null, android = app.android?.app ?? null;
|
||||
const p = app.project;
|
||||
const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab });
|
||||
const sides = (['ios', 'android'] as const).filter(k => app[k] || app.ignoredStores.includes(k));
|
||||
const storeName = (k: 'ios' | 'android') => (k === 'ios' ? 'App Store' : 'Google Play');
|
||||
return [
|
||||
{ label: t('cm.open'), icon: 'arrowRight', onSelect: () => go('releases') },
|
||||
{ label: t('cm.openTab'), icon: 'grid', submenu: tabsFor(!!p).map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) },
|
||||
'sep',
|
||||
(ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [
|
||||
ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) },
|
||||
android && { label: 'Google Play Console', icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) },
|
||||
] },
|
||||
p && !p.missing && { label: t('cm.openFolder'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(e => d.toast({ kind: 'bad', title: t('cm.folderFailed'), detail: errorText(e) })); } },
|
||||
{ label: t('cm.copy'), icon: 'copy', submenu: [
|
||||
{ label: t('cm.copyName'), icon: 'text', onSelect: () => copyToast(d, app.name) },
|
||||
ios && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: ios.bundleId, onSelect: () => copyToast(d, ios.bundleId) },
|
||||
android && { label: t('cm.copyPackage'), icon: 'tag', shortcut: android.id, onSelect: () => copyToast(d, android.id) },
|
||||
ios && { label: t('cm.copyAscId'), icon: 'key', onSelect: () => copyToast(d, ios.id) },
|
||||
p && { label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) },
|
||||
] },
|
||||
'sep',
|
||||
{ label: t('cm.refreshReads'), icon: 'refresh', disabled: app.ignored ? t('cm.ignoredNoReads') : false, onSelect: () => { d.invalidate(app.target); d.toast({ kind: 'info', title: t('cm.refreshing', { name: app.name }) }); } },
|
||||
'sep',
|
||||
app.ignored
|
||||
? { label: t('app.unignore'), icon: 'eye', onSelect: () => { void d.updateOverrides(o => withIgnored(o, app.ignoreKeys, false)); } }
|
||||
: { label: t('cm.ignore'), icon: 'eyeOff', submenu: [
|
||||
{ label: t('app.ignore'), icon: 'eyeOff', danger: true, onSelect: () => {
|
||||
void d.updateOverrides(o => withIgnored(o, app.ignoreKeys, true)).then(() => { if (nav.route.screen === 'app' && nav.route.appId === app.key) nav.go({ screen: 'portfolio' }); });
|
||||
} },
|
||||
sides.length === 2 && 'sep',
|
||||
...(sides.length === 2 ? sides.map(k => {
|
||||
const off = app.ignoredStores.includes(k);
|
||||
return { label: off ? t('app.unignoreSide', { store: storeName(k) }) : t('app.ignoreSide', { store: storeName(k) }), icon: off ? 'eye' as const : 'eyeOff' as const,
|
||||
onSelect: () => { void d.updateOverrides(o => withSideIgnored(o, app, k, !off)); } };
|
||||
}) : []),
|
||||
] },
|
||||
{ label: t('cm.showIgnored'), icon: 'filter', checked: opts.showIgnored, onSelect: () => setShowIgnored(!opts.showIgnored) },
|
||||
];
|
||||
}
|
||||
|
||||
/** "Open in App Store Connect / Play Console" for one store side of an app. */
|
||||
export function consoleItem(d: Data, app: UnifiedApp, store: 'ios' | 'android'): MenuItem {
|
||||
return store === 'ios'
|
||||
? { label: t('cm.openIn', { place: 'App Store Connect' }), icon: 'external', onSelect: () => openUrl(d, ASC_APP(app.ios?.app.id)) }
|
||||
: { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) };
|
||||
}
|
||||
|
||||
/** Menu of one store release: copy version / build, open the console. */
|
||||
export function releaseMenu(d: Data, app: UnifiedApp, store: 'ios' | 'android', version: string | null | undefined, build: string | number | null | undefined): MenuInput {
|
||||
return [
|
||||
version && { label: t('cm.copyVersion'), icon: 'copy', shortcut: String(version), onSelect: () => copyToast(d, String(version)) },
|
||||
build != null && build !== '' && { label: store === 'ios' ? t('cm.copyBuild') : t('cm.copyVersionCode'), icon: 'copy', shortcut: String(build), onSelect: () => copyToast(d, String(build)) },
|
||||
'sep',
|
||||
consoleItem(d, app, store),
|
||||
];
|
||||
}
|
||||
|
||||
/** Both consoles of an app folder (App Store Connect with the app's id when the status knows it). */
|
||||
export function projectConsoles(d: Data, ascAppId: string | null | undefined, ios: boolean, android: boolean): MenuInput {
|
||||
return [
|
||||
ios && { label: t('cm.openIn', { place: 'App Store Connect' }), icon: 'external', onSelect: () => openUrl(d, ASC_APP(ascAppId)) },
|
||||
android && { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) },
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue