The tray's right-click menu is drawn by the app: one tree from tray.rs (stable submenu keys), one frameless always-on-top menu-N window per panel rendered by tray-menu.html with the design system, placed inside the monitor's work area. A pure panel book keeps transient and pinned panels apart; pinned panels (pin button, Ctrl+click, middle click) are draggable, step aside from each other and are saved in state.json (menuPins) and restored at start. tray_update pushes the tree to the open panels, which update in place; closed panels are destroyed. The native menu, rendered from the same tree, stays as the fallback and on Linux. Only menu-* windows get the menu_* commands (menu.json). In-app context menus get the same behaviour through a pure menu stack (ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels survive the next menu, are draggable and re-read their entries from live sources so data changes update them in place. Also drops a stray diff3 marker line from CHANGELOG.md.
184 lines
12 KiB
TypeScript
184 lines
12 KiB
TypeScript
// One app across both stores: header (icon, studio, ids, store state, pairing), then five sections by job (Release,
|
|
// Store page, Revenue, Players, Health; docs IA), each with a header (what it is for, the one next step) and its sub views. 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, useMenuButton } from '../ui/ContextMenu';
|
|
import { appMenu, playUrlFor } from '../ui/app-menu';
|
|
import { useShowIgnored } from '../lib/show-ignored';
|
|
import { useState } from 'react';
|
|
import { GamesTab } from './tabs/Games';
|
|
import { SectionHead } from './SectionHead';
|
|
import { api, errorText } from '../api';
|
|
import { type Key, t } from '../i18n';
|
|
import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity';
|
|
import { attention, storeNow } from '../lib/model';
|
|
import { Hero, nextStep, Tabs } from '../ui/ds';
|
|
import { isGameSummary } from '../lib/model';
|
|
import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, SECTION_TABS, sectionOf, sectionsFor, subsOf, useNav } from '../nav';
|
|
import { useData } from '../store';
|
|
import { Icon } from '../ui/icons';
|
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
|
import { FolderMissing } from '../ui/FolderMissing';
|
|
import { Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
|
|
import { AppInfoTab } from './tabs/AppInfo';
|
|
import { ArtTab } from './tabs/Art';
|
|
import { LiveArt } from './tabs/LiveArt';
|
|
import { CatalogTab } from './tabs/Catalog';
|
|
import { ChecksTab } from './tabs/Checks';
|
|
import { HealthTab } from './tabs/Health';
|
|
import { GrowthTab } from './tabs/Growth';
|
|
import { ListingsTab } from './tabs/Listings';
|
|
import { PricesTab } from './tabs/Prices';
|
|
import { ReleasesTab } from './tabs/Releases';
|
|
import { ReviewsTab } from './tabs/Reviews';
|
|
import { TestFlightTab } from './tabs/TestFlight';
|
|
import { ToolsTab } from './tabs/Tools';
|
|
import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor';
|
|
import { StoreListingEditor } from './editors/StoreListingEditor';
|
|
import { useEdits } from '../ui/Editor';
|
|
|
|
export const ascUrl = (appId: string | null | undefined): string => (appId ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(appId)}/distribution` : 'https://appstoreconnect.apple.com/apps');
|
|
|
|
const PAIRING: Record<UnifiedApp['pairing'], Key> = { folder: 'pair.folder', manual: 'pair.manual', id: 'pair.id', close: 'pair.close', name: 'pair.name', single: 'pair.single' };
|
|
|
|
/** Pairing, studio and ignore controls of one app. */
|
|
function AppControls({ app }: { app: UnifiedApp }) {
|
|
const d = useData();
|
|
const nav = useNav();
|
|
const cands = pairCandidates(app, d.apps);
|
|
const more = useMenuButton();
|
|
const [showIgnored] = useShowIgnored();
|
|
return (
|
|
<div className="app-controls">
|
|
<span className="small muted row" style={{ gap: 6 }}><Icon name="link" className="ic13" />{t(PAIRING[app.pairing])}</span>
|
|
<label className="row small" style={{ gap: 6 }}><span className="muted">{t('studio.studio')}</span>
|
|
<select className="input input-sm" value={app.studio ?? ''} onChange={e => d.updateOverrides(o => withStudio(o, app, e.target.value || null))} aria-label={t('app.moveStudio')}>
|
|
<option value="">{t('studio.none')}</option>
|
|
{d.studios.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select></label>
|
|
{cands.length > 0 && (
|
|
<label className="row small" style={{ gap: 6 }}><span className="muted">{t('app.pairWith')}</span>
|
|
<select className="input input-sm" value="" onChange={e => {
|
|
const other = cands.find(c => c.key === e.target.value);
|
|
if (!other) return;
|
|
const ios = (app.ios ?? other.ios)!.key, android = (app.android ?? other.android)!.key;
|
|
d.updateOverrides(o => withPair(o, ios, android)).then(() => nav.go({ screen: 'portfolio' }));
|
|
}} aria-label={t('app.pairWith')}>
|
|
<option value="">{t('app.pick')}</option>
|
|
{cands.slice(0, 30).map(c => <option key={c.key} value={c.key}>{c.name} · {(c.ios ?? c.android)!.app.bundleId}</option>)}
|
|
</select></label>
|
|
)}
|
|
<span className="grow" />
|
|
<button className="btn btn-sm btn-ghost" {...more(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" />{app.ignored || app.ignoredStores.length ? t('nav.ignored') : null}</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StoreOnlyBanner({ app }: { app: UnifiedApp }) {
|
|
const d = useData();
|
|
const [busy, setBusy] = useState(false);
|
|
const [profile, setProfile] = useState<'app' | 'game' | 'subscription-app'>('app');
|
|
const link = async (how: 'add' | 'init' | 'fastlane') => {
|
|
setBusy(true);
|
|
try {
|
|
const p = how === 'init' ? await api.initProject(profile) : how === 'fastlane' ? await api.importFastlane(profile) : await api.addProject();
|
|
if (p) {
|
|
await d.updateOverrides(o => withLink(o, app, p.id));
|
|
await d.reload();
|
|
d.toast({ kind: 'ok', title: t('app.linked', { name: p.name }), detail: how === 'init' ? t('folders.startedDetail') : how === 'fastlane' ? t('folders.importedDetail') : undefined });
|
|
}
|
|
} catch (e) { d.toast({ kind: 'bad', title: t('app.linkFailed'), detail: errorText(e) }); } finally { setBusy(false); }
|
|
};
|
|
return (
|
|
<Notice tone="info" icon="cloud" action={<div className="actions">
|
|
<button className="btn btn-sm btn-primary" onClick={() => link('add')} disabled={busy}><Icon name="link" />{t('app.linkFolder')}</button>
|
|
<select className="input input-sm" value={profile} onChange={e => setProfile(e.target.value as typeof profile)} aria-label={t('folders.profile')}>
|
|
<option value="app">{t('profile.app')}</option><option value="game">{t('profile.game')}</option><option value="subscription-app">{t('profile.subscription-app')}</option>
|
|
</select>
|
|
<button className="btn btn-sm" onClick={() => link('init')} disabled={busy}><Icon name="folderPlus" />{t('app.initHere')}</button>
|
|
<button className="btn btn-sm" onClick={() => link('fastlane')} disabled={busy} title={t('folders.fastlaneHint')}><Icon name="download" />{t('folders.fastlane')}</button>
|
|
</div>}>
|
|
<b>{t('app.storeOnlyTitle')}</b> {t('app.storeOnlyText')}
|
|
</Notice>
|
|
);
|
|
}
|
|
|
|
export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|
const d = useData();
|
|
const nav = useNav();
|
|
const e = d.statusFor(app);
|
|
const l = d.liveFor(app);
|
|
const ios = e?.app?.platforms.ios ?? (app.ios ? { bundleId: app.ios.app.bundleId, ascAppId: app.ios.app.id } : null);
|
|
const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null);
|
|
const name = (app.project && e?.app?.name) || app.name;
|
|
const game = isGameSummary(e?.summary);
|
|
const sections = sectionsFor(!!app.project, game);
|
|
const section = sectionOf(tab);
|
|
const ed = useEdits();
|
|
useUnsavedGuard();
|
|
const studio = d.studios.find(s => s.id === app.studio) ?? null;
|
|
const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x });
|
|
const setSection = (x: AppSection) => setTab(subsOf(x, !!app.project, game)[0] ?? SECTION_TABS[x][0]!);
|
|
const editCount = (x: AppTab) => (EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0);
|
|
const all = l?.release.all ?? [];
|
|
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 }), `app:${app.key}`)}
|
|
badges={<>
|
|
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
|
|
{!app.project && <StoreOnlyTag />}
|
|
<button className="studio-pill" onClick={() => nav.go({ screen: 'accounts', section: app.studio ?? 'none' })} title={t('app.studioHint')}><StudioMark studio={studio} size="sm" />{studio?.name ?? t('studio.none')}</button>
|
|
</>}
|
|
ids={<>
|
|
<StoreBadge store="ios" id={ios?.bundleId} off={!ios && (!!e || !app.project)}>{iosNow && <><span className="num strong">{iosNow.version}</span><PhaseChip r={iosNow} /></>}</StoreBadge>
|
|
<StoreBadge store="android" id={android?.packageName} off={!android && (!!e || !app.project)}>{androidNow && <><span className="num strong">{androidNow.version}</span><PhaseChip r={androidNow} /></>}</StoreBadge>
|
|
</>}
|
|
links={(ios || android) && <>
|
|
{ios && <button className="btn btn-sm btn-ghost" onClick={() => open(ascUrl(ios.ascAppId))} title={t('app.consoleHint')}><Icon name="external" />App Store Connect</button>}
|
|
{android && <button className="btn btn-sm btn-ghost" onClick={() => open(playUrlFor(d, app))} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
|
</>}
|
|
all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null}
|
|
compact={section !== 'release' || tab !== 'releases'}
|
|
cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} />
|
|
<AppControls app={app} />
|
|
{!app.project && <StoreOnlyBanner app={app} />}
|
|
{app.project?.storeboundPin && <Notice tone="info" icon="info">{t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}</Notice>}
|
|
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
|
|
<div className="ds-app-sticky">
|
|
<Tabs label={t('app.sections', { name })} value={section} onChange={setSection} items={sections.map((x, i) => {
|
|
const n = subsOf(x, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0);
|
|
return { id: x, label: t(SECTION_KEY[x]), icon: SECTION_ICON[x], hint: String(i + 1), count: att.filter(a => a.tab && sectionOf(a.tab) === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined };
|
|
})} />
|
|
</div>
|
|
<SectionHead app={app} section={section} tab={tab} subs={subsOf(section, !!app.project, game)} att={att} all={all} next={l?.release.next?.version ?? null} unsaved={subsOf(section, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0)} editCount={editCount} onTab={setTab} />
|
|
<div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${section}`} className="stack" style={{ gap: 16 }}>
|
|
<ErrorBoundary key={tab} where={`tab ${tab}`}>
|
|
{tab === 'releases' && <ReleasesTab app={app} />}
|
|
{tab === 'catalog' && app.project && <CatalogTab project={app.project} />}
|
|
{tab === 'prices' && app.project && <PricesTab project={app.project} />}
|
|
{tab === 'listings' && app.project && <><TabEditor tab={tab} project={app.project} /><ListingsTab project={app.project} /></>}
|
|
{tab === 'listings' && !app.project && <StoreListingEditor app={app} />}
|
|
{tab === 'art' && app.project && <ArtTab project={app.project} />}
|
|
{tab === 'art' && !app.project && <LiveArt target={app.target} />}
|
|
{tab === 'health' && <HealthTab app={app} />}
|
|
{tab === 'reviews' && <ReviewsTab app={app} />}
|
|
{tab === 'games' && app.project && <GamesTab project={app.project} />}
|
|
{tab === 'testflight' && <TestFlightTab app={app} />}
|
|
{tab === 'growth' && <GrowthTab app={app} />}
|
|
{tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />}
|
|
{tab === 'checks' && app.project && <ChecksTab project={app.project} />}
|
|
{tab === 'tools' && app.project && <ToolsTab project={app.project} />}
|
|
{tab !== 'listings' && app.project && !app.project.missing && EDIT_TABS[tab] && <TabEditor tab={tab} project={app.project} />}
|
|
</ErrorBoundary>
|
|
</div>
|
|
</>}
|
|
</div>
|
|
);
|
|
}
|