storebound/apps/desktop/src/App.tsx

263 lines
18 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 } 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, 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 { 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';
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 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' })); });
return () => { un.then(f => f()); unFolder.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(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
const appItem = (a: UnifiedApp) => {
const e = d.statusFor(a);
const missing = !!a.project?.missing;
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
return (
<div role="listitem" key={a.key}>
<button className="nav nav-app" 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' })}>
<AppIcon name={a.name} src={d.iconFor(a)} />
<span className="grow">{a.name}</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 && <p className="side-empty">{t('nav.noApps')}</p>}
<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>;
}