storebound/apps/desktop/src/nav.ts

57 lines
3.8 KiB
TypeScript

// Routes: Portfolio -> one App (tabs) plus the workspace screens. Pure, so tests can check the deep links.
// An app is keyed by its project id, or (store-only apps, no folder) by its store target ("store:...").
import { createContext, useContext } from 'react';
import type { Key } from './i18n';
import type { IconName } from './ui/icons';
export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const;
export type AppTab = (typeof APP_TABS)[number];
/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and
* store art are the live ones; Growth shows the Product Page Optimization tests only). */
export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth'];
export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS);
export const TAB_KEY: Record<AppTab, Key> = {
releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews',
testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools',
};
export const TAB_ICON: Record<AppTab, IconName> = {
releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart',
appinfo: 'clipboard', checks: 'shield', tools: 'sliders',
};
export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const;
export type WorkspaceScreen = (typeof WORKSPACE)[number];
export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen; section?: string };
/** storebound://<screen> deep links and the dev-only start switch ("app:health" = first app, Health tab;
* "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */
export function parseStart(v: string, apps: string[]): Route | null {
const [screen, tab, n] = v.split(':');
const app = apps[Math.max(0, (Number(n) || 1) - 1)];
if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' };
if (screen === 'portfolio') return { screen: 'portfolio' };
if (!(WORKSPACE as readonly string[]).includes(screen ?? '')) return null;
return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen };
}
/** A route sent as JSON by the host (tray menu, notification target); anything that is not a known route is null. */
export function parseRouteJson(v: string): Route | null {
let o: unknown;
try { o = JSON.parse(v); } catch { return null; }
if (!o || typeof o !== 'object') return null;
const r = o as Record<string, unknown>;
if (r.screen === 'portfolio') return { screen: 'portfolio' };
if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null;
if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen };
return null;
}
export const sameRoute = (a: Route, b: Route): boolean => JSON.stringify(a) === JSON.stringify(b);
export interface Nav { route: Route; go: (r: Route) => void; openPalette: () => void; openWelcome: () => void }
export const NavCtx = createContext<Nav | null>(null);
export const useNav = (): Nav => {
const n = useContext(NavCtx);
if (!n) throw new Error('useNav outside NavCtx');
return n;
};