Refine shared shell, router, and service worker behavior

This commit is contained in:
Amer Agovic
2026-05-09 06:46:30 -05:00
parent 2157e1aea6
commit ff515fddf6
7 changed files with 221 additions and 166 deletions
+36 -7
View File
@@ -2,13 +2,12 @@
* Service Worker Registration * Service Worker Registration
*/ */
import { isElectronHost } from './host.js'; import { isElectronHost, isTauriHost } from './host.js';
import { getConfig, isDevelopment, CONFIG_KEYS } from './env.js'; import { getConfig, isDevelopment, CONFIG_KEYS } from './env.js';
const SW_PATH = '/sw.js'; const SW_PATH = '/sw.js';
const SW_SCOPE = '/'; const SW_SCOPE = '/';
const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__'; const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__';
/** /**
* Clear all caches * Clear all caches
*/ */
@@ -127,8 +126,9 @@ export async function clearPWACache() {
* Register service worker * Register service worker
*/ */
export async function registerServiceWorker() { export async function registerServiceWorker() {
if (isElectronHost()) { if (isElectronHost() || isTauriHost()) {
console.log('[SW] Skipping service worker registration in Electron host'); await unregisterAllServiceWorkers();
console.log('[SW] Skipping service worker registration in desktop host');
return null; return null;
} }
@@ -185,8 +185,9 @@ export async function registerServiceWorker() {
* In development, stale service workers can break Vite module loading by * In development, stale service workers can break Vite module loading by
* intercepting /@fs and related requests. Clear them once and reload cleanly. * intercepting /@fs and related requests. Clear them once and reload cleanly.
*/ */
export async function ensureDevelopmentServiceWorkerState() { export async function resetServiceWorkers() {
if (isElectronHost()) { if (isElectronHost() || isTauriHost()) {
await unregisterAllServiceWorkers();
return false; return false;
} }
@@ -214,11 +215,39 @@ export async function ensureDevelopmentServiceWorkerState() {
return true; return true;
} }
export async function getServiceWorkerStatus() {
if (isElectronHost() || isTauriHost()) {
return 'Desktop Disabled';
}
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return 'Not Supported';
}
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
try {
if (devHost) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
registration.update();
return 'Active';
}
return 'Development Disabled';
}
await navigator.serviceWorker.ready;
return 'Active';
} catch (error) {
console.warn('[SW] Failed to resolve service worker status:', error);
return 'Not Supported';
}
}
/** /**
* Unregister service worker * Unregister service worker
*/ */
export async function unregisterServiceWorker() { export async function unregisterServiceWorker() {
if (isElectronHost()) { if (isElectronHost() || isTauriHost()) {
return; return;
} }
+1 -1
View File
@@ -122,7 +122,7 @@ export function SecurityAdminPage() {
</Paragraph> </Paragraph>
</YStack> </YStack>
), 'No permits registered.') ), 'No permits registered.')
} },
]; ];
return ( return (
+19 -26
View File
@@ -6,7 +6,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui'; import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui';
import { registerServiceWorker, clearPWACache } from '../platform/sw-register.js'; import { clearPWACache, getServiceWorkerStatus } from '../platform/sw-register.js';
import { getProvider } from '../platform/storage.js'; import { getProvider } from '../platform/storage.js';
import * as apiClient from '../platform/api.js'; import * as apiClient from '../platform/api.js';
import * as storageModuleRef from '../platform/storage.js'; import * as storageModuleRef from '../platform/storage.js';
@@ -68,7 +68,15 @@ function getCachedTamaguiConfig(styleThemeName) {
// App Component // App Component
// ============================================================================ // ============================================================================
function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, initialProfile = null }) { function App({
onInit,
renderBootScreen = null,
showInitialBootSplash = false,
initialProfile = null,
initialThemeMode = THEME_MODES.SYSTEM,
initialStyleThemeName = DEFAULT_STYLE_THEME,
initialThemePreferencesLoaded = false
}) {
// App state // App state
const [swStatus, setSwStatus] = useState('Checking...'); const [swStatus, setSwStatus] = useState('Checking...');
const [storageBackend, setStorageBackend] = useState('localStorage'); const [storageBackend, setStorageBackend] = useState('localStorage');
@@ -80,10 +88,10 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i
const [bootModeOverride, setBootModeOverride] = useState(null); const [bootModeOverride, setBootModeOverride] = useState(null);
// Theme state // Theme state
const [themeMode, setThemeModeState] = useState(THEME_MODES.SYSTEM); const [themeMode, setThemeModeState] = useState(initialThemeMode);
const [systemScheme, setSystemScheme] = useState(getSystemThemeMode()); const [systemScheme, setSystemScheme] = useState(getSystemThemeMode());
const [styleThemeName, setStyleThemeName] = useState(DEFAULT_STYLE_THEME); const [styleThemeName, setStyleThemeName] = useState(() => normalizeStyleThemeName(initialStyleThemeName));
const [themePreferencesLoaded, setThemePreferencesLoaded] = useState(false); const [themePreferencesLoaded, setThemePreferencesLoaded] = useState(initialThemePreferencesLoaded);
const securityState = useSecurityState(); const securityState = useSecurityState();
// Initialize theme manager // Initialize theme manager
@@ -111,6 +119,10 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i
// Load theme preferences from storage on mount // Load theme preferences from storage on mount
useEffect(() => { useEffect(() => {
if (initialThemePreferencesLoaded) {
return;
}
async function loadThemePreferences() { async function loadThemePreferences() {
try { try {
// Load theme mode (light/dark/system) // Load theme mode (light/dark/system)
@@ -132,7 +144,7 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i
} }
loadThemePreferences(); loadThemePreferences();
}, []); }, [initialThemePreferencesLoaded]);
// Listen for system theme changes using platform-agnostic compat layer // Listen for system theme changes using platform-agnostic compat layer
useEffect(() => { useEffect(() => {
@@ -285,26 +297,7 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i
appLogger.warn('Menu service not available'); appLogger.warn('Menu service not available');
} }
// Update service worker status if available setSwStatus(await getServiceWorkerStatus());
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
if ('serviceWorker' in navigator) {
try {
if (devHost) {
const registration = await navigator.serviceWorker.getRegistration();
setSwStatus(registration ? 'Active' : 'Development Disabled');
if (registration) {
registration.update();
}
} else {
const registration = await navigator.serviceWorker.ready;
setSwStatus('Active');
}
} catch (error) {
setSwStatus('Not Supported');
}
} else {
setSwStatus('Not Supported');
}
setInitialized(true); setInitialized(true);
initTrace.end({ initTrace.end({
+5 -3
View File
@@ -15,12 +15,14 @@ import { SideBar } from './SideBar.jsx';
* @param {React.ReactNode} props.children - Content to render inside shell * @param {React.ReactNode} props.children - Content to render inside shell
*/ */
export function DashboardShell(props) { export function DashboardShell(props) {
const { secondaryStyle = 'inline', ...shellProps } = props;
return ( return (
<EmptyShell {...props} initialLeftWidth={250}> <EmptyShell {...shellProps} initialLeftWidth={250}>
<SideBar placement="leftSide"> <SideBar placement="leftSide" secondaryStyle={secondaryStyle}>
{/* Menu items will be placed here via placement */} {/* Menu items will be placed here via placement */}
</SideBar> </SideBar>
{props.children} {shellProps.children}
</EmptyShell> </EmptyShell>
); );
} }
+76 -17
View File
@@ -28,16 +28,26 @@ function getComponentLabel(component) {
* Route registry for module-declared routes * Route registry for module-declared routes
* Modules can publish routes here during initialization, and Router will collect them on mount * Modules can publish routes here during initialization, and Router will collect them on mount
*/ */
const routeRegistry = []; const ROUTE_REGISTRY_KEY = '__bface_route_registry__';
let registryModified = false; // Flag to indicate routes have been published
const routeRegistryListeners = new Set(); function getRouteRegistryState() {
const scope = typeof globalThis !== 'undefined' ? globalThis : window;
if (!scope[ROUTE_REGISTRY_KEY]) {
scope[ROUTE_REGISTRY_KEY] = {
routes: [],
registryModified: false,
listeners: new Set()
};
}
return scope[ROUTE_REGISTRY_KEY];
}
/** /**
* Get all published routes from registry (called by Router on mount) * Get all published routes from registry (called by Router on mount)
* @returns {Array} Array of route definitions * @returns {Array} Array of route definitions
*/ */
function getPublishedRoutes() { function getPublishedRoutes() {
return routeRegistry; // Return registry directly return getRouteRegistryState().routes;
} }
/** /**
@@ -45,13 +55,14 @@ function getPublishedRoutes() {
* @returns {boolean} True if registry was modified since last check * @returns {boolean} True if registry was modified since last check
*/ */
function getAndClearRegistryModified() { function getAndClearRegistryModified() {
const wasModified = registryModified; const state = getRouteRegistryState();
registryModified = false; // Clear the flag const wasModified = state.registryModified;
state.registryModified = false;
return wasModified; return wasModified;
} }
function notifyRouteRegistryListeners() { function notifyRouteRegistryListeners() {
for (const listener of routeRegistryListeners) { for (const listener of getRouteRegistryState().listeners) {
try { try {
listener(); listener();
} catch (error) { } catch (error) {
@@ -61,9 +72,10 @@ function notifyRouteRegistryListeners() {
} }
function subscribeToRouteRegistry(listener) { function subscribeToRouteRegistry(listener) {
routeRegistryListeners.add(listener); const state = getRouteRegistryState();
state.listeners.add(listener);
return () => { return () => {
routeRegistryListeners.delete(listener); state.listeners.delete(listener);
}; };
} }
@@ -71,8 +83,9 @@ function subscribeToRouteRegistry(listener) {
* Clear the route registry (for testing/cleanup) * Clear the route registry (for testing/cleanup)
*/ */
export function clearRouteRegistry() { export function clearRouteRegistry() {
routeRegistry.length = 0; const state = getRouteRegistryState();
registryModified = false; state.routes.length = 0;
state.registryModified = false;
notifyRouteRegistryListeners(); notifyRouteRegistryListeners();
} }
@@ -261,10 +274,11 @@ export function publishRoutes(input, options = {}) {
} }
// Add to registry (Router will pick up on mount) // Add to registry (Router will pick up on mount)
routeRegistry.push(...normalizedRoutes); const state = getRouteRegistryState();
registryModified = true; // Set flag to indicate routes have been published state.routes.push(...normalizedRoutes);
state.registryModified = true;
notifyRouteRegistryListeners(); notifyRouteRegistryListeners();
console.log(`[Router] Published ${normalizedRoutes.length} route(s) to registry. Total: ${routeRegistry.length}`); console.log(`[Router] Published ${normalizedRoutes.length} route(s) to registry. Total: ${state.routes.length}`);
} }
// ============================================================================ // ============================================================================
@@ -275,6 +289,45 @@ const RouterContext = createContext(null);
const EMPTY_ROUTE = { path: '', component: null, params: {}, state: null, is_fragment: false, fragment_path: null, options: {} }; const EMPTY_ROUTE = { path: '', component: null, params: {}, state: null, is_fragment: false, fragment_path: null, options: {} };
const RouteContext = createContext(EMPTY_ROUTE); const RouteContext = createContext(EMPTY_ROUTE);
class RouteErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error) {
console.error('[Router.SelectedComponent] Route component failed to render:', error);
}
componentDidUpdate(prevProps) {
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
render() {
const { error } = this.state;
const { children, routePath, debug } = this.props;
if (error) {
return (
<ErrorPage
title="Page Failed To Load"
icon="error"
message={`The route "${routePath || '/'}" could not be rendered.`}
error={error}
debug={debug}
/>
);
}
return children;
}
}
/** /**
* useRouter Hook * useRouter Hook
* Access router instance and navigation methods * Access router instance and navigation methods
@@ -434,9 +487,15 @@ function SelectedComponent({ placement, fallback }) {
console.log('[Router.SelectedComponent] Rendering component:', getComponentLabel(RouteComponent), route.fragment_path ? `(fragment path: ${route.fragment_path})` : ''); console.log('[Router.SelectedComponent] Rendering component:', getComponentLabel(RouteComponent), route.fragment_path ? `(fragment path: ${route.fragment_path})` : '');
} }
return ( return (
<Suspense fallback={null}> <RouteErrorBoundary
<RouteComponent /> resetKey={routeSignature}
</Suspense> routePath={route.path}
debug={true}
>
<Suspense fallback={null}>
<RouteComponent />
</Suspense>
</RouteErrorBoundary>
); );
} }
+64 -46
View File
@@ -112,41 +112,6 @@ function useSideBarContent(children) {
} }
}); });
// Add secondary menu items to bottomSide
secondaryMenuItems.forEach((item) => {
// Validate that item is a MenuItem instance
if (!(item instanceof MenuItem)) {
console.error('[SideBar] Expected MenuItem instance but got:', {
type: typeof item,
constructor: item?.constructor?.name,
item: item,
stack: new Error().stack
});
return; // Skip invalid items
}
sections.bottomSide.push(
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
stateVersion={menuVersion}
/>
);
});
// Add personal menu item as last element in bottomSide
if (personalRoot && personalRoot instanceof MenuItem) {
sections.bottomSide.push(
<PersonalMenuItem
key="personal-menu"
personalRoot={personalRoot}
orientation="vertical"
expand_mode="popup"
stateVersion={menuVersion}
/>
);
}
return { return {
sections, sections,
primaryMenuItems, primaryMenuItems,
@@ -167,7 +132,8 @@ function SideBarWide({
topSideHeight = 0, topSideHeight = 0,
bottomSideHeight = 0, bottomSideHeight = 0,
expandedWidth = 250, expandedWidth = 250,
collapsedWidth = 80 collapsedWidth = 80,
secondaryStyle = 'inline'
}) { }) {
const [brandLogo, setBrandLogo] = useState(null); const [brandLogo, setBrandLogo] = useState(null);
const [appName, setAppName] = useState(null); const [appName, setAppName] = useState(null);
@@ -222,12 +188,30 @@ function SideBarWide({
}; };
const currentWidth = isCollapsed ? collapsedWidth : expandedWidth; const currentWidth = isCollapsed ? collapsedWidth : expandedWidth;
const canFitInlineSecondary = (() => {
const itemCount = organizedChildren.secondaryMenuItems.length;
if (itemCount <= 0) {
return true;
}
// Account for the sidebar's horizontal padding and a compact icon-only row.
const availableWidth = Math.max(currentWidth - 16, 0);
const estimatedButtonWidth = 24;
const estimatedGapWidth = 4;
const requiredWidth = (itemCount * estimatedButtonWidth) + ((itemCount - 1) * estimatedGapWidth);
return requiredWidth <= availableWidth;
})();
const resolvedSecondaryStyle = secondaryStyle === 'inline' && canFitInlineSecondary ? 'inline' : 'stacked';
const hasBottomContent = organizedChildren.sections.bottomSide.length > 0
|| organizedChildren.secondaryMenuItems.length > 0
|| (organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem);
return ( return (
<YStack <YStack
width={currentWidth} width={currentWidth}
height="100%" height="100%"
gap="$2" gap="$1"
padding="$2" padding="$2"
backgroundColor="$bgPanel" backgroundColor="$bgPanel"
borderRightWidth={1} borderRightWidth={1}
@@ -252,7 +236,7 @@ function SideBarWide({
<XStack <XStack
width="100%" width="100%"
alignItems="center" alignItems="center"
gap="$2" gap="$1"
paddingVertical="$2" paddingVertical="$2"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
@@ -316,21 +300,54 @@ function SideBarWide({
</YStack> </YStack>
{/* Bottom Side - Secondary and Personal menu items (always shown if has content) */} {/* Bottom Side - Secondary and Personal menu items (always shown if has content) */}
{organizedChildren.sections.bottomSide.length > 0 && ( {hasBottomContent && (
<YStack <YStack
width="100%" width="100%"
gap="$2" gap="$1"
alignItems="flex-start" alignItems="flex-start"
justifyContent="flex-end" justifyContent="flex-end"
paddingTop="$2" paddingTop="$2"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
{React.Children.map(organizedChildren.sections.bottomSide, (child) => { {organizedChildren.sections.bottomSide}
if (React.isValidElement(child) && (child.type === MenuItemButton || child.type === PersonalMenuItem)) {
return React.cloneElement(child, { collapsed: isCollapsed }); {organizedChildren.secondaryMenuItems.length > 0 && resolvedSecondaryStyle === 'inline' ? (
} <XStack width="100%" alignItems="center" justifyContent="space-between" gap="$1" flexWrap="nowrap">
return child; {organizedChildren.secondaryMenuItems.map((item) => (
})} <XStack key={item.id || item.path} flex={1} justifyContent="center" minWidth={0}>
<MenuItemButton
menuItem={item}
orientation="horizontal"
displayStyle="icon_only"
width="100%"
padding="$1"
stateVersion={organizedChildren.menuVersion}
/>
</XStack>
))}
</XStack>
) : (
organizedChildren.secondaryMenuItems.map((item) => (
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
collapsed={isCollapsed}
stateVersion={organizedChildren.menuVersion}
/>
))
)}
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
<PersonalMenuItem
key="personal-menu"
personalRoot={organizedChildren.personalRoot}
orientation="vertical"
expand_mode="popup"
collapsed={isCollapsed}
stateVersion={organizedChildren.menuVersion}
/>
)}
</YStack> </YStack>
)} )}
</YStack> </YStack>
@@ -474,6 +491,7 @@ function SideBarNarrow({ children }) {
* @param {React.ReactNode} props.children - Content to render (defaults to topSide) * @param {React.ReactNode} props.children - Content to render (defaults to topSide)
* @param {number} [props.topSideHeight=0] - Top side height (default: 0, wide only) * @param {number} [props.topSideHeight=0] - Top side height (default: 0, wide only)
* @param {number} [props.bottomSideHeight=0] - Bottom side height (default: 0, wide only) * @param {number} [props.bottomSideHeight=0] - Bottom side height (default: 0, wide only)
* @param {string} [props.secondaryStyle='inline'] - Secondary menu rendering: 'stacked' | 'inline' (wide only)
*/ */
export function SideBar(props) { export function SideBar(props) {
const media = useMedia(); const media = useMedia();
+20 -66
View File
@@ -1,7 +1,7 @@
/** /**
* SettingsPage - Settings page component * SettingsPage - Settings page component
* Derived from Page component * Platform default: lists registered fragment routes under the settings base path
* Displays all settings fragment routes as panels * and mounts each with {@code settingsFragmentPath} so fragments can resolve their own meta.
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
@@ -11,51 +11,21 @@ import { useRouter, useRoute } from '../components/Router.jsx';
import { getRootItem } from '../../platform/menu.js'; import { getRootItem } from '../../platform/menu.js';
import { securityService, useSecurityState } from '../../security/runtime/security-service.js'; import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
/**
* SettingsPage Component
* Settings page with icon and title
* Queries Router for all routes under /settings and renders them as panels
*/
export function SettingsPage() { export function SettingsPage() {
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const securityState = useSecurityState(); const securityState = useSecurityState();
const [searchQuery, setSearchQuery] = React.useState('');
// Initialize search query from fragment path if we navigated via a fragment route
const initialSearchQuery = useMemo(() => {
if (route.fragment_path) {
// Extract the last segment from fragment path (e.g., "general" from "/settings/general")
const segments = route.fragment_path.split('/').filter(s => s.length > 0);
if (segments.length > 1) {
return segments[segments.length - 1]; // Return last segment
}
}
return ''; // No fragment, show all
}, [route.fragment_path]);
const [searchQuery, setSearchQuery] = React.useState(initialSearchQuery);
// Update search query when fragment path changes (e.g., navigating between fragments)
React.useEffect(() => {
setSearchQuery(initialSearchQuery);
}, [initialSearchQuery]);
// Get the base path for this settings page (flexible - could be /settings, /config, etc.)
const basePath = useMemo(() => { const basePath = useMemo(() => {
// Get current route path and extract the base (e.g., /settings from /settings/general)
if (route.path) { if (route.path) {
// If it's a fragment route, use fragment_path, otherwise use path
const currentPath = route.fragment_path || route.path; const currentPath = route.fragment_path || route.path;
// Extract base path (first segment) const segments = currentPath.split('/').filter((s) => s.length > 0);
const segments = currentPath.split('/').filter(s => s.length > 0);
return segments.length > 0 ? `/${segments[0]}` : '/settings'; return segments.length > 0 ? `/${segments[0]}` : '/settings';
} }
return '/settings'; // Default fallback return '/settings';
}, [route.path, route.fragment_path]); }, [route.path, route.fragment_path]);
// Query Router for all routes under the base path
// Depend on router.currentRoute to trigger recalculation when routes are registered
// (currentRoute depends on routesVersion which changes when routes are registered)
const childRoutes = useMemo(() => { const childRoutes = useMemo(() => {
const allRoutes = router.getRoutes(); const allRoutes = router.getRoutes();
const settingsRoot = getRootItem('settings'); const settingsRoot = getRootItem('settings');
@@ -65,11 +35,9 @@ export function SettingsPage() {
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options) isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
}; };
const routes = []; const routes = [];
// Find all routes that start with basePath but are not the basePath itself
for (const [path, routeData] of allRoutes.entries()) { for (const [path, routeData] of allRoutes.entries()) {
if (path.startsWith(basePath + '/') && path !== basePath) { if (path.startsWith(`${basePath}/`) && path !== basePath) {
// Only include fragment routes
if (routeData.is_fragment) { if (routeData.is_fragment) {
const matchingMenuItem = settingsItems.find((item) => item.invoke_target === path); const matchingMenuItem = settingsItems.find((item) => item.invoke_target === path);
if (matchingMenuItem && !matchingMenuItem.isRenderable(security)) { if (matchingMenuItem && !matchingMenuItem.isRenderable(security)) {
@@ -83,56 +51,42 @@ export function SettingsPage() {
} }
} }
} }
// Sort by path for consistent ordering
routes.sort((a, b) => a.path.localeCompare(b.path)); routes.sort((a, b) => a.path.localeCompare(b.path));
return routes; return routes;
}, [router, basePath, router.currentRoute, securityState]); // Include router.currentRoute to trigger when routes are registered }, [router, basePath, router.currentRoute, securityState]);
// Filter routes based on search query
const filteredRoutes = useMemo(() => { const filteredRoutes = useMemo(() => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return childRoutes; return childRoutes;
} }
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return childRoutes.filter(route => { return childRoutes.filter((r) => {
// Extract the last segment of the path as the route name const segments = r.path.split('/').filter((s) => s.length > 0);
const segments = route.path.split('/').filter(s => s.length > 0);
const routeName = segments[segments.length - 1] || ''; const routeName = segments[segments.length - 1] || '';
return routeName.toLowerCase().includes(query); return routeName.toLowerCase().includes(query);
}); });
}, [childRoutes, searchQuery]); }, [childRoutes, searchQuery]);
return ( return (
<Page <Page icon="settings" title="Settings" headerRight={[]}>
icon="settings"
title="Settings"
headerRight={[
// Add buttons or controls here later
]}
>
<YStack gap="$4" width="100%"> <YStack gap="$4" width="100%">
{/* Search bar */}
<Input <Input
placeholder="Search settings..." placeholder="Search settings"
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
size="$4" size="$4"
/> />
{/* Render all child route components */}
{filteredRoutes.length > 0 ? ( {filteredRoutes.length > 0 ? (
filteredRoutes.map((routeItem) => { filteredRoutes.map((routeItem) => {
const RouteComponent = routeItem.component; const RouteComponent = routeItem.component;
return ( return (
<RouteComponent key={routeItem.path} /> <RouteComponent key={routeItem.path} settingsFragmentPath={routeItem.path} />
); );
}) })
) : ( ) : (
<Text fontSize="$4" color="$color" opacity={0.6}> <Text fontSize="$4" color="$textMuted">
{searchQuery ? 'No settings found matching your search.' : 'No settings available.'} {searchQuery.trim() ? 'No settings found matching your search.' : 'No settings available.'}
</Text> </Text>
)} )}
</YStack> </YStack>