desktop: five sections by job (Release, Store page, Revenue, Players, Health), Game services view, sidebar by attention

- app view: 12 tabs become 5 sections, each with a header (what it is for, the one next step from state
  already read) and its sub views in a segmented control; 1-5 jump, Ctrl+Shift+[ ] cycle, Ctrl+K and the
  context menu list sections and sub views; old tab ids, section and section/sub links all resolve
- Players > Game services (games only): both stores, one row per leaderboard / achievement with its
  languages and each store's state, Game Center locale map, Play API-disabled state with the console plan
- games status --json: items, localeMap, playApiDisabled; inspect summary: games
- sidebar: apps grouped by attention / in progress / live / not on a store, one status line each, studio
  filter, workspace as a footer icon row
- boot check opens places like a person (section, then sub view)
This commit is contained in:
CRTkafa 2026-10-24 13:01:50 +08:00
parent 98cd1d3c92
commit 0ef27cf2e4
22 changed files with 918 additions and 115 deletions

View file

@ -1,4 +1,5 @@
// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh),
// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line
// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh),
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
import { DryRunPreview } from './ui/DryRunPreview';
import { useCallback, useEffect, useMemo, useState } from 'react';
@ -6,8 +7,8 @@ import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api';
import { devSwitch } from './dev/switches';
import { type Key, t } from './i18n';
import { ago, num } from './lib/format';
import { groupByStudio, type UnifiedApp } from './lib/identity';
import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
import type { UnifiedApp } from './lib/identity';
import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
import { usePrefs } from './prefs-ctx';
import { Accounts } from './screens/Accounts';
import { AppFolders } from './screens/AppFolders';
@ -28,16 +29,18 @@ import { Toasts } from './ui/Toasts';
import { useContextMenu } from './ui/ContextMenu';
import { appMenu } from './ui/app-menu';
import { isEditable, isItem, type MenuInput } from './ui/menu-logic';
import { worstTone } from './ui/ds';
import { attention } from './lib/model';
import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model';
const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' };
export const WS_KEY: Record<WorkspaceScreen, Key> = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' };
const SIDE_KEY = 'storebound.side.v1';
const STUDIO_KEY = 'storebound.side.studio.v1';
function loadCollapsed(): string[] {
try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; }
}
function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } }
const GROUP_KEY: Record<SideGroup | 'ignored', Key> = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' };
export function App({ onTheme }: { onTheme: () => void }) {
const d = useData();
@ -47,6 +50,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
const [welcome, setWelcome] = useState(false);
const [started, setStarted] = useState(false);
const [collapsed, setCollapsed] = useState<string[]>(loadCollapsed);
const [studioFilter, setStudioFilterState] = useState<string | null>(loadStudio);
const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } };
const exports = useExportItems();
const runExport = useRunExport();
@ -56,16 +61,27 @@ export function App({ onTheme }: { onTheme: () => void }) {
const allReads = useReadState({ tags: ['all'] });
const [showIgnored, setShowIgnored] = useShowIgnored();
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
const listed = showIgnored ? [...visible, ...ignoredApps] : visible;
const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }];
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it)
const sideOrder = groups.flatMap(x => x.rows.map(r => r.app));
const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]);
const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]);
// the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each
const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null;
const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]);
const sideRows = useMemo(() => visible.filter(inStudio).map(a => {
const e = d.statusFor(a), l = d.liveFor(a);
return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) };
}), [visible, d, inStudio]);
const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [
...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length),
...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []),
];
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it)
const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app)));
const stepApp = useCallback((dir: 1 | -1) => {
if (!sideOrder.length) return;
const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1;
const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!;
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' });
}, [sideOrder, route, go]);
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' });
}, [sideOrder, route, go, tabsOf]);
const toggleGroup = (id: string) => setCollapsed(c => {
const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id];
try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ }
@ -94,9 +110,12 @@ export function App({ onTheme }: { onTheme: () => void }) {
}, [go, d.reload]);
const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined;
const tabs = app ? tabsFor(!!app.project) : [];
const game = app ? gameOf(app) : false;
const tabs = app ? tabsOf(app) : [];
const sections = app ? sectionsFor(!!app.project, game) : [];
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections,
// Cmd/Ctrl+Shift+[ ] the previous / next section
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey;
@ -109,15 +128,20 @@ export function App({ onTheme }: { onTheme: () => void }) {
go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! });
return;
}
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) {
if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) {
const sec = sections[Number(e.key) - 1];
if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); }
return;
}
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) {
e.preventDefault();
const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1);
go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! });
const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1);
go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! });
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [palette, welcome, route, go, tabs, stepApp]);
}, [palette, welcome, route, go, app, sections, game, stepApp]);
const closeWelcome = useCallback(() => {
setWelcome(false);
@ -131,13 +155,18 @@ export function App({ onTheme }: { onTheme: () => void }) {
const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') };
const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name;
for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) },
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) });
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) });
c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) });
c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) });
c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) });
WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) }));
for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) });
if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
// every section of the open app (1-5) and every sub view in it
if (app) sections.forEach((sec, i) => {
const subs = subsOf(sec, !!app.project, game);
c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) });
if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
});
// the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is
// also one Ctrl+K away; opening a tab is already above
if (app) {
@ -166,10 +195,10 @@ export function App({ onTheme }: { onTheme: () => void }) {
c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme });
c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) });
return c;
}, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update]);
}, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]);
const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }]
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])]
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
const lastRead = d.live?.at ?? null;
// the app on screen is read first by the background reader
@ -180,26 +209,19 @@ export function App({ onTheme }: { onTheme: () => void }) {
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;
const tone = missing ? 'bad' : (worstTone(attention(a.key, a.name, e, d.liveFor(a))) ?? (e ? 'ok' : null));
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
return (
<div role="listitem" key={a.key}>
<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>
{a.ignored && <span className="so-dot" title={t('ignored.rowHint')}><Icon name="eyeOff" /></span>}
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
{missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span>
: bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>}
</button>
</div>
);
};
const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
<div role="listitem" key={a.key}>
<button {...menu(() => appMenu(a, d, nav, { showIgnored }))} className={`nav nav-app has-line${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' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' })}
title={`${a.name} · ${st.text}`}>
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{(st.tone === 'bad' || st.tone === 'warn') && <span className={`ds-sdot ${st.tone}`} role="img" aria-label={st.tone === 'bad' ? t('pf.blocker') : t('pf.warning')} />}</span>
<span className="grow side-t"><span className="side-n">{a.name}</span><span className={`side-s ${st.tone}`} data-testid="side-status">{st.text}</span></span>
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
</button>
</div>
);
const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE];
return (
<NavCtx.Provider value={nav}>
@ -213,36 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
</button>
</div>
<div className="side-scroll">
<button className="nav" aria-current={current({ screen: 'portfolio' })} onClick={() => go({ screen: 'portfolio' })}>
<Icon name="portfolio" weight={current({ screen: 'portfolio' }) ? 'fill' : 'regular'} /><span className="grow">{t('nav.portfolio')}</span><span className="count">{visible.length}</span>
</button>
<div className="side-label" id="apps-label"><span>{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')}</span>
<div className="side-label" id="apps-label"><span>{t('nav.appsLabel')} <span className="count">{visible.length}</span></span>
<button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button>
</div>
{d.studios.length > 1 && (
<div className="side-studios" role="group" aria-label={t('side.studioFilter')}>
<button aria-pressed={!studioOn} onClick={() => setStudioFilter(null)}>{t('side.allStudios')}</button>
{d.studios.map(s => <button key={s.id} aria-pressed={studioOn === s.id} onClick={() => setStudioFilter(studioOn === s.id ? null : s.id)}><StudioMark studio={s} size="sm" />{s.name}</button>)}
</div>
)}
{groups.map(g => {
const s = d.studios.find(x => x.id === g.studio) ?? null;
const gid = g.studio ?? 'none';
const gid = `g:${g.id}`;
const open = !collapsed.includes(gid);
if (!d.studios.length) return <div role="list" aria-labelledby="apps-label" key={gid}>{g.rows.map(r => appItem(r.app))}</div>;
return (
<div className="side-group" key={gid}>
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${gid}`} onClick={() => toggleGroup(gid)}>
<Icon name="chevronRight" className="chev" /><StudioMark studio={s} size="sm" /><span className="grow">{s?.name ?? t('studio.none')}</span><span className="count">{g.rows.length}</span>
<div className="side-group" key={gid} data-group={g.id}>
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${g.id}`} onClick={() => toggleGroup(gid)}>
<Icon name="chevronRight" className="chev" /><span className="grow">{t(GROUP_KEY[g.id])}</span><span className="count">{g.rows.length}</span>
</button>
{open && <div role="list" id={`sg-${gid}`} aria-label={s?.name ?? t('studio.none')}>{g.rows.map(r => appItem(r.app))}</div>}
{open && <div role="list" id={`sg-${g.id}`} aria-label={t(GROUP_KEY[g.id])}>{g.rows.map(r => appItem(r.app, r.st))}</div>}
</div>
);
})}
{!visible.length && !showIgnored && <p className="side-empty">{t('nav.noApps')}</p>}
{ignoredApps.length > 0 && <button className="link side-ignored-toggle" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)} data-testid="toggle-ignored">{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>}
<div className="side-label"><span>{t('nav.workspace')}</span></div>
{WORKSPACE.map((s, i) => (
<button key={s} className="nav" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${t(WS_KEY[s])} (${MOD} ${i + 2})`}>
<Icon name={WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} /><span className="grow">{t(WS_KEY[s])}</span>
{s === 'accounts' && <span className="count">{d.accounts.length}</span>}
{s === 'ignored' && ignoredCount > 0 && <span className="count">{ignoredCount}</span>}
</button>
))}
</div>
<div className="side-ws" role="group" aria-label={t('nav.workspace')}>
{WS_ROW.map((s, i) => {
const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]);
const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0;
return (
<button key={s} className="nav-ws" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${label} (${MOD} ${i + 1})`} aria-label={label} data-screen={s}>
<Icon name={s === 'portfolio' ? 'portfolio' : WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} />
{n > 0 && <span className="ws-n">{n}</span>}
</button>
);
})}
</div>
<div className="side-foot" aria-live="polite">
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />