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.
278 lines
20 KiB
TypeScript
278 lines
20 KiB
TypeScript
// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh),
|
|
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
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 { usePrefs } from './prefs-ctx';
|
|
import { Accounts } from './screens/Accounts';
|
|
import { AppFolders } from './screens/AppFolders';
|
|
import { AppView } from './screens/AppView';
|
|
import { Dependencies } from './screens/Dependencies';
|
|
import { Ignored } from './screens/Ignored';
|
|
import { useShowIgnored } from './lib/show-ignored';
|
|
import { Onboarding } from './screens/Onboarding';
|
|
import { Portfolio } from './screens/Portfolio';
|
|
import { Settings } from './screens/Settings';
|
|
import { useData } from './store';
|
|
import { Icon, type IconName, Mark } from './ui/icons';
|
|
import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit';
|
|
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';
|
|
|
|
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';
|
|
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 []; }
|
|
}
|
|
|
|
export function App({ onTheme }: { onTheme: () => void }) {
|
|
const d = useData();
|
|
const { prefs, update } = usePrefs();
|
|
const [route, setRoute] = useState<Route>({ screen: 'portfolio' });
|
|
const [palette, setPalette] = useState(false);
|
|
const [welcome, setWelcome] = useState(false);
|
|
const [started, setStarted] = useState(false);
|
|
const [collapsed, setCollapsed] = useState<string[]>(loadCollapsed);
|
|
const exports = useExportItems();
|
|
const runExport = useRunExport();
|
|
|
|
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
|
|
const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
|
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 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 */ }
|
|
return next;
|
|
});
|
|
|
|
// first screen: dev switch (VITE_START), else the welcome guide on a first run
|
|
useEffect(() => {
|
|
if (!d.ready || started) return;
|
|
setStarted(true);
|
|
const v = devSwitch('start');
|
|
if (v === 'welcome' || v.startsWith('welcome:')) { setWelcome(true); return; }
|
|
if (v === 'palette') setPalette(true);
|
|
const r = parseStart(v, d.apps.filter(a => !a.ignored).map(a => a.key));
|
|
if (r) { go(r); return; }
|
|
if (!d.projects.length && !d.accounts.length && !prefs.onboarded) setWelcome(true);
|
|
}, [d.ready, d.apps, d.projects, d.accounts, started, go, prefs.onboarded]);
|
|
|
|
useEffect(() => {
|
|
const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); });
|
|
// `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder
|
|
const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); });
|
|
// the tray menu (an app, Settings, Check for updates) and the first show after a notification
|
|
const unTray = onTrayRoute(s => { const r = parseRouteJson(s); if (r) go(r); });
|
|
return () => { un.then(f => f()); unFolder.then(f => f()); unTray.then(f => f()); };
|
|
}, [go, d.reload]);
|
|
|
|
const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined;
|
|
const tabs = app ? tabsFor(!!app.project) : [];
|
|
|
|
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const mod = e.metaKey || e.ctrlKey;
|
|
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; }
|
|
if (palette || welcome) return;
|
|
if (mod && !e.shiftKey && !e.altKey && /^[1-6]$/.test(e.key)) {
|
|
e.preventDefault();
|
|
const n = Number(e.key);
|
|
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) {
|
|
e.preventDefault();
|
|
const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1);
|
|
go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! });
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [palette, welcome, route, go, tabs]);
|
|
|
|
const closeWelcome = useCallback(() => {
|
|
setWelcome(false);
|
|
if (!prefs.onboarded) update({ ...prefs, onboarded: true });
|
|
}, [prefs, update]);
|
|
|
|
useEffect(() => { if (route.screen === 'app' && d.ready && d.apps.length && !app) go({ screen: 'portfolio' }); }, [route, app, d.ready, d.apps.length, go]);
|
|
|
|
const commands = useMemo<Command[]>(() => {
|
|
const c: Command[] = [];
|
|
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' }) });
|
|
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 }) });
|
|
for (const x of exports) c.push({ id: `export-${x.id}`, group: G.exp, title: t('palette.export', { what: x.label }), icon: x.icon ?? 'download', keywords: `export save download ${x.kind}`, hint: x.disabled || `.${x.kind}`, run: () => { runExport(x); } });
|
|
if (app?.project) {
|
|
const p = app.project.id;
|
|
c.push({ id: 'act-prices', group: G.act, title: t('palette.prices', { name: app.name }), icon: 'coins', keywords: 'prices csv sheet', run: () => go({ screen: 'app', appId: p, tab: 'prices' }) });
|
|
c.push({ id: 'act-art', group: G.act, title: t('palette.art', { name: app.name }), icon: 'grid', keywords: 'screenshots gallery renders', run: () => go({ screen: 'app', appId: p, tab: 'art' }) });
|
|
}
|
|
c.push({ id: 'act-fastlane', group: G.act, title: t('folders.fastlane'), icon: 'download', keywords: 'fastlane import deliver supply migrate', run: () => go({ screen: 'apps' }) });
|
|
c.push({ id: 'act-live', group: G.act, title: t('action.readStores'), icon: 'refresh', keywords: 'refresh sync', run: () => { d.refreshPortfolio(true); } });
|
|
c.push({ id: 'act-import', group: G.act, title: t('action.importApps'), icon: 'download', keywords: 'import sync accounts', run: () => { d.importApps(); } });
|
|
c.push({ id: 'act-check', group: G.act, title: t('action.checkConfigs'), icon: 'shield', run: () => { d.refreshPortfolio(false); } });
|
|
c.push({ id: 'act-add', group: G.act, title: t('action.addFolder'), icon: 'folderPlus', run: () => go({ screen: 'apps' }) });
|
|
c.push({ id: 'act-account', group: G.act, title: t('action.addAccount'), icon: 'key', run: () => go({ screen: 'accounts' }) });
|
|
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]);
|
|
|
|
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]) }]
|
|
: [{ 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
|
|
const { setFocus } = d;
|
|
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
|
|
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
|
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
|
|
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;
|
|
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>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<NavCtx.Provider value={nav}>
|
|
<a className="skip" href="#content">{t('a11y.skip')}</a>
|
|
<div className="shell">
|
|
<nav className="side" aria-label={t('nav.sections')}>
|
|
<div className="side-top">
|
|
<div className="brand"><Mark className="brand-mark" /><span>storebound</span>{d.info?.debug && <small>DEV</small>}</div>
|
|
<button className="search-btn" onClick={() => setPalette(true)} aria-keyshortcuts="Meta+K Control+K">
|
|
<Icon name="search" /><span className="lbl">{t('palette.button')}</span><Kbd keys={[MOD, 'K']} />
|
|
</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>
|
|
<button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button>
|
|
</div>
|
|
{groups.map(g => {
|
|
const s = d.studios.find(x => x.id === g.studio) ?? null;
|
|
const gid = g.studio ?? 'none';
|
|
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>
|
|
</button>
|
|
{open && <div role="list" id={`sg-${gid}`} aria-label={s?.name ?? t('studio.none')}>{g.rows.map(r => appItem(r.app))}</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-foot" aria-live="polite">
|
|
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
|
|
<span>{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></div>
|
|
<div className="row"><Icon name="clock" className="sr-icon" /><span>{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}</span></div>
|
|
<ReaderLine />
|
|
</div>
|
|
</nav>
|
|
<div className="main">
|
|
<header className="topbar">
|
|
<div className="crumbs" aria-label={t('a11y.breadcrumbs')}>
|
|
{crumbs.map((c, i) => (
|
|
<span key={i} className="row" style={{ gap: 6, minWidth: 0 }}>
|
|
{i > 0 && <Icon name="chevronRight" />}
|
|
{c.to && i < crumbs.length - 1 ? <button onClick={() => go(c.to!)}>{c.label}</button> : <span className={i === crumbs.length - 1 ? 'here' : ''}>{c.label}</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<span className="spacer" />
|
|
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
|
|
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
|
|
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all">
|
|
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')}
|
|
</button>
|
|
<ExportMenu />
|
|
</header>
|
|
<main className="content" id="content" tabIndex={-1}>
|
|
{/* one boundary per screen (keyed, so moving to another screen starts fresh): a failing screen keeps the shell */}
|
|
<ErrorBoundary key={route.screen === 'app' ? `app:${route.appId}` : route.screen} where={`screen ${route.screen}`}>
|
|
{route.screen === 'portfolio' && <Portfolio />}
|
|
{route.screen === 'app' && app && <AppView key={app.key} app={app} tab={tabs.includes(route.tab) ? route.tab : 'releases'} />}
|
|
{route.screen === 'apps' && <AppFolders />}
|
|
{route.screen === 'accounts' && <Accounts focus={route.section} />}
|
|
{route.screen === 'ignored' && <Ignored />}
|
|
{route.screen === 'dependencies' && <Dependencies />}
|
|
{route.screen === 'settings' && <Settings onTheme={onTheme} section={route.section} />}
|
|
</ErrorBoundary>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
{palette && <ErrorBoundary where="palette"><Palette commands={commands} onClose={() => setPalette(false)} /></ErrorBoundary>}
|
|
{welcome && <ErrorBoundary where="welcome"><Onboarding onClose={closeWelcome} /></ErrorBoundary>}
|
|
<ErrorBoundary where="toasts"><Toasts /></ErrorBoundary>
|
|
</NavCtx.Provider>
|
|
);
|
|
}
|
|
|
|
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */
|
|
function ReaderLine() {
|
|
const d = useData();
|
|
const r = d.reader;
|
|
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
|
|
const parts: string[] = [];
|
|
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total }));
|
|
else if (!d.reader.autoRead) parts.push(t('reader.off'));
|
|
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate'));
|
|
if (Object.keys(r.paused).length) parts.push(t('reader.backoff'));
|
|
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) }));
|
|
if (!parts.length) return null;
|
|
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>;
|
|
}
|