@zapstra/core-app-shell
Version:
Unified app shell with navigation slots for Zapstra platform apps
511 lines (507 loc) • 17 kB
JavaScript
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