// 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 = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; export const WS_KEY: Record = { 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({ screen: 'portfolio' }); const [palette, setPalette] = useState(false); const [welcome, setWelcome] = useState(false); const [started, setStarted] = useState(false); const [collapsed, setCollapsed] = useState(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(() => { 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 (
); }; return ( {t('a11y.skip')}
{crumbs.map((c, i) => ( {i > 0 && } {c.to && i < crumbs.length - 1 ? : {c.label}} ))}
{FIXTURES && {t('dev.fixtures')}} {lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}
{/* one boundary per screen (keyed, so moving to another screen starts fresh): a failing screen keeps the shell */} {route.screen === 'portfolio' && } {route.screen === 'app' && app && } {route.screen === 'apps' && } {route.screen === 'accounts' && } {route.screen === 'ignored' && } {route.screen === 'dependencies' && } {route.screen === 'settings' && }
{palette && setPalette(false)} />} {welcome && }
); } /** 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
{parts.join(' · ')}
; }