@shellui/core 0.2.0-alpha.3 → 0.2.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app.tsx +1 -1
- package/src/components/ContentView.tsx +119 -102
- package/src/components/LoadingOverlay.tsx +6 -2
- package/src/components/NotFoundView.tsx +6 -1
- package/src/components/ViewRoute.tsx +39 -13
- package/src/constants.ts +2 -0
- package/src/features/config/types.ts +2 -0
- package/src/features/layouts/AppBarLayout.tsx +1 -2
- package/src/features/layouts/DefaultLayout.tsx +35 -26
- package/src/features/layouts/OverlayShell.tsx +38 -5
- package/src/features/layouts/WindowsLayout.tsx +26 -5
- package/src/features/layouts/utils.ts +30 -7
- package/src/features/settings/SettingsProvider.tsx +13 -11
- package/src/features/settings/SettingsView.tsx +1 -1
- package/src/router/routes.tsx +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shellui/core",
|
|
3
|
-
"version": "0.2.0-
|
|
3
|
+
"version": "0.2.0-beta.0",
|
|
4
4
|
"description": "ShellUI Core - Core React application runtime",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"workbox-strategies": "^7.1.0",
|
|
59
59
|
"workbox-cacheable-response": "^7.1.0",
|
|
60
60
|
"workbox-expiration": "^7.1.0",
|
|
61
|
-
"@shellui/sdk": "0.2.0-
|
|
61
|
+
"@shellui/sdk": "0.2.0-beta.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/app.tsx
CHANGED
|
@@ -33,7 +33,7 @@ const AppContent = () => {
|
|
|
33
33
|
unregisterServiceWorker();
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
-
const serviceWorkerEnabled = settings?.serviceWorker?.enabled ??
|
|
36
|
+
const serviceWorkerEnabled = settings?.serviceWorker?.enabled ?? false; // Default to enabled
|
|
37
37
|
|
|
38
38
|
// Don't register service worker if navigation is empty or undefined
|
|
39
39
|
// This helps prevent issues in development or misconfigured apps
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
2
|
import type { NavigationItem } from '../features/config/types';
|
|
3
|
+
import { isHashRouterNavItem, getHashPathFromUrl } from '../features/layouts/utils';
|
|
3
4
|
import {
|
|
4
5
|
addIframe,
|
|
5
6
|
removeIframe,
|
|
@@ -10,10 +11,14 @@ import {
|
|
|
10
11
|
} from '@shellui/sdk';
|
|
11
12
|
import { useEffect, useRef, useState } from 'react';
|
|
12
13
|
import { useNavigate } from 'react-router';
|
|
14
|
+
import { LOADING_OVERLAY_DURATION_MS } from '../constants';
|
|
13
15
|
import { LoadingOverlay } from './LoadingOverlay';
|
|
14
16
|
|
|
15
17
|
const logger = getLogger('shellcore');
|
|
16
18
|
|
|
19
|
+
/** URL of the last main-content iframe that sent SHELLUI_INITIALIZED. Used to skip the loading overlay when navigating between nav items that point to the same app URL. */
|
|
20
|
+
let lastLoadedIframeUrl: string | null = null;
|
|
21
|
+
|
|
17
22
|
interface ContentViewProps {
|
|
18
23
|
url: string;
|
|
19
24
|
pathPrefix: string;
|
|
@@ -29,9 +34,18 @@ export const ContentView = ({
|
|
|
29
34
|
}: ContentViewProps) => {
|
|
30
35
|
const navigate = useNavigate();
|
|
31
36
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
32
|
-
const
|
|
37
|
+
const cancelRevealRef = useRef<(() => void) | null>(null);
|
|
38
|
+
const mountTimeRef = useRef(Date.now());
|
|
39
|
+
const urlRef = useRef(url);
|
|
40
|
+
urlRef.current = url;
|
|
33
41
|
const [initialUrl] = useState(url);
|
|
34
|
-
const [isLoading, setIsLoading] = useState(
|
|
42
|
+
const [isLoading, setIsLoading] = useState(() => {
|
|
43
|
+
// Skip overlay when same app URL was just loaded (e.g. switching App ↔ Root with same url)
|
|
44
|
+
if (!ignoreMessages && url === lastLoadedIframeUrl) return false;
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const MIN_LOADING_MS = 80; // Don't reveal before this, reduces blink from theme/layout paint
|
|
35
49
|
|
|
36
50
|
useEffect(() => {
|
|
37
51
|
if (!iframeRef.current) {
|
|
@@ -58,23 +72,44 @@ export const ContentView = ({
|
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
const { pathname, search, hash } = data.payload as ShellUIUrlPayload;
|
|
61
|
-
//
|
|
62
|
-
let
|
|
63
|
-
|
|
64
|
-
:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
// Shell URL is always path + search only (no hash) so it's transparent whether the sub-app uses hash routing or not
|
|
76
|
+
let pathSegment: string;
|
|
77
|
+
if (isHashRouterNavItem(navItem) && hash) {
|
|
78
|
+
// Hash-router app: use path relative to nav item's hash (e.g. nav #/themes, iframe #/themes → segment ''; iframe #/themes/foo → segment 'foo')
|
|
79
|
+
const iframeHashPath = hash.replace(/^#\/?/, '').replace(/\/+$/, '') || '';
|
|
80
|
+
const navHashPath = getHashPathFromUrl(navItem.url).replace(/^\/+|\/+$/g, '');
|
|
81
|
+
const relative = navHashPath
|
|
82
|
+
? iframeHashPath === navHashPath || iframeHashPath.startsWith(`${navHashPath}/`)
|
|
83
|
+
? iframeHashPath.slice(navHashPath.length).replace(/^\//, '')
|
|
84
|
+
: iframeHashPath
|
|
85
|
+
: iframeHashPath;
|
|
86
|
+
pathSegment = relative;
|
|
87
|
+
} else {
|
|
88
|
+
// Non-hash app: route is pathname
|
|
89
|
+
let cleanPathname = pathname.startsWith(navItem.url)
|
|
90
|
+
? pathname.slice(navItem.url.length)
|
|
91
|
+
: pathname;
|
|
92
|
+
cleanPathname = cleanPathname.startsWith('/') ? cleanPathname.slice(1) : cleanPathname;
|
|
93
|
+
pathSegment = cleanPathname.replace(/\/+$/, '');
|
|
94
|
+
}
|
|
95
|
+
// Root (pathPrefix '' or '/') must produce /segment not //segment
|
|
96
|
+
const isRoot = pathPrefix === '' || pathPrefix === '/';
|
|
97
|
+
let newShellPath = isRoot
|
|
98
|
+
? pathSegment
|
|
99
|
+
? `/${pathSegment}${search}`
|
|
100
|
+
: search
|
|
101
|
+
? `/${search}`
|
|
102
|
+
: '/'
|
|
103
|
+
: pathSegment
|
|
104
|
+
? `/${pathPrefix}/${pathSegment}${search}`
|
|
105
|
+
: `/${pathPrefix}${search}`;
|
|
71
106
|
|
|
72
|
-
// Normalize: remove trailing slashes from pathname part only (preserve query
|
|
107
|
+
// Normalize: remove trailing slashes from pathname part only (preserve query)
|
|
73
108
|
const urlParts = newShellPath.match(/^([^?#]*)([?#].*)?$/);
|
|
74
109
|
if (urlParts) {
|
|
75
110
|
const pathnamePart = urlParts[1].replace(/\/+$/, '') || '/';
|
|
76
|
-
const
|
|
77
|
-
newShellPath = pathnamePart +
|
|
111
|
+
const queryPart = urlParts[2] || '';
|
|
112
|
+
newShellPath = pathnamePart + queryPart;
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
// Normalize current path for comparison (remove trailing slashes from pathname)
|
|
@@ -87,14 +122,7 @@ export const ContentView = ({
|
|
|
87
122
|
const normalizedNewPath = normalizedNewPathname + (newPathParts?.[2] || '');
|
|
88
123
|
|
|
89
124
|
if (currentPath !== normalizedNewPath) {
|
|
90
|
-
|
|
91
|
-
isInternalNavigation.current = true;
|
|
92
|
-
navigate(newShellPath, { replace: true });
|
|
93
|
-
|
|
94
|
-
// Reset the flag after a short delay to allow the render cycle to complete
|
|
95
|
-
setTimeout(() => {
|
|
96
|
-
isInternalNavigation.current = false;
|
|
97
|
-
}, 100);
|
|
125
|
+
navigate(newShellPath);
|
|
98
126
|
}
|
|
99
127
|
},
|
|
100
128
|
);
|
|
@@ -102,105 +130,85 @@ export const ContentView = ({
|
|
|
102
130
|
return () => {
|
|
103
131
|
cleanup();
|
|
104
132
|
};
|
|
105
|
-
}, [pathPrefix, navigate]);
|
|
133
|
+
}, [pathPrefix, navigate, navItem]);
|
|
134
|
+
|
|
135
|
+
const scheduleReveal = (reveal: () => void) => {
|
|
136
|
+
const doReveal = () => {
|
|
137
|
+
const elapsed = Date.now() - mountTimeRef.current;
|
|
138
|
+
if (elapsed < MIN_LOADING_MS) {
|
|
139
|
+
const timer = setTimeout(doReveal, MIN_LOADING_MS - elapsed);
|
|
140
|
+
cancelRevealRef.current = () => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
cancelRevealRef.current = null;
|
|
143
|
+
};
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
reveal();
|
|
147
|
+
};
|
|
148
|
+
requestAnimationFrame(() => requestAnimationFrame(doReveal));
|
|
149
|
+
};
|
|
106
150
|
|
|
107
|
-
// Hide loading overlay when iframe sends SHELLUI_INITIALIZED
|
|
151
|
+
// Hide loading overlay when iframe sends SHELLUI_INITIALIZED.
|
|
152
|
+
// Defer reveal (double rAF + min time) so the iframe has time to apply theme and paint.
|
|
153
|
+
// Remember this URL so we can skip the overlay when navigating to the same app (e.g. App ↔ Root).
|
|
108
154
|
useEffect(() => {
|
|
109
155
|
const cleanup = shellui.addMessageListener(
|
|
110
156
|
'SHELLUI_INITIALIZED',
|
|
111
157
|
(_data: ShellUIMessage, event: MessageEvent) => {
|
|
112
|
-
if (event.source
|
|
113
|
-
|
|
114
|
-
|
|
158
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
159
|
+
if (!ignoreMessages) lastLoadedIframeUrl = urlRef.current;
|
|
160
|
+
cancelRevealRef.current?.();
|
|
161
|
+
let cancelled = false;
|
|
162
|
+
cancelRevealRef.current = () => {
|
|
163
|
+
cancelled = true;
|
|
164
|
+
cancelRevealRef.current = null;
|
|
165
|
+
};
|
|
166
|
+
scheduleReveal(() => {
|
|
167
|
+
if (!cancelled) setIsLoading(false);
|
|
168
|
+
cancelRevealRef.current = null;
|
|
169
|
+
});
|
|
115
170
|
},
|
|
116
171
|
);
|
|
117
|
-
return () =>
|
|
118
|
-
|
|
172
|
+
return () => {
|
|
173
|
+
cancelRevealRef.current?.();
|
|
174
|
+
cancelRevealRef.current = null;
|
|
175
|
+
cleanup();
|
|
176
|
+
};
|
|
177
|
+
}, [ignoreMessages]);
|
|
119
178
|
|
|
120
|
-
// Fallback: hide overlay after
|
|
179
|
+
// Fallback: hide overlay after LOADING_OVERLAY_DURATION_MS if SHELLUI_INITIALIZED was not received.
|
|
121
180
|
useEffect(() => {
|
|
122
181
|
if (!isLoading) return;
|
|
123
182
|
const timeoutId = setTimeout(() => {
|
|
124
183
|
logger.info('ContentView: Timeout expired, hiding loading overlay');
|
|
125
|
-
|
|
126
|
-
|
|
184
|
+
cancelRevealRef.current?.();
|
|
185
|
+
let cancelled = false;
|
|
186
|
+
cancelRevealRef.current = () => {
|
|
187
|
+
cancelled = true;
|
|
188
|
+
cancelRevealRef.current = null;
|
|
189
|
+
};
|
|
190
|
+
scheduleReveal(() => {
|
|
191
|
+
if (!cancelled) setIsLoading(false);
|
|
192
|
+
cancelRevealRef.current = null;
|
|
193
|
+
});
|
|
194
|
+
}, LOADING_OVERLAY_DURATION_MS);
|
|
127
195
|
return () => clearTimeout(timeoutId);
|
|
128
196
|
}, [isLoading]);
|
|
129
197
|
|
|
130
198
|
// Handle external URL changes (e.g. from Sidebar)
|
|
131
199
|
useEffect(() => {
|
|
132
|
-
if (iframeRef.current
|
|
133
|
-
// Only update iframe src if it's actually different from its current src
|
|
134
|
-
// to avoid unnecessary reloads
|
|
200
|
+
if (iframeRef.current) {
|
|
135
201
|
if (iframeRef.current.src !== url) {
|
|
136
202
|
iframeRef.current.src = url;
|
|
137
|
-
|
|
203
|
+
// Skip overlay when switching to the same app URL (e.g. App ↔ Root); different app still shows overlay
|
|
204
|
+
const sameAppAlreadyLoaded = !ignoreMessages;
|
|
205
|
+
if (!sameAppAlreadyLoaded) {
|
|
206
|
+
setIsLoading(true);
|
|
207
|
+
mountTimeRef.current = Date.now(); // apply min delay for this load too
|
|
208
|
+
}
|
|
138
209
|
}
|
|
139
210
|
}
|
|
140
|
-
}, [url]);
|
|
141
|
-
|
|
142
|
-
// Inject script to prevent "Layout was forced" warning by deferring layout until stylesheets load
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
const iframe = iframeRef.current;
|
|
145
|
-
if (!iframe) return;
|
|
146
|
-
|
|
147
|
-
const handleLoad = () => {
|
|
148
|
-
try {
|
|
149
|
-
const iframeWindow = iframe.contentWindow;
|
|
150
|
-
const iframeDoc = iframe.contentDocument || iframeWindow?.document;
|
|
151
|
-
if (!iframeDoc || !iframeWindow) return;
|
|
152
|
-
|
|
153
|
-
// Inject a script that waits for stylesheets before allowing layout calculations
|
|
154
|
-
const script = iframeDoc.createElement('script');
|
|
155
|
-
script.textContent = `
|
|
156
|
-
(function() {
|
|
157
|
-
// Wait for all stylesheets to load
|
|
158
|
-
function waitForStylesheets() {
|
|
159
|
-
const styleSheets = Array.from(document.styleSheets);
|
|
160
|
-
const pendingSheets = styleSheets.filter(function(sheet) {
|
|
161
|
-
try {
|
|
162
|
-
return sheet.cssRules === null;
|
|
163
|
-
} catch (e) {
|
|
164
|
-
return false; // Cross-origin stylesheets, assume loaded
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
if (pendingSheets.length === 0) {
|
|
169
|
-
// All stylesheets loaded
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Check again after a short delay
|
|
174
|
-
setTimeout(waitForStylesheets, 10);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Start checking after DOM is ready
|
|
178
|
-
if (document.readyState === 'complete') {
|
|
179
|
-
waitForStylesheets();
|
|
180
|
-
} else {
|
|
181
|
-
window.addEventListener('load', waitForStylesheets);
|
|
182
|
-
}
|
|
183
|
-
})();
|
|
184
|
-
`;
|
|
185
|
-
iframeDoc.head.appendChild(script);
|
|
186
|
-
} catch (error) {
|
|
187
|
-
// Cross-origin or other errors - ignore (this is expected for some iframes)
|
|
188
|
-
logger.debug('Could not inject stylesheet wait script:', { error });
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// Wait for iframe to load before injecting script
|
|
193
|
-
iframe.addEventListener('load', handleLoad);
|
|
194
|
-
|
|
195
|
-
// Also try immediately if already loaded
|
|
196
|
-
if (iframe.contentDocument?.readyState === 'complete') {
|
|
197
|
-
handleLoad();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return () => {
|
|
201
|
-
iframe.removeEventListener('load', handleLoad);
|
|
202
|
-
};
|
|
203
|
-
}, [initialUrl]);
|
|
211
|
+
}, [url, ignoreMessages]);
|
|
204
212
|
|
|
205
213
|
// Suppress browser warnings that are expected and acceptable
|
|
206
214
|
useEffect(() => {
|
|
@@ -234,19 +242,28 @@ export const ContentView = ({
|
|
|
234
242
|
}, []);
|
|
235
243
|
|
|
236
244
|
return (
|
|
237
|
-
<div
|
|
245
|
+
<div
|
|
246
|
+
style={{ width: '100%', height: '100%', display: 'flex', position: 'relative' }}
|
|
247
|
+
className="bg-background"
|
|
248
|
+
>
|
|
238
249
|
{/* Note: allow-same-origin is required for same-origin iframe content (e.g., Vite dev server, cookies, localStorage).
|
|
239
250
|
While this allows the iframe to remove its own sandboxing, it's acceptable here because the iframe content
|
|
240
251
|
is trusted microfrontend content from the same application origin.
|
|
241
252
|
Browser security warnings about this combination cannot be suppressed programmatically. */}
|
|
253
|
+
{/* Strategy to prevent browser deprioritizing iframe rendering:
|
|
254
|
+
- loading="eager" explicitly requests immediate loading (not deferred)
|
|
255
|
+
- opacity:0 hides the iframe during loading while keeping it in the rendering pipeline
|
|
256
|
+
- Reveal is instant (no transition) after deferred double-rAF to avoid blink */}
|
|
242
257
|
<iframe
|
|
243
258
|
ref={iframeRef}
|
|
244
259
|
src={initialUrl}
|
|
260
|
+
loading="eager"
|
|
245
261
|
style={{
|
|
246
262
|
width: '100%',
|
|
247
263
|
height: '100%',
|
|
248
264
|
border: 'none',
|
|
249
265
|
display: 'block',
|
|
266
|
+
opacity: isLoading ? 0 : 1,
|
|
250
267
|
}}
|
|
251
268
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
252
269
|
referrerPolicy="no-referrer-when-downgrade"
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { LOADING_OVERLAY_DURATION_MS } from '../constants';
|
|
2
|
+
|
|
1
3
|
export function LoadingOverlay() {
|
|
2
4
|
return (
|
|
3
|
-
<div className="absolute inset-0 z-10
|
|
5
|
+
<div className="absolute inset-x-0 top-0 z-10">
|
|
4
6
|
<div className="h-1 w-full overflow-hidden bg-muted/30">
|
|
5
7
|
<div
|
|
6
8
|
className="h-full w-0 bg-muted-foreground/50"
|
|
7
|
-
style={{
|
|
9
|
+
style={{
|
|
10
|
+
animation: `loading-bar-slide ${LOADING_OVERLAY_DURATION_MS}ms linear infinite`,
|
|
11
|
+
}}
|
|
8
12
|
/>
|
|
9
13
|
</div>
|
|
10
14
|
</div>
|
|
@@ -37,7 +37,12 @@ export const NotFoundView = () => {
|
|
|
37
37
|
: [];
|
|
38
38
|
|
|
39
39
|
const handleNavigate = (path: string) => {
|
|
40
|
-
|
|
40
|
+
const targetPath = path.startsWith('/') ? path : `/${path}`;
|
|
41
|
+
if (window.self !== window.top) {
|
|
42
|
+
shellui.navigate(targetPath);
|
|
43
|
+
} else {
|
|
44
|
+
window.location.href = targetPath;
|
|
45
|
+
}
|
|
41
46
|
};
|
|
42
47
|
|
|
43
48
|
return (
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
2
|
import { Navigate, useLocation } from 'react-router';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getNavPathPrefix,
|
|
5
|
+
isHashRouterNavItem,
|
|
6
|
+
getBaseUrlWithoutHash,
|
|
7
|
+
getHashPathFromUrl,
|
|
8
|
+
} from '../features/layouts/utils';
|
|
4
9
|
import { ContentView } from './ContentView';
|
|
5
10
|
import type { NavigationItem } from '../features/config/types';
|
|
6
11
|
|
|
@@ -19,7 +24,22 @@ export const ViewRoute = ({ navigation }: ViewRouteProps) => {
|
|
|
19
24
|
});
|
|
20
25
|
}, [navigation, pathname]);
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
// When no nav matches (e.g. /layout on refresh): use root item (path '' or '/') with pathname as hash subpath to avoid 404
|
|
28
|
+
const rootItem = useMemo(
|
|
29
|
+
() => navigation.find((item) => item.path === '' || item.path === '/'),
|
|
30
|
+
[navigation],
|
|
31
|
+
);
|
|
32
|
+
const useRootFallback = !navItem && rootItem && pathname !== '/';
|
|
33
|
+
const actualNavItem = navItem ?? (useRootFallback ? rootItem : null);
|
|
34
|
+
const actualSubPath = useRootFallback
|
|
35
|
+
? pathname.replace(/^\//, '')
|
|
36
|
+
: actualNavItem
|
|
37
|
+
? pathname.length > getNavPathPrefix(actualNavItem).length
|
|
38
|
+
? pathname.slice(getNavPathPrefix(actualNavItem).length + 1)
|
|
39
|
+
: ''
|
|
40
|
+
: '';
|
|
41
|
+
|
|
42
|
+
if (!actualNavItem) {
|
|
23
43
|
return (
|
|
24
44
|
<Navigate
|
|
25
45
|
to="/"
|
|
@@ -27,22 +47,28 @@ export const ViewRoute = ({ navigation }: ViewRouteProps) => {
|
|
|
27
47
|
/>
|
|
28
48
|
);
|
|
29
49
|
}
|
|
30
|
-
|
|
31
|
-
// e.g. if item.path is "docs" and pathname is "/docs/intro", subPath is "intro"
|
|
32
|
-
const pathPrefix = getNavPathPrefix(navItem);
|
|
33
|
-
const subPath = pathname.length > pathPrefix.length ? pathname.slice(pathPrefix.length + 1) : '';
|
|
50
|
+
const subPath = actualSubPath;
|
|
34
51
|
|
|
35
|
-
// Construct the final URL for the iframe
|
|
36
|
-
let finalUrl
|
|
37
|
-
if (
|
|
38
|
-
const
|
|
39
|
-
|
|
52
|
+
// Construct the final URL for the iframe (non-hash: base + path; hash app: preserve nav url hash path + subPath)
|
|
53
|
+
let finalUrl: string;
|
|
54
|
+
if (isHashRouterNavItem(actualNavItem)) {
|
|
55
|
+
const base = getBaseUrlWithoutHash(actualNavItem.url).replace(/\/$/, '');
|
|
56
|
+
const navHashPath = getHashPathFromUrl(actualNavItem.url).replace(/^\/+|\/+$/g, '');
|
|
57
|
+
const segments = [navHashPath, subPath].filter(Boolean);
|
|
58
|
+
const fullHashPath = `/${segments.join('/')}`;
|
|
59
|
+
finalUrl = `${base}#${fullHashPath}`;
|
|
60
|
+
} else {
|
|
61
|
+
finalUrl = actualNavItem.url;
|
|
62
|
+
if (subPath) {
|
|
63
|
+
const baseUrl = actualNavItem.url.endsWith('/') ? actualNavItem.url : `${actualNavItem.url}/`;
|
|
64
|
+
finalUrl = `${baseUrl}${subPath}`;
|
|
65
|
+
}
|
|
40
66
|
}
|
|
41
67
|
return (
|
|
42
68
|
<ContentView
|
|
43
69
|
url={finalUrl}
|
|
44
|
-
pathPrefix={
|
|
45
|
-
navItem={
|
|
70
|
+
pathPrefix={actualNavItem.path}
|
|
71
|
+
navItem={actualNavItem}
|
|
46
72
|
/>
|
|
47
73
|
);
|
|
48
74
|
};
|
package/src/constants.ts
ADDED
|
@@ -26,6 +26,8 @@ export interface NavigationItem {
|
|
|
26
26
|
hiddenOnDesktop?: boolean;
|
|
27
27
|
/** How to open this link: 'default' (navigate in main area), 'modal', 'drawer', or 'external' (target="_blank"). */
|
|
28
28
|
openIn?: 'default' | 'modal' | 'drawer' | 'external';
|
|
29
|
+
/** When true, the app uses hash-based routing (e.g. /#/path). If omitted, inferred from url containing /#/. */
|
|
30
|
+
useHashRouter?: boolean;
|
|
29
31
|
/** Optional drawer position when openIn === 'drawer'. Default is 'right' if omitted. */
|
|
30
32
|
drawerPosition?: DrawerPosition;
|
|
31
33
|
/** Sidebar position: 'start' (default) or 'end'. End items are rendered in the sidebar footer. */
|
|
@@ -61,8 +61,7 @@ function TopBarEndItem({
|
|
|
61
61
|
const pathPrefix = getNavPathPrefix(item);
|
|
62
62
|
const isOverlay = item.openIn === 'modal' || item.openIn === 'drawer';
|
|
63
63
|
const isExternal = item.openIn === 'external';
|
|
64
|
-
const isActive =
|
|
65
|
-
!isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
64
|
+
const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
66
65
|
|
|
67
66
|
const faviconUrl = isExternal && !item.icon ? getExternalFaviconUrl(item.url) : null;
|
|
68
67
|
const iconSrc = item.icon ?? faviconUrl ?? null;
|
|
@@ -99,8 +99,7 @@ const NavigationContent = ({
|
|
|
99
99
|
const pathPrefix = getNavPathPrefix(navItem);
|
|
100
100
|
const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
|
|
101
101
|
const isExternal = navItem.openIn === 'external';
|
|
102
|
-
const isActive =
|
|
103
|
-
!isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
102
|
+
const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
|
|
104
103
|
const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
|
|
105
104
|
const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
|
|
106
105
|
const iconSrc = navItem.icon ?? faviconUrl ?? null;
|
|
@@ -434,13 +433,16 @@ const HomeIcon = ({ className }: { className?: string }) => (
|
|
|
434
433
|
</svg>
|
|
435
434
|
);
|
|
436
435
|
|
|
437
|
-
/** Mobile bottom nav: Home + nav items; More only when not all fit. Dynamic from width. Reuses HOMEPAGE_NAV_ITEM for label. */
|
|
436
|
+
/** Mobile bottom nav: optional Home + nav items; More only when not all fit. Dynamic from width. Reuses HOMEPAGE_NAV_ITEM for label. Home is shown only when no nav item is defined for "/". */
|
|
438
437
|
const MobileBottomNav = ({
|
|
439
438
|
items,
|
|
440
439
|
currentLanguage,
|
|
440
|
+
showHomeButton,
|
|
441
441
|
}: {
|
|
442
442
|
items: NavigationItem[];
|
|
443
443
|
currentLanguage: string;
|
|
444
|
+
/** When false, do not show the Home button (e.g. when a nav item for "/" exists). */
|
|
445
|
+
showHomeButton: boolean;
|
|
444
446
|
}) => {
|
|
445
447
|
const location = useLocation();
|
|
446
448
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -471,9 +473,11 @@ const MobileBottomNav = ({
|
|
|
471
473
|
const computedSlots =
|
|
472
474
|
rowWidth > 0 ? Math.floor((contentWidth + BOTTOM_NAV_GAP) / slotTotal) : 5;
|
|
473
475
|
const totalSlots = Math.min(Math.max(0, computedSlots), BOTTOM_NAV_MAX_SLOTS);
|
|
474
|
-
const slotsForNav = totalSlots - 1;
|
|
476
|
+
const slotsForNav = showHomeButton ? totalSlots - 1 : totalSlots;
|
|
475
477
|
const allFit = list.length <= slotsForNav;
|
|
476
|
-
const maxInRow = allFit
|
|
478
|
+
const maxInRow = allFit
|
|
479
|
+
? list.length
|
|
480
|
+
: Math.max(0, showHomeButton ? totalSlots - 2 : totalSlots - 1);
|
|
477
481
|
const row = list.slice(0, maxInRow);
|
|
478
482
|
const rowPaths = new Set(row.map((i) => i.path));
|
|
479
483
|
const overflow = list.filter((item) => !rowPaths.has(item.path));
|
|
@@ -482,7 +486,7 @@ const MobileBottomNav = ({
|
|
|
482
486
|
overflowItems: overflow,
|
|
483
487
|
hasMore: overflow.length > 0,
|
|
484
488
|
};
|
|
485
|
-
}, [items, rowWidth]);
|
|
489
|
+
}, [items, rowWidth, showHomeButton]);
|
|
486
490
|
|
|
487
491
|
useEffect(() => {
|
|
488
492
|
setExpanded(false);
|
|
@@ -519,25 +523,27 @@ const MobileBottomNav = ({
|
|
|
519
523
|
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
|
520
524
|
}}
|
|
521
525
|
>
|
|
522
|
-
{/* Top row: Home + nav items + More/Less — single row, no wrap */}
|
|
526
|
+
{/* Top row: optional Home + nav items + More/Less — single row, no wrap */}
|
|
523
527
|
<div className="flex flex-row flex-nowrap items-center justify-center gap-1 px-3 overflow-x-hidden">
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
<
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
528
|
+
{showHomeButton && (
|
|
529
|
+
<Link
|
|
530
|
+
to="/"
|
|
531
|
+
className={cn(
|
|
532
|
+
'flex flex-col items-center justify-center gap-1 rounded-md py-1.5 px-2 min-w-0 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
533
|
+
location.pathname === '/' || location.pathname === ''
|
|
534
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground [&_span]:text-sidebar-accent-foreground'
|
|
535
|
+
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground [&_span]:inherit',
|
|
536
|
+
)}
|
|
537
|
+
aria-label={resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
|
|
538
|
+
>
|
|
539
|
+
<span className="size-4 shrink-0 flex items-center justify-center [&_svg]:text-current">
|
|
540
|
+
<HomeIcon className="size-4" />
|
|
541
|
+
</span>
|
|
542
|
+
<span className="text-[11px] leading-tight">
|
|
543
|
+
{resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
|
|
544
|
+
</span>
|
|
545
|
+
</Link>
|
|
546
|
+
)}
|
|
541
547
|
{rowItems.map((item, i) => renderItem(item, i))}
|
|
542
548
|
{hasMore && (
|
|
543
549
|
<button
|
|
@@ -582,17 +588,19 @@ const DefaultLayoutContent = ({ title, logo, navigation }: DefaultLayoutProps) =
|
|
|
582
588
|
const { i18n } = useTranslation();
|
|
583
589
|
const currentLanguage = i18n.language || 'en';
|
|
584
590
|
|
|
585
|
-
const { startNav, endItems, navigationItems, mobileNavItems } = useMemo(() => {
|
|
591
|
+
const { startNav, endItems, navigationItems, mobileNavItems, hasRootNavItem } = useMemo(() => {
|
|
586
592
|
const desktopNav = filterNavigationByViewport(navigation, 'desktop');
|
|
587
593
|
const mobileNav = filterNavigationByViewport(navigation, 'mobile');
|
|
588
594
|
const { start, end } = splitNavigationByPosition(desktopNav);
|
|
589
595
|
const flat = flattenNavigationItems(desktopNav);
|
|
590
596
|
const mobileFlat = flattenNavigationItems(mobileNav);
|
|
597
|
+
const hasRoot = flat.some((item) => item.path === '' || item.path === '/');
|
|
591
598
|
return {
|
|
592
599
|
startNav: filterNavigationForSidebar(start),
|
|
593
600
|
endItems: end,
|
|
594
601
|
navigationItems: flat,
|
|
595
602
|
mobileNavItems: mobileFlat,
|
|
603
|
+
hasRootNavItem: hasRoot,
|
|
596
604
|
};
|
|
597
605
|
}, [navigation]);
|
|
598
606
|
|
|
@@ -638,10 +646,11 @@ const DefaultLayoutContent = ({ title, logo, navigation }: DefaultLayoutProps) =
|
|
|
638
646
|
</main>
|
|
639
647
|
</div>
|
|
640
648
|
|
|
641
|
-
{/* Mobile bottom nav: visible only below md */}
|
|
649
|
+
{/* Mobile bottom nav: visible only below md; Home button only when no view for / */}
|
|
642
650
|
<MobileBottomNav
|
|
643
651
|
items={mobileNavItems}
|
|
644
652
|
currentLanguage={currentLanguage}
|
|
653
|
+
showHomeButton={!hasRootNavItem}
|
|
645
654
|
/>
|
|
646
655
|
</OverlayShell>
|
|
647
656
|
</SidebarProvider>
|
|
@@ -9,7 +9,12 @@ import { Toaster } from '../../components/ui/sonner';
|
|
|
9
9
|
import { ContentView } from '../../components/ContentView';
|
|
10
10
|
import { useModal } from '../modal/ModalContext';
|
|
11
11
|
import { useDrawer } from '../drawer/DrawerContext';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getNavPathPrefix,
|
|
14
|
+
getBaseUrlWithoutHash,
|
|
15
|
+
isHashRouterNavItem,
|
|
16
|
+
resolveLocalizedString,
|
|
17
|
+
} from './utils';
|
|
13
18
|
|
|
14
19
|
interface OverlayShellProps {
|
|
15
20
|
navigationItems: NavigationItem[];
|
|
@@ -43,8 +48,39 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
43
48
|
const rawUrl = payload?.url;
|
|
44
49
|
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return;
|
|
45
50
|
|
|
51
|
+
closeModal();
|
|
52
|
+
closeDrawer();
|
|
53
|
+
|
|
46
54
|
let pathname: string;
|
|
47
|
-
|
|
55
|
+
|
|
56
|
+
// Hash-based URL (e.g. http://localhost:5173/#/themes/foo): show non-hash path in shell, match by nav item base URL
|
|
57
|
+
if (rawUrl.includes('/#/')) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = new URL(rawUrl);
|
|
60
|
+
const hashPart = parsed.hash.slice(1); // strip leading #
|
|
61
|
+
const hashPath = hashPart.startsWith('/') ? hashPart : `/${hashPart}`;
|
|
62
|
+
const baseUrl = getBaseUrlWithoutHash(rawUrl);
|
|
63
|
+
const navItem = navigationItems.find(
|
|
64
|
+
(item) => isHashRouterNavItem(item) && getBaseUrlWithoutHash(item.url) === baseUrl,
|
|
65
|
+
);
|
|
66
|
+
if (!navItem) {
|
|
67
|
+
shellui.toast({
|
|
68
|
+
type: 'error',
|
|
69
|
+
title: t('navigationError') ?? 'Navigation error',
|
|
70
|
+
description:
|
|
71
|
+
t('navigationNotAllowed') ?? 'This URL is not configured in the app navigation.',
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pathPrefix = getNavPathPrefix(navItem);
|
|
76
|
+
pathname =
|
|
77
|
+
hashPath === '/' || hashPath === ''
|
|
78
|
+
? pathPrefix
|
|
79
|
+
: `${pathPrefix.replace(/\/$/, '')}${hashPath}`;
|
|
80
|
+
} catch {
|
|
81
|
+
pathname = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
|
82
|
+
}
|
|
83
|
+
} else if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
|
|
48
84
|
try {
|
|
49
85
|
pathname = new URL(rawUrl).pathname;
|
|
50
86
|
} catch {
|
|
@@ -54,9 +90,6 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
54
90
|
pathname = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
|
55
91
|
}
|
|
56
92
|
|
|
57
|
-
closeModal();
|
|
58
|
-
closeDrawer();
|
|
59
|
-
|
|
60
93
|
const isHomepage = pathname === '/' || pathname === '';
|
|
61
94
|
const isAllowed =
|
|
62
95
|
isHomepage ||
|
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
useEffect,
|
|
7
7
|
type PointerEvent as ReactPointerEvent,
|
|
8
8
|
} from 'react';
|
|
9
|
+
import { useLocation } from 'react-router';
|
|
9
10
|
import { useTranslation } from 'react-i18next';
|
|
10
11
|
import { shellui } from '@shellui/sdk';
|
|
11
12
|
import type { NavigationItem, NavigationGroup } from '../config/types';
|
|
12
13
|
import {
|
|
13
14
|
flattenNavigationItems,
|
|
15
|
+
getActivePathPrefix,
|
|
14
16
|
getNavPathPrefix,
|
|
15
17
|
resolveLocalizedString as resolveNavLabel,
|
|
16
18
|
splitNavigationByPosition,
|
|
@@ -121,13 +123,16 @@ function AppWindow({
|
|
|
121
123
|
const resizeRafRef = useRef<number | null>(null);
|
|
122
124
|
const pendingResizeBoundsRef = useRef<WindowState['bounds'] | null>(null);
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
// Use a ref for onBoundsChange to avoid it in effect deps (prevents infinite render loop).
|
|
127
|
+
// The parent creates a new callback reference on every render (inline arrow), so including
|
|
128
|
+
// it in a dependency array would re-fire the effect every render, triggering a state update
|
|
129
|
+
// in the parent, which re-renders, which re-fires the effect → React error #185.
|
|
130
|
+
const onBoundsChangeRef = useRef(onBoundsChange);
|
|
131
|
+
onBoundsChangeRef.current = onBoundsChange;
|
|
127
132
|
|
|
128
133
|
useEffect(() => {
|
|
129
|
-
|
|
130
|
-
}, [bounds
|
|
134
|
+
onBoundsChangeRef.current(bounds);
|
|
135
|
+
}, [bounds]);
|
|
131
136
|
|
|
132
137
|
// When maximized, keep filling the viewport on window resize
|
|
133
138
|
useEffect(() => {
|
|
@@ -504,6 +509,7 @@ export function WindowsLayout({
|
|
|
504
509
|
logo: _logo,
|
|
505
510
|
navigation,
|
|
506
511
|
}: WindowsLayoutProps) {
|
|
512
|
+
const location = useLocation();
|
|
507
513
|
const { i18n } = useTranslation();
|
|
508
514
|
const { settings } = useSettings();
|
|
509
515
|
const currentLanguage = i18n.language || 'en';
|
|
@@ -523,6 +529,7 @@ export function WindowsLayout({
|
|
|
523
529
|
const [startMenuOpen, setStartMenuOpen] = useState(false);
|
|
524
530
|
const [now, setNow] = useState(() => new Date());
|
|
525
531
|
const startPanelRef = useRef<HTMLDivElement>(null);
|
|
532
|
+
const initialOpenFromUrlDoneRef = useRef(false);
|
|
526
533
|
|
|
527
534
|
// Update date/time every second for taskbar clock
|
|
528
535
|
useEffect(() => {
|
|
@@ -568,6 +575,20 @@ export function WindowsLayout({
|
|
|
568
575
|
[currentLanguage, windows.length],
|
|
569
576
|
);
|
|
570
577
|
|
|
578
|
+
// On first load only: open a window for the current URL if it matches a nav item (no reaction to later URL changes)
|
|
579
|
+
useEffect(() => {
|
|
580
|
+
if (initialOpenFromUrlDoneRef.current) return;
|
|
581
|
+
initialOpenFromUrlDoneRef.current = true;
|
|
582
|
+
const pathname = location.pathname;
|
|
583
|
+
const windowableItems = navigationItems.filter(
|
|
584
|
+
(i) => i.openIn !== 'modal' && i.openIn !== 'drawer' && i.openIn !== 'external',
|
|
585
|
+
);
|
|
586
|
+
const pathPrefix = getActivePathPrefix(pathname, windowableItems);
|
|
587
|
+
if (!pathPrefix) return;
|
|
588
|
+
const item = windowableItems.find((i) => getNavPathPrefix(i) === pathPrefix);
|
|
589
|
+
if (item) openWindow(item);
|
|
590
|
+
}, [location.pathname, navigationItems, openWindow]);
|
|
591
|
+
|
|
571
592
|
const closeWindow = useCallback((id: string) => {
|
|
572
593
|
setWindows((prev) => prev.filter((w) => w.id !== id));
|
|
573
594
|
setFrontWindowId((current) => (current === id ? null : current));
|
|
@@ -5,19 +5,42 @@ export function getNavPathPrefix(item: NavigationItem): string {
|
|
|
5
5
|
return item.path === '/' || item.path === '' ? '/' : `/${item.path}`;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Whether a URL string uses hash-based routing (e.g. contains /#/). */
|
|
9
|
+
export function isHashRouterUrl(url: string): boolean {
|
|
10
|
+
return url.includes('/#/');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whether a nav item uses hash-based routing (explicit flag or inferred from url). */
|
|
14
|
+
export function isHashRouterNavItem(item: NavigationItem): boolean {
|
|
15
|
+
if (item.useHashRouter === true) return true;
|
|
16
|
+
if (item.useHashRouter === false) return false;
|
|
17
|
+
return isHashRouterUrl(item.url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Base URL without hash (origin + pathname before #). Used to match and build iframe URLs for hash apps. */
|
|
21
|
+
export function getBaseUrlWithoutHash(url: string): string {
|
|
22
|
+
const hashIndex = url.indexOf('#');
|
|
23
|
+
if (hashIndex === -1) return url;
|
|
24
|
+
const base = url.slice(0, hashIndex);
|
|
25
|
+
return base.endsWith('/') ? base : `${base}/`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Hash path from a URL (part after #), e.g. "/themes" from "http://localhost:5173/#/themes". Returns "" if no hash. */
|
|
29
|
+
export function getHashPathFromUrl(url: string): string {
|
|
30
|
+
const hashIndex = url.indexOf('#');
|
|
31
|
+
if (hashIndex === -1) return '';
|
|
32
|
+
const hash = url.slice(hashIndex + 1);
|
|
33
|
+
return hash.startsWith('/') ? hash : `/${hash}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
/** Among items that match the current pathname, return the longest path prefix. Used so only one nav item is active when URLs nest (e.g. /foo and /foo/bar). */
|
|
9
|
-
export function getActivePathPrefix(
|
|
10
|
-
pathname: string,
|
|
11
|
-
items: NavigationItem[],
|
|
12
|
-
): string | null {
|
|
37
|
+
export function getActivePathPrefix(pathname: string, items: NavigationItem[]): string | null {
|
|
13
38
|
const linkItems = items.filter(
|
|
14
39
|
(i) => i.openIn !== 'modal' && i.openIn !== 'drawer' && i.openIn !== 'external',
|
|
15
40
|
);
|
|
16
41
|
const matching = linkItems
|
|
17
42
|
.map((i) => getNavPathPrefix(i))
|
|
18
|
-
.filter(
|
|
19
|
-
(p) => pathname === p || pathname.startsWith(p === '/' ? '/' : p + '/'),
|
|
20
|
-
);
|
|
43
|
+
.filter((p) => pathname === p || pathname.startsWith(p === '/' ? '/' : `${p}/`));
|
|
21
44
|
if (matching.length === 0) return null;
|
|
22
45
|
return matching.reduce((a, b) => (a.length >= b.length ? a : b));
|
|
23
46
|
}
|
|
@@ -65,8 +65,7 @@ function getResolvedAppearanceForSettings(
|
|
|
65
65
|
): Appearance | undefined {
|
|
66
66
|
if (typeof window === 'undefined') return undefined;
|
|
67
67
|
config?.themes?.forEach(registerTheme);
|
|
68
|
-
const themeName =
|
|
69
|
-
settings.appearance?.name || config?.defaultTheme || 'default';
|
|
68
|
+
const themeName = settings.appearance?.name || config?.defaultTheme || 'default';
|
|
70
69
|
const themeDef = getTheme(themeName) || getTheme('default');
|
|
71
70
|
if (!themeDef) return undefined;
|
|
72
71
|
const colorScheme = settings.appearance?.colorScheme ?? 'system';
|
|
@@ -135,13 +134,13 @@ function buildSettingsForPropagation(
|
|
|
135
134
|
};
|
|
136
135
|
}
|
|
137
136
|
if (config?.navigation?.length) {
|
|
138
|
-
const items: SettingsNavigationItem[] = flattenNavigationItems(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
const items: SettingsNavigationItem[] = flattenNavigationItems(config.navigation).map(
|
|
138
|
+
(item) => ({
|
|
139
|
+
path: item.path,
|
|
140
|
+
url: item.url,
|
|
141
|
+
label: resolveLabel(item.label, lang),
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
145
144
|
result = { ...result, navigation: { items } };
|
|
146
145
|
}
|
|
147
146
|
return result;
|
|
@@ -292,9 +291,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|
|
292
291
|
...defaultAppearance,
|
|
293
292
|
...parsed.appearance,
|
|
294
293
|
// Migrate from legacy theme/themeName
|
|
295
|
-
name:
|
|
294
|
+
name:
|
|
295
|
+
parsed.appearance?.name ?? parsed.appearance?.themeName ?? defaultAppearance.name,
|
|
296
296
|
colorScheme:
|
|
297
|
-
parsed.appearance?.colorScheme ??
|
|
297
|
+
parsed.appearance?.colorScheme ??
|
|
298
|
+
parsed.appearance?.theme ??
|
|
299
|
+
defaultAppearance.colorScheme,
|
|
298
300
|
colors: parsed.appearance?.colors ?? defaultAppearance.colors,
|
|
299
301
|
},
|
|
300
302
|
language: {
|
|
@@ -184,7 +184,7 @@ export const SettingsView = () => {
|
|
|
184
184
|
// Navigate back to settings root
|
|
185
185
|
const handleBackToSettings = useCallback(() => {
|
|
186
186
|
// Navigate to settings root, replacing current history entry
|
|
187
|
-
navigate(urls.settings
|
|
187
|
+
navigate(urls.settings);
|
|
188
188
|
}, [navigate]);
|
|
189
189
|
|
|
190
190
|
return (
|
package/src/router/routes.tsx
CHANGED
|
@@ -109,8 +109,18 @@ export const createRoutes = (config: ShellUIConfig): RouteObject[] => {
|
|
|
109
109
|
),
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
// Catch-all: no nav match (e.g. /layout) → ViewRoute can use root item with pathname as hash subpath to avoid 404
|
|
113
|
+
(layoutRoute.children as RouteObject[]).push({
|
|
114
|
+
path: '*',
|
|
115
|
+
element: (
|
|
116
|
+
<Suspense fallback={<RouteFallback />}>
|
|
117
|
+
<ViewRoute navigation={navigationItems} />
|
|
118
|
+
</Suspense>
|
|
119
|
+
),
|
|
120
|
+
});
|
|
112
121
|
}
|
|
113
|
-
(
|
|
122
|
+
// Layout must be before the catch-all (*) so paths like /layout are handled by layout → ViewRoute (root fallback), not 404
|
|
123
|
+
(routes[0].children as RouteObject[]).unshift(layoutRoute);
|
|
114
124
|
|
|
115
125
|
return routes;
|
|
116
126
|
};
|