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
|
|
@ -24,6 +24,8 @@ import { type Command, Palette } from './ui/Palette';
|
|||
import { ExportMenu, useExportItems, useRunExport } from './ui/Exports';
|
||||
import { ErrorBoundary } from './ui/ErrorBoundary';
|
||||
import { Toasts } from './ui/Toasts';
|
||||
import { useContextMenu } from './ui/ContextMenu';
|
||||
import { appMenu } from './ui/app-menu';
|
||||
import { worstTone } from './ui/ds';
|
||||
import { attention } from './lib/model';
|
||||
|
||||
|
|
@ -149,6 +151,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }];
|
||||
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
|
||||
|
||||
const menu = useContextMenu();
|
||||
const appItem = (a: UnifiedApp) => {
|
||||
const e = d.statusFor(a);
|
||||
const missing = !!a.project?.missing;
|
||||
|
|
@ -156,7 +159,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
|
||||
return (
|
||||
<div role="listitem" key={a.key}>
|
||||
<button className={`nav nav-app${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
<button {...menu(() => appMenu(a, d, nav, { showIgnored }))} className={`nav nav-app${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
||||
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' })}>
|
||||
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{tone && <span className={`ds-sdot ${tone}`} role="img" aria-label={tone === 'bad' ? t('pf.blocker') : tone === 'warn' ? t('pf.warning') : t('pf.note')} />}</span>
|
||||
<span className="grow">{a.name}</span>
|
||||
|
|
|
|||
|
|
@ -196,3 +196,27 @@
|
|||
/* ---------- reduced motion ---------- */
|
||||
@media (prefers-reduced-motion: reduce) { .ds-hero-shots img, .ds-stat.clickable, .ds-scrim, .ds-dialog { transform: none !important; transition: none; animation: none; } }
|
||||
:root[data-motion="reduce"] .ds-hero-shots img, :root[data-motion="reduce"] .ds-stat.clickable, :root[data-motion="reduce"] .ds-scrim, :root[data-motion="reduce"] .ds-dialog { transform: none !important; transition: none; animation: none; }
|
||||
|
||||
/* ---------- context menu (ui/ContextMenu.tsx) ---------- */
|
||||
.ds-menu { position: fixed; z-index: 1000; min-width: 200px; max-width: 320px; max-height: calc(100vh - 16px); overflow: auto; padding: 5px;
|
||||
border-radius: 12px; background: color-mix(in srgb, var(--ds-raised) 94%, transparent); border: 1px solid var(--ds-stroke-strong);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, .05) inset, 0 14px 38px -10px rgba(10, 14, 30, .38), 0 3px 10px -4px rgba(10, 14, 30, .22);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.4); backdrop-filter: blur(18px) saturate(1.4);
|
||||
font-size: var(--ds-t-body); color: var(--ink); outline: none; user-select: none;
|
||||
opacity: 0; transform: scale(.97); transform-origin: top left; transition: opacity 120ms var(--ease), transform 120ms var(--ease); }
|
||||
.ds-menu.in { opacity: 1; transform: none; }
|
||||
.ds-menu-item { display: flex; align-items: center; gap: 9px; height: 30px; padding: 0 10px 0 8px; border-radius: 7px; cursor: default; white-space: nowrap; }
|
||||
.ds-menu-item.active { background: var(--accent-soft); color: var(--ink); }
|
||||
.ds-menu-item.active .ds-menu-ic { color: var(--accent); }
|
||||
.ds-menu-item.danger { color: var(--bad-solid); }
|
||||
.ds-menu-item.danger.active { background: var(--bad-bg); color: var(--bad-ink); }
|
||||
.ds-menu-item.danger .ds-menu-ic, .ds-menu-item.danger.active .ds-menu-ic { color: inherit; }
|
||||
.ds-menu-item.disabled { opacity: .45; }
|
||||
.ds-menu-ic { width: 16px; height: 16px; flex: none; display: grid; place-items: center; color: var(--muted); }
|
||||
.ds-menu-ic svg { width: 15px; height: 15px; }
|
||||
.ds-menu-l { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ds-menu-k { max-width: 150px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--faint); font-size: var(--ds-t-micro); letter-spacing: .02em; font-variant-numeric: tabular-nums; }
|
||||
.ds-menu-sub { width: 12px; height: 12px; color: var(--faint); margin-left: 10px; }
|
||||
.ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); }
|
||||
@media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } }
|
||||
:root[data-motion="reduce"] .ds-menu { transition: none; transform: none; }
|
||||
|
|
|
|||
|
|
@ -2019,4 +2019,57 @@ export const en = {
|
|||
'v2rel.tfInGroups': "in {count} groups",
|
||||
'v2rel.tfSplit': "{internal} internal · {external} external",
|
||||
'v2rel.tfUsable': "Ready to test",
|
||||
|
||||
// context menus (ui/ContextMenu.tsx)
|
||||
'cm.cut': "Cut",
|
||||
'cm.copy': "Copy",
|
||||
'cm.paste': "Paste",
|
||||
'cm.selectAll': "Select all",
|
||||
'cm.copied': "Copied",
|
||||
'cm.open': "Open",
|
||||
'cm.openTab': "Open tab",
|
||||
'cm.openConsole': "Open in console",
|
||||
'cm.openIn': "Open in {place}",
|
||||
'cm.openFolder': "Open folder",
|
||||
'cm.folderFailed': "Could not open the folder",
|
||||
'cm.copyName': "Name",
|
||||
'cm.copyBundleId': "Bundle id",
|
||||
'cm.copyPackage': "Package name",
|
||||
'cm.copyAscId': "App Store Connect app id",
|
||||
'cm.copyPath': "Copy path",
|
||||
'cm.refreshReads': "Read again from the stores",
|
||||
'cm.refreshing': "Reading {name} again",
|
||||
'cm.ignoredNoReads': "Ignored apps are not read",
|
||||
'cm.ignore': "Ignore",
|
||||
'cm.showIgnored': "Show ignored apps",
|
||||
'cm.copyVersion': "Copy version",
|
||||
'cm.copyBuild': "Copy build",
|
||||
'cm.copyVersionCode': "Copy version code",
|
||||
'cm.preview': "Preview",
|
||||
'cm.openFile': "Open file",
|
||||
'cm.reveal': "Show in folder",
|
||||
'cm.readAgain': "Read again",
|
||||
'cm.reading': "Reading…",
|
||||
'cm.openDraft': "Show reply draft",
|
||||
'cm.copyReview': "Copy review",
|
||||
'cm.copyReply': "Copy the published reply",
|
||||
'cm.copyDraft': "Copy the draft",
|
||||
'cm.revealEvidence': "Show the file",
|
||||
'cm.copyTitle': "Copy title",
|
||||
'cm.copyFix': "Copy the fix",
|
||||
'cm.copySkill': "Copy skill name",
|
||||
'cm.copyEvidence': "Copy evidence lines",
|
||||
'cm.copyCode': "Copy check code",
|
||||
'cm.copyValue': "Copy value",
|
||||
'cm.notChanged': "Not changed",
|
||||
'cm.copyProductId': "Copy product id",
|
||||
'cm.showPrices': "Show prices",
|
||||
'cm.copyRow': "Copy row",
|
||||
'cm.showPerRegion': "Show per region",
|
||||
'cm.copyPriceOf': "Copy {store} price",
|
||||
'cm.copyRegion': "Copy region code",
|
||||
'cm.busy': "Another action is running",
|
||||
'cm.copyPrincipal': "Service account / key",
|
||||
'cm.copyProfile': "Profile name",
|
||||
'cm.openAccounts': "Open Studios & accounts",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -2021,4 +2021,57 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'v2rel.tfInGroups': "{count} grupta",
|
||||
'v2rel.tfSplit': "{internal} dahili · {external} harici",
|
||||
'v2rel.tfUsable': "Teste hazır",
|
||||
|
||||
// context menus (ui/ContextMenu.tsx)
|
||||
'cm.cut': "Kes",
|
||||
'cm.copy': "Kopyala",
|
||||
'cm.paste': "Yapıştır",
|
||||
'cm.selectAll': "Tümünü seç",
|
||||
'cm.copied': "Kopyalandı",
|
||||
'cm.open': "Aç",
|
||||
'cm.openTab': "Sekme aç",
|
||||
'cm.openConsole': "Konsolda aç",
|
||||
'cm.openIn': "{place} içinde aç",
|
||||
'cm.openFolder': "Klasörü aç",
|
||||
'cm.folderFailed': "Klasör açılamadı",
|
||||
'cm.copyName': "Ad",
|
||||
'cm.copyBundleId': "Bundle id",
|
||||
'cm.copyPackage': "Paket adı",
|
||||
'cm.copyAscId': "App Store Connect uygulama kimliği",
|
||||
'cm.copyPath': "Yolu kopyala",
|
||||
'cm.refreshReads': "Mağazalardan yeniden oku",
|
||||
'cm.refreshing': "{name} yeniden okunuyor",
|
||||
'cm.ignoredNoReads': "Yok sayılan uygulamalar okunmaz",
|
||||
'cm.ignore': "Yok say",
|
||||
'cm.showIgnored': "Yok sayılanları göster",
|
||||
'cm.copyVersion': "Sürümü kopyala",
|
||||
'cm.copyBuild': "Build'i kopyala",
|
||||
'cm.copyVersionCode': "Sürüm kodunu kopyala",
|
||||
'cm.preview': "Önizle",
|
||||
'cm.openFile': "Dosyayı aç",
|
||||
'cm.reveal': "Klasörde göster",
|
||||
'cm.readAgain': "Yeniden oku",
|
||||
'cm.reading': "Okunuyor…",
|
||||
'cm.openDraft': "Yanıt taslağını göster",
|
||||
'cm.copyReview': "Yorumu kopyala",
|
||||
'cm.copyReply': "Yayındaki yanıtı kopyala",
|
||||
'cm.copyDraft': "Taslağı kopyala",
|
||||
'cm.revealEvidence': "Dosyayı göster",
|
||||
'cm.copyTitle': "Başlığı kopyala",
|
||||
'cm.copyFix': "Düzeltmeyi kopyala",
|
||||
'cm.copySkill': "Skill adını kopyala",
|
||||
'cm.copyEvidence': "Kanıt satırlarını kopyala",
|
||||
'cm.copyCode': "Kontrol kodunu kopyala",
|
||||
'cm.copyValue': "Değeri kopyala",
|
||||
'cm.notChanged': "Değişmedi",
|
||||
'cm.copyProductId': "Ürün kimliğini kopyala",
|
||||
'cm.showPrices': "Fiyatları göster",
|
||||
'cm.copyRow': "Satırı kopyala",
|
||||
'cm.showPerRegion': "Bölge bölge göster",
|
||||
'cm.copyPriceOf': "{store} fiyatını kopyala",
|
||||
'cm.copyRegion': "Bölge kodunu kopyala",
|
||||
'cm.busy': "Başka bir işlem sürüyor",
|
||||
'cm.copyPrincipal': "Hizmet hesabı / anahtar",
|
||||
'cm.copyProfile': "Profil adı",
|
||||
'cm.openAccounts': "Stüdyolar ve hesapları aç",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { PrefsContext } from './prefs-ctx';
|
|||
import { installGlobalHandlers, report } from './lib/crash';
|
||||
import { DataProvider } from './store';
|
||||
import { ErrorBoundary } from './ui/ErrorBoundary';
|
||||
import { ContextMenuProvider } from './ui/ContextMenu';
|
||||
import { ExportProvider } from './ui/Exports';
|
||||
import './styles.css';
|
||||
import './design.css';
|
||||
|
|
@ -36,7 +37,7 @@ function Root() {
|
|||
update({ ...prefs, theme: dark ? 'light' : 'dark' });
|
||||
}, [prefs, update]);
|
||||
// a language change re-renders the whole tree in the new language
|
||||
return <PrefsContext.Provider value={{ prefs, update }}><DataProvider><ExportProvider><App key={lang} onTheme={onTheme} /></ExportProvider></DataProvider></PrefsContext.Provider>;
|
||||
return <PrefsContext.Provider value={{ prefs, update }}><DataProvider><ExportProvider><ContextMenuProvider><App key={lang} onTheme={onTheme} /></ContextMenuProvider></ExportProvider></DataProvider></PrefsContext.Provider>;
|
||||
}
|
||||
|
||||
installGlobalHandlers();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
|
||||
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
|
||||
import { type MenuInput, useContextMenu } from '../ui/ContextMenu';
|
||||
import { copyToast } from '../ui/app-menu';
|
||||
import { failureText } from '../lib/results';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
|
||||
|
|
@ -19,9 +21,10 @@ type Profile = 'app' | 'game' | 'subscription-app';
|
|||
interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string }
|
||||
|
||||
/** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */
|
||||
function Tinted({ src, name, children }: { src: string | null | undefined; name: string; children: ReactNode }) {
|
||||
function Tinted({ src, name, children, menu }: { src: string | null | undefined; name: string; children: ReactNode; menu: () => MenuInput }) {
|
||||
const hue = useTint(src, name);
|
||||
return <div className="li fold-card" style={tintStyle(hue)}>{children}</div>;
|
||||
const cm = useContextMenu();
|
||||
return <div className="li fold-card" style={tintStyle(hue)} {...cm(menu)}>{children}</div>;
|
||||
}
|
||||
|
||||
function LocateButton({ project }: { project: Project }) {
|
||||
|
|
@ -100,7 +103,21 @@ export function AppFolders() {
|
|||
const e = d.statusOf(p);
|
||||
const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e);
|
||||
return (
|
||||
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name}>
|
||||
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name} menu={() => [
|
||||
!p.missing && { label: t('folders.open'), icon: 'arrowRight', onSelect: () => nav.go({ screen: 'app', appId: p.id, tab: 'releases' }) },
|
||||
!p.missing && { label: t('folders.reveal'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(x => setError(errorText(x))); } },
|
||||
'sep',
|
||||
!p.missing && { label: t('folders.check'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => check(p) },
|
||||
!p.missing && { label: t('folders.gallery'), icon: 'image', disabled: busy ? t('cm.busy') : false, onSelect: () => gallery(p) },
|
||||
'sep',
|
||||
{ label: t('cm.copy'), icon: 'copy', submenu: [
|
||||
{ label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) },
|
||||
e?.app?.platforms.ios?.bundleId && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: e.app.platforms.ios.bundleId, onSelect: () => copyToast(d, e.app!.platforms.ios!.bundleId) },
|
||||
e?.app?.platforms.android?.packageName && { label: t('cm.copyPackage'), icon: 'tag', shortcut: e.app.platforms.android.packageName, onSelect: () => copyToast(d, e.app!.platforms.android!.packageName) },
|
||||
] },
|
||||
'sep',
|
||||
{ label: t('folders.remove', { name: p.name }), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => remove(p) },
|
||||
]}>
|
||||
<AppIcon name={p.name} src={d.icons[p.id]} className="fold-icon" />
|
||||
<div className="li-main">
|
||||
<span className="row" style={{ gap: 8 }}><span className="li-title">{e?.app?.name ?? p.name}</span><StatusPill tone={st.tone}>{st.text}</StatusPill></span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no
|
||||
// app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and
|
||||
// "Start an app folder here" to make it a full app.
|
||||
import { useContextMenu } from '../ui/ContextMenu';
|
||||
import { appMenu } from '../ui/app-menu';
|
||||
import { useShowIgnored } from '../lib/show-ignored';
|
||||
import { useState } from 'react';
|
||||
import { api, errorText } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
|
|
@ -122,10 +125,13 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null;
|
||||
const att = attention(app.key, app.name, e, l);
|
||||
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
|
||||
const cm = useContextMenu();
|
||||
const [showIgnored] = useShowIgnored();
|
||||
|
||||
return (
|
||||
<div className="page ds-app">
|
||||
<Hero name={name} icon={d.iconFor(app)} projectId={app.project && !app.project.missing ? app.project.id : null}
|
||||
{...cm(() => appMenu(app, d, nav, { showIgnored }))}
|
||||
badges={<>
|
||||
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
|
||||
{!app.project && <StoreOnlyTag />}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// Ignored: apps and whole store accounts left out of the portfolio, the sidebar and store reads. Restore brings them
|
||||
// back (nothing on the stores changes either way).
|
||||
import { useContextMenu } from '../ui/ContextMenu';
|
||||
import { appMenu } from '../ui/app-menu';
|
||||
import { t } from '../i18n';
|
||||
import { withIgnored, withSideIgnored } from '../lib/identity';
|
||||
import { useNav } from '../nav';
|
||||
|
|
@ -16,6 +18,7 @@ export function Ignored() {
|
|||
const accounts = d.accounts.filter(a => d.overrides.ignored.includes(`account:${a.id}`));
|
||||
const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] }));
|
||||
const empty = !apps.length && !accounts.length;
|
||||
const cm = useContextMenu();
|
||||
return (
|
||||
<div className="page scr-ignored">
|
||||
<PageHeader icon="eyeOff" eyebrow={t('v2cfg.workspace')} title={t('nav.ignored')} subtitle={t('ignored.lead')} actions={!empty ? <><button className="btn" onClick={restoreAll}><Icon name="eye" />{t('ignored.restoreAll')}</button></> : undefined} />
|
||||
|
|
@ -27,7 +30,7 @@ export function Ignored() {
|
|||
{apps.map(a => {
|
||||
const s = d.studios.find(x => x.id === a.studio) ?? null;
|
||||
return (
|
||||
<div className="li" key={a.key}>
|
||||
<div className="li" key={a.key} {...cm(() => appMenu(a, d, nav, { showIgnored: true }))}>
|
||||
<AppIcon name={a.name} src={d.iconFor(a)} />
|
||||
<div className="li-main">
|
||||
<span className="li-title row" style={{ gap: 8 }}>{a.name}{!a.project && <StoreOnlyTag />}</span>
|
||||
|
|
@ -49,7 +52,7 @@ export function Ignored() {
|
|||
{accounts.length ? (
|
||||
<div className="list">
|
||||
{accounts.map(a => (
|
||||
<div className="li" key={a.id}>
|
||||
<div className="li" key={a.id} {...cm(() => [{ label: t('ignored.restore'), icon: 'eye', onSelect: () => { void d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], false)); } }, { label: t('cm.openAccounts'), icon: 'key', onSelect: () => nav.go({ screen: 'accounts' }) }])}>
|
||||
<span className="li-icon tone-bg-neutral"><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} /></span>
|
||||
<div className="li-main"><span className="li-title">{a.label}</span><span className="li-sub">{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')} · {t('ignored.accountApps', { count: d.storeApps[a.id]?.value.apps.length ?? 0 })}</span></div>
|
||||
<button className="btn btn-sm" onClick={() => d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], false))}><Icon name="eye" />{t('ignored.restore')}</button>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { useShowIgnored } from '../lib/show-ignored';
|
|||
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp, withIgnored } from '../lib/identity';
|
||||
import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model';
|
||||
import { type AppTab, useNav } from '../nav';
|
||||
import { useContextMenu } from '../ui/ContextMenu';
|
||||
import { appMenu } from '../ui/app-menu';
|
||||
import type { IconName } from '../ui/icons';
|
||||
import { useData } from '../store';
|
||||
import { exportName, portfolioCsv } from '../lib/exports';
|
||||
|
|
@ -144,8 +146,11 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
|
|||
const name = e?.app?.name && app.project ? e.app.name : app.name;
|
||||
const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab });
|
||||
const style = tintStyle(hue);
|
||||
const d = useData();
|
||||
const menu = useContextMenu();
|
||||
const [showIgnored] = useShowIgnored();
|
||||
return (
|
||||
<article className="pf-card" style={style} aria-label={name}>
|
||||
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
|
||||
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
|
||||
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} loading="lazy" />) : <AppIcon name={app.name} src={icon} className="pf-hero-icon" />}
|
||||
</div>
|
||||
|
|
@ -285,6 +290,7 @@ export function Portfolio() {
|
|||
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
|
||||
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored), [d.apps]);
|
||||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const menu = useContextMenu();
|
||||
const [round, setRound] = useState(0);
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const health = useCachedHealth(visible.map(a => a.target), round);
|
||||
|
|
@ -349,7 +355,7 @@ export function Portfolio() {
|
|||
const ios = !!(app.ios || e?.app?.platforms.ios), android = !!(app.android || e?.app?.platforms.android);
|
||||
const sub = app.ios?.app.bundleId ?? e?.app?.platforms.ios?.bundleId ?? app.android?.app.id ?? e?.app?.platforms.android?.packageName ?? '';
|
||||
return (
|
||||
<tr key={app.key} className="clickable" onClick={() => open(app)}>
|
||||
<tr key={app.key} className="clickable" onClick={() => open(app)} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
|
||||
<td>
|
||||
<div className="cell-app">
|
||||
<AppIcon name={app.name} src={d.iconFor(app)} />
|
||||
|
|
@ -435,7 +441,7 @@ export function Portfolio() {
|
|||
<button className="link" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)}>{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>
|
||||
{showIgnored && ignoredApps.map(a => (
|
||||
<span key={a.key} className="row muted" style={{ gap: 4 }}>
|
||||
<button className="link" onClick={() => open(a)}>{a.name}</button>
|
||||
<button className="link" onClick={() => open(a)} {...menu(() => appMenu(a, d, nav, { showIgnored }))}>{a.name}</button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))} aria-label={t('ignored.restore')} title={t('ignored.restore')}><Icon name="eye" /></button>
|
||||
</span>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// size (`shots gallery --json`, offline), store check issues outlined. Images load one by one as they scroll into view
|
||||
// (`project.render`, a data: URL each: the window loads no files itself). Re-render (dry run, dialog, real run), write
|
||||
// the review page, open it or the folder.
|
||||
import { type MenuInput, useContextMenu } from '../../ui/ContextMenu';
|
||||
import { copyToast } from '../../ui/app-menu';
|
||||
import '../../styles/art.css';
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, errorText, type Project, type RenderImage, type RendersResult, type RenderTile, type ToolResult } from '../../api';
|
||||
|
|
@ -41,7 +43,8 @@ function loadRender(projectId: string, file: string, stamp: string): Promise<str
|
|||
});
|
||||
}
|
||||
|
||||
function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: RenderTile; stamp: string; onOpen: () => void }) {
|
||||
function Thumb({ projectId, tile, stamp, onOpen, menu }: { projectId: string; tile: RenderTile; stamp: string; onOpen: () => void; menu: () => MenuInput }) {
|
||||
const cm = useContextMenu();
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const file = tile.kind === 'video' ? tile.poster : tile.src;
|
||||
const [src, setSrc] = useState<string | null | undefined>(file ? cache.get(`${projectId}|${file}|${stamp}`) : null);
|
||||
|
|
@ -61,7 +64,7 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re
|
|||
const lvl = tile.issues.some(i => i.level === 'error') ? 'err' : tile.issues.length ? 'warn' : '';
|
||||
const shape = tile.h > tile.w ? 'portrait' : 'landscape';
|
||||
return (
|
||||
<figure className={`art-tile ${lvl} ${shape}`} style={{ ['--ar' as string]: `${tile.w} / ${tile.h}` }}>
|
||||
<figure {...cm(menu)} className={`art-tile ${lvl} ${shape}`} style={{ ['--ar' as string]: `${tile.w} / ${tile.h}` }}>
|
||||
<button ref={ref} className="art-pic" onClick={onOpen} aria-label={t('art.open', { name: tile.name })}>
|
||||
{src ? <img src={src} alt="" draggable={false} /> : src === null && file ? <span className="art-ph"><Icon name="image" />{t('art.tooBig')}</span> : <span className="art-ph">{tile.kind === 'video' ? <Icon name="play" /> : <span className="sk sk-block" />}</span>}
|
||||
{tile.kind === 'video' && <span className="art-play"><Icon name="play" /></span>}
|
||||
|
|
@ -170,7 +173,17 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
<h3><span>{row.label}</span><code>{lg}/{row.size}</code><Chip tone="outline" dot={false}>{row.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>{row.issues.length > 0 && <StatusPill tone={row.issues.some(i => i.level === 'error') ? 'bad' : 'warn'}>{t('v2lst.issues', { count: row.issues.length })}</StatusPill>}<span className="muted small">{t('art.count', { count: row.tiles.length })}</span></h3>
|
||||
{row.issues.length > 0 && <ul className="note-list art-issues">{row.issues.map((i, k) => <li key={k} className={i.level}>{i.msg}</li>)}</ul>}
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: row.size === 'feature' || /landscape/.test(row.size) ? '300px' : row.tiles[0] && row.tiles[0].w > row.tiles[0].h ? '260px' : '170px' }}>
|
||||
{row.tiles.map((tile, i) => <Thumb key={tile.src} projectId={project.id} tile={tile} stamp={stamp} onOpen={() => setOpen({ tiles: row.tiles, i })} />)}
|
||||
{row.tiles.map((tile, i) => <Thumb key={tile.src} projectId={project.id} tile={tile} stamp={stamp} onOpen={() => setOpen({ tiles: row.tiles, i })} menu={() => {
|
||||
const path = `${dir}/${tile.src}`;
|
||||
return [
|
||||
{ label: t('cm.preview'), icon: 'eye', shortcut: 'Enter', onSelect: () => setOpen({ tiles: row.tiles, i }) },
|
||||
{ label: t('cm.openFile'), icon: 'external', onSelect: () => reveal(path, true) },
|
||||
{ label: t('cm.reveal'), icon: 'folder', onSelect: () => reveal(path) },
|
||||
{ label: t('cm.copyPath'), icon: 'copy', onSelect: () => copyToast(d, path) },
|
||||
'sep',
|
||||
tile.kind === 'image' && { label: t('art.replace'), icon: 'upload', onSelect: () => { void replace(tile); } },
|
||||
];
|
||||
}} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// Catalog: products and subscriptions defined once in the config against both stores (catalog diff): a parity
|
||||
// matrix (present, mismatched, missing, not sold there, not read), and price gaps between the stores per region.
|
||||
import { useContextMenu } from '../../ui/ContextMenu';
|
||||
import { copyToast, projectConsoles } from '../../ui/app-menu';
|
||||
import '../../styles/catalog.css';
|
||||
import type { Project } from '../../api';
|
||||
import { type Key, t } from '../../i18n';
|
||||
|
|
@ -71,6 +73,9 @@ export function CatalogTab({ project }: { project: Project }) {
|
|||
const d = useData();
|
||||
const e = d.statusOf(project);
|
||||
const diff = useRead<CatalogDiff>(project.id, 'catalog.diff', { network: true, title: t('tab.catalog') });
|
||||
const cm = useContextMenu();
|
||||
const nav = useNav();
|
||||
const plat = e?.app?.platforms;
|
||||
const s = e?.summary ?? null;
|
||||
const name = s?.name ?? project.name;
|
||||
useExports('catalog', [
|
||||
|
|
@ -101,7 +106,12 @@ export function CatalogTab({ project }: { project: Project }) {
|
|||
{rows.map(r => {
|
||||
const worst = [r.ios.state, r.android.state].includes('error') ? 'bad' : [r.ios.state, r.android.state].includes('warn') || r.kind === 'extra' ? 'warn' : 'ok';
|
||||
return (
|
||||
<li key={r.id} className={`cat-card t-${worst}`} data-item={r.id}>
|
||||
<li key={r.id} className={`cat-card t-${worst}`} data-item={r.id} {...cm(() => [
|
||||
{ label: t('cm.copyProductId'), icon: 'copy', shortcut: r.id, onSelect: () => copyToast(d, r.id) },
|
||||
{ label: t('cm.showPrices'), icon: 'coins', onSelect: () => nav.go({ screen: 'app', appId: project.id, tab: 'prices' }) },
|
||||
'sep',
|
||||
...projectConsoles(d, plat?.ios?.ascAppId, !!plat?.ios, !!plat?.android),
|
||||
])}>
|
||||
<div className="cat-card-h">
|
||||
<span className="cat-ic" aria-hidden="true"><Icon name={r.kind === 'subscription' ? 'refresh' : 'tag'} /></span>
|
||||
<div className="cat-names">
|
||||
|
|
@ -127,7 +137,10 @@ export function CatalogTab({ project }: { project: Project }) {
|
|||
<thead><tr><th>{t('col.item')}</th><th>{t('col.region')}</th><th className="right">App Store</th><th className="right">Google Play</th><th className="right">{t('col.diff')}</th></tr></thead>
|
||||
<tbody>
|
||||
{gaps.slice(0, 12).map((g, i) => (
|
||||
<tr key={i}>
|
||||
<tr key={i} {...cm(() => [
|
||||
{ label: t('cm.copyProductId'), icon: 'copy', shortcut: g.id, onSelect: () => copyToast(d, g.id) },
|
||||
{ label: t('cm.copyRow'), icon: 'clipboard', onSelect: () => copyToast(d, [g.id, g.region, `${g.apple} ${g.currency}`, `${g.play} ${g.currency}`, `${g.diffPct} %`].join('\t')) },
|
||||
])}>
|
||||
<td className="mono-id">{g.id}</td><td>{g.region}</td>
|
||||
<td className="right num">{num(g.apple, 2)} {g.currency}</td><td className="right num">{num(g.play, 2)} {g.currency}</td>
|
||||
<td className="right"><Chip tone={g.diffPct > 25 ? 'bad' : 'warn'}>{`${num(g.diffPct, 1)} %`}</Chip></td>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// Checks: the offline rejection-risk scan of the project files (`scan`, every time), the offline config check and
|
||||
// the read-only store readiness report (`validate`, on demand) with its ordered blockers and fix hints.
|
||||
import { type MenuInput, type MenuItem, useContextMenu } from '../../ui/ContextMenu';
|
||||
import { copyToast, openUrl } from '../../ui/app-menu';
|
||||
import type { Project } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { ago } from '../../lib/format';
|
||||
|
|
@ -26,6 +28,8 @@ const LEVEL: Record<Finding['level'], ['bad' | 'warn' | 'ok', 'alertCircle' | 'a
|
|||
/** Fix methods the window runs from a finding (cli/lib/fix-actions.ts FIX_METHODS): local config writes and store
|
||||
* text pushes, each a dry run, the native dialog showing it, then the real run. Anything else gets no button. */
|
||||
export const FIX_METHODS = new Set(['scan.write', 'play.listing', 'apple.listing', 'apple.review_details']);
|
||||
/** The context-menu twin of FixButton (same write: dry run, then confirm). */
|
||||
const fixItem = (w: ReturnType<typeof useWrite>, action: FixAction, what: string): MenuItem => ({ label: t('checks.fix'), icon: 'wand', onSelect: () => { void w.run({ method: action.method, args: action.args, title: t('checks.fixing', { what }), done: t('checks.fixed') }); } });
|
||||
export const fixable = (a: FixAction | undefined): a is FixAction => !!a && FIX_METHODS.has(a.method) && !!a.args && typeof a.args === 'object';
|
||||
|
||||
/** The Fix button of one finding: its action as a confirm-gated write. */
|
||||
|
|
@ -40,9 +44,10 @@ const SEV: Record<ScanSeverity, ['bad' | 'warn' | 'info', 'alertCircle' | 'alert
|
|||
const SEV_ORDER: ScanSeverity[] = ['blocker', 'high', 'medium', 'low'];
|
||||
|
||||
/** One check as a card: tone icon tile, title, detail lines, the fix, and its actions on the right. */
|
||||
function CheckCard({ tone, icon, title, children, actions, tags }: { tone: 'bad' | 'warn' | 'ok' | 'info'; icon: IconName; title: ReactNode; children?: ReactNode; actions?: ReactNode; tags?: ReactNode }) {
|
||||
function CheckCard({ tone, icon, title, children, actions, tags, menu }: { tone: 'bad' | 'warn' | 'ok' | 'info'; icon: IconName; title: ReactNode; children?: ReactNode; actions?: ReactNode; tags?: ReactNode; menu?: () => MenuInput }) {
|
||||
const cm = useContextMenu();
|
||||
return (
|
||||
<div className={`ck-card tone-${tone}`}>
|
||||
<div className={`ck-card tone-${tone}`} {...(menu ? cm(menu) : {})}>
|
||||
<span className={`ck-ic tone-bg-${tone}`}><Icon name={icon} /></span>
|
||||
<div className="ck-main">
|
||||
<div className="ck-title">{title}</div>
|
||||
|
|
@ -61,6 +66,7 @@ function CheckCard({ tone, icon, title, children, actions, tags }: { tone: 'bad'
|
|||
function ScanCard({ project }: { project: Project }) {
|
||||
const s = useRead<ScanResult>(project.id, 'scan', { network: false, title: t('checks.scan') });
|
||||
const w = useWrite(project.id);
|
||||
const d = useData();
|
||||
const findings = Array.isArray(s.data?.findings) ? s.data!.findings : [];
|
||||
// Fix with AI: is claude installed (else Copy for AI); one run at a time
|
||||
const [claude, setClaude] = useState<boolean | null>(null);
|
||||
|
|
@ -86,6 +92,18 @@ function ScanCard({ project }: { project: Project }) {
|
|||
const ev = Array.isArray(f.evidence) ? f.evidence : [];
|
||||
return (
|
||||
<CheckCard key={k} tone={tone} icon={icon} title={<><span className="ck-gl">{f.guideline}</span>{f.title}</>}
|
||||
menu={() => [
|
||||
!f.accepted && fixable(f.action) && fixItem(w, f.action, f.title),
|
||||
!f.accepted && f.url && /^https:\/\//.test(f.url) && { label: t('link.open'), icon: 'external', onSelect: () => openUrl(d, f.url!) },
|
||||
ev[0] && { label: t('cm.revealEvidence'), icon: 'folder', shortcut: `${ev[0].file.split('/').pop()}:${ev[0].line}`, onSelect: () => { api.reveal(project.id, ev[0]!.file).catch(() => {}); } },
|
||||
'sep',
|
||||
{ label: t('cm.copy'), icon: 'copy', submenu: [
|
||||
{ label: t('cm.copyTitle'), icon: 'text', onSelect: () => copyToast(d, `${f.guideline} ${f.title}`) },
|
||||
f.fix && { label: t('cm.copyFix'), icon: 'wand', onSelect: () => copyToast(d, f.fix) },
|
||||
f.skill && { label: t('cm.copySkill'), icon: 'sparkle', shortcut: f.skill, onSelect: () => copyToast(d, f.skill) },
|
||||
ev.length > 0 && { label: t('cm.copyEvidence'), icon: 'clipboard', onSelect: () => copyToast(d, ev.map(e => `${e.file}:${e.line}${e.text ? ` ${e.text}` : ''}`).join('\n')) },
|
||||
] },
|
||||
]}
|
||||
tags={<><StatusPill tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</StatusPill><span className="ck-store"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} className="ic13" />{f.store === 'apple' ? 'App Store' : 'Google Play'}</span></>}
|
||||
actions={<>{!f.accepted && <OpenLink url={f.url} />}{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}</>}>
|
||||
{ev.slice(0, 2).map((e, j) => <span className="ck-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
|
||||
|
|
@ -182,6 +200,14 @@ export function ChecksTab({ project }: { project: Project }) {
|
|||
const [tone, icon] = LEVEL[f.level];
|
||||
return (
|
||||
<CheckCard key={`${f.level}${i}`} tone={tone} icon={icon} title={f.msg}
|
||||
menu={() => [
|
||||
f.level !== 'ok' && fixable(f.action) && fixItem(w, f.action, f.msg),
|
||||
f.url && /^https:\/\//.test(f.url) && { label: t('link.open'), icon: 'external', onSelect: () => openUrl(d, f.url!) },
|
||||
'sep',
|
||||
{ label: t('cm.copyTitle'), icon: 'copy', onSelect: () => copyToast(d, f.msg) },
|
||||
f.fix && { label: t('cm.copyFix'), icon: 'wand', onSelect: () => copyToast(d, f.fix!) },
|
||||
{ label: t('cm.copyCode'), icon: 'tag', shortcut: f.code, onSelect: () => copyToast(d, f.code) },
|
||||
]}
|
||||
tags={<span className="ck-store"><StoreGlyph store={f.store === 'apple' ? 'ios' : 'android'} className="ic13" />{f.store === 'apple' ? 'App Store' : 'Google Play'}</span>}
|
||||
actions={<><OpenLink url={f.url} />{f.level !== 'ok' && fixable(f.action) && <FixButton action={f.action} what={f.msg} w={w} />}</>}>
|
||||
{f.fix && <span className="ck-fix"><Icon name="wand" /><code className="selectable">{f.fix}</code></span>}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Icon } from '../../ui/icons';
|
|||
import { Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { Section, StatusPill } from '../../ui/ds';
|
||||
import { type MenuInput, useContextMenu } from '../../ui/ContextMenu';
|
||||
|
||||
// thumbnails by target + URL, three at a time
|
||||
const cache = new Map<string, string | null>();
|
||||
|
|
@ -79,6 +80,11 @@ export function LiveArt({ target, project }: { target: string; project?: Project
|
|||
<WriteButton run={w} primary icon="download" w={{ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }} hint={t('art.pullHint')}>{t('art.pull')}</WriteButton>
|
||||
) : null;
|
||||
const actions = <>{pull}<ReadControl r={r} /></>;
|
||||
const cm = useContextMenu();
|
||||
const setMenu = (): MenuInput => [
|
||||
project && { label: t('art.pull'), icon: 'download', onSelect: () => { void w.run({ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }); } },
|
||||
{ label: t('cm.readAgain'), icon: 'refresh', disabled: r.loading ? t('cm.reading') : false, onSelect: () => { r.refresh(); } },
|
||||
];
|
||||
if (!data) {
|
||||
return (
|
||||
<Section className="scr-art art-live" title={t('art.liveTitle')} icon="cloud" actions={actions} id="live-art" pad={false}>
|
||||
|
|
@ -115,7 +121,7 @@ export function LiveArt({ target, project }: { target: string; project?: Project
|
|||
{!total ? <Empty compact illo="text" title={t('art.liveNone')}>{t('art.liveNoneText')}</Empty> : (
|
||||
<div className="art-rows">
|
||||
{sets.map(s => (
|
||||
<section className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
|
||||
<section {...cm(setMenu)} className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
|
||||
<h3><span>{s.label}</span>{s.lang && <code>{s.lang}/{s.size}</code>}<Chip tone="outline" dot={false}>{s.store === 'apple' ? 'App Store' : 'Google Play'}</Chip><span className="muted small">{t('art.count', { count: s.tiles.length })}</span></h3>
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: s.size === 'feature' ? '300px' : s.tiles[0] && (s.tiles[0].w ?? 0) > (s.tiles[0].h ?? 1) ? '260px' : '170px' }}>
|
||||
{s.tiles.map(tile => <LiveThumb key={`${tile.index}-${tile.thumb}`} target={target} tile={tile} name={String(tile.index + 1).padStart(2, '0')} />)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Prices: what every product costs in every territory on both stores (`prices export --json`, read-only), the price
|
||||
// sheet as a CSV (save it anywhere, or write store/prices.csv for the terminal), and a price sheet compared with the
|
||||
// stores (`prices import --dry --json`) and applied as an owner action: dry run, native dialog, then the real run.
|
||||
import { useContextMenu } from '../../ui/ContextMenu';
|
||||
import { copyToast } from '../../ui/app-menu';
|
||||
import '../../styles/prices.css';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorText, type PricesCurrent, type PricesDiff, type Project } from '../../api';
|
||||
|
|
@ -91,6 +93,7 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
const s = e?.summary ?? null;
|
||||
const w = useWrite(project.id);
|
||||
const runExport = useRunExport();
|
||||
const cm = useContextMenu();
|
||||
const [withAuto, setWithAuto] = useState(true);
|
||||
const cur = useRead<PricesCurrent>(project.id, 'prices.current', { network: true, args: withAuto ? { with_automatic: true } : {}, title: t('prices.readTitle') });
|
||||
const [sheet, setSheet] = useState<{ csv?: string } | null>(null);
|
||||
|
|
@ -170,7 +173,11 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
<thead><tr><th>{t('col.item')}</th>{matrix!.regions.map(r => <th key={r}><Region code={r} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{matrix!.rows.map(row => (
|
||||
<tr key={row.product} className={`clickable${row.product === sel ? ' sel' : ''}`} onClick={() => setProduct(row.product)}>
|
||||
<tr key={row.product} className={`clickable${row.product === sel ? ' sel' : ''}`} onClick={() => setProduct(row.product)} {...cm(() => [
|
||||
{ label: t('cm.showPerRegion'), icon: 'globe', onSelect: () => setProduct(row.product) },
|
||||
'sep',
|
||||
{ label: t('cm.copyProductId'), icon: 'copy', shortcut: row.product, onSelect: () => copyToast(d, row.product) },
|
||||
])}>
|
||||
<td><span className="mono-id strong">{row.product}</span></td>
|
||||
{matrix!.regions.map(r => <td key={r}><PriceStack r={row.cells[r]} apple={apple} play={play} /></td>)}
|
||||
</tr>
|
||||
|
|
@ -190,7 +197,11 @@ export function PricesTab({ project }: { project: Project }) {
|
|||
<thead><tr><th>{t('col.region')}</th>{apple && <th className="right">App Store</th>}{apple && <th>{t('prices.colKind')}</th>}{play && <th className="right">Google Play</th>}<th className="right">{t('col.priceGap')}</th></tr></thead>
|
||||
<tbody>
|
||||
{regions.map(r => (
|
||||
<tr key={r.region} className={r.gapPct !== null && r.gapPct >= GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined}>
|
||||
<tr key={r.region} className={r.gapPct !== null && r.gapPct >= GAP ? (r.gapPct > 25 ? 'gap-bad' : 'gap-warn') : undefined} {...cm(() => [
|
||||
r.apple && { label: t('cm.copyPriceOf', { store: 'App Store' }), icon: 'copy', shortcut: money(r.apple.price, r.apple.currency), onSelect: () => copyToast(d, money(r.apple!.price, r.apple!.currency)) },
|
||||
r.play && { label: t('cm.copyPriceOf', { store: 'Google Play' }), icon: 'copy', shortcut: money(r.play.price, r.play.currency), onSelect: () => copyToast(d, money(r.play!.price, r.play!.currency)) },
|
||||
{ label: t('cm.copyRegion'), icon: 'globe', shortcut: r.region, onSelect: () => copyToast(d, r.region) },
|
||||
])}>
|
||||
<td className="strong"><Region code={r.region} /></td>
|
||||
{apple && <td className="right num">{r.apple ? money(r.apple.price, r.apple.currency) : <span className="muted">–</span>}{r.scheduled && <div className="small muted">{t('prices.scheduled', { price: money(r.scheduled.price, r.scheduled.currency), date: r.scheduled.startDate ?? '' })}</div>}</td>}
|
||||
{apple && <td>{r.apple ? <Chip tone={r.apple.kind === 'auto' ? 'neutral' : 'accent'}>{r.apple.kind === 'auto' ? t('prices.auto') : t('prices.manual')}</Chip> : null}</td>}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { RolloutBar } from '../../ui/charts';
|
|||
import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
|
||||
import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds';
|
||||
import { AppleSubmitCard, PlayProductionCard } from './Submit';
|
||||
import { useContextMenu } from '../../ui/ContextMenu';
|
||||
import { releaseMenu } from '../../ui/app-menu';
|
||||
import '../../styles/releases.css';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
|
@ -49,8 +51,10 @@ function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st:
|
|||
const ph = r ? phaseLabel(r) : null;
|
||||
const rolling = lane.at === STAGES.length - 1 && lane.share !== null;
|
||||
const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent';
|
||||
const d = useData();
|
||||
const menu = useContextMenu();
|
||||
return (
|
||||
<article className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
|
||||
<article {...menu(() => releaseMenu(d, app, lane.store, r?.version ?? version, r?.build))} className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
|
||||
<header className="rel-store-h">
|
||||
<span className="rel-mark"><StoreGlyph store={lane.store} /></span>
|
||||
<div className="rel-who">
|
||||
|
|
@ -168,6 +172,7 @@ function Train({ title, train, tf, tone, app, st }: { title: string; train: Rele
|
|||
|
||||
export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
||||
const d = useData();
|
||||
const menu = useContextMenu();
|
||||
const l = d.liveFor(app);
|
||||
const project = app.project;
|
||||
const hue = useTint(d.iconFor(app), app.name);
|
||||
|
|
@ -221,7 +226,10 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
<thead><tr><th>{t('col.version')}</th><th>App Store</th><th>{t('col.build')}</th><th>Google Play</th><th>{t('col.versionCode')}</th><th>{t('col.track')}</th></tr></thead>
|
||||
<tbody>
|
||||
{history.map(h => (
|
||||
<tr key={h.version}>
|
||||
<tr key={h.version} {...menu(() => [
|
||||
...(h.ios ? [{ label: 'App Store', icon: 'phone' as const, submenu: releaseMenu(d, app, 'ios', h.ios.version, h.ios.build) }] : []),
|
||||
...(h.android ? [{ label: 'Google Play', icon: 'phone' as const, submenu: releaseMenu(d, app, 'android', h.android.version, h.android.build) }] : []),
|
||||
])}>
|
||||
<td className="strong num rel-hv">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
|
||||
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
|
||||
<td className="num muted">{h.ios?.build ?? '–'}</td>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Reviews: the inbox of both stores with the star distribution and filters, and per review its local reply draft
|
||||
// (.storebound/replies) with the send checks. Approving a draft is a local, confirmed write; sending stays in the
|
||||
// terminal (`storebound reviews send`): replies are public.
|
||||
import { type MenuInput, useContextMenu } from '../../ui/ContextMenu';
|
||||
import { consoleItem, copyToast } from '../../ui/app-menu';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { DraftsResult, ReplyDraft } from '../../api';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
|
|
@ -91,6 +93,17 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
|
|||
const [f, setF] = useState<ReviewFilter>({ store: 'all', stars: [], unanswered: false, query: '' });
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
const w = useWrite(project?.id ?? null);
|
||||
const cm = useContextMenu();
|
||||
const reviewMenu = (r: Review, k: string, dr: ReplyDraft | undefined): MenuInput => [
|
||||
{ label: t('cm.openDraft'), icon: 'edit', onSelect: () => setSel(k) },
|
||||
!dr && !r.reply && project && { label: t('reviews.makeDrafts'), icon: 'text', onSelect: () => { void w.run({ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: () => drafts.refresh() }); } },
|
||||
'sep',
|
||||
{ label: t('cm.copyReview'), icon: 'copy', onSelect: () => copyToast(d, [r.title, r.body].filter(Boolean).join('\n\n')) },
|
||||
r.reply && { label: t('cm.copyReply'), icon: 'copy', onSelect: () => copyToast(d, r.reply!.text) },
|
||||
dr?.reply && { label: t('cm.copyDraft'), icon: 'copy', onSelect: () => copyToast(d, dr.reply) },
|
||||
'sep',
|
||||
consoleItem(d, app, r.store === 'apple' ? 'ios' : 'android'),
|
||||
];
|
||||
|
||||
const all = list.data ?? [];
|
||||
const stats = useMemo(() => starStats(f.store === 'all' ? all : all.filter(r => r.store === f.store)), [all, f.store]);
|
||||
|
|
@ -154,7 +167,7 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
|
|||
const dr = byFile.get(draftName(r));
|
||||
const k = `${r.store}:${r.id}`;
|
||||
return (
|
||||
<button key={k} type="button" role="option" aria-selected={cur === r} className="rv2-card inbox-item" onClick={() => setSel(k)}>
|
||||
<button key={k} type="button" role="option" aria-selected={cur === r} className="rv2-card inbox-item" onClick={() => setSel(k)} {...cm(() => reviewMenu(r, k, dr))}>
|
||||
<span className="rv2-c-top">
|
||||
<Stars n={r.rating} />
|
||||
<span className="rv2-c-date">{date(r.date)}</span>
|
||||
|
|
|
|||
252
apps/desktop/src/ui/ContextMenu.tsx
Normal file
252
apps/desktop/src/ui/ContextMenu.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// Context menus (design system): the browser's own menu never shows. ContextMenuProvider (main.tsx) prevents every
|
||||
// native `contextmenu`, opens a small Cut / Copy / Paste / Select all menu on text fields and a Copy menu on selected
|
||||
// text, and turns Shift+F10 / the Menu key into a context menu on the focused element. Screens attach menus with
|
||||
// `useContextMenu()`: `<div {...menu(() => [...entries])}>`. Keyboard: arrows, Home / End, Enter / Space, Esc,
|
||||
// ArrowRight / ArrowLeft for submenus, type-ahead. Logic (placement, moves, type-ahead) in menu-logic.ts.
|
||||
import { createContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { t } from '../i18n';
|
||||
import { copyText } from '../lib/crash';
|
||||
import { Icon } from './icons';
|
||||
import { MOD } from './kit';
|
||||
import { enabled, first, isEditable, isItem, last, type MenuEntry, type MenuInput, type MenuItem, move, placeAt, placeBeside, tidy, typeahead } from './menu-logic';
|
||||
|
||||
export type { MenuEntry, MenuInput, MenuItem } from './menu-logic';
|
||||
|
||||
interface Open { x: number; y: number; items: MenuEntry[]; from: Element | null; keyboard: boolean }
|
||||
type OpenFn = (x: number, y: number, input: MenuInput, opts?: { from?: Element | null; keyboard?: boolean }) => void;
|
||||
const Ctx = createContext<OpenFn | null>(null);
|
||||
|
||||
/** Marks a native event a menu handled, so the global fallback leaves it alone. */
|
||||
const HANDLED = '__sbMenu';
|
||||
/** Marks the contextmenu event Shift+F10 / the Menu key sent (the menu starts on its first item). */
|
||||
const BY_KEY = '__sbKey';
|
||||
|
||||
/** Props that attach a menu to an element: `<button {...menu(() => entries)}>`. Entries are built when it opens. */
|
||||
export function useContextMenu(): (build: () => MenuInput) => { onContextMenu: (e: ReactMouseEvent) => void } {
|
||||
const open = useContext(Ctx);
|
||||
return useCallback((build: () => MenuInput) => ({
|
||||
onContextMenu: (e: ReactMouseEvent) => {
|
||||
if ((e.nativeEvent as unknown as Record<string, unknown>)[HANDLED]) return;
|
||||
// a text field or a selection inside keeps the edit menu
|
||||
const target = e.target as HTMLElement;
|
||||
if (isEditable(target as HTMLInputElement) || (window.getSelection()?.toString() ?? '').trim()) return;
|
||||
e.preventDefault();
|
||||
(e.nativeEvent as unknown as Record<string, unknown>)[HANDLED] = true;
|
||||
if (!open) return;
|
||||
open(e.clientX, e.clientY, build(), { from: e.currentTarget as Element, keyboard: !!(e.nativeEvent as unknown as Record<string, unknown>)[BY_KEY] });
|
||||
},
|
||||
}), [open]);
|
||||
}
|
||||
|
||||
/** Copies and says so in a toast-less way (the menu closes; failures are silent like the browser's own Copy). */
|
||||
export const copyEntry = (label: string, text: string | null | undefined, icon: MenuItem['icon'] = 'copy'): MenuItem | null =>
|
||||
text ? { label, icon, onSelect: () => { void copyText(text); } } : null;
|
||||
|
||||
function editMenu(el: HTMLInputElement | HTMLTextAreaElement | HTMLElement): MenuEntry[] {
|
||||
const field = 'selectionStart' in el ? el as HTMLInputElement : null;
|
||||
const ro = !!field && (field.readOnly || field.disabled);
|
||||
const secret = field?.type === 'password';
|
||||
const start = field?.selectionStart ?? 0, end = field?.selectionEnd ?? 0;
|
||||
const sel = field ? (field.value ?? '').slice(start, end) : (window.getSelection()?.toString() ?? '');
|
||||
const refocus = () => { el.focus(); if (field && field.type !== 'number' && field.type !== 'email') field.setSelectionRange(start, end); };
|
||||
return tidy([
|
||||
{ label: t('cm.cut'), icon: 'edit', shortcut: `${MOD} X`, disabled: ro || secret || !sel, onSelect: () => { refocus(); void copyText(sel).then(() => { refocus(); document.execCommand('delete'); }); } },
|
||||
{ label: t('cm.copy'), icon: 'copy', shortcut: `${MOD} C`, disabled: secret || !sel, onSelect: () => { void copyText(sel); refocus(); } },
|
||||
{ label: t('cm.paste'), icon: 'clipboard', shortcut: `${MOD} V`, disabled: ro, onSelect: () => {
|
||||
refocus();
|
||||
navigator.clipboard?.readText().then(s => { refocus(); document.execCommand('insertText', false, s); }, () => {});
|
||||
} },
|
||||
'sep',
|
||||
{ label: t('cm.selectAll'), icon: 'grid', shortcut: `${MOD} A`, onSelect: () => { el.focus(); if (field) field.select(); else document.execCommand('selectAll'); } },
|
||||
]);
|
||||
}
|
||||
|
||||
export function ContextMenuProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<Open | null>(null);
|
||||
const open = useCallback<OpenFn>((x, y, input, opts) => {
|
||||
const items = tidy(input);
|
||||
if (!items.length) return;
|
||||
setState({ x, y, items, from: opts?.from ?? (document.activeElement as Element | null), keyboard: !!opts?.keyboard });
|
||||
}, []);
|
||||
const close = useCallback((restore: boolean) => {
|
||||
setState(s => {
|
||||
if (restore && s?.from instanceof HTMLElement && s.from.isConnected) s.from.focus({ preventScroll: true });
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// the fallback for everything no screen menu handled: never the browser's menu
|
||||
const onMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if ((e as unknown as Record<string, unknown>)[HANDLED]) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest?.('.ds-menu')) return;
|
||||
const editable = target && isEditable(target as HTMLInputElement) ? target : null;
|
||||
const keyboard = !!(e as unknown as Record<string, unknown>)[BY_KEY];
|
||||
if (editable) { open(e.clientX, e.clientY, editMenu(editable), { from: editable, keyboard }); return; }
|
||||
const sel = window.getSelection()?.toString() ?? '';
|
||||
if (sel.trim()) open(e.clientX, e.clientY, [{ label: t('cm.copy'), icon: 'copy', shortcut: `${MOD} C`, onSelect: () => { void copyText(sel); } }], { keyboard });
|
||||
};
|
||||
// Shift+F10 and the Menu key: a context menu on the focused element, at its corner
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu')) return;
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
if (!el || el === document.body || el.closest('.ds-menu')) return;
|
||||
e.preventDefault();
|
||||
const r = el.getBoundingClientRect();
|
||||
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: Math.round(r.left + Math.min(24, r.width / 2)), clientY: Math.round(r.bottom - 2), button: 2 });
|
||||
(ev as unknown as Record<string, unknown>)[BY_KEY] = true;
|
||||
el.dispatchEvent(ev);
|
||||
};
|
||||
document.addEventListener('contextmenu', onMenu);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => { document.removeEventListener('contextmenu', onMenu); document.removeEventListener('keydown', onKey, true); };
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={open}>
|
||||
{children}
|
||||
{state && createPortal(<MenuRoot key={`${state.x},${state.y}`} state={state} close={close} />, document.body)}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuRoot({ state, close }: { state: Open; close: (restore: boolean) => void }) {
|
||||
useEffect(() => {
|
||||
const outside = (e: Event) => { if (!(e.target as Element | null)?.closest?.('.ds-menu')) close(false); };
|
||||
const scroll = (e: Event) => { if (!(e.target instanceof Element && e.target.closest('.ds-menu'))) close(false); };
|
||||
const blur = () => close(false);
|
||||
document.addEventListener('pointerdown', outside, true);
|
||||
document.addEventListener('scroll', scroll, true);
|
||||
window.addEventListener('blur', blur);
|
||||
window.addEventListener('resize', blur);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', outside, true);
|
||||
document.removeEventListener('scroll', scroll, true);
|
||||
window.removeEventListener('blur', blur);
|
||||
window.removeEventListener('resize', blur);
|
||||
};
|
||||
}, [close]);
|
||||
return <MenuList items={state.items} at={{ x: state.x, y: state.y }} close={close} root keyboard={state.keyboard} />;
|
||||
}
|
||||
|
||||
type Anchor = { x: number; y: number } | { left: number; right: number; top: number };
|
||||
|
||||
function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEntry[]; at: Anchor; close: (restore: boolean) => void; root?: boolean; keyboard?: boolean; onBack?: () => void }) {
|
||||
const uid = useId();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [sub, setSub] = useState<{ index: number; anchor: Anchor; focus: boolean } | null>(null);
|
||||
const typed = useRef({ q: '', at: 0 });
|
||||
const hoverTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const box = { w: el.offsetWidth, h: el.offsetHeight }, view = { w: window.innerWidth, h: window.innerHeight };
|
||||
setPos('x' in at ? placeAt(at.x, at.y, box, view) : placeBeside(at, box, view));
|
||||
}, [at]);
|
||||
useEffect(() => {
|
||||
if (!pos) return;
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
// a submenu, or a menu opened from the keyboard, starts on its first item; a mouse-opened menu waits for a key
|
||||
if (!root || keyboard) setActive(first(items));
|
||||
}, [pos, root, keyboard, items]);
|
||||
useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
|
||||
|
||||
const itemEl = (i: number) => ref.current?.querySelector<HTMLElement>(`[data-i="${i}"]`) ?? null;
|
||||
const openSub = (i: number, focus: boolean) => {
|
||||
const el = itemEl(i);
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setSub({ index: i, anchor: { left: r.left, right: r.right, top: r.top }, focus });
|
||||
};
|
||||
const choose = (i: number) => {
|
||||
const e = items[i];
|
||||
if (!enabled(e)) return;
|
||||
if (e.submenu) { openSub(i, true); return; }
|
||||
close(true);
|
||||
// after the menu is gone, so the action sees the restored focus (paste, select all)
|
||||
window.setTimeout(() => e.onSelect?.(), 0);
|
||||
};
|
||||
|
||||
const onKey = (ev: ReactKeyboardEvent) => {
|
||||
if (sub?.focus) return; // the submenu has the keys
|
||||
const k = ev.key;
|
||||
const stop = () => { ev.preventDefault(); ev.stopPropagation(); };
|
||||
if (k === 'ArrowDown') { stop(); setActive(a => move(items, a, 1)); }
|
||||
else if (k === 'ArrowUp') { stop(); setActive(a => move(items, a, -1)); }
|
||||
else if (k === 'Home' || k === 'PageUp') { stop(); setActive(first(items)); }
|
||||
else if (k === 'End' || k === 'PageDown') { stop(); setActive(last(items)); }
|
||||
else if (k === 'Enter' || k === ' ') { stop(); if (active >= 0) choose(active); }
|
||||
else if (k === 'ArrowRight') { stop(); const e = items[active]; if (enabled(e) && e.submenu) openSub(active, true); }
|
||||
else if (k === 'ArrowLeft') { stop(); onBack?.(); }
|
||||
else if (k === 'Escape') { stop(); if (onBack) onBack(); else close(true); }
|
||||
else if (k === 'Tab') { stop(); close(true); }
|
||||
else if (k.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
||||
stop();
|
||||
const now = Date.now();
|
||||
const q = (now - typed.current.at > 700 ? '' : typed.current.q) + k;
|
||||
typed.current = { q, at: now };
|
||||
const i = typeahead(items, active, q);
|
||||
if (i >= 0) setActive(i);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (active >= 0) itemEl(active)?.scrollIntoView({ block: 'nearest' }); }, [active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className={`ds-menu${pos ? ' in' : ''}`} role="menu" tabIndex={-1} onKeyDown={onKey}
|
||||
style={pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999 }}
|
||||
aria-activedescendant={active >= 0 ? `${uid}-${active}` : undefined}
|
||||
onContextMenu={e => e.preventDefault()}>
|
||||
{items.map((e, i) => {
|
||||
if (!isItem(e)) return <div key={`s${i}`} className="ds-menu-sep" role="separator" />;
|
||||
const dis = !!e.disabled;
|
||||
const checkable = e.checked !== undefined;
|
||||
return (
|
||||
<div key={i} id={`${uid}-${i}`} data-i={i} role={checkable ? 'menuitemcheckbox' : 'menuitem'}
|
||||
aria-checked={checkable ? e.checked : undefined} aria-disabled={dis || undefined}
|
||||
aria-haspopup={e.submenu ? 'menu' : undefined} aria-expanded={e.submenu ? sub?.index === i : undefined}
|
||||
title={typeof e.disabled === 'string' ? e.disabled : undefined}
|
||||
className={`ds-menu-item${i === active ? ' active' : ''}${e.danger ? ' danger' : ''}${dis ? ' disabled' : ''}`}
|
||||
onPointerMove={() => {
|
||||
if (active !== i) setActive(dis ? -1 : i);
|
||||
window.clearTimeout(hoverTimer.current);
|
||||
if (!dis && e.submenu) { if (sub?.index !== i) hoverTimer.current = window.setTimeout(() => openSub(i, false), 120); }
|
||||
else if (sub) hoverTimer.current = window.setTimeout(() => setSub(null), 200);
|
||||
}}
|
||||
onClick={() => choose(i)}>
|
||||
<span className="ds-menu-ic">{checkable ? (e.checked ? <Icon name="check" /> : null) : e.icon ? <Icon name={e.icon} /> : null}</span>
|
||||
<span className="ds-menu-l">{e.label}</span>
|
||||
{e.shortcut && <span className="ds-menu-k">{e.shortcut}</span>}
|
||||
{e.submenu && <Icon name="chevronRight" className="ds-menu-sub" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sub && isItem(items[sub.index]) && (items[sub.index] as MenuItem).submenu && (
|
||||
<MenuList key={sub.index} items={(items[sub.index] as MenuItem).submenu as MenuEntry[]} at={sub.anchor} close={close}
|
||||
onBack={() => { setSub(null); ref.current?.focus({ preventScroll: true }); }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/** A text field's menu: Cut / Copy / Paste / Select all plus the field's own entries (copy value, reset). */
|
||||
export function useFieldMenu(): (extras: () => MenuInput) => { onContextMenu: (e: ReactMouseEvent) => void } {
|
||||
const open = useContext(Ctx);
|
||||
return useCallback((extras: () => MenuInput) => ({
|
||||
onContextMenu: (e: ReactMouseEvent) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
e.preventDefault();
|
||||
(e.nativeEvent as unknown as Record<string, unknown>)[HANDLED] = true;
|
||||
open?.(e.clientX, e.clientY, [...editMenu(el), 'sep', ...extras()], { from: el, keyboard: !!(e.nativeEvent as unknown as Record<string, unknown>)[BY_KEY] });
|
||||
},
|
||||
}), [open]);
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
// - ApplyPanel: a store write's dry run in the window (old -> new per store and language), then Apply (native
|
||||
// dialog, the real run)
|
||||
// - GitNote: the edited files are uncommitted in git (storebound never commits)
|
||||
import { useFieldMenu } from './ContextMenu';
|
||||
import { copyText } from '../lib/crash';
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { api, type EditableResult, type EditResult, type Project, type ToolResult } from '../api';
|
||||
import { t } from '../i18n';
|
||||
|
|
@ -65,13 +67,18 @@ export function TextField({ project, area, op, saved, label, limit, multiline, r
|
|||
saved: string; limit?: number; multiline?: boolean; rows?: number; placeholder?: string; mono?: boolean; kind?: 'url' | 'email' | 'text';
|
||||
}) {
|
||||
const e = useEdits();
|
||||
const fm = useFieldMenu();
|
||||
const make = (v: string): EditOp => (op.kind === 'config' ? (v === '' ? { kind: 'config', path: op.path, remove: true } : { kind: 'config', path: op.path, value: v }) : { ...op, value: v } as EditOp);
|
||||
const { value, dirty } = useValue<string>(project, make(saved), saved);
|
||||
const fid = id ?? `ed-${keyOf(make('')).replace(/[^A-Za-z0-9_-]/g, '-')}`;
|
||||
const n = chars(value ?? '');
|
||||
const set = (v: string) => e.set(project.id, make(v), area, saved);
|
||||
const common = { id: fid, className: `input${mono ? ' mono' : ''}`, value: value ?? '', placeholder, disabled, 'aria-invalid': issues.some(i => i.level === 'error') || undefined,
|
||||
onChange: (ev: { target: { value: string } }) => set(ev.target.value), 'data-edit': keyOf(make('')) };
|
||||
onChange: (ev: { target: { value: string } }) => set(ev.target.value), 'data-edit': keyOf(make('')),
|
||||
...fm(() => [
|
||||
{ label: t('cm.copyValue'), icon: 'copy', disabled: !value, onSelect: () => { void copyText(value ?? ''); } },
|
||||
{ label: t('ed.reset'), icon: 'rotate', disabled: dirty ? false : t('cm.notChanged'), onSelect: () => set(saved) },
|
||||
]) };
|
||||
return (
|
||||
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
|
||||
<div className="ed-head">
|
||||
|
|
|
|||
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) },
|
||||
];
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// the portfolio home as reusable parts. PageHeader, Hero (app hero with screenshots, two-store pipeline, next step),
|
||||
// Section, StatTile, Pipeline, StatusPill, EmptyState, Tabs, Toolbar, Dialog, plus the per-app tint (useTint / tintStyle)
|
||||
// and the rendered-screenshot loader (useShots) the cards and the hero share.
|
||||
import { type CSSProperties, type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react';
|
||||
import { type CSSProperties, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react';
|
||||
import { api, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { share } from '../lib/format';
|
||||
|
|
@ -179,14 +179,16 @@ export function PageHeader({ title, subtitle, eyebrow, actions, icon }: { title:
|
|||
}
|
||||
|
||||
/** App hero: screenshots strip, big icon, name, ids, store links, the two-store pipeline and the next step. */
|
||||
export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children }: {
|
||||
export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children, onContextMenu }: {
|
||||
name: string; icon: string | null; projectId: string | null; ids?: ReactNode; badges?: ReactNode; links?: ReactNode;
|
||||
/** The app's context menu (useContextMenu props). */
|
||||
onContextMenu?: (e: ReactMouseEvent) => void;
|
||||
all: StoreRelease[]; ios: boolean; android: boolean; cta?: Cta | null; onCta?: (tab: AppTab) => void; next?: string | null; children?: ReactNode;
|
||||
}) {
|
||||
const hue = useTint(icon, name);
|
||||
const shots = useShots(projectId, 4);
|
||||
return (
|
||||
<section className="ds-hero" style={tintStyle(hue)} aria-label={name}>
|
||||
<section className="ds-hero" style={tintStyle(hue)} aria-label={name} onContextMenu={onContextMenu}>
|
||||
<div className="ds-hero-main">
|
||||
<div className="ds-hero-id">
|
||||
<AppIcon name={name} src={icon} className="ds-hero-icon" />
|
||||
|
|
|
|||
98
apps/desktop/src/ui/menu-logic.ts
Normal file
98
apps/desktop/src/ui/menu-logic.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Context menu logic without React or the DOM (tested in tests/context-menu.test.ts): the entry model, tidying,
|
||||
// keyboard moves, type-ahead and placement inside the viewport.
|
||||
import type { IconName } from './icons';
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
/** Right-aligned hint ("Ctrl C"). Only a hint: the menu does not bind it. */
|
||||
shortcut?: string;
|
||||
onSelect?: () => void;
|
||||
/** true, or the reason shown as a tooltip. */
|
||||
disabled?: boolean | string;
|
||||
danger?: boolean;
|
||||
/** A check item: true shows the checkmark (role menuitemcheckbox). */
|
||||
checked?: boolean;
|
||||
/** Entries of a submenu (falsy ones dropped, like the top level). */
|
||||
submenu?: MenuInput;
|
||||
}
|
||||
export type MenuEntry = MenuItem | 'sep';
|
||||
/** What callers pass: falsy entries are dropped, so `cond && {...}` works. */
|
||||
export type MenuInput = (MenuEntry | false | null | undefined | '' | 0)[];
|
||||
|
||||
export const isItem = (e: MenuEntry | undefined): e is MenuItem => !!e && e !== 'sep';
|
||||
export const enabled = (e: MenuEntry | undefined): e is MenuItem => isItem(e) && !e.disabled;
|
||||
|
||||
/** Drops falsy entries, empty submenus, and leading, trailing and doubled separators. */
|
||||
export function tidy(input: MenuInput): MenuEntry[] {
|
||||
const out: MenuEntry[] = [];
|
||||
for (const e of input) {
|
||||
if (!e) continue;
|
||||
if (e === 'sep') { if (out.length && out[out.length - 1] !== 'sep') out.push('sep'); continue; }
|
||||
if (e.submenu) {
|
||||
const sub = tidy(e.submenu);
|
||||
if (!sub.length) continue;
|
||||
out.push({ ...e, submenu: sub });
|
||||
} else out.push(e);
|
||||
}
|
||||
while (out[out.length - 1] === 'sep') out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Next enabled item from `from` in direction `dir` (wraps); -1 when none. `from` -1 starts before the first. */
|
||||
export function move(items: MenuEntry[], from: number, dir: 1 | -1): number {
|
||||
const n = items.length;
|
||||
if (!n) return -1;
|
||||
let i = from;
|
||||
for (let k = 0; k < n; k++) {
|
||||
i = i < 0 ? (dir > 0 ? 0 : n - 1) : (i + dir + n) % n;
|
||||
if (enabled(items[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
export const first = (items: MenuEntry[]): number => move(items, -1, 1);
|
||||
export const last = (items: MenuEntry[]): number => { const n = move(items, -1, -1); return n; };
|
||||
|
||||
/** Type-ahead: the next enabled item after `from` whose label starts with `query` (case-insensitive); -1 when none. */
|
||||
export function typeahead(items: MenuEntry[], from: number, query: string): number {
|
||||
const q = query.toLocaleLowerCase();
|
||||
if (!q) return -1;
|
||||
const n = items.length;
|
||||
// a repeated single letter cycles; a longer query may stay on the current item
|
||||
const start = q.length > 1 ? Math.max(from, 0) : from + 1;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const i = (start + k + n) % n;
|
||||
const e = items[i];
|
||||
if (enabled(e) && e.label.toLocaleLowerCase().startsWith(q)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface Box { w: number; h: number }
|
||||
export interface View { w: number; h: number }
|
||||
const M = 8;
|
||||
|
||||
/** Top-left of a menu opened at the cursor: right/below it, flipped left/up when it would leave the viewport. */
|
||||
export function placeAt(x: number, y: number, box: Box, view: View): { left: number; top: number } {
|
||||
let left = x + box.w + M > view.w ? x - box.w : x;
|
||||
let top = y + box.h + M > view.h ? y - box.h : y;
|
||||
left = Math.min(Math.max(M, left), Math.max(M, view.w - box.w - M));
|
||||
top = Math.min(Math.max(M, top), Math.max(M, view.h - box.h - M));
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
/** Top-left of a submenu beside its parent item: right of it, else left; aligned with the item, kept inside. */
|
||||
export function placeBeside(item: { left: number; right: number; top: number }, box: Box, view: View): { left: number; top: number } {
|
||||
const left = item.right - 4 + box.w + M <= view.w ? item.right - 4 : Math.max(M, item.left + 4 - box.w);
|
||||
const top = Math.min(Math.max(M, item.top - 5), Math.max(M, view.h - box.h - M));
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
/** Elements that get the Cut / Copy / Paste menu. */
|
||||
export function isEditable(el: { tagName?: string; type?: string; isContentEditable?: boolean; readOnly?: boolean; disabled?: boolean } | null): boolean {
|
||||
if (!el) return false;
|
||||
if (el.isContentEditable) return true;
|
||||
if (el.tagName === 'TEXTAREA') return true;
|
||||
if (el.tagName !== 'INPUT') return false;
|
||||
return ['text', 'search', 'url', 'email', 'tel', 'number', 'password', ''].includes((el.type ?? '').toLowerCase());
|
||||
}
|
||||
93
apps/desktop/tests/context-menu.test.ts
Normal file
93
apps/desktop/tests/context-menu.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Context menus: the entry model, keyboard moves, type-ahead and placement (ui/menu-logic.ts), and the rules that keep
|
||||
// the browser's own menu out of the window (ui/ContextMenu.tsx, main.tsx, the Windows webview settings).
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { first, isEditable, last, type MenuEntry, move, placeAt, placeBeside, tidy, typeahead } from '../src/ui/menu-logic';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const src = (f: string) => readFileSync(join(ROOT, f), 'utf8');
|
||||
const item = (label: string, extra: object = {}) => ({ label, ...extra });
|
||||
|
||||
describe('menu entries', () => {
|
||||
test('tidy drops falsy entries, empty submenus and stray separators', () => {
|
||||
const out = tidy(['sep', item('Open'), false, null, 'sep', 'sep', item('More', { submenu: [false, 'sep'] }), item('Copy'), 'sep', undefined, '']);
|
||||
expect(out.map(e => (e === 'sep' ? '-' : e.label))).toEqual(['Open', '-', 'Copy']);
|
||||
});
|
||||
test('tidy keeps a submenu with entries, tidied', () => {
|
||||
const [m] = tidy([item('Copy', { submenu: ['sep', item('Name'), null, 'sep'] })]);
|
||||
expect(m !== 'sep' && (m!.submenu as MenuEntry[]).map(e => (e === 'sep' ? '-' : e.label))).toEqual(['Name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard', () => {
|
||||
const items: MenuEntry[] = [item('Open'), 'sep', item('Copy', { disabled: 'nothing selected' }), item('Cut'), item('Paste'), item('Remove', { danger: true })];
|
||||
test('arrows skip separators and disabled items and wrap', () => {
|
||||
expect(first(items)).toBe(0);
|
||||
expect(last(items)).toBe(5);
|
||||
expect(move(items, 0, 1)).toBe(3);
|
||||
expect(move(items, 3, -1)).toBe(0);
|
||||
expect(move(items, 5, 1)).toBe(0);
|
||||
expect(move(items, 0, -1)).toBe(5);
|
||||
expect(move([], -1, 1)).toBe(-1);
|
||||
expect(move(['sep', item('x', { disabled: true })], -1, 1)).toBe(-1);
|
||||
});
|
||||
test('type-ahead finds the next enabled match; a repeated letter cycles', () => {
|
||||
expect(typeahead(items, -1, 'p')).toBe(4);
|
||||
expect(typeahead(items, -1, 'c')).toBe(3); // Copy is disabled: Cut
|
||||
expect(typeahead(items, 3, 'c')).toBe(3);
|
||||
expect(typeahead(items, 0, 'o')).toBe(0);
|
||||
expect(typeahead(items, 3, 'cu')).toBe(3);
|
||||
expect(typeahead(items, 0, 'zz')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placement', () => {
|
||||
const view = { w: 1000, h: 700 }, box = { w: 220, h: 300 };
|
||||
test('opens right of and below the cursor', () => expect(placeAt(100, 100, box, view)).toEqual({ left: 100, top: 100 }));
|
||||
test('flips left and up at the edges, and stays inside', () => {
|
||||
expect(placeAt(900, 600, box, view)).toEqual({ left: 680, top: 300 });
|
||||
expect(placeAt(5, 5, { w: 2000, h: 2000 }, view)).toEqual({ left: 8, top: 8 });
|
||||
});
|
||||
test('submenus open beside the item, or on the other side near the edge', () => {
|
||||
expect(placeBeside({ left: 100, right: 320, top: 50 }, box, view)).toEqual({ left: 316, top: 45 });
|
||||
expect(placeBeside({ left: 700, right: 920, top: 600 }, box, view)).toEqual({ left: 484, top: 392 });
|
||||
});
|
||||
});
|
||||
|
||||
test('text fields get the edit menu', () => {
|
||||
expect(isEditable({ tagName: 'INPUT', type: 'text' })).toBe(true);
|
||||
expect(isEditable({ tagName: 'INPUT', type: 'checkbox' })).toBe(false);
|
||||
expect(isEditable({ tagName: 'TEXTAREA' })).toBe(true);
|
||||
expect(isEditable({ tagName: 'DIV', isContentEditable: true })).toBe(true);
|
||||
expect(isEditable({ tagName: 'BUTTON' })).toBe(false);
|
||||
expect(isEditable(null)).toBe(false);
|
||||
});
|
||||
|
||||
describe('no browser menu', () => {
|
||||
test('the provider prevents every native contextmenu and handles Shift+F10 / the Menu key', () => {
|
||||
const s = src('src/ui/ContextMenu.tsx');
|
||||
expect(s).toMatch(/document\.addEventListener\('contextmenu', onMenu\)/);
|
||||
// the fallback handler prevents the default before anything else
|
||||
expect(s).toMatch(/const onMenu = \(e: MouseEvent\) => \{\s*e\.preventDefault\(\);/);
|
||||
expect(s).toMatch(/e\.key === 'F10' && e\.shiftKey/);
|
||||
expect(s).toMatch(/e\.key === 'ContextMenu'/);
|
||||
expect(s).toMatch(/role="menu"/);
|
||||
expect(s).toMatch(/menuitemcheckbox/);
|
||||
});
|
||||
test('main.tsx wraps the app in the provider', () => {
|
||||
expect(src('src/main.tsx')).toMatch(/<ContextMenuProvider><App /);
|
||||
});
|
||||
test('release builds on Windows turn off the WebView2 menu and browser accelerator keys', () => {
|
||||
const rs = src('src-tauri/src/lib.rs');
|
||||
expect(rs).toMatch(/#\[cfg\(all\(windows, not\(debug_assertions\)\)\)\]\s*fn harden_webview/);
|
||||
expect(rs).toMatch(/SetAreDefaultContextMenusEnabled\(false\)/);
|
||||
expect(rs).toMatch(/SetAreBrowserAcceleratorKeysEnabled\(false\)/);
|
||||
});
|
||||
test('menus use the catalog in every language', () => {
|
||||
for (const k of ['cm.cut', 'cm.copy', 'cm.paste', 'cm.selectAll']) {
|
||||
expect(src('src/i18n/en.ts')).toContain(`'${k}'`);
|
||||
expect(src('src/i18n/tr.ts')).toContain(`'${k}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue