@shellui/core 0.0.23 → 0.2.0-alpha.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 +4 -2
- package/src/components/AppPathView.tsx +31 -0
- package/src/components/ContentView.tsx +14 -6
- package/src/components/HomeView.tsx +9 -2
- package/src/components/IndexRoute.tsx +37 -0
- package/src/components/NotFoundView.tsx +3 -2
- package/src/components/ViewRoute.tsx +11 -7
- package/src/components/ui/drawer.tsx +3 -2
- package/src/components/ui/tooltip.tsx +52 -0
- package/src/constants/urls.ts +2 -0
- package/src/features/config/ConfigProvider.ts +20 -76
- package/src/features/config/shellui-config.d.ts +13 -0
- package/src/features/config/types.ts +14 -3
- package/src/features/config/useConfig.ts +1 -10
- package/src/features/cookieConsent/cookieConsent.ts +2 -4
- package/src/features/layouts/AppBarLayout.tsx +260 -0
- package/src/features/layouts/AppLayout.tsx +6 -0
- package/src/features/layouts/DefaultLayout.tsx +25 -17
- package/src/features/layouts/OverlayShell.tsx +19 -8
- package/src/features/layouts/WindowsLayout.tsx +11 -9
- package/src/features/layouts/utils.ts +44 -0
- package/src/features/sentry/initSentry.ts +82 -12
- package/src/features/settings/SettingsProvider.tsx +2 -1
- package/src/features/settings/SettingsView.tsx +79 -15
- package/src/features/settings/components/Advanced.tsx +17 -2
- package/src/features/settings/components/ApplicationSettingsPanel.tsx +25 -0
- package/src/features/settings/components/Develop.tsx +68 -4
- package/src/i18n/translations/en/common.json +5 -0
- package/src/i18n/translations/en/settings.json +3 -1
- package/src/i18n/translations/fr/common.json +5 -0
- package/src/i18n/translations/fr/settings.json +3 -1
- package/src/index.css +10 -0
- package/src/lib/z-index.ts +2 -0
- package/src/router/routes.tsx +18 -5
- package/tailwind.config.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shellui/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.2.0-alpha.0",
|
|
4
4
|
"description": "ShellUI Core - Core React application runtime",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
43
43
|
"@radix-ui/react-separator": "^1.1.8",
|
|
44
44
|
"@radix-ui/react-slot": "^1.2.4",
|
|
45
|
+
"@radix-ui/react-tooltip": "^1.1.6",
|
|
45
46
|
"class-variance-authority": "^0.7.1",
|
|
46
47
|
"clsx": "^2.1.1",
|
|
47
48
|
"i18next": "^23.15.0",
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"workbox-strategies": "^7.1.0",
|
|
58
59
|
"workbox-cacheable-response": "^7.1.0",
|
|
59
60
|
"workbox-expiration": "^7.1.0",
|
|
60
|
-
"@shellui/sdk": "0.0.
|
|
61
|
+
"@shellui/sdk": "0.2.0-alpha.0"
|
|
61
62
|
},
|
|
62
63
|
"peerDependencies": {
|
|
63
64
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"@types/react-dom": "^19.2.3",
|
|
73
74
|
"react": "^18.2.0",
|
|
74
75
|
"react-dom": "^18.2.0",
|
|
76
|
+
"tailwindcss-animate": "^1.0.7",
|
|
75
77
|
"typescript": "^5.0.0"
|
|
76
78
|
},
|
|
77
79
|
"scripts": {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useParams } from 'react-router';
|
|
2
|
+
import { useConfig } from '../features/config/useConfig';
|
|
3
|
+
import { flattenNavigationItems } from '../features/layouts/utils';
|
|
4
|
+
import { NotFoundView } from './NotFoundView';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders the React component for a navigation item when the app is loaded at /__app/:path.
|
|
8
|
+
* Uses navItem.component (when config is not serialized) or shelluiComponents from context (injected by CLI when componentPath is set).
|
|
9
|
+
*/
|
|
10
|
+
export const AppPathView = () => {
|
|
11
|
+
const { config, shelluiComponents } = useConfig();
|
|
12
|
+
const { path: pathParam } = useParams<{ path: string }>();
|
|
13
|
+
const navigation = config?.navigation;
|
|
14
|
+
const items = navigation?.length ? flattenNavigationItems(navigation) : [];
|
|
15
|
+
const navItem = pathParam
|
|
16
|
+
? items.find(
|
|
17
|
+
(item) =>
|
|
18
|
+
item.path === pathParam ||
|
|
19
|
+
(pathParam === 'home' && (item.path === '' || item.path === '/')),
|
|
20
|
+
)
|
|
21
|
+
: null;
|
|
22
|
+
|
|
23
|
+
const pathKey = pathParam === 'home' || !pathParam ? 'home' : pathParam;
|
|
24
|
+
const Component = navItem?.component ?? shelluiComponents?.[pathKey];
|
|
25
|
+
|
|
26
|
+
if (!Component) {
|
|
27
|
+
return <NotFoundView />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return <Component />;
|
|
31
|
+
};
|
|
@@ -16,17 +16,22 @@ const logger = getLogger('shellcore');
|
|
|
16
16
|
|
|
17
17
|
interface ContentViewProps {
|
|
18
18
|
url: string;
|
|
19
|
+
/** Base URL for this content (effective nav item URL). Used for URL sync; when omitted, falls back to navItem.url or url. */
|
|
20
|
+
baseUrl?: string;
|
|
19
21
|
pathPrefix: string;
|
|
20
22
|
ignoreMessages?: boolean;
|
|
21
|
-
|
|
23
|
+
/** Nav item for this content; may be undefined when opening by URL (e.g. settings modal) if no matching item found. */
|
|
24
|
+
navItem?: NavigationItem | null;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export const ContentView = ({
|
|
25
28
|
url,
|
|
29
|
+
baseUrl: baseUrlProp,
|
|
26
30
|
pathPrefix,
|
|
27
31
|
ignoreMessages = false,
|
|
28
32
|
navItem,
|
|
29
33
|
}: ContentViewProps) => {
|
|
34
|
+
const baseUrl = baseUrlProp ?? navItem?.url ?? url;
|
|
30
35
|
const navigate = useNavigate();
|
|
31
36
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
32
37
|
const isInternalNavigation = useRef(false);
|
|
@@ -58,9 +63,13 @@ export const ContentView = ({
|
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
const { pathname, search, hash } = data.payload as ShellUIUrlPayload;
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
// Use pathname part of baseUrl for comparison (iframe pathname is always path-only)
|
|
67
|
+
const basePathname =
|
|
68
|
+
baseUrl.startsWith('http') || baseUrl.startsWith('//')
|
|
69
|
+
? new URL(baseUrl, window.location.origin).pathname
|
|
70
|
+
: baseUrl;
|
|
71
|
+
let cleanPathname = pathname.startsWith(basePathname)
|
|
72
|
+
? pathname.slice(basePathname.length)
|
|
64
73
|
: pathname;
|
|
65
74
|
cleanPathname = cleanPathname.startsWith('/') ? cleanPathname.slice(1) : cleanPathname;
|
|
66
75
|
cleanPathname = cleanPathname.replace(/\/+$/, ''); // Remove trailing slashes
|
|
@@ -102,7 +111,7 @@ export const ContentView = ({
|
|
|
102
111
|
return () => {
|
|
103
112
|
cleanup();
|
|
104
113
|
};
|
|
105
|
-
}, [pathPrefix, navigate]);
|
|
114
|
+
}, [pathPrefix, navigate, baseUrl]);
|
|
106
115
|
|
|
107
116
|
// Hide loading overlay when iframe sends SHELLUI_INITIALIZED
|
|
108
117
|
useEffect(() => {
|
|
@@ -248,7 +257,6 @@ export const ContentView = ({
|
|
|
248
257
|
border: 'none',
|
|
249
258
|
display: 'block',
|
|
250
259
|
}}
|
|
251
|
-
title="Content Frame"
|
|
252
260
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
253
261
|
referrerPolicy="no-referrer-when-downgrade"
|
|
254
262
|
/>
|
|
@@ -6,14 +6,21 @@ export const HomeView = () => {
|
|
|
6
6
|
const { config } = useConfig();
|
|
7
7
|
|
|
8
8
|
return (
|
|
9
|
-
<div className="flex flex-col items-center justify-center h-full p-8 md:p-10">
|
|
9
|
+
<div className="flex flex-col items-center justify-center h-full p-8 md:p-10 max-w-2xl mx-auto text-center">
|
|
10
10
|
<h1
|
|
11
11
|
className="m-0 text-3xl font-light text-foreground"
|
|
12
12
|
style={{ fontFamily: 'var(--heading-font-family, inherit)' }}
|
|
13
13
|
>
|
|
14
|
-
{t('welcome', { title: config
|
|
14
|
+
{t('welcome', { title: config?.title ?? 'ShellUI' })}
|
|
15
15
|
</h1>
|
|
16
16
|
<p className="mt-4 text-lg text-muted-foreground">{t('getStarted')}</p>
|
|
17
|
+
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border text-left">
|
|
18
|
+
<p className="text-sm font-medium text-foreground mb-2">{t('homeConfig.intro')}</p>
|
|
19
|
+
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
|
20
|
+
<li>{t('homeConfig.startUrl')}</li>
|
|
21
|
+
<li>{t('homeConfig.rootNav')}</li>
|
|
22
|
+
</ul>
|
|
23
|
+
</div>
|
|
17
24
|
</div>
|
|
18
25
|
);
|
|
19
26
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Navigate } from 'react-router';
|
|
2
|
+
import { useConfig } from '../features/config/useConfig';
|
|
3
|
+
import { flattenNavigationItems } from '../features/layouts/utils';
|
|
4
|
+
import { HomeView } from './HomeView';
|
|
5
|
+
import { ViewRoute } from './ViewRoute';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders the root path "/":
|
|
9
|
+
* - If start_url is set in config, redirects to start_url.
|
|
10
|
+
* - Else if a navigation item has path "" or "/", shows that item's content.
|
|
11
|
+
* - Otherwise shows the default HomeView.
|
|
12
|
+
*/
|
|
13
|
+
export const IndexRoute = () => {
|
|
14
|
+
const { config } = useConfig();
|
|
15
|
+
|
|
16
|
+
const startUrl = config?.start_url?.trim();
|
|
17
|
+
if (startUrl) {
|
|
18
|
+
const to = startUrl.startsWith('/') ? startUrl : `/${startUrl}`;
|
|
19
|
+
return (
|
|
20
|
+
<Navigate
|
|
21
|
+
to={to}
|
|
22
|
+
replace
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const navigation = config?.navigation;
|
|
28
|
+
if (navigation?.length) {
|
|
29
|
+
const navigationItems = flattenNavigationItems(navigation);
|
|
30
|
+
const rootNavItem = navigationItems.find((item) => item.path === '' || item.path === '/');
|
|
31
|
+
if (rootNavItem) {
|
|
32
|
+
return <ViewRoute navigation={navigationItems} />;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return <HomeView />;
|
|
37
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next';
|
|
2
2
|
import { shellui } from '@shellui/sdk';
|
|
3
3
|
import { useConfig } from '../features/config/useConfig';
|
|
4
|
+
import { getNavPathPrefix } from '../features/layouts/utils';
|
|
4
5
|
import type { NavigationItem, NavigationGroup } from '../features/config/types';
|
|
5
6
|
|
|
6
7
|
const flattenNavigationItems = (
|
|
@@ -56,7 +57,7 @@ export const NotFoundView = () => {
|
|
|
56
57
|
>
|
|
57
58
|
{navItems.map((item, index) => (
|
|
58
59
|
<span
|
|
59
|
-
key={item.path}
|
|
60
|
+
key={item.path || 'root'}
|
|
60
61
|
className="inline-flex items-center gap-x-2"
|
|
61
62
|
>
|
|
62
63
|
{index > 0 && (
|
|
@@ -69,7 +70,7 @@ export const NotFoundView = () => {
|
|
|
69
70
|
)}
|
|
70
71
|
<button
|
|
71
72
|
type="button"
|
|
72
|
-
onClick={() => handleNavigate(
|
|
73
|
+
onClick={() => handleNavigate(getNavPathPrefix(item))}
|
|
73
74
|
className="text-muted-foreground hover:text-foreground hover:underline underline-offset-2 cursor-pointer bg-transparent border-0 p-0 font-normal"
|
|
74
75
|
>
|
|
75
76
|
{resolveLocalizedString(item.label, currentLanguage)}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
2
|
import { Navigate, useLocation } from 'react-router';
|
|
3
|
+
import { getNavPathPrefix, getEffectiveUrl } from '../features/layouts/utils';
|
|
3
4
|
import { ContentView } from './ContentView';
|
|
4
5
|
import type { NavigationItem } from '../features/config/types';
|
|
5
6
|
|
|
@@ -13,7 +14,7 @@ export const ViewRoute = ({ navigation }: ViewRouteProps) => {
|
|
|
13
14
|
|
|
14
15
|
const navItem = useMemo(() => {
|
|
15
16
|
return navigation.find((item) => {
|
|
16
|
-
const pathPrefix =
|
|
17
|
+
const pathPrefix = getNavPathPrefix(item);
|
|
17
18
|
return pathname === pathPrefix || pathname.startsWith(`${pathPrefix}/`);
|
|
18
19
|
});
|
|
19
20
|
}, [navigation, pathname]);
|
|
@@ -28,18 +29,21 @@ export const ViewRoute = ({ navigation }: ViewRouteProps) => {
|
|
|
28
29
|
}
|
|
29
30
|
// Calculate the relative path from the navItem.path
|
|
30
31
|
// e.g. if item.path is "docs" and pathname is "/docs/intro", subPath is "intro"
|
|
31
|
-
const pathPrefix =
|
|
32
|
+
const pathPrefix = getNavPathPrefix(navItem);
|
|
32
33
|
const subPath = pathname.length > pathPrefix.length ? pathname.slice(pathPrefix.length + 1) : '';
|
|
33
34
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// Effective URL: url string or app-path URL when item uses a component
|
|
36
|
+
const baseUrl = getEffectiveUrl(navItem);
|
|
37
|
+
let finalUrl = baseUrl;
|
|
38
|
+
if (!navItem.component && subPath && navItem.url) {
|
|
39
|
+
const base = navItem.url.endsWith('/') ? navItem.url : `${navItem.url}/`;
|
|
40
|
+
finalUrl = `${base}${subPath}`;
|
|
39
41
|
}
|
|
42
|
+
|
|
40
43
|
return (
|
|
41
44
|
<ContentView
|
|
42
45
|
url={finalUrl}
|
|
46
|
+
baseUrl={baseUrl}
|
|
43
47
|
pathPrefix={navItem.path}
|
|
44
48
|
navItem={navItem}
|
|
45
49
|
/>
|
|
@@ -79,9 +79,10 @@ const DrawerContent = forwardRef<ComponentRef<typeof VaulDrawer.Content>, Drawer
|
|
|
79
79
|
const isVertical = pos === 'top' || pos === 'bottom';
|
|
80
80
|
// Set dimension via inline style; use both width/height and max so Vaul/defaults don't override.
|
|
81
81
|
const effectiveSize = size?.trim() || (isVertical ? '80vh' : '80vw');
|
|
82
|
+
// Use min() to ensure drawer doesn't exceed viewport on mobile devices
|
|
82
83
|
const sizeStyle = isVertical
|
|
83
|
-
? { height: effectiveSize, maxHeight: effectiveSize }
|
|
84
|
-
: { width: effectiveSize, maxWidth: effectiveSize };
|
|
84
|
+
? { height: effectiveSize, maxHeight: `min(${effectiveSize}, 100vh)` }
|
|
85
|
+
: { width: effectiveSize, maxWidth: `min(${effectiveSize}, 100%)` };
|
|
85
86
|
return (
|
|
86
87
|
<DrawerPortal>
|
|
87
88
|
<DrawerOverlay />
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ComponentPropsWithoutRef, type ElementRef, forwardRef, type ReactNode } from 'react';
|
|
2
|
+
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
|
3
|
+
import { cn } from '../../lib/utils';
|
|
4
|
+
import { Z_INDEX } from '../../lib/z-index';
|
|
5
|
+
|
|
6
|
+
const TooltipProvider = TooltipPrimitive.Provider;
|
|
7
|
+
|
|
8
|
+
const Tooltip = TooltipPrimitive.Root;
|
|
9
|
+
|
|
10
|
+
const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
11
|
+
|
|
12
|
+
const TooltipContent = forwardRef<
|
|
13
|
+
ElementRef<typeof TooltipPrimitive.Content>,
|
|
14
|
+
ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
15
|
+
>(({ className, sideOffset = 6, ...props }, ref) => (
|
|
16
|
+
<TooltipPrimitive.Portal>
|
|
17
|
+
<TooltipPrimitive.Content
|
|
18
|
+
ref={ref}
|
|
19
|
+
sideOffset={sideOffset}
|
|
20
|
+
className={cn(
|
|
21
|
+
'overflow-hidden rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md',
|
|
22
|
+
'animate-none',
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
style={{ zIndex: Z_INDEX.TOOLTIP }}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
</TooltipPrimitive.Portal>
|
|
29
|
+
));
|
|
30
|
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
31
|
+
|
|
32
|
+
export interface AppBarTooltipProps {
|
|
33
|
+
label: string;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Themed tooltip for app-bar end buttons: no arrow, no animation. */
|
|
38
|
+
function AppBarTooltip({ label, children }: AppBarTooltipProps) {
|
|
39
|
+
return (
|
|
40
|
+
<Tooltip delayDuration={200}>
|
|
41
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
42
|
+
<TooltipContent
|
|
43
|
+
side="bottom"
|
|
44
|
+
align="center"
|
|
45
|
+
>
|
|
46
|
+
{label}
|
|
47
|
+
</TooltipContent>
|
|
48
|
+
</Tooltip>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, AppBarTooltip };
|
package/src/constants/urls.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { createContext, useState, createElement, type ReactNode } from 'react';
|
|
2
|
+
import type { ComponentType } from 'react';
|
|
2
3
|
import { getLogger } from '@shellui/sdk';
|
|
3
4
|
import type { ShellUIConfig } from './types';
|
|
5
|
+
import shelluiConfig, { shelluiComponents } from '@shellui/config';
|
|
4
6
|
|
|
5
7
|
const logger = getLogger('shellcore');
|
|
6
8
|
|
|
7
9
|
export interface ConfigContextValue {
|
|
8
10
|
config: ShellUIConfig;
|
|
11
|
+
/** Map of nav path -> component for /__app/:path (injected when componentPath is set in config). */
|
|
12
|
+
shelluiComponents: Record<string, ComponentType>;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
export const ConfigContext = createContext<ConfigContextValue | null>(null);
|
|
@@ -19,93 +23,33 @@ export interface ConfigProviderProps {
|
|
|
19
23
|
let configLogged = false;
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* Provides ShellUI config (loaded from @shellui/config at build time) via context.
|
|
27
|
+
* Children can use useConfig() to read config.
|
|
24
28
|
*/
|
|
25
|
-
|
|
26
29
|
export function ConfigProvider(props: ConfigProviderProps): ReturnType<typeof createElement> {
|
|
27
30
|
const [config] = useState<ShellUIConfig>(() => {
|
|
28
31
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// Access it directly (no typeof check) so Vite can statically analyze and replace it
|
|
33
|
-
// @ts-expect-error - __SHELLUI_CONFIG__ is injected by Vite at build time
|
|
34
|
-
let configValue: unknown = __SHELLUI_CONFIG__;
|
|
35
|
-
|
|
36
|
-
// In development, if __SHELLUI_CONFIG__ is undefined, it might not have been replaced by Vite
|
|
37
|
-
// This can happen if the Vite define configuration isn't working properly
|
|
38
|
-
if (configValue === undefined && typeof window !== 'undefined') {
|
|
39
|
-
// Try to get it from window (fallback for dev mode issues)
|
|
40
|
-
const g = window as unknown as { __SHELLUI_CONFIG__?: unknown };
|
|
41
|
-
if (g.__SHELLUI_CONFIG__ !== undefined) {
|
|
42
|
-
configValue = g.__SHELLUI_CONFIG__;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// After Vite replacement, configValue will be a JSON string
|
|
47
|
-
// Example: "{\"title\":\"shellui\"}" -> parse -> {title: "shellui"}
|
|
48
|
-
if (configValue !== undefined && configValue !== null && typeof configValue === 'string') {
|
|
49
|
-
try {
|
|
50
|
-
// Parse the JSON string to get the config object
|
|
51
|
-
const parsedConfig: ShellUIConfig = JSON.parse(configValue);
|
|
52
|
-
if (typeof window !== 'undefined' && parsedConfig.runtime === 'tauri') {
|
|
53
|
-
(window as Window & { __SHELLUI_TAURI__?: boolean }).__SHELLUI_TAURI__ = true;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Log in dev mode to help debug (only once per page load)
|
|
57
|
-
if (process.env.NODE_ENV === 'development' && !configLogged) {
|
|
58
|
-
configLogged = true;
|
|
59
|
-
logger.info('Config loaded from __SHELLUI_CONFIG__', {
|
|
60
|
-
hasNavigation: !!parsedConfig.navigation,
|
|
61
|
-
navigationItems: parsedConfig.navigation?.length || 0,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return parsedConfig;
|
|
66
|
-
} catch (parseError) {
|
|
67
|
-
logger.error('Failed to parse config JSON:', { error: parseError });
|
|
68
|
-
logger.error('Config value (first 200 chars):', { value: configValue.substring(0, 200) });
|
|
69
|
-
// Fall through to return empty config
|
|
70
|
-
}
|
|
32
|
+
const resolved = shelluiConfig ?? ({} as ShellUIConfig);
|
|
33
|
+
if (typeof window !== 'undefined' && resolved.runtime === 'tauri') {
|
|
34
|
+
(window as Window & { __SHELLUI_TAURI__?: boolean }).__SHELLUI_TAURI__ = true;
|
|
71
35
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const parsedConfig: ShellUIConfig =
|
|
79
|
-
typeof fallbackValue === 'string'
|
|
80
|
-
? JSON.parse(fallbackValue)
|
|
81
|
-
: (fallbackValue as ShellUIConfig);
|
|
82
|
-
if (typeof window !== 'undefined' && parsedConfig.runtime === 'tauri') {
|
|
83
|
-
(window as Window & { __SHELLUI_TAURI__?: boolean }).__SHELLUI_TAURI__ = true;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (process.env.NODE_ENV === 'development') {
|
|
87
|
-
logger.warn('Config loaded from globalThis fallback (define may not have worked)');
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return parsedConfig;
|
|
36
|
+
if (process.env.NODE_ENV === 'development' && !configLogged) {
|
|
37
|
+
configLogged = true;
|
|
38
|
+
logger.info('Config loaded from @shellui/config', {
|
|
39
|
+
hasNavigation: !!resolved.navigation,
|
|
40
|
+
navigationItems: resolved.navigation?.length || 0,
|
|
41
|
+
});
|
|
91
42
|
}
|
|
92
|
-
|
|
93
|
-
// Return empty config if __SHELLUI_CONFIG__ is undefined (fallback for edge cases)
|
|
94
|
-
// This ensures the provider always provides a value, preventing "useConfig must be used within ConfigProvider" errors
|
|
95
|
-
if (process.env.NODE_ENV === 'development') {
|
|
96
|
-
logger.warn(
|
|
97
|
-
'Config not found. Using empty config. Make sure shellui.config.ts is properly loaded and Vite define is configured correctly.',
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
return {} as ShellUIConfig;
|
|
43
|
+
return resolved;
|
|
101
44
|
} catch (err) {
|
|
102
45
|
logger.error('Failed to load ShellUI config:', { error: err });
|
|
103
|
-
// Don't throw - return empty config instead to prevent app crash
|
|
104
46
|
return {} as ShellUIConfig;
|
|
105
47
|
}
|
|
106
48
|
});
|
|
107
49
|
|
|
108
|
-
|
|
109
|
-
|
|
50
|
+
const value: ConfigContextValue = {
|
|
51
|
+
config,
|
|
52
|
+
shelluiComponents: typeof shelluiComponents !== 'undefined' ? shelluiComponents : {},
|
|
53
|
+
};
|
|
110
54
|
return createElement(ConfigContext.Provider, { value }, props.children);
|
|
111
55
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declaration for the virtual @shellui/config module.
|
|
3
|
+
* The CLI injects this module at build time via Vite (virtual:shellui-config).
|
|
4
|
+
* When nav items have componentPath, the CLI also injects shelluiComponents for /__app/:path routes.
|
|
5
|
+
*/
|
|
6
|
+
declare module '@shellui/config' {
|
|
7
|
+
import type { ComponentType } from 'react';
|
|
8
|
+
import type { ShellUIConfig } from './types';
|
|
9
|
+
export const shelluiConfig: ShellUIConfig;
|
|
10
|
+
/** Map of nav path -> component for component-based items (injected when componentPath is set). */
|
|
11
|
+
export const shelluiComponents: Record<string, ComponentType>;
|
|
12
|
+
export default shelluiConfig;
|
|
13
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
1
3
|
// Language-specific label/title
|
|
2
4
|
export type LocalizedString =
|
|
3
5
|
| string
|
|
@@ -10,13 +12,18 @@ export type LocalizedString =
|
|
|
10
12
|
/** Drawer position when opening a link in a drawer (optional, used when openIn === 'drawer'). */
|
|
11
13
|
export type DrawerPosition = 'top' | 'bottom' | 'left' | 'right';
|
|
12
14
|
|
|
13
|
-
/** Layout mode: 'sidebar' (default) shows navigation sidebar; 'fullscreen' shows only content area; 'windows' shows a taskbar with start menu and multi-window desktop. */
|
|
14
|
-
export type LayoutType = 'sidebar' | 'fullscreen' | 'windows';
|
|
15
|
+
/** Layout mode: 'sidebar' (default) shows navigation sidebar; 'fullscreen' shows only content area; 'windows' shows a taskbar with start menu and multi-window desktop; 'app-bar' shows a compact top bar with select menu for start links and icon-only end links. */
|
|
16
|
+
export type LayoutType = 'sidebar' | 'fullscreen' | 'windows' | 'app-bar';
|
|
15
17
|
|
|
16
18
|
export interface NavigationItem {
|
|
17
19
|
label: string | LocalizedString;
|
|
18
20
|
path: string;
|
|
19
|
-
|
|
21
|
+
/** URL to load in the content area (iframe). Omit when using component; then the app-path URL is used. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** React component to render for this item. When set, a URL under the app-path is generated so the content can be loaded in an iframe like URL-based items. The CLI infers the component module path from your config file imports so you don't need to set componentPath. */
|
|
24
|
+
component?: ComponentType;
|
|
25
|
+
/** @internal Set by the CLI when loading config (inferred from component + config file imports). Only needed if you bypass the CLI. */
|
|
26
|
+
componentPath?: string;
|
|
20
27
|
icon?: string; // Path to SVG icon file (e.g., '/icons/book-open.svg')
|
|
21
28
|
/** When true, hide this item from the sidebar and 404 page; route remains valid and item still appears in Develop settings. */
|
|
22
29
|
hidden?: boolean;
|
|
@@ -30,6 +37,8 @@ export interface NavigationItem {
|
|
|
30
37
|
drawerPosition?: DrawerPosition;
|
|
31
38
|
/** Sidebar position: 'start' (default) or 'end'. End items are rendered in the sidebar footer. */
|
|
32
39
|
position?: 'start' | 'end';
|
|
40
|
+
/** URL to display as a settings panel in Settings > Applications. When set, the nav item appears in the Applications group. */
|
|
41
|
+
settings?: string;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export interface NavigationGroup {
|
|
@@ -185,6 +194,8 @@ export interface ShellUIConfig {
|
|
|
185
194
|
language?: string | string[]; // Single language code or array of enabled language codes (e.g., 'en' or ['en', 'fr'])
|
|
186
195
|
/** Layout mode: 'sidebar' (default) or 'fullscreen'. Fullscreen shows only content with no navigation. */
|
|
187
196
|
layout?: LayoutType;
|
|
197
|
+
/** When set, opening the app at "/" redirects to this path (e.g. "/playground"). */
|
|
198
|
+
start_url?: string;
|
|
188
199
|
navigation?: (NavigationItem | NavigationGroup)[];
|
|
189
200
|
themes?: ThemeDefinition[]; // Custom themes to register
|
|
190
201
|
defaultTheme?: string; // Default theme name to use
|
|
@@ -10,22 +10,13 @@ export function useConfig(): ConfigContextValue {
|
|
|
10
10
|
const context = useContext(ConfigContext);
|
|
11
11
|
if (context === null) {
|
|
12
12
|
// This error should never happen if ConfigProvider is properly wrapping the app
|
|
13
|
-
// If you see this error, check that:
|
|
14
|
-
// 1. Your component is rendered inside a <ConfigProvider> tree
|
|
15
|
-
// 2. The ConfigProvider is mounted before components that use useConfig()
|
|
16
|
-
// 3. Vite's define configuration is working correctly (check __SHELLUI_CONFIG__)
|
|
17
13
|
const error = new Error(
|
|
18
14
|
'useConfig must be used within a ConfigProvider. ' +
|
|
19
|
-
'Make sure your app is wrapped with <ConfigProvider
|
|
15
|
+
'Make sure your app is wrapped with <ConfigProvider>.',
|
|
20
16
|
);
|
|
21
17
|
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
22
18
|
// eslint-disable-next-line no-console
|
|
23
19
|
console.error('[ShellUI] ConfigProvider error:', error);
|
|
24
|
-
// eslint-disable-next-line no-console
|
|
25
|
-
console.error(
|
|
26
|
-
'[ShellUI] Check that __SHELLUI_CONFIG__ is defined:',
|
|
27
|
-
typeof (window as unknown as { __SHELLUI_CONFIG__?: unknown }).__SHELLUI_CONFIG__,
|
|
28
|
-
);
|
|
29
20
|
}
|
|
30
21
|
throw error;
|
|
31
22
|
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { ShellUIConfig } from '../config/types';
|
|
2
|
+
import shelluiConfig from '@shellui/config';
|
|
2
3
|
|
|
3
4
|
const STORAGE_KEY = 'shellui:settings';
|
|
4
5
|
|
|
5
6
|
function getConfig(): ShellUIConfig | undefined {
|
|
6
7
|
if (typeof globalThis === 'undefined') return undefined;
|
|
7
|
-
|
|
8
|
-
const raw = g.__SHELLUI_CONFIG__;
|
|
9
|
-
if (raw === null || raw === undefined) return undefined;
|
|
10
|
-
return typeof raw === 'string' ? (JSON.parse(raw) as ShellUIConfig) : raw;
|
|
8
|
+
return shelluiConfig ?? undefined;
|
|
11
9
|
}
|
|
12
10
|
|
|
13
11
|
function getStoredCookieConsent(): {
|