@shellui/core 0.2.0-alpha.4 → 0.2.0-beta.1
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 +2 -2
- package/src/components/ContentView.tsx +70 -135
- package/src/components/LoadingOverlay.tsx +5 -1
- package/src/components/ui/sidebar.tsx +2 -124
- package/src/constants/loading.ts +2 -0
- package/src/features/config/types.ts +2 -0
- package/src/features/layouts/AppLayout.tsx +22 -19
- package/src/features/layouts/LayoutFallback.tsx +8 -0
- package/src/features/layouts/OverlayShell.tsx +23 -9
- package/src/features/layouts/{AppBarLayout.tsx → appbar/AppBarLayout.tsx} +72 -78
- package/src/features/layouts/{FullscreenLayout.tsx → fullscreen/FullscreenLayout.tsx} +5 -11
- package/src/features/layouts/sidebar/BottomNavItem.tsx +88 -0
- package/src/features/layouts/sidebar/MobileBottomNav.tsx +168 -0
- package/src/features/layouts/sidebar/NavigationContent.tsx +159 -0
- package/src/features/layouts/sidebar/SidebarIcons.tsx +93 -0
- package/src/features/layouts/sidebar/SidebarInner.tsx +48 -0
- package/src/features/layouts/sidebar/SidebarLayout.tsx +86 -0
- package/src/features/layouts/sidebar/sidebarUtils.ts +23 -0
- package/src/features/layouts/sidebar/types.ts +8 -0
- package/src/features/layouts/utils.ts +29 -1
- package/src/features/layouts/{WindowsLayout.tsx → windows/WindowsLayout.tsx} +199 -204
- package/src/features/settings/SettingsView.tsx +178 -181
- package/src/{components → routes/components}/HomeView.tsx +1 -1
- package/src/{components → routes/components}/IndexRoute.tsx +4 -4
- package/src/routes/components/NavigationItemRoute.tsx +19 -0
- package/src/{components → routes/components}/NotFoundView.tsx +9 -4
- package/src/{components → routes/components}/RouteErrorBoundary.tsx +1 -1
- package/src/routes/components/RouteFallback.tsx +8 -0
- package/src/routes/hooks/useNavigationItems.ts +84 -0
- package/src/{router → routes}/routes.tsx +18 -16
- package/src/components/ViewRoute.tsx +0 -48
- package/src/dist/CookiePreferencesView.52b5aec8.js +0 -1182
- package/src/dist/CookiePreferencesView.52b5aec8.js.map +0 -1
- package/src/dist/DefaultLayout.045a82ff.js +0 -1964
- package/src/dist/DefaultLayout.045a82ff.js.map +0 -1
- package/src/dist/DefaultLayout.4454f259.js +0 -4414
- package/src/dist/DefaultLayout.4454f259.js.map +0 -1
- package/src/dist/FullscreenLayout.555c4987.js +0 -1054
- package/src/dist/FullscreenLayout.555c4987.js.map +0 -1
- package/src/dist/HomeView.ddfa7b68.js +0 -771
- package/src/dist/HomeView.ddfa7b68.js.map +0 -1
- package/src/dist/NotFoundView.c75be4f1.js +0 -811
- package/src/dist/NotFoundView.c75be4f1.js.map +0 -1
- package/src/dist/SettingsView.052b03a6.js +0 -4965
- package/src/dist/SettingsView.052b03a6.js.map +0 -1
- package/src/dist/ViewRoute.e6e3b142.js +0 -1042
- package/src/dist/ViewRoute.e6e3b142.js.map +0 -1
- package/src/dist/WindowsLayout.08724167.js +0 -1762
- package/src/dist/WindowsLayout.08724167.js.map +0 -1
- package/src/dist/esm.f0d741e6.js +0 -29520
- package/src/dist/esm.f0d741e6.js.map +0 -1
- package/src/dist/favicon.4367ac1e.svg +0 -14
- package/src/dist/index.parcel.36d65383.js +0 -54089
- package/src/dist/index.parcel.36d65383.js.map +0 -1
- package/src/dist/index.parcel.ca6d8a47.css +0 -3493
- package/src/dist/index.parcel.ca6d8a47.css.map +0 -1
- package/src/dist/index.parcel.html +0 -88
- package/src/features/layouts/DefaultLayout.tsx +0 -660
- package/src/features/layouts/LayoutProviders.tsx +0 -20
- /package/src/{router → routes}/router.tsx +0 -0
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.1",
|
|
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.1"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/app.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { RouterProvider } from 'react-router';
|
|
|
3
3
|
import { shellui } from '@shellui/sdk';
|
|
4
4
|
import { useConfig } from './features/config/useConfig';
|
|
5
5
|
import { ConfigProvider } from './features/config/ConfigProvider';
|
|
6
|
-
import { createAppRouter } from './
|
|
6
|
+
import { createAppRouter } from './routes/router';
|
|
7
7
|
import { SettingsProvider } from './features/settings/SettingsProvider';
|
|
8
8
|
import { ThemeProvider } from './features/theme/ThemeProvider';
|
|
9
9
|
import { I18nProvider } from './i18n/I18nProvider';
|
|
@@ -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,5 @@
|
|
|
1
|
-
/* eslint-disable no-console */
|
|
2
1
|
import type { NavigationItem } from '../features/config/types';
|
|
2
|
+
import { isHashRouterNavItem, getHashPathFromUrl } from '../features/layouts/utils';
|
|
3
3
|
import {
|
|
4
4
|
addIframe,
|
|
5
5
|
removeIframe,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '@shellui/sdk';
|
|
11
11
|
import { useEffect, useRef, useState } from 'react';
|
|
12
12
|
import { useNavigate } from 'react-router';
|
|
13
|
+
import { LOADING_OVERLAY_DURATION_MS } from '../constants/loading';
|
|
13
14
|
import { LoadingOverlay } from './LoadingOverlay';
|
|
14
15
|
|
|
15
16
|
const logger = getLogger('shellcore');
|
|
@@ -29,11 +30,21 @@ export const ContentView = ({
|
|
|
29
30
|
}: ContentViewProps) => {
|
|
30
31
|
const navigate = useNavigate();
|
|
31
32
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
32
|
-
const isInternalNavigation = useRef(false);
|
|
33
33
|
const cancelRevealRef = useRef<(() => void) | null>(null);
|
|
34
34
|
const mountTimeRef = useRef(Date.now());
|
|
35
|
-
|
|
36
|
-
const
|
|
35
|
+
/** Real history methods; stored once so we never restore our no-ops after a second effect run. */
|
|
36
|
+
const historyOriginalsRef = useRef<{
|
|
37
|
+
pushState: History['pushState'];
|
|
38
|
+
replaceState: History['replaceState'];
|
|
39
|
+
} | null>(null);
|
|
40
|
+
|
|
41
|
+
const [isLoading, setIsLoading] = useState(() => {
|
|
42
|
+
// Skip overlay when same app URL was just loaded (e.g. switching App ↔ Root with same url)
|
|
43
|
+
if (!ignoreMessages) return false;
|
|
44
|
+
return true;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const [iframeUrl, setIframeUrl] = useState(url);
|
|
37
48
|
|
|
38
49
|
const MIN_LOADING_MS = 80; // Don't reveal before this, reduces blink from theme/layout paint
|
|
39
50
|
|
|
@@ -47,6 +58,18 @@ export const ContentView = ({
|
|
|
47
58
|
};
|
|
48
59
|
}, []);
|
|
49
60
|
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (ignoreMessages) return;
|
|
63
|
+
if (isLoading) return;
|
|
64
|
+
if (iframeRef.current) {
|
|
65
|
+
setIsLoading(true);
|
|
66
|
+
setIframeUrl('about:blank');
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
setIframeUrl(url);
|
|
69
|
+
}, 60);
|
|
70
|
+
}
|
|
71
|
+
}, [navItem]);
|
|
72
|
+
|
|
50
73
|
// Sync parent URL when iframe notifies us of a change
|
|
51
74
|
useEffect(() => {
|
|
52
75
|
const cleanup = shellui.addMessageListener(
|
|
@@ -56,29 +79,52 @@ export const ContentView = ({
|
|
|
56
79
|
return;
|
|
57
80
|
}
|
|
58
81
|
|
|
82
|
+
if (isLoading) return;
|
|
83
|
+
|
|
59
84
|
// Ignore URL CHANGE from other than ContentView iframe
|
|
60
85
|
if (event.source !== iframeRef.current?.contentWindow) {
|
|
61
86
|
return;
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
const { pathname, search, hash } = data.payload as ShellUIUrlPayload;
|
|
65
|
-
//
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
// Shell URL is always path + search only (no hash) so it's transparent whether the sub-app uses hash routing or not
|
|
91
|
+
let pathSegment: string;
|
|
92
|
+
if (isHashRouterNavItem(navItem) && hash) {
|
|
93
|
+
// Hash-router app: use path relative to nav item's hash (e.g. nav #/themes, iframe #/themes → segment ''; iframe #/themes/foo → segment 'foo')
|
|
94
|
+
const iframeHashPath = hash.replace(/^#\/?/, '').replace(/\/+$/, '') || '';
|
|
95
|
+
const navHashPath = getHashPathFromUrl(navItem.url).replace(/^\/+|\/+$/g, '');
|
|
96
|
+
const relative = navHashPath
|
|
97
|
+
? iframeHashPath === navHashPath || iframeHashPath.startsWith(`${navHashPath}/`)
|
|
98
|
+
? iframeHashPath.slice(navHashPath.length).replace(/^\//, '')
|
|
99
|
+
: iframeHashPath
|
|
100
|
+
: iframeHashPath;
|
|
101
|
+
pathSegment = relative;
|
|
102
|
+
} else {
|
|
103
|
+
// Non-hash app: route is pathname
|
|
104
|
+
let cleanPathname = pathname.startsWith(navItem.url)
|
|
105
|
+
? pathname.slice(navItem.url.length)
|
|
106
|
+
: pathname;
|
|
107
|
+
cleanPathname = cleanPathname.startsWith('/') ? cleanPathname.slice(1) : cleanPathname;
|
|
108
|
+
pathSegment = cleanPathname.replace(/\/+$/, '');
|
|
109
|
+
}
|
|
110
|
+
// Root (pathPrefix '' or '/') must produce /segment not //segment
|
|
111
|
+
const isRoot = pathPrefix === '' || pathPrefix === '/';
|
|
112
|
+
let newShellPath = isRoot
|
|
113
|
+
? pathSegment
|
|
114
|
+
? `/${pathSegment}${search}`
|
|
115
|
+
: search
|
|
116
|
+
? `/${search}`
|
|
117
|
+
: '/'
|
|
118
|
+
: pathSegment
|
|
119
|
+
? `/${pathPrefix}/${pathSegment}${search}`
|
|
120
|
+
: `/${pathPrefix}${search}`;
|
|
121
|
+
|
|
122
|
+
// Normalize: remove trailing slashes from pathname part only (preserve query)
|
|
77
123
|
const urlParts = newShellPath.match(/^([^?#]*)([?#].*)?$/);
|
|
78
124
|
if (urlParts) {
|
|
79
125
|
const pathnamePart = urlParts[1].replace(/\/+$/, '') || '/';
|
|
80
|
-
const
|
|
81
|
-
newShellPath = pathnamePart +
|
|
126
|
+
const queryPart = urlParts[2] || '';
|
|
127
|
+
newShellPath = pathnamePart + queryPart;
|
|
82
128
|
}
|
|
83
129
|
|
|
84
130
|
// Normalize current path for comparison (remove trailing slashes from pathname)
|
|
@@ -91,14 +137,7 @@ export const ContentView = ({
|
|
|
91
137
|
const normalizedNewPath = normalizedNewPathname + (newPathParts?.[2] || '');
|
|
92
138
|
|
|
93
139
|
if (currentPath !== normalizedNewPath) {
|
|
94
|
-
// Mark this navigation as internal so we don't try to "push" it back to the iframe
|
|
95
|
-
isInternalNavigation.current = true;
|
|
96
140
|
navigate(newShellPath, { replace: true });
|
|
97
|
-
|
|
98
|
-
// Reset the flag after a short delay to allow the render cycle to complete
|
|
99
|
-
setTimeout(() => {
|
|
100
|
-
isInternalNavigation.current = false;
|
|
101
|
-
}, 100);
|
|
102
141
|
}
|
|
103
142
|
},
|
|
104
143
|
);
|
|
@@ -106,7 +145,7 @@ export const ContentView = ({
|
|
|
106
145
|
return () => {
|
|
107
146
|
cleanup();
|
|
108
147
|
};
|
|
109
|
-
}, [pathPrefix, navigate]);
|
|
148
|
+
}, [pathPrefix, navigate, navItem]);
|
|
110
149
|
|
|
111
150
|
const scheduleReveal = (reveal: () => void) => {
|
|
112
151
|
const doReveal = () => {
|
|
@@ -126,6 +165,7 @@ export const ContentView = ({
|
|
|
126
165
|
|
|
127
166
|
// Hide loading overlay when iframe sends SHELLUI_INITIALIZED.
|
|
128
167
|
// Defer reveal (double rAF + min time) so the iframe has time to apply theme and paint.
|
|
168
|
+
// Remember this URL so we can skip the overlay when navigating to the same app (e.g. App ↔ Root).
|
|
129
169
|
useEffect(() => {
|
|
130
170
|
const cleanup = shellui.addMessageListener(
|
|
131
171
|
'SHELLUI_INITIALIZED',
|
|
@@ -148,9 +188,9 @@ export const ContentView = ({
|
|
|
148
188
|
cancelRevealRef.current = null;
|
|
149
189
|
cleanup();
|
|
150
190
|
};
|
|
151
|
-
}, []);
|
|
191
|
+
}, [ignoreMessages]);
|
|
152
192
|
|
|
153
|
-
// Fallback: hide overlay after
|
|
193
|
+
// Fallback: hide overlay after LOADING_OVERLAY_DURATION_MS if SHELLUI_INITIALIZED was not received.
|
|
154
194
|
useEffect(() => {
|
|
155
195
|
if (!isLoading) return;
|
|
156
196
|
const timeoutId = setTimeout(() => {
|
|
@@ -165,115 +205,10 @@ export const ContentView = ({
|
|
|
165
205
|
if (!cancelled) setIsLoading(false);
|
|
166
206
|
cancelRevealRef.current = null;
|
|
167
207
|
});
|
|
168
|
-
},
|
|
208
|
+
}, LOADING_OVERLAY_DURATION_MS);
|
|
169
209
|
return () => clearTimeout(timeoutId);
|
|
170
210
|
}, [isLoading]);
|
|
171
211
|
|
|
172
|
-
// Handle external URL changes (e.g. from Sidebar)
|
|
173
|
-
useEffect(() => {
|
|
174
|
-
if (iframeRef.current && !isInternalNavigation.current) {
|
|
175
|
-
if (iframeRef.current.src !== url) {
|
|
176
|
-
iframeRef.current.src = url;
|
|
177
|
-
setIsLoading(true);
|
|
178
|
-
mountTimeRef.current = Date.now(); // apply min delay for this load too
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}, [url]);
|
|
182
|
-
|
|
183
|
-
// Inject script to prevent "Layout was forced" warning by deferring layout until stylesheets load
|
|
184
|
-
useEffect(() => {
|
|
185
|
-
const iframe = iframeRef.current;
|
|
186
|
-
if (!iframe) return;
|
|
187
|
-
|
|
188
|
-
const handleLoad = () => {
|
|
189
|
-
try {
|
|
190
|
-
const iframeWindow = iframe.contentWindow;
|
|
191
|
-
const iframeDoc = iframe.contentDocument || iframeWindow?.document;
|
|
192
|
-
if (!iframeDoc || !iframeWindow) return;
|
|
193
|
-
|
|
194
|
-
// Inject a script that waits for stylesheets before allowing layout calculations
|
|
195
|
-
const script = iframeDoc.createElement('script');
|
|
196
|
-
script.textContent = `
|
|
197
|
-
(function() {
|
|
198
|
-
// Wait for all stylesheets to load
|
|
199
|
-
function waitForStylesheets() {
|
|
200
|
-
const styleSheets = Array.from(document.styleSheets);
|
|
201
|
-
const pendingSheets = styleSheets.filter(function(sheet) {
|
|
202
|
-
try {
|
|
203
|
-
return sheet.cssRules === null;
|
|
204
|
-
} catch (e) {
|
|
205
|
-
return false; // Cross-origin stylesheets, assume loaded
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
if (pendingSheets.length === 0) {
|
|
210
|
-
// All stylesheets loaded
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Check again after a short delay
|
|
215
|
-
setTimeout(waitForStylesheets, 10);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Start checking after DOM is ready
|
|
219
|
-
if (document.readyState === 'complete') {
|
|
220
|
-
waitForStylesheets();
|
|
221
|
-
} else {
|
|
222
|
-
window.addEventListener('load', waitForStylesheets);
|
|
223
|
-
}
|
|
224
|
-
})();
|
|
225
|
-
`;
|
|
226
|
-
iframeDoc.head.appendChild(script);
|
|
227
|
-
} catch (error) {
|
|
228
|
-
// Cross-origin or other errors - ignore (this is expected for some iframes)
|
|
229
|
-
logger.debug('Could not inject stylesheet wait script:', { error });
|
|
230
|
-
}
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
// Wait for iframe to load before injecting script
|
|
234
|
-
iframe.addEventListener('load', handleLoad);
|
|
235
|
-
|
|
236
|
-
// Also try immediately if already loaded
|
|
237
|
-
if (iframe.contentDocument?.readyState === 'complete') {
|
|
238
|
-
handleLoad();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return () => {
|
|
242
|
-
iframe.removeEventListener('load', handleLoad);
|
|
243
|
-
};
|
|
244
|
-
}, [initialUrl]);
|
|
245
|
-
|
|
246
|
-
// Suppress browser warnings that are expected and acceptable
|
|
247
|
-
useEffect(() => {
|
|
248
|
-
if (process.env.NODE_ENV === 'development') {
|
|
249
|
-
const originalWarn = console.warn;
|
|
250
|
-
console.warn = (...args: unknown[]) => {
|
|
251
|
-
const message = String(args[0] ?? '');
|
|
252
|
-
// Suppress the specific sandbox warning
|
|
253
|
-
if (
|
|
254
|
-
message.includes('allow-scripts') &&
|
|
255
|
-
message.includes('allow-same-origin') &&
|
|
256
|
-
message.includes('sandbox')
|
|
257
|
-
) {
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
// Suppress "Layout was forced" warning from iframe content
|
|
261
|
-
// This is a performance warning that occurs when iframe content calculates layout before stylesheets load
|
|
262
|
-
// It's harmless and common in iframe scenarios, especially with React apps
|
|
263
|
-
if (
|
|
264
|
-
message.includes('Layout was forced') &&
|
|
265
|
-
message.includes('before the page was fully loaded')
|
|
266
|
-
) {
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
originalWarn.apply(console, args);
|
|
270
|
-
};
|
|
271
|
-
return () => {
|
|
272
|
-
console.warn = originalWarn;
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
}, []);
|
|
276
|
-
|
|
277
212
|
return (
|
|
278
213
|
<div
|
|
279
214
|
style={{ width: '100%', height: '100%', display: 'flex', position: 'relative' }}
|
|
@@ -289,7 +224,7 @@ export const ContentView = ({
|
|
|
289
224
|
- Reveal is instant (no transition) after deferred double-rAF to avoid blink */}
|
|
290
225
|
<iframe
|
|
291
226
|
ref={iframeRef}
|
|
292
|
-
src={
|
|
227
|
+
src={iframeUrl}
|
|
293
228
|
loading="eager"
|
|
294
229
|
style={{
|
|
295
230
|
width: '100%',
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { LOADING_OVERLAY_DURATION_MS } from '../constants/loading';
|
|
2
|
+
|
|
1
3
|
export function LoadingOverlay() {
|
|
2
4
|
return (
|
|
3
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>
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
createContext,
|
|
3
|
-
useContext,
|
|
4
|
-
useState,
|
|
5
|
-
useCallback,
|
|
6
2
|
forwardRef,
|
|
7
|
-
type ReactNode,
|
|
8
3
|
type HTMLAttributes,
|
|
9
4
|
type ButtonHTMLAttributes,
|
|
10
5
|
type AnchorHTMLAttributes,
|
|
@@ -12,47 +7,16 @@ import {
|
|
|
12
7
|
import { Slot } from '@radix-ui/react-slot';
|
|
13
8
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
14
9
|
import { cn } from '../../lib/utils';
|
|
15
|
-
import { Z_INDEX } from '../../lib/z-index';
|
|
16
|
-
|
|
17
|
-
type SidebarContextValue = {
|
|
18
|
-
isCollapsed: boolean;
|
|
19
|
-
toggle: () => void;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
|
23
|
-
|
|
24
|
-
const useSidebar = () => {
|
|
25
|
-
const context = useContext(SidebarContext);
|
|
26
|
-
if (!context) {
|
|
27
|
-
throw new Error('useSidebar must be used within a SidebarProvider');
|
|
28
|
-
}
|
|
29
|
-
return context;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const SidebarProvider = ({ children }: { children: ReactNode }) => {
|
|
33
|
-
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
34
|
-
|
|
35
|
-
const toggle = useCallback(() => {
|
|
36
|
-
setIsCollapsed((prev) => !prev);
|
|
37
|
-
}, []);
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<SidebarContext.Provider value={{ isCollapsed, toggle }}>{children}</SidebarContext.Provider>
|
|
41
|
-
);
|
|
42
|
-
};
|
|
43
10
|
|
|
44
11
|
const Sidebar = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
45
12
|
({ className, ...props }, ref) => {
|
|
46
|
-
const { isCollapsed } = useSidebar();
|
|
47
|
-
|
|
48
13
|
return (
|
|
49
14
|
<div
|
|
50
15
|
ref={ref}
|
|
51
16
|
data-sidebar="sidebar"
|
|
52
|
-
data-collapsed={isCollapsed}
|
|
53
17
|
className={cn(
|
|
54
18
|
'flex h-full flex-col gap-2 border-r bg-sidebar-background p-2 text-sidebar-foreground transition-all duration-300 ease-in-out overflow-hidden',
|
|
55
|
-
|
|
19
|
+
'w-64',
|
|
56
20
|
className,
|
|
57
21
|
)}
|
|
58
22
|
{...props}
|
|
@@ -62,83 +26,6 @@ const Sidebar = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
|
62
26
|
);
|
|
63
27
|
Sidebar.displayName = 'Sidebar';
|
|
64
28
|
|
|
65
|
-
/** Inline SVG: panel-left-open (expand sidebar) */
|
|
66
|
-
const PanelLeftOpenIcon = () => (
|
|
67
|
-
<svg
|
|
68
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
69
|
-
width="24"
|
|
70
|
-
height="24"
|
|
71
|
-
viewBox="0 0 24 24"
|
|
72
|
-
fill="none"
|
|
73
|
-
stroke="currentColor"
|
|
74
|
-
strokeWidth="2"
|
|
75
|
-
strokeLinecap="round"
|
|
76
|
-
strokeLinejoin="round"
|
|
77
|
-
className="h-5 w-5 transition-transform duration-300"
|
|
78
|
-
aria-hidden
|
|
79
|
-
>
|
|
80
|
-
<rect
|
|
81
|
-
width="18"
|
|
82
|
-
height="18"
|
|
83
|
-
x="3"
|
|
84
|
-
y="3"
|
|
85
|
-
rx="2"
|
|
86
|
-
/>
|
|
87
|
-
<path d="M9 3v18" />
|
|
88
|
-
<path d="m14 9 3 3-3 3" />
|
|
89
|
-
</svg>
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
/** Inline SVG: panel-left-close (collapse sidebar) */
|
|
93
|
-
const PanelLeftCloseIcon = () => (
|
|
94
|
-
<svg
|
|
95
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
96
|
-
width="24"
|
|
97
|
-
height="24"
|
|
98
|
-
viewBox="0 0 24 24"
|
|
99
|
-
fill="none"
|
|
100
|
-
stroke="currentColor"
|
|
101
|
-
strokeWidth="2"
|
|
102
|
-
strokeLinecap="round"
|
|
103
|
-
strokeLinejoin="round"
|
|
104
|
-
className="h-5 w-5 transition-transform duration-300"
|
|
105
|
-
aria-hidden
|
|
106
|
-
>
|
|
107
|
-
<rect
|
|
108
|
-
width="18"
|
|
109
|
-
height="18"
|
|
110
|
-
x="3"
|
|
111
|
-
y="3"
|
|
112
|
-
rx="2"
|
|
113
|
-
/>
|
|
114
|
-
<path d="M9 3v18" />
|
|
115
|
-
<path d="m16 15-3-3 3-3" />
|
|
116
|
-
</svg>
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
const SidebarTrigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
|
|
120
|
-
({ className, ...props }, ref) => {
|
|
121
|
-
const { toggle, isCollapsed } = useSidebar();
|
|
122
|
-
|
|
123
|
-
return (
|
|
124
|
-
<button
|
|
125
|
-
ref={ref}
|
|
126
|
-
onClick={toggle}
|
|
127
|
-
className={cn(
|
|
128
|
-
'relative flex items-center justify-center rounded-md p-2 text-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring shadow-lg backdrop-blur-md cursor-pointer bg-background/95 border border-border',
|
|
129
|
-
className,
|
|
130
|
-
)}
|
|
131
|
-
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
132
|
-
style={{ zIndex: Z_INDEX.SIDEBAR_TRIGGER }}
|
|
133
|
-
{...props}
|
|
134
|
-
>
|
|
135
|
-
{isCollapsed ? <PanelLeftOpenIcon /> : <PanelLeftCloseIcon />}
|
|
136
|
-
</button>
|
|
137
|
-
);
|
|
138
|
-
},
|
|
139
|
-
);
|
|
140
|
-
SidebarTrigger.displayName = 'SidebarTrigger';
|
|
141
|
-
|
|
142
29
|
const SidebarHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
143
30
|
({ className, ...props }, ref) => {
|
|
144
31
|
return (
|
|
@@ -271,19 +158,13 @@ const SidebarMenuButton = forwardRef<
|
|
|
271
158
|
isActive?: boolean;
|
|
272
159
|
}
|
|
273
160
|
>(({ className, variant, size, asChild = false, isActive, children, ...props }, ref) => {
|
|
274
|
-
const { isCollapsed } = useSidebar();
|
|
275
161
|
const Comp = asChild ? Slot : 'button';
|
|
276
162
|
|
|
277
163
|
return (
|
|
278
164
|
<Comp
|
|
279
165
|
ref={ref}
|
|
280
166
|
data-active={isActive}
|
|
281
|
-
|
|
282
|
-
className={cn(
|
|
283
|
-
sidebarMenuButtonVariants({ variant, size }),
|
|
284
|
-
isCollapsed && 'justify-center',
|
|
285
|
-
className,
|
|
286
|
-
)}
|
|
167
|
+
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
|
287
168
|
{...props}
|
|
288
169
|
>
|
|
289
170
|
{children}
|
|
@@ -434,8 +315,6 @@ SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
|
|
|
434
315
|
|
|
435
316
|
export {
|
|
436
317
|
Sidebar,
|
|
437
|
-
SidebarProvider,
|
|
438
|
-
SidebarTrigger,
|
|
439
318
|
SidebarHeader,
|
|
440
319
|
SidebarContent,
|
|
441
320
|
SidebarFooter,
|
|
@@ -452,5 +331,4 @@ export {
|
|
|
452
331
|
SidebarMenuSub,
|
|
453
332
|
SidebarMenuSubButton,
|
|
454
333
|
SidebarMenuSubItem,
|
|
455
|
-
useSidebar,
|
|
456
334
|
};
|
|
@@ -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. */
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { lazy, Suspense, type LazyExoticComponent, type ComponentType } from 'react';
|
|
2
2
|
import type { LayoutType, NavigationItem, NavigationGroup } from '../config/types';
|
|
3
3
|
import { useSettings } from '../settings/SettingsContext';
|
|
4
|
+
import { SonnerProvider } from '../sonner/SonnerContext';
|
|
5
|
+
import { ModalProvider } from '../modal/ModalContext';
|
|
6
|
+
import { DrawerProvider } from '../drawer/DrawerContext';
|
|
7
|
+
import { OverlayShell } from './OverlayShell';
|
|
8
|
+
import { LayoutFallback } from './LayoutFallback';
|
|
4
9
|
|
|
5
|
-
const
|
|
6
|
-
import('./
|
|
10
|
+
const SidebarLayout = lazy(() =>
|
|
11
|
+
import('./sidebar/SidebarLayout').then((m) => ({ default: m.SidebarLayout })),
|
|
7
12
|
);
|
|
8
13
|
const FullscreenLayout = lazy(() =>
|
|
9
|
-
import('./FullscreenLayout').then((m) => ({ default: m.FullscreenLayout })),
|
|
14
|
+
import('./fullscreen/FullscreenLayout').then((m) => ({ default: m.FullscreenLayout })),
|
|
10
15
|
);
|
|
11
16
|
const WindowsLayout = lazy(() =>
|
|
12
|
-
import('./WindowsLayout').then((m) => ({ default: m.WindowsLayout })),
|
|
17
|
+
import('./windows/WindowsLayout').then((m) => ({ default: m.WindowsLayout })),
|
|
13
18
|
);
|
|
14
19
|
const AppBarLayout = lazy(() =>
|
|
15
|
-
import('./AppBarLayout').then((m) => ({ default: m.AppBarLayout })),
|
|
20
|
+
import('./appbar/AppBarLayout').then((m) => ({ default: m.AppBarLayout })),
|
|
16
21
|
);
|
|
17
22
|
|
|
18
23
|
interface AppLayoutProps {
|
|
@@ -23,15 +28,6 @@ interface AppLayoutProps {
|
|
|
23
28
|
navigation: (NavigationItem | NavigationGroup)[];
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
function LayoutFallback() {
|
|
27
|
-
return (
|
|
28
|
-
<div
|
|
29
|
-
className="min-h-screen bg-background"
|
|
30
|
-
aria-hidden
|
|
31
|
-
/>
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
31
|
/** Renders the layout based on settings.layout (override) or config.layout: 'sidebar' (default), 'fullscreen', or 'windows'. Lazy-loads only the active layout. */
|
|
36
32
|
export function AppLayout({
|
|
37
33
|
layout = 'sidebar',
|
|
@@ -57,13 +53,20 @@ export function AppLayout({
|
|
|
57
53
|
LayoutComponent = AppBarLayout;
|
|
58
54
|
layoutProps = { title, appIcon, logo, navigation };
|
|
59
55
|
} else {
|
|
60
|
-
LayoutComponent =
|
|
56
|
+
LayoutComponent = SidebarLayout;
|
|
61
57
|
layoutProps = { title, appIcon, logo, navigation };
|
|
62
58
|
}
|
|
63
|
-
|
|
64
59
|
return (
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
60
|
+
<ModalProvider>
|
|
61
|
+
<DrawerProvider>
|
|
62
|
+
<SonnerProvider>
|
|
63
|
+
<OverlayShell>
|
|
64
|
+
<Suspense fallback={<LayoutFallback />}>
|
|
65
|
+
<LayoutComponent {...layoutProps} />
|
|
66
|
+
</Suspense>
|
|
67
|
+
</OverlayShell>
|
|
68
|
+
</SonnerProvider>
|
|
69
|
+
</DrawerProvider>
|
|
70
|
+
</ModalProvider>
|
|
68
71
|
);
|
|
69
72
|
}
|