UNPKG

@zapstra/core-app-shell

Version:

Unified app shell with navigation slots for Zapstra platform apps

511 lines (507 loc) 17 kB
import { createContext, useState, useEffect, useCallback, useContext } from 'react'; import { AppProvider } from '@shopify/shopify-app-remix/react'; import { Icon, AppProvider as AppProvider$1, Frame, Text } from '@shopify/polaris'; import { useLocation, Link as Link$1 } from '@remix-run/react'; import { useAppRouter, AppRouterProvider } from '@zapstra/core-router'; import enTranslations from '@shopify/polaris/locales/en.json'; import { PinFilledIcon, PinIcon, ChevronLeftIcon, ChevronRightIcon } from '@shopify/polaris-icons'; import { jsxs, jsx, Fragment } from 'react/jsx-runtime'; // src/app-shell.tsx function AppNavigation({ items, onWidthChange, theme = "zapstra" }) { const location = useLocation(); const { route: navWithQuery, isNavigating } = useAppRouter(); const [isExpanded, setIsExpanded] = useState(false); const [isPinned, setIsPinned] = useState(() => { if (typeof window !== "undefined") { return localStorage.getItem("nav-pinned") === "true"; } return false; }); useEffect(() => { if (isPinned) setIsExpanded(true); }, [isPinned]); useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem("nav-pinned", String(isPinned)); } }, [isPinned]); const isActive = useCallback( (path) => { if (path === "/app") { return location.pathname === "/app"; } return location.pathname.startsWith(path); }, [location.pathname] ); const handleNavigate = useCallback( (path) => { if (!isNavigating && location.pathname !== path) { navWithQuery(path); } }, [isNavigating, location.pathname, navWithQuery] ); const handleMouseEnter = useCallback(() => { if (!isPinned) setIsExpanded(true); }, [isPinned]); const handleMouseLeave = useCallback(() => { if (!isPinned) setIsExpanded(false); }, [isPinned]); const togglePin = useCallback(() => { setIsPinned((prev) => !prev); setIsExpanded((prev) => !prev); }, []); const showExpanded = isExpanded || isPinned; const currentWidth = showExpanded ? 240 : 56; useEffect(() => { onWidthChange?.(currentWidth); }, [currentWidth, onWidthChange]); const renderNavItem = (item) => { const active = isActive(item.path); const subItems = item.subItems || []; const hasSubItems = subItems.length > 0; const isSubItemActive = subItems.some((subItem) => isActive(subItem.path)); const isActiveOrSubActive = active || isSubItemActive; const getNavColors = () => { if (theme === "zapstra") { return { activeBackground: "linear-gradient(135deg, rgba(33,141,253,.1) 0%, rgba(32,227,178,.1) 100%)", activeColor: "var(--zapstra-primary)", hoverBackground: "rgba(33,141,253,.05)", indicatorBackground: "var(--zapstra-gradient-primary)", indicatorShadow: "0 0 8px rgba(33,141,253,.4)" }; } return { activeBackground: "var(--p-color-bg-surface-selected)", activeColor: "var(--p-color-text-brand)", hoverBackground: "var(--p-color-bg-surface-hover)", indicatorBackground: "var(--p-color-border-brand)", indicatorShadow: "none" }; }; const colors = getNavColors(); return /* @__PURE__ */ jsxs("div", { style: { marginBottom: 4 }, children: [ /* @__PURE__ */ jsxs( "button", { onClick: () => handleNavigate(item.path), disabled: isNavigating, style: { background: isActiveOrSubActive ? colors.activeBackground : "transparent", border: "none", borderRadius: 6, cursor: isNavigating ? "not-allowed" : "pointer", padding: "8px 12px", display: "flex", alignItems: "center", gap: 12, width: "100%", color: isActiveOrSubActive ? colors.activeColor : "var(--p-color-text)", opacity: isNavigating ? 0.6 : 1, transition: "all 0.15s ease", position: "relative", justifyContent: "flex-start" }, title: !showExpanded ? item.label : void 0, onMouseEnter: (e) => { if (!isActiveOrSubActive && !isNavigating) { e.currentTarget.style.backgroundColor = colors.hoverBackground; } }, onMouseLeave: (e) => { if (!isActiveOrSubActive && !isNavigating) { e.currentTarget.style.backgroundColor = "transparent"; } }, children: [ /* @__PURE__ */ jsx( "div", { style: { display: "flex", alignItems: "center", minWidth: 20, justifyContent: "center", color: isActiveOrSubActive ? colors.activeColor : "currentColor" }, children: item.icon && /* @__PURE__ */ jsx(Icon, { source: item.icon }) } ), showExpanded && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( Text, { as: "span", variant: "bodyMd", fontWeight: isActiveOrSubActive ? "medium" : "regular", truncate: true, children: item.label } ), item.badge && /* @__PURE__ */ jsx( "span", { style: { marginLeft: "auto", padding: "2px 6px", borderRadius: 4, backgroundColor: "var(--p-color-bg-surface-secondary)", fontSize: 11, fontWeight: 500 }, children: item.badge } ) ] }), isActiveOrSubActive && /* @__PURE__ */ jsx( "div", { style: { position: "absolute", left: 0, top: "50%", transform: "translateY(-50%)", width: 3, height: "70%", background: colors.indicatorBackground, borderRadius: "0 2px 2px 0", boxShadow: colors.indicatorShadow } } ) ] } ), showExpanded && hasSubItems && /* @__PURE__ */ jsx("div", { style: { paddingLeft: 44, paddingTop: 4 }, children: subItems.map((subItem) => { const subActive = isActive(subItem.path); return /* @__PURE__ */ jsx( "button", { onClick: () => handleNavigate(subItem.path), disabled: isNavigating, style: { background: "transparent", border: "none", borderRadius: 4, cursor: isNavigating ? "not-allowed" : "pointer", padding: "6px 8px", display: "block", width: "100%", textAlign: "left", fontSize: 13, color: subActive ? colors.activeColor : "var(--p-color-text-secondary)", opacity: isNavigating ? 0.6 : 1, transition: "all 0.15s ease", fontWeight: subActive ? 500 : 400 }, onMouseEnter: (e) => { if (!subActive && !isNavigating) { e.currentTarget.style.backgroundColor = colors.hoverBackground; } }, onMouseLeave: (e) => { if (!subActive && !isNavigating) { e.currentTarget.style.backgroundColor = "transparent"; } }, children: subItem.label }, subItem.path ); }) }) ] }, item.path); }; return /* @__PURE__ */ jsxs( "nav", { className: `zapstra-nav ${theme === "zapstra" ? "zapstra-theme" : ""}`, style: { height: "100%", backgroundColor: "var(--p-color-bg-surface)", borderRight: "1px solid var(--p-color-border-subdued)", width: currentWidth, transition: "width 0.2s ease-in-out", position: "absolute", left: 0, top: 0, bottom: 0, display: "flex", flexDirection: "column", zIndex: 100 }, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [ /* @__PURE__ */ jsx("div", { style: { padding: 8, flex: 1, overflowY: "auto", overflowX: "hidden" }, children: items.map(renderNavItem) }), /* @__PURE__ */ jsx("div", { style: { padding: 8, borderTop: "1px solid var(--p-color-border-subdued)" }, children: /* @__PURE__ */ jsx( "button", { onClick: togglePin, style: { background: isPinned && theme === "zapstra" ? "linear-gradient(135deg, rgba(33,141,253,.1) 0%, rgba(32,227,178,.1) 100%)" : isPinned ? "var(--p-color-bg-surface-selected)" : "transparent", border: "none", cursor: "pointer", padding: 8, borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", color: isPinned && theme === "zapstra" ? "var(--zapstra-primary)" : isPinned ? "var(--p-color-text-brand)" : "var(--p-color-text-secondary)", transition: "all 0.2s ease" }, title: isPinned ? "Unpin navigation" : "Pin navigation", children: showExpanded ? /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Icon, { source: isPinned ? PinFilledIcon : PinIcon }), /* @__PURE__ */ jsx( "span", { style: { fontSize: 13, color: isPinned && theme === "zapstra" ? "var(--zapstra-primary)" : isPinned ? "var(--p-color-text-brand)" : "var(--p-color-text-secondary)" }, children: isPinned ? "Pinned" : "Pin" } ) ] }) : /* @__PURE__ */ jsx( Icon, { source: isPinned ? PinFilledIcon : showExpanded ? ChevronLeftIcon : ChevronRightIcon } ) } ) }), isNavigating && /* @__PURE__ */ jsx( "div", { style: { position: "absolute", top: 0, left: 0, right: 0, height: 2, background: "var(--p-color-border-subdued)", overflow: "hidden" }, children: /* @__PURE__ */ jsx( "div", { style: { position: "absolute", top: 0, left: 0, right: 0, height: "100%", background: theme === "zapstra" ? "var(--zapstra-gradient-primary)" : "var(--p-color-bg-fill-brand)", animation: "loading 1s ease-in-out infinite" } } ) } ), /* @__PURE__ */ jsx("style", { children: ` @keyframes loading { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } .zapstra-theme { --zapstra-primary: #218dfd; --zapstra-gradient-primary: linear-gradient(135deg, #218dfd 0%, #20e3b2 100%); } ` }) ] } ); } var Link = ({ url = "", external, children, ...rest }) => external ? /* @__PURE__ */ jsx("a", { href: url, ...rest, children }) : /* @__PURE__ */ jsx(Link$1, { to: url, ...rest, children }); var AppShellCtx = createContext(null); function AppShell({ config, apiKey, children, isEmbeddedApp = true, i18n = enTranslations, showRouterSpinner = true }) { const [shellConfig, setShellConfig] = useState(config); const [navWidth, setNavWidth] = useState(56); const updateConfig = useCallback((updates) => { setShellConfig((prev) => ({ ...prev, ...updates })); }, []); const handleNavWidthChange = useCallback((width) => { setNavWidth(width); }, []); const appShellContext = { config: shellConfig, navWidth, updateConfig }; const logo = shellConfig.logo || { width: 86, topBarSource: "/favicon.ico", accessibilityLabel: shellConfig.appName || "Zapstra App" }; const navigationItems = shellConfig.navigation || []; return /* @__PURE__ */ jsx(AppShellCtx.Provider, { value: appShellContext, children: /* @__PURE__ */ jsx(AppProvider, { isEmbeddedApp, apiKey, children: /* @__PURE__ */ jsx(AppProvider$1, { i18n, linkComponent: Link, children: /* @__PURE__ */ jsx(AppRouterProvider, { showLoadingSpinner: showRouterSpinner, children: /* @__PURE__ */ jsx( Frame, { logo, showMobileNavigation: shellConfig.showMobileNavigation ?? false, children: /* @__PURE__ */ jsxs( "div", { style: { display: "flex", height: "100vh", position: "relative" }, children: [ navigationItems.length > 0 && /* @__PURE__ */ jsx( AppNavigation, { items: navigationItems, onWidthChange: handleNavWidthChange, theme: shellConfig.theme } ), /* @__PURE__ */ jsx( "div", { style: { flex: 1, marginLeft: navigationItems.length > 0 ? `${navWidth}px` : "0", transition: "margin-left 0.2s ease-in-out", position: "relative", overflow: "auto" }, children } ) ] } ) } ) }) }) }) }); } function useAppShell() { const context = useContext(AppShellCtx); if (!context) { throw new Error("useAppShell must be used within AppShell"); } return context; } function useUpdateNavigation() { const { updateConfig } = useAppShell(); return useCallback( (navigation) => { updateConfig({ navigation }); }, [updateConfig] ); } function useNavigationWidth() { const { navWidth } = useAppShell(); return navWidth; } function SimpleAppShell({ appId, appName, apiKey, navigation = [], children, theme = "zapstra" }) { const config = { appId, appName, navigation, theme, logo: { width: 86, topBarSource: "/favicon.ico", accessibilityLabel: appName || "Zapstra App" } }; return /* @__PURE__ */ jsx(AppShell, { config, apiKey, children }); } // src/route-registry.ts var RouteRegistryImpl = class { routes = /* @__PURE__ */ new Map(); register(appId, routes) { this.routes.set(appId, routes); } unregister(appId) { this.routes.delete(appId); } getRoutes(appId) { return this.routes.get(appId) || []; } getAllRoutes() { const result = {}; for (const [appId, routes] of this.routes.entries()) { result[appId] = routes; } return result; } /** * Get flattened routes for navigation generation */ getFlattenedRoutes(appId) { const routes = this.getRoutes(appId); return routes.sort((a, b) => (a.order || 0) - (b.order || 0)); } /** * Get hierarchical routes grouped by parent */ getHierarchicalRoutes(appId) { const routes = this.getRoutes(appId); const grouped = { root: [] }; routes.forEach((route) => { const parent = route.parent || "root"; if (!grouped[parent]) { grouped[parent] = []; } grouped[parent].push(route); }); Object.keys(grouped).forEach((key) => { grouped[key].sort((a, b) => (a.order || 0) - (b.order || 0)); }); return grouped; } }; var routeRegistry = new RouteRegistryImpl(); function routesToNavigationItems(routes) { const grouped = routes.reduce((acc, route) => { const parent = route.parent || "root"; if (!acc[parent]) { acc[parent] = []; } acc[parent].push(route); return acc; }, {}); const rootRoutes = grouped.root || []; return rootRoutes.map((route) => ({ label: route.label, icon: route.icon, path: route.path, badge: route.badge, subItems: (grouped[route.path] || []).map((subRoute) => ({ label: subRoute.label, path: subRoute.path })) })); } function registerRoutes(appId, routes) { routeRegistry.register(appId, routes); } export { AppNavigation, AppShell, SimpleAppShell, registerRoutes, routeRegistry, routesToNavigationItems, useAppShell, useNavigationWidth, useUpdateNavigation }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map