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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue