Refine shared shell, router, and service worker behavior
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function SecurityAdminPage() {
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
</YStack>
|
</YStack>
|
||||||
), 'No permits registered.')
|
), 'No permits registered.')
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+19
-26
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user