@shellui/core 0.1.0 → 0.2.0-alpha.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 +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/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
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { useMemo, useEffect, type ReactNode } from 'react';
|
|
2
|
+
import { Link, useLocation, Outlet, useNavigate } from 'react-router';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { shellui } from '@shellui/sdk';
|
|
5
|
+
import type { NavigationItem, NavigationGroup } from '../config/types';
|
|
6
|
+
import {
|
|
7
|
+
filterNavigationByViewport,
|
|
8
|
+
flattenNavigationItems,
|
|
9
|
+
getEffectiveUrl,
|
|
10
|
+
getNavPathPrefix,
|
|
11
|
+
resolveLocalizedString as resolveNavLabel,
|
|
12
|
+
splitNavigationByPosition,
|
|
13
|
+
withHomepageWhenNoRoot,
|
|
14
|
+
} from './utils';
|
|
15
|
+
import { LayoutProviders } from './LayoutProviders';
|
|
16
|
+
import { OverlayShell } from './OverlayShell';
|
|
17
|
+
import { Select } from '../../components/ui/select';
|
|
18
|
+
import { AppBarTooltip, TooltipProvider } from '../../components/ui/tooltip';
|
|
19
|
+
import { cn } from '../../lib/utils';
|
|
20
|
+
|
|
21
|
+
const TOP_BAR_MAX_HEIGHT = 42;
|
|
22
|
+
|
|
23
|
+
interface AppBarLayoutProps {
|
|
24
|
+
title?: string;
|
|
25
|
+
appIcon?: string;
|
|
26
|
+
logo?: string;
|
|
27
|
+
navigation: (NavigationItem | NavigationGroup)[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const getExternalFaviconUrl = (url: string): string | null => {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(url);
|
|
33
|
+
const hostname = parsed.hostname;
|
|
34
|
+
if (!hostname) return null;
|
|
35
|
+
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isAppIcon = (src: string) => src.startsWith('/icons/');
|
|
42
|
+
|
|
43
|
+
function resolveLocalizedLabel(
|
|
44
|
+
value: string | { en: string; fr: string; [key: string]: string },
|
|
45
|
+
lang: string,
|
|
46
|
+
): string {
|
|
47
|
+
if (typeof value === 'string') return value;
|
|
48
|
+
return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** End link: icon-only or first-letter badge with themed tooltip. */
|
|
52
|
+
function TopBarEndItem({ item, label }: { item: NavigationItem; label: string }) {
|
|
53
|
+
const pathPrefix = getNavPathPrefix(item);
|
|
54
|
+
const isOverlay = item.openIn === 'modal' || item.openIn === 'drawer';
|
|
55
|
+
const isExternal = item.openIn === 'external';
|
|
56
|
+
const location = useLocation();
|
|
57
|
+
const isActive =
|
|
58
|
+
!isOverlay &&
|
|
59
|
+
!isExternal &&
|
|
60
|
+
(location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
|
|
61
|
+
|
|
62
|
+
const faviconUrl = isExternal && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
|
|
63
|
+
const iconSrc = item.icon ?? faviconUrl ?? null;
|
|
64
|
+
const firstLetter = label ? label.charAt(0).toUpperCase() : '?';
|
|
65
|
+
|
|
66
|
+
const iconEl = iconSrc ? (
|
|
67
|
+
<img
|
|
68
|
+
src={iconSrc}
|
|
69
|
+
alt=""
|
|
70
|
+
className={cn(
|
|
71
|
+
'size-5 shrink-0 rounded-sm object-cover',
|
|
72
|
+
isAppIcon(iconSrc) && 'opacity-90 dark:opacity-100 dark:invert',
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
) : (
|
|
76
|
+
<span
|
|
77
|
+
className="size-6 shrink-0 rounded-md bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground"
|
|
78
|
+
aria-hidden
|
|
79
|
+
>
|
|
80
|
+
{firstLetter}
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const buttonClass = cn(
|
|
85
|
+
'flex items-center justify-center size-8 rounded-md transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
86
|
+
isActive
|
|
87
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
88
|
+
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground',
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const wrap = (node: ReactNode) => <AppBarTooltip label={label}>{node}</AppBarTooltip>;
|
|
92
|
+
|
|
93
|
+
if (item.openIn === 'modal') {
|
|
94
|
+
return wrap(
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => shellui.openModal(getEffectiveUrl(item))}
|
|
98
|
+
className={buttonClass}
|
|
99
|
+
aria-label={label}
|
|
100
|
+
>
|
|
101
|
+
{iconEl}
|
|
102
|
+
</button>,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (item.openIn === 'drawer') {
|
|
106
|
+
return wrap(
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition })}
|
|
110
|
+
className={buttonClass}
|
|
111
|
+
aria-label={label}
|
|
112
|
+
>
|
|
113
|
+
{iconEl}
|
|
114
|
+
</button>,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (item.openIn === 'external') {
|
|
118
|
+
return wrap(
|
|
119
|
+
<a
|
|
120
|
+
href={getEffectiveUrl(item)}
|
|
121
|
+
target="_blank"
|
|
122
|
+
rel="noopener noreferrer"
|
|
123
|
+
className={buttonClass}
|
|
124
|
+
aria-label={label}
|
|
125
|
+
>
|
|
126
|
+
{iconEl}
|
|
127
|
+
</a>,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return wrap(
|
|
131
|
+
<Link
|
|
132
|
+
to={pathPrefix}
|
|
133
|
+
className={buttonClass}
|
|
134
|
+
aria-label={label}
|
|
135
|
+
>
|
|
136
|
+
{iconEl}
|
|
137
|
+
</Link>,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
|
|
142
|
+
const { i18n } = useTranslation();
|
|
143
|
+
const location = useLocation();
|
|
144
|
+
const navigate = useNavigate();
|
|
145
|
+
const currentLanguage = i18n.language || 'en';
|
|
146
|
+
|
|
147
|
+
const { endNavItems, navigationItems, displayStartItems } = useMemo(() => {
|
|
148
|
+
const desktopNav = filterNavigationByViewport(navigation, 'desktop');
|
|
149
|
+
const { start, end } = splitNavigationByPosition(desktopNav);
|
|
150
|
+
const startItems = flattenNavigationItems(start).filter((i) => !i.hidden);
|
|
151
|
+
return {
|
|
152
|
+
endNavItems: flattenNavigationItems(end).filter((i) => !i.hidden),
|
|
153
|
+
navigationItems: flattenNavigationItems(navigation),
|
|
154
|
+
displayStartItems: withHomepageWhenNoRoot(startItems),
|
|
155
|
+
};
|
|
156
|
+
}, [navigation]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!title) return;
|
|
160
|
+
const pathname = location.pathname.replace(/^\/+|\/+$/g, '') || '';
|
|
161
|
+
const segment = pathname.split('/')[0];
|
|
162
|
+
if (!segment) {
|
|
163
|
+
const rootNavItem = navigationItems.find((item) => item.path === '' || item.path === '/');
|
|
164
|
+
document.title = rootNavItem
|
|
165
|
+
? `${resolveLocalizedLabel(rootNavItem.label, currentLanguage)} | ${title}`
|
|
166
|
+
: title;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const navItem = navigationItems.find((item) => item.path === segment);
|
|
170
|
+
if (navItem) {
|
|
171
|
+
const label = resolveLocalizedLabel(navItem.label, currentLanguage);
|
|
172
|
+
document.title = `${label} | ${title}`;
|
|
173
|
+
} else {
|
|
174
|
+
document.title = title;
|
|
175
|
+
}
|
|
176
|
+
}, [location.pathname, title, navigationItems, currentLanguage]);
|
|
177
|
+
|
|
178
|
+
const currentPathPrefix =
|
|
179
|
+
location.pathname === '/'
|
|
180
|
+
? '/'
|
|
181
|
+
: `/${location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0]}`;
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<LayoutProviders>
|
|
185
|
+
<OverlayShell navigationItems={navigationItems}>
|
|
186
|
+
<div className="flex flex-col h-screen overflow-hidden bg-background">
|
|
187
|
+
{/* Top bar: max 42px */}
|
|
188
|
+
<header
|
|
189
|
+
className="flex items-center gap-3 px-3 border-b border-border bg-sidebar-background shrink-0"
|
|
190
|
+
style={{ minHeight: 32, maxHeight: TOP_BAR_MAX_HEIGHT }}
|
|
191
|
+
data-layout="app-bar"
|
|
192
|
+
>
|
|
193
|
+
{/* Logo / title (home link) */}
|
|
194
|
+
<Link
|
|
195
|
+
to="/"
|
|
196
|
+
className="flex items-center gap-2 shrink-0 min-w-0 py-1.5 pr-2 text-sidebar-foreground hover:text-sidebar-foreground/80 transition-colors"
|
|
197
|
+
>
|
|
198
|
+
{logo && logo.trim() ? (
|
|
199
|
+
<img
|
|
200
|
+
src={logo}
|
|
201
|
+
alt={title || 'Logo'}
|
|
202
|
+
className="h-5 w-auto max-h-6 object-contain app-bar-logo m-1.5"
|
|
203
|
+
/>
|
|
204
|
+
) : title ? (
|
|
205
|
+
<span className="text-sm font-semibold truncate">{title}</span>
|
|
206
|
+
) : null}
|
|
207
|
+
</Link>
|
|
208
|
+
|
|
209
|
+
{/* Start links: select menu (includes synthetic Homepage when nav has no "/" path) */}
|
|
210
|
+
{displayStartItems.length > 0 && (
|
|
211
|
+
<Select
|
|
212
|
+
className="h-8 max-w-[200px] text-sm leading-tight py-1.5 border-sidebar-border bg-sidebar-background"
|
|
213
|
+
value={currentPathPrefix}
|
|
214
|
+
onChange={(e) => {
|
|
215
|
+
const path = e.target.value;
|
|
216
|
+
if (path) {
|
|
217
|
+
navigate(path.startsWith('/') ? path : `/${path}`);
|
|
218
|
+
}
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{displayStartItems.map((item) => (
|
|
222
|
+
<option
|
|
223
|
+
key={item.path || 'root'}
|
|
224
|
+
value={getNavPathPrefix(item)}
|
|
225
|
+
>
|
|
226
|
+
{resolveNavLabel(item.label, currentLanguage) || item.path || 'Home'}
|
|
227
|
+
</option>
|
|
228
|
+
))}
|
|
229
|
+
</Select>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
<div className="flex-1 min-w-0" />
|
|
233
|
+
|
|
234
|
+
{/* End links: icon-only or first letter + tooltip */}
|
|
235
|
+
{endNavItems.length > 0 && (
|
|
236
|
+
<TooltipProvider
|
|
237
|
+
delayDuration={200}
|
|
238
|
+
skipDelayDuration={0}
|
|
239
|
+
>
|
|
240
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
241
|
+
{endNavItems.map((item) => (
|
|
242
|
+
<TopBarEndItem
|
|
243
|
+
key={item.path}
|
|
244
|
+
item={item}
|
|
245
|
+
label={resolveNavLabel(item.label, currentLanguage) || item.path || ''}
|
|
246
|
+
/>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
</TooltipProvider>
|
|
250
|
+
)}
|
|
251
|
+
</header>
|
|
252
|
+
|
|
253
|
+
<main className="flex-1 flex flex-col overflow-auto min-h-0">
|
|
254
|
+
<Outlet />
|
|
255
|
+
</main>
|
|
256
|
+
</div>
|
|
257
|
+
</OverlayShell>
|
|
258
|
+
</LayoutProviders>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -11,6 +11,9 @@ const FullscreenLayout = lazy(() =>
|
|
|
11
11
|
const WindowsLayout = lazy(() =>
|
|
12
12
|
import('./WindowsLayout').then((m) => ({ default: m.WindowsLayout })),
|
|
13
13
|
);
|
|
14
|
+
const AppBarLayout = lazy(() =>
|
|
15
|
+
import('./AppBarLayout').then((m) => ({ default: m.AppBarLayout })),
|
|
16
|
+
);
|
|
14
17
|
|
|
15
18
|
interface AppLayoutProps {
|
|
16
19
|
layout?: LayoutType;
|
|
@@ -50,6 +53,9 @@ export function AppLayout({
|
|
|
50
53
|
} else if (effectiveLayout === 'windows') {
|
|
51
54
|
LayoutComponent = WindowsLayout;
|
|
52
55
|
layoutProps = { title, appIcon, logo, navigation };
|
|
56
|
+
} else if (effectiveLayout === 'app-bar') {
|
|
57
|
+
LayoutComponent = AppBarLayout;
|
|
58
|
+
layoutProps = { title, appIcon, logo, navigation };
|
|
53
59
|
} else {
|
|
54
60
|
LayoutComponent = DefaultLayout;
|
|
55
61
|
layoutProps = { title, appIcon, logo, navigation };
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
filterNavigationByViewport,
|
|
23
23
|
filterNavigationForSidebar,
|
|
24
24
|
flattenNavigationItems,
|
|
25
|
+
getEffectiveUrl,
|
|
26
|
+
getNavPathPrefix,
|
|
27
|
+
HOMEPAGE_NAV_ITEM,
|
|
25
28
|
resolveLocalizedString as resolveNavLabel,
|
|
26
29
|
splitNavigationByPosition,
|
|
27
30
|
} from './utils';
|
|
@@ -87,7 +90,7 @@ const NavigationContent = ({
|
|
|
87
90
|
|
|
88
91
|
// Render a single nav item link or modal/drawer trigger
|
|
89
92
|
const renderNavItem = (navItem: NavigationItem) => {
|
|
90
|
-
const pathPrefix =
|
|
93
|
+
const pathPrefix = getNavPathPrefix(navItem);
|
|
91
94
|
const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
|
|
92
95
|
const isExternal = navItem.openIn === 'external';
|
|
93
96
|
const isActive =
|
|
@@ -95,7 +98,7 @@ const NavigationContent = ({
|
|
|
95
98
|
!isExternal &&
|
|
96
99
|
(location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
|
|
97
100
|
const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
|
|
98
|
-
const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem
|
|
101
|
+
const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(getEffectiveUrl(navItem)) : null;
|
|
99
102
|
const iconSrc = navItem.icon ?? faviconUrl ?? null;
|
|
100
103
|
const iconEl = iconSrc ? (
|
|
101
104
|
<img
|
|
@@ -120,7 +123,7 @@ const NavigationContent = ({
|
|
|
120
123
|
navItem.openIn === 'modal' ? (
|
|
121
124
|
<button
|
|
122
125
|
type="button"
|
|
123
|
-
onClick={() => shellui.openModal(navItem
|
|
126
|
+
onClick={() => shellui.openModal(getEffectiveUrl(navItem))}
|
|
124
127
|
className="flex items-center gap-2 w-full cursor-pointer text-left"
|
|
125
128
|
>
|
|
126
129
|
{content}
|
|
@@ -128,14 +131,14 @@ const NavigationContent = ({
|
|
|
128
131
|
) : navItem.openIn === 'drawer' ? (
|
|
129
132
|
<button
|
|
130
133
|
type="button"
|
|
131
|
-
onClick={() => shellui.openDrawer({ url: navItem
|
|
134
|
+
onClick={() => shellui.openDrawer({ url: getEffectiveUrl(navItem), position: navItem.drawerPosition })}
|
|
132
135
|
className="flex items-center gap-2 w-full cursor-pointer text-left"
|
|
133
136
|
>
|
|
134
137
|
{content}
|
|
135
138
|
</button>
|
|
136
139
|
) : navItem.openIn === 'external' ? (
|
|
137
140
|
<a
|
|
138
|
-
href={navItem
|
|
141
|
+
href={getEffectiveUrl(navItem)}
|
|
139
142
|
target="_blank"
|
|
140
143
|
rel="noopener noreferrer"
|
|
141
144
|
className="flex items-center gap-2 w-full"
|
|
@@ -144,7 +147,7 @@ const NavigationContent = ({
|
|
|
144
147
|
</a>
|
|
145
148
|
) : (
|
|
146
149
|
<Link
|
|
147
|
-
to={
|
|
150
|
+
to={pathPrefix}
|
|
148
151
|
className="flex items-center gap-2 w-full"
|
|
149
152
|
>
|
|
150
153
|
{content}
|
|
@@ -273,7 +276,7 @@ const BottomNavItem = ({
|
|
|
273
276
|
iconSrc: string | null;
|
|
274
277
|
applyIconTheme: boolean;
|
|
275
278
|
}) => {
|
|
276
|
-
const pathPrefix =
|
|
279
|
+
const pathPrefix = getNavPathPrefix(item);
|
|
277
280
|
const content = (
|
|
278
281
|
<span className="flex flex-col items-center justify-center gap-1 w-full min-w-0 max-w-full overflow-hidden">
|
|
279
282
|
{iconSrc ? (
|
|
@@ -303,7 +306,7 @@ const BottomNavItem = ({
|
|
|
303
306
|
return (
|
|
304
307
|
<button
|
|
305
308
|
type="button"
|
|
306
|
-
onClick={() => shellui.openModal(item
|
|
309
|
+
onClick={() => shellui.openModal(getEffectiveUrl(item))}
|
|
307
310
|
className={baseClass}
|
|
308
311
|
>
|
|
309
312
|
{content}
|
|
@@ -314,7 +317,7 @@ const BottomNavItem = ({
|
|
|
314
317
|
return (
|
|
315
318
|
<button
|
|
316
319
|
type="button"
|
|
317
|
-
onClick={() => shellui.openDrawer({ url: item
|
|
320
|
+
onClick={() => shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition })}
|
|
318
321
|
className={baseClass}
|
|
319
322
|
>
|
|
320
323
|
{content}
|
|
@@ -324,7 +327,7 @@ const BottomNavItem = ({
|
|
|
324
327
|
if (item.openIn === 'external') {
|
|
325
328
|
return (
|
|
326
329
|
<a
|
|
327
|
-
href={item
|
|
330
|
+
href={getEffectiveUrl(item)}
|
|
328
331
|
target="_blank"
|
|
329
332
|
rel="noopener noreferrer"
|
|
330
333
|
className={baseClass}
|
|
@@ -427,7 +430,7 @@ const HomeIcon = ({ className }: { className?: string }) => (
|
|
|
427
430
|
</svg>
|
|
428
431
|
);
|
|
429
432
|
|
|
430
|
-
/** Mobile bottom nav: Home + nav items; More only when not all fit. Dynamic from width. */
|
|
433
|
+
/** Mobile bottom nav: Home + nav items; More only when not all fit. Dynamic from width. Reuses HOMEPAGE_NAV_ITEM for label. */
|
|
431
434
|
const MobileBottomNav = ({
|
|
432
435
|
items,
|
|
433
436
|
currentLanguage,
|
|
@@ -477,7 +480,7 @@ const MobileBottomNav = ({
|
|
|
477
480
|
}, [location.pathname]);
|
|
478
481
|
|
|
479
482
|
const renderItem = (item: NavigationItem, index: number) => {
|
|
480
|
-
const pathPrefix =
|
|
483
|
+
const pathPrefix = getNavPathPrefix(item);
|
|
481
484
|
const isOverlayOrExternal =
|
|
482
485
|
item.openIn === 'modal' || item.openIn === 'drawer' || item.openIn === 'external';
|
|
483
486
|
const isActive =
|
|
@@ -485,12 +488,12 @@ const MobileBottomNav = ({
|
|
|
485
488
|
(location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
|
|
486
489
|
const label = resolveNavLabel(item.label, currentLanguage);
|
|
487
490
|
const faviconUrl =
|
|
488
|
-
item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item
|
|
491
|
+
item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
|
|
489
492
|
const iconSrc = item.icon ?? faviconUrl ?? null;
|
|
490
493
|
const applyIconTheme = iconSrc ? isAppIcon(iconSrc) : false;
|
|
491
494
|
return (
|
|
492
495
|
<BottomNavItem
|
|
493
|
-
key={`${item.path}-${item
|
|
496
|
+
key={`${item.path}-${getEffectiveUrl(item)}-${index}`}
|
|
494
497
|
item={item}
|
|
495
498
|
label={label}
|
|
496
499
|
isActive={isActive}
|
|
@@ -519,12 +522,14 @@ const MobileBottomNav = ({
|
|
|
519
522
|
? 'bg-sidebar-accent text-sidebar-accent-foreground [&_span]:text-sidebar-accent-foreground'
|
|
520
523
|
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground [&_span]:inherit',
|
|
521
524
|
)}
|
|
522
|
-
aria-label=
|
|
525
|
+
aria-label={resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
|
|
523
526
|
>
|
|
524
527
|
<span className="size-4 shrink-0 flex items-center justify-center [&_svg]:text-current">
|
|
525
528
|
<HomeIcon className="size-4" />
|
|
526
529
|
</span>
|
|
527
|
-
<span className="text-[11px] leading-tight">
|
|
530
|
+
<span className="text-[11px] leading-tight">
|
|
531
|
+
{resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
|
|
532
|
+
</span>
|
|
528
533
|
</Link>
|
|
529
534
|
{rowItems.map((item, i) => renderItem(item, i))}
|
|
530
535
|
{hasMore && (
|
|
@@ -589,7 +594,10 @@ const DefaultLayoutContent = ({ title, logo, navigation }: DefaultLayoutProps) =
|
|
|
589
594
|
const pathname = location.pathname.replace(/^\/+|\/+$/g, '') || '';
|
|
590
595
|
const segment = pathname.split('/')[0];
|
|
591
596
|
if (!segment) {
|
|
592
|
-
|
|
597
|
+
const rootNavItem = navigationItems.find((item) => item.path === '' || item.path === '/');
|
|
598
|
+
document.title = rootNavItem
|
|
599
|
+
? `${resolveLocalizedLabel(rootNavItem.label, currentLanguage)} | ${title}`
|
|
600
|
+
: title;
|
|
593
601
|
return;
|
|
594
602
|
}
|
|
595
603
|
const navItem = navigationItems.find((item) => item.path === segment);
|
|
@@ -9,7 +9,7 @@ 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 { resolveLocalizedString } from './utils';
|
|
12
|
+
import { getEffectiveUrl, getNavPathPrefix, normalizeUrlToPathname, resolveLocalizedString } from './utils';
|
|
13
13
|
|
|
14
14
|
interface OverlayShellProps {
|
|
15
15
|
navigationItems: NavigationItem[];
|
|
@@ -60,9 +60,10 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
60
60
|
const isHomepage = pathname === '/' || pathname === '';
|
|
61
61
|
const isAllowed =
|
|
62
62
|
isHomepage ||
|
|
63
|
-
navigationItems.some(
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
navigationItems.some((item) => {
|
|
64
|
+
const pathPrefix = getNavPathPrefix(item);
|
|
65
|
+
return pathname === pathPrefix || pathname.startsWith(`${pathPrefix}/`);
|
|
66
|
+
});
|
|
66
67
|
if (isAllowed) {
|
|
67
68
|
navigate(pathname || '/');
|
|
68
69
|
} else {
|
|
@@ -82,14 +83,16 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
82
83
|
{children}
|
|
83
84
|
<Dialog
|
|
84
85
|
open={isOpen}
|
|
85
|
-
onOpenChange={closeModal}
|
|
86
|
+
onOpenChange={(open) => !open && closeModal()}
|
|
86
87
|
>
|
|
87
88
|
<DialogContent className="max-w-4xl w-full h-[80vh] max-h-[680px] flex flex-col p-0 overflow-hidden">
|
|
88
89
|
{modalUrl ? (
|
|
89
90
|
<>
|
|
90
91
|
<DialogTitle className="sr-only">
|
|
91
92
|
{resolveLocalizedString(
|
|
92
|
-
navigationItems.find(
|
|
93
|
+
navigationItems.find(
|
|
94
|
+
(item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(modalUrl),
|
|
95
|
+
)?.label,
|
|
93
96
|
currentLanguage,
|
|
94
97
|
)}
|
|
95
98
|
</DialogTitle>
|
|
@@ -104,7 +107,11 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
104
107
|
url={modalUrl}
|
|
105
108
|
pathPrefix="settings"
|
|
106
109
|
ignoreMessages={true}
|
|
107
|
-
navItem={
|
|
110
|
+
navItem={
|
|
111
|
+
navigationItems.find(
|
|
112
|
+
(item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(modalUrl),
|
|
113
|
+
) ?? undefined
|
|
114
|
+
}
|
|
108
115
|
/>
|
|
109
116
|
</div>
|
|
110
117
|
</>
|
|
@@ -146,7 +153,11 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
|
|
|
146
153
|
url={drawerUrl}
|
|
147
154
|
pathPrefix="settings"
|
|
148
155
|
ignoreMessages={true}
|
|
149
|
-
navItem={
|
|
156
|
+
navItem={
|
|
157
|
+
navigationItems.find(
|
|
158
|
+
(item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(drawerUrl),
|
|
159
|
+
) ?? undefined
|
|
160
|
+
}
|
|
150
161
|
/>
|
|
151
162
|
</div>
|
|
152
163
|
) : (
|
|
@@ -11,6 +11,8 @@ import { shellui } from '@shellui/sdk';
|
|
|
11
11
|
import type { NavigationItem, NavigationGroup } from '../config/types';
|
|
12
12
|
import {
|
|
13
13
|
flattenNavigationItems,
|
|
14
|
+
getEffectiveUrl,
|
|
15
|
+
getNavPathPrefix,
|
|
14
16
|
resolveLocalizedString as resolveNavLabel,
|
|
15
17
|
splitNavigationByPosition,
|
|
16
18
|
} from './utils';
|
|
@@ -70,7 +72,7 @@ function getMaximizedBounds(): WindowState['bounds'] {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function buildFinalUrl(baseUrl: string, path: string, pathname: string): string {
|
|
73
|
-
const pathPrefix =
|
|
75
|
+
const pathPrefix = getNavPathPrefix({ path } as NavigationItem);
|
|
74
76
|
const subPath = pathname.length > pathPrefix.length ? pathname.slice(pathPrefix.length + 1) : '';
|
|
75
77
|
if (!subPath) return baseUrl;
|
|
76
78
|
const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
@@ -540,7 +542,7 @@ export function WindowsLayout({
|
|
|
540
542
|
const label =
|
|
541
543
|
typeof item.label === 'string' ? item.label : resolveNavLabel(item.label, currentLanguage);
|
|
542
544
|
const faviconUrl =
|
|
543
|
-
item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item
|
|
545
|
+
item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
|
|
544
546
|
const icon = item.icon ?? faviconUrl ?? null;
|
|
545
547
|
const id = genId();
|
|
546
548
|
const bounds = {
|
|
@@ -554,8 +556,8 @@ export function WindowsLayout({
|
|
|
554
556
|
{
|
|
555
557
|
id,
|
|
556
558
|
path: item.path,
|
|
557
|
-
pathname:
|
|
558
|
-
baseUrl: item
|
|
559
|
+
pathname: getNavPathPrefix(item),
|
|
560
|
+
baseUrl: getEffectiveUrl(item),
|
|
559
561
|
label,
|
|
560
562
|
icon,
|
|
561
563
|
bounds,
|
|
@@ -608,17 +610,17 @@ export function WindowsLayout({
|
|
|
608
610
|
const handleNavClick = useCallback(
|
|
609
611
|
(item: NavigationItem) => {
|
|
610
612
|
if (item.openIn === 'modal') {
|
|
611
|
-
shellui.openModal(item
|
|
613
|
+
shellui.openModal(getEffectiveUrl(item));
|
|
612
614
|
setStartMenuOpen(false);
|
|
613
615
|
return;
|
|
614
616
|
}
|
|
615
617
|
if (item.openIn === 'drawer') {
|
|
616
|
-
shellui.openDrawer({ url: item
|
|
618
|
+
shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition });
|
|
617
619
|
setStartMenuOpen(false);
|
|
618
620
|
return;
|
|
619
621
|
}
|
|
620
622
|
if (item.openIn === 'external') {
|
|
621
|
-
window.open(item
|
|
623
|
+
window.open(getEffectiveUrl(item), '_blank', 'noopener,noreferrer');
|
|
622
624
|
setStartMenuOpen(false);
|
|
623
625
|
return;
|
|
624
626
|
}
|
|
@@ -708,7 +710,7 @@ export function WindowsLayout({
|
|
|
708
710
|
: resolveNavLabel(item.label, currentLanguage);
|
|
709
711
|
const icon =
|
|
710
712
|
item.icon ??
|
|
711
|
-
(item.openIn === 'external' ? getExternalFaviconUrl(item
|
|
713
|
+
(item.openIn === 'external' ? getExternalFaviconUrl(getEffectiveUrl(item)) : null);
|
|
712
714
|
return (
|
|
713
715
|
<button
|
|
714
716
|
key={item.path}
|
|
@@ -790,7 +792,7 @@ export function WindowsLayout({
|
|
|
790
792
|
: resolveNavLabel(item.label, currentLanguage);
|
|
791
793
|
const icon =
|
|
792
794
|
item.icon ??
|
|
793
|
-
(item.openIn === 'external' ? getExternalFaviconUrl(item
|
|
795
|
+
(item.openIn === 'external' ? getExternalFaviconUrl(getEffectiveUrl(item)) : null);
|
|
794
796
|
return (
|
|
795
797
|
<button
|
|
796
798
|
key={item.path}
|
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
import type { NavigationItem, NavigationGroup, LocalizedString } from '../config/types';
|
|
2
|
+
import urls from '../../constants/urls';
|
|
3
|
+
|
|
4
|
+
/** Path prefix for a nav item: "/" for root (path '' or '/'), otherwise "/{path}". */
|
|
5
|
+
export function getNavPathPrefix(item: NavigationItem): string {
|
|
6
|
+
return item.path === '/' || item.path === '' ? '/' : `/${item.path}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Effective URL for a nav item: url if set, otherwise app-path URL for component-based items. */
|
|
10
|
+
export function getEffectiveUrl(item: NavigationItem): string {
|
|
11
|
+
if (item.url != null && item.url !== '') {
|
|
12
|
+
return item.url;
|
|
13
|
+
}
|
|
14
|
+
const base = typeof window !== 'undefined' ? window.location.origin : '';
|
|
15
|
+
const path = item.path === '/' || item.path === '' ? 'home' : item.path;
|
|
16
|
+
return `${base}${urls.appPath}/${path}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Normalize a URL to pathname for comparison (handles full URLs and path-only). */
|
|
20
|
+
export function normalizeUrlToPathname(url: string): string {
|
|
21
|
+
if (!url || typeof url !== 'string') return '';
|
|
22
|
+
const s = url.trim();
|
|
23
|
+
if (s.startsWith('http://') || s.startsWith('https://') || s.startsWith('//')) {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(s, 'http://localhost').pathname.replace(/\/+$/, '') || '/';
|
|
26
|
+
} catch {
|
|
27
|
+
return s.startsWith('/') ? s.replace(/\/+$/, '') || '/' : `/${s}`.replace(/\/+$/, '') || '/';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return (s.startsWith('/') ? s : `/${s}`).replace(/\/+$/, '') || '/';
|
|
31
|
+
}
|
|
2
32
|
|
|
3
33
|
/** Resolve a localized string to a single string for the given language. */
|
|
4
34
|
export function resolveLocalizedString(value: LocalizedString | undefined, lang: string): string {
|
|
@@ -74,6 +104,20 @@ export function filterNavigationForSidebar(
|
|
|
74
104
|
.filter((item): item is NavigationItem | NavigationGroup => item !== null);
|
|
75
105
|
}
|
|
76
106
|
|
|
107
|
+
/** Synthetic homepage nav item: used when there is no root (path '' or '/') in the list, so users can navigate to "/". Reused by app-bar and sidebar mobile. */
|
|
108
|
+
export const HOMEPAGE_NAV_ITEM: NavigationItem = {
|
|
109
|
+
path: '/',
|
|
110
|
+
label: { en: 'Home', fr: 'Accueil' },
|
|
111
|
+
url: '/',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** If there is no root item (path '' or '/') in the list, prepend a synthetic Homepage item. Use in app-bar and anywhere that needs a "Home" entry when nav has no "/" path. */
|
|
115
|
+
export function withHomepageWhenNoRoot(items: NavigationItem[]): NavigationItem[] {
|
|
116
|
+
const hasRoot = items.some((i) => i.path === '' || i.path === '/');
|
|
117
|
+
if (hasRoot) return items;
|
|
118
|
+
return [HOMEPAGE_NAV_ITEM, ...items];
|
|
119
|
+
}
|
|
120
|
+
|
|
77
121
|
/** Split navigation by position: start (main content) and end (footer). */
|
|
78
122
|
export function splitNavigationByPosition(navigation: (NavigationItem | NavigationGroup)[]): {
|
|
79
123
|
start: (NavigationItem | NavigationGroup)[];
|