@shellui/core 0.0.4

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.
Files changed (247) hide show
  1. package/README.md +17 -0
  2. package/dist/ContentView-CZG-ro_B.js +146 -0
  3. package/dist/ContentView-CZG-ro_B.js.map +1 -0
  4. package/dist/CookiePreferencesView-MhO9FO-4.js +213 -0
  5. package/dist/CookiePreferencesView-MhO9FO-4.js.map +1 -0
  6. package/dist/DefaultLayout-Dbb3uJED.js +394 -0
  7. package/dist/DefaultLayout-Dbb3uJED.js.map +1 -0
  8. package/dist/FullscreenLayout-1SgPHWw-.js +30 -0
  9. package/dist/FullscreenLayout-1SgPHWw-.js.map +1 -0
  10. package/dist/HomeView-DYU-O_Il.js +21 -0
  11. package/dist/HomeView-DYU-O_Il.js.map +1 -0
  12. package/dist/NotFoundView-CeYjJNg0.js +52 -0
  13. package/dist/NotFoundView-CeYjJNg0.js.map +1 -0
  14. package/dist/OverlayShell-pzbqQW25.js +642 -0
  15. package/dist/OverlayShell-pzbqQW25.js.map +1 -0
  16. package/dist/SettingsView-Bndrta44.js +2207 -0
  17. package/dist/SettingsView-Bndrta44.js.map +1 -0
  18. package/dist/ViewRoute-ChSPabOy.js +32 -0
  19. package/dist/ViewRoute-ChSPabOy.js.map +1 -0
  20. package/dist/WindowsLayout-CXGNPKoY.js +633 -0
  21. package/dist/WindowsLayout-CXGNPKoY.js.map +1 -0
  22. package/dist/app.d.ts +3 -0
  23. package/dist/app.d.ts.map +1 -0
  24. package/dist/components/ContentView.d.ts +10 -0
  25. package/dist/components/ContentView.d.ts.map +1 -0
  26. package/dist/components/HomeView.d.ts +2 -0
  27. package/dist/components/HomeView.d.ts.map +1 -0
  28. package/dist/components/LoadingOverlay.d.ts +2 -0
  29. package/dist/components/LoadingOverlay.d.ts.map +1 -0
  30. package/dist/components/NotFoundView.d.ts +2 -0
  31. package/dist/components/NotFoundView.d.ts.map +1 -0
  32. package/dist/components/RouteErrorBoundary.d.ts +2 -0
  33. package/dist/components/RouteErrorBoundary.d.ts.map +1 -0
  34. package/dist/components/ViewRoute.d.ts +7 -0
  35. package/dist/components/ViewRoute.d.ts.map +1 -0
  36. package/dist/components/ui/alert-dialog.d.ts +32 -0
  37. package/dist/components/ui/alert-dialog.d.ts.map +1 -0
  38. package/dist/components/ui/breadcrumb.d.ts +20 -0
  39. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  40. package/dist/components/ui/button-group.d.ts +7 -0
  41. package/dist/components/ui/button-group.d.ts.map +1 -0
  42. package/dist/components/ui/button.d.ts +12 -0
  43. package/dist/components/ui/button.d.ts.map +1 -0
  44. package/dist/components/ui/dialog.d.ts +24 -0
  45. package/dist/components/ui/dialog.d.ts.map +1 -0
  46. package/dist/components/ui/drawer.d.ts +38 -0
  47. package/dist/components/ui/drawer.d.ts.map +1 -0
  48. package/dist/components/ui/select.d.ts +5 -0
  49. package/dist/components/ui/select.d.ts.map +1 -0
  50. package/dist/components/ui/sidebar.d.ts +46 -0
  51. package/dist/components/ui/sidebar.d.ts.map +1 -0
  52. package/dist/components/ui/sonner.d.ts +6 -0
  53. package/dist/components/ui/sonner.d.ts.map +1 -0
  54. package/dist/components/ui/switch.d.ts +8 -0
  55. package/dist/components/ui/switch.d.ts.map +1 -0
  56. package/dist/constants/urls.d.ts +6 -0
  57. package/dist/constants/urls.d.ts.map +1 -0
  58. package/dist/constants/urls.js +8 -0
  59. package/dist/constants/urls.js.map +1 -0
  60. package/dist/features/alertDialog/DialogContext.d.ts +12 -0
  61. package/dist/features/alertDialog/DialogContext.d.ts.map +1 -0
  62. package/dist/features/config/ConfigProvider.d.ts +15 -0
  63. package/dist/features/config/ConfigProvider.d.ts.map +1 -0
  64. package/dist/features/config/types.d.ts +177 -0
  65. package/dist/features/config/types.d.ts.map +1 -0
  66. package/dist/features/config/useConfig.d.ts +8 -0
  67. package/dist/features/config/useConfig.d.ts.map +1 -0
  68. package/dist/features/cookieConsent/CookieConsentModal.d.ts +6 -0
  69. package/dist/features/cookieConsent/CookieConsentModal.d.ts.map +1 -0
  70. package/dist/features/cookieConsent/CookiePreferencesView.d.ts +2 -0
  71. package/dist/features/cookieConsent/CookiePreferencesView.d.ts.map +1 -0
  72. package/dist/features/cookieConsent/cookieConsent.d.ts +22 -0
  73. package/dist/features/cookieConsent/cookieConsent.d.ts.map +1 -0
  74. package/dist/features/cookieConsent/useCookieConsent.d.ts +15 -0
  75. package/dist/features/cookieConsent/useCookieConsent.d.ts.map +1 -0
  76. package/dist/features/drawer/DrawerContext.d.ts +24 -0
  77. package/dist/features/drawer/DrawerContext.d.ts.map +1 -0
  78. package/dist/features/layouts/AppLayout.d.ts +12 -0
  79. package/dist/features/layouts/AppLayout.d.ts.map +1 -0
  80. package/dist/features/layouts/DefaultLayout.d.ts +10 -0
  81. package/dist/features/layouts/DefaultLayout.d.ts.map +1 -0
  82. package/dist/features/layouts/FullscreenLayout.d.ts +9 -0
  83. package/dist/features/layouts/FullscreenLayout.d.ts.map +1 -0
  84. package/dist/features/layouts/LayoutProviders.d.ts +9 -0
  85. package/dist/features/layouts/LayoutProviders.d.ts.map +1 -0
  86. package/dist/features/layouts/OverlayShell.d.ts +10 -0
  87. package/dist/features/layouts/OverlayShell.d.ts.map +1 -0
  88. package/dist/features/layouts/WindowsLayout.d.ts +24 -0
  89. package/dist/features/layouts/WindowsLayout.d.ts.map +1 -0
  90. package/dist/features/layouts/utils.d.ts +16 -0
  91. package/dist/features/layouts/utils.d.ts.map +1 -0
  92. package/dist/features/modal/ModalContext.d.ts +20 -0
  93. package/dist/features/modal/ModalContext.d.ts.map +1 -0
  94. package/dist/features/sentry/initSentry.d.ts +14 -0
  95. package/dist/features/sentry/initSentry.d.ts.map +1 -0
  96. package/dist/features/settings/SettingsContext.d.ts +10 -0
  97. package/dist/features/settings/SettingsContext.d.ts.map +1 -0
  98. package/dist/features/settings/SettingsIcons.d.ts +22 -0
  99. package/dist/features/settings/SettingsIcons.d.ts.map +1 -0
  100. package/dist/features/settings/SettingsProvider.d.ts +5 -0
  101. package/dist/features/settings/SettingsProvider.d.ts.map +1 -0
  102. package/dist/features/settings/SettingsRoutes.d.ts +7 -0
  103. package/dist/features/settings/SettingsRoutes.d.ts.map +1 -0
  104. package/dist/features/settings/SettingsView.d.ts +2 -0
  105. package/dist/features/settings/SettingsView.d.ts.map +1 -0
  106. package/dist/features/settings/components/Advanced.d.ts +2 -0
  107. package/dist/features/settings/components/Advanced.d.ts.map +1 -0
  108. package/dist/features/settings/components/Appearance.d.ts +2 -0
  109. package/dist/features/settings/components/Appearance.d.ts.map +1 -0
  110. package/dist/features/settings/components/DataPrivacy.d.ts +2 -0
  111. package/dist/features/settings/components/DataPrivacy.d.ts.map +1 -0
  112. package/dist/features/settings/components/Develop.d.ts +2 -0
  113. package/dist/features/settings/components/Develop.d.ts.map +1 -0
  114. package/dist/features/settings/components/LanguageAndRegion.d.ts +2 -0
  115. package/dist/features/settings/components/LanguageAndRegion.d.ts.map +1 -0
  116. package/dist/features/settings/components/ServiceWorker.d.ts +2 -0
  117. package/dist/features/settings/components/ServiceWorker.d.ts.map +1 -0
  118. package/dist/features/settings/components/UpdateApp.d.ts +2 -0
  119. package/dist/features/settings/components/UpdateApp.d.ts.map +1 -0
  120. package/dist/features/settings/components/develop/DialogTestButtons.d.ts +2 -0
  121. package/dist/features/settings/components/develop/DialogTestButtons.d.ts.map +1 -0
  122. package/dist/features/settings/components/develop/DrawerTestButtons.d.ts +2 -0
  123. package/dist/features/settings/components/develop/DrawerTestButtons.d.ts.map +1 -0
  124. package/dist/features/settings/components/develop/ModalTestButtons.d.ts +2 -0
  125. package/dist/features/settings/components/develop/ModalTestButtons.d.ts.map +1 -0
  126. package/dist/features/settings/components/develop/ToastTestButtons.d.ts +2 -0
  127. package/dist/features/settings/components/develop/ToastTestButtons.d.ts.map +1 -0
  128. package/dist/features/settings/hooks/useSettings.d.ts +2 -0
  129. package/dist/features/settings/hooks/useSettings.d.ts.map +1 -0
  130. package/dist/features/sonner/SonnerContext.d.ts +29 -0
  131. package/dist/features/sonner/SonnerContext.d.ts.map +1 -0
  132. package/dist/features/theme/ThemeProvider.d.ts +11 -0
  133. package/dist/features/theme/ThemeProvider.d.ts.map +1 -0
  134. package/dist/features/theme/themes.d.ts +114 -0
  135. package/dist/features/theme/themes.d.ts.map +1 -0
  136. package/dist/features/theme/useTheme.d.ts +10 -0
  137. package/dist/features/theme/useTheme.d.ts.map +1 -0
  138. package/dist/i18n/I18nProvider.d.ts +9 -0
  139. package/dist/i18n/I18nProvider.d.ts.map +1 -0
  140. package/dist/i18n/config.d.ts +23 -0
  141. package/dist/i18n/config.d.ts.map +1 -0
  142. package/dist/i18n/translations/en/common.json.d.ts +19 -0
  143. package/dist/i18n/translations/en/cookieConsent.json.d.ts +53 -0
  144. package/dist/i18n/translations/en/settings.json.d.ts +358 -0
  145. package/dist/i18n/translations/fr/common.json.d.ts +19 -0
  146. package/dist/i18n/translations/fr/cookieConsent.json.d.ts +53 -0
  147. package/dist/i18n/translations/fr/settings.json.d.ts +358 -0
  148. package/dist/index-lmRk5L6z.js +2160 -0
  149. package/dist/index-lmRk5L6z.js.map +1 -0
  150. package/dist/index.d.ts +7 -0
  151. package/dist/index.d.ts.map +1 -0
  152. package/dist/index.js +12 -0
  153. package/dist/index.js.map +1 -0
  154. package/dist/lib/utils.d.ts +3 -0
  155. package/dist/lib/utils.d.ts.map +1 -0
  156. package/dist/lib/z-index.d.ts +29 -0
  157. package/dist/lib/z-index.d.ts.map +1 -0
  158. package/dist/router/router.d.ts +3 -0
  159. package/dist/router/router.d.ts.map +1 -0
  160. package/dist/router/routes.d.ts +4 -0
  161. package/dist/router/routes.d.ts.map +1 -0
  162. package/dist/sidebar-ClIeZ2zb.js +303 -0
  163. package/dist/sidebar-ClIeZ2zb.js.map +1 -0
  164. package/dist/style.css +1 -0
  165. package/dist/switch-8SzUJz7Q.js +44 -0
  166. package/dist/switch-8SzUJz7Q.js.map +1 -0
  167. package/dist/types.js +2 -0
  168. package/dist/types.js.map +1 -0
  169. package/package.json +93 -0
  170. package/postcss.config.js +6 -0
  171. package/src/app.tsx +119 -0
  172. package/src/components/ContentView.tsx +258 -0
  173. package/src/components/HomeView.tsx +19 -0
  174. package/src/components/LoadingOverlay.tsx +12 -0
  175. package/src/components/NotFoundView.tsx +84 -0
  176. package/src/components/RouteErrorBoundary.tsx +95 -0
  177. package/src/components/ViewRoute.tsx +47 -0
  178. package/src/components/ui/alert-dialog.tsx +181 -0
  179. package/src/components/ui/breadcrumb.tsx +155 -0
  180. package/src/components/ui/button-group.tsx +52 -0
  181. package/src/components/ui/button.tsx +51 -0
  182. package/src/components/ui/dialog.tsx +160 -0
  183. package/src/components/ui/drawer.tsx +200 -0
  184. package/src/components/ui/select.tsx +24 -0
  185. package/src/components/ui/sidebar.tsx +406 -0
  186. package/src/components/ui/sonner.tsx +36 -0
  187. package/src/components/ui/switch.tsx +45 -0
  188. package/src/constants/urls.ts +4 -0
  189. package/src/features/alertDialog/DialogContext.tsx +468 -0
  190. package/src/features/config/ConfigProvider.ts +96 -0
  191. package/src/features/config/types.ts +195 -0
  192. package/src/features/config/useConfig.ts +15 -0
  193. package/src/features/cookieConsent/CookieConsentModal.tsx +122 -0
  194. package/src/features/cookieConsent/CookiePreferencesView.tsx +328 -0
  195. package/src/features/cookieConsent/cookieConsent.ts +84 -0
  196. package/src/features/cookieConsent/useCookieConsent.ts +39 -0
  197. package/src/features/drawer/DrawerContext.tsx +116 -0
  198. package/src/features/layouts/AppLayout.tsx +63 -0
  199. package/src/features/layouts/DefaultLayout.tsx +625 -0
  200. package/src/features/layouts/FullscreenLayout.tsx +55 -0
  201. package/src/features/layouts/LayoutProviders.tsx +20 -0
  202. package/src/features/layouts/OverlayShell.tsx +171 -0
  203. package/src/features/layouts/WindowsLayout.tsx +860 -0
  204. package/src/features/layouts/utils.ts +99 -0
  205. package/src/features/modal/ModalContext.tsx +112 -0
  206. package/src/features/sentry/initSentry.ts +72 -0
  207. package/src/features/settings/SettingsContext.tsx +19 -0
  208. package/src/features/settings/SettingsIcons.tsx +452 -0
  209. package/src/features/settings/SettingsProvider.tsx +341 -0
  210. package/src/features/settings/SettingsRoutes.tsx +66 -0
  211. package/src/features/settings/SettingsView.tsx +327 -0
  212. package/src/features/settings/components/Advanced.tsx +128 -0
  213. package/src/features/settings/components/Appearance.tsx +306 -0
  214. package/src/features/settings/components/DataPrivacy.tsx +142 -0
  215. package/src/features/settings/components/Develop.tsx +174 -0
  216. package/src/features/settings/components/LanguageAndRegion.tsx +329 -0
  217. package/src/features/settings/components/ServiceWorker.tsx +363 -0
  218. package/src/features/settings/components/UpdateApp.tsx +206 -0
  219. package/src/features/settings/components/develop/DialogTestButtons.tsx +137 -0
  220. package/src/features/settings/components/develop/DrawerTestButtons.tsx +67 -0
  221. package/src/features/settings/components/develop/ModalTestButtons.tsx +30 -0
  222. package/src/features/settings/components/develop/ToastTestButtons.tsx +179 -0
  223. package/src/features/settings/hooks/useSettings.tsx +10 -0
  224. package/src/features/sonner/SonnerContext.tsx +286 -0
  225. package/src/features/theme/ThemeProvider.tsx +16 -0
  226. package/src/features/theme/themes.ts +561 -0
  227. package/src/features/theme/useTheme.tsx +71 -0
  228. package/src/i18n/I18nProvider.tsx +32 -0
  229. package/src/i18n/config.ts +107 -0
  230. package/src/i18n/translations/en/common.json +16 -0
  231. package/src/i18n/translations/en/cookieConsent.json +50 -0
  232. package/src/i18n/translations/en/settings.json +355 -0
  233. package/src/i18n/translations/fr/common.json +16 -0
  234. package/src/i18n/translations/fr/cookieConsent.json +50 -0
  235. package/src/i18n/translations/fr/settings.json +355 -0
  236. package/src/index.css +412 -0
  237. package/src/index.html +100 -0
  238. package/src/index.ts +31 -0
  239. package/src/lib/utils.ts +6 -0
  240. package/src/lib/z-index.ts +29 -0
  241. package/src/main.tsx +26 -0
  242. package/src/router/router.tsx +8 -0
  243. package/src/router/routes.tsx +115 -0
  244. package/src/service-worker/register.ts +1199 -0
  245. package/src/service-worker/sw-dev.ts +87 -0
  246. package/src/service-worker/sw.ts +105 -0
  247. package/tailwind.config.js +60 -0
@@ -0,0 +1,625 @@
1
+ import { Link, useLocation, Outlet } from 'react-router';
2
+ import { useMemo, useEffect, useState, useRef, useLayoutEffect } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { shellui } from '@shellui/sdk';
5
+ import type { NavigationItem, NavigationGroup } from '../config/types';
6
+ import {
7
+ Sidebar,
8
+ SidebarProvider,
9
+ SidebarHeader,
10
+ SidebarContent,
11
+ SidebarFooter,
12
+ SidebarGroup,
13
+ SidebarGroupLabel,
14
+ SidebarGroupContent,
15
+ SidebarMenu,
16
+ SidebarMenuItem,
17
+ SidebarMenuButton,
18
+ } from '@/components/ui/sidebar';
19
+ import { cn } from '@/lib/utils';
20
+ import { Z_INDEX } from '@/lib/z-index';
21
+ import {
22
+ filterNavigationByViewport,
23
+ filterNavigationForSidebar,
24
+ flattenNavigationItems,
25
+ resolveLocalizedString as resolveNavLabel,
26
+ splitNavigationByPosition,
27
+ } from './utils';
28
+ import { LayoutProviders } from './LayoutProviders';
29
+ import { OverlayShell } from './OverlayShell';
30
+
31
+ interface DefaultLayoutProps {
32
+ title?: string;
33
+ appIcon?: string;
34
+ logo?: string;
35
+ navigation: (NavigationItem | NavigationGroup)[];
36
+ }
37
+
38
+ // DuckDuckGo favicon URL for a given page URL (used when openIn === 'external' and no icon is set)
39
+ const getExternalFaviconUrl = (url: string): string | null => {
40
+ try {
41
+ const parsed = new URL(url);
42
+ const hostname = parsed.hostname;
43
+ if (!hostname) return null;
44
+ return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ const NavigationContent = ({
51
+ navigation,
52
+ }: {
53
+ navigation: (NavigationItem | NavigationGroup)[];
54
+ }) => {
55
+ const location = useLocation();
56
+ const { i18n } = useTranslation();
57
+ const currentLanguage = i18n.language || 'en';
58
+
59
+ // Helper function to resolve localized strings
60
+ const resolveLocalizedString = (
61
+ value: string | { en: string; fr: string; [key: string]: string },
62
+ lang: string,
63
+ ): string => {
64
+ if (typeof value === 'string') {
65
+ return value;
66
+ }
67
+ // Try current language first, then English as fallback
68
+ return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
69
+ };
70
+
71
+ // Check if at least one navigation item has an icon
72
+ const hasAnyIcons = useMemo(() => {
73
+ return navigation.some((item) => {
74
+ if ('title' in item && 'items' in item) {
75
+ // It's a group
76
+ return (item as NavigationGroup).items.some((navItem) => !!navItem.icon);
77
+ }
78
+ // It's a standalone item
79
+ return !!(item as NavigationItem).icon;
80
+ });
81
+ }, [navigation]);
82
+
83
+ // Helper to check if an item is a group
84
+ const isGroup = (item: NavigationItem | NavigationGroup): item is NavigationGroup => {
85
+ return 'title' in item && 'items' in item;
86
+ };
87
+
88
+ // Render a single nav item link or modal/drawer trigger
89
+ const renderNavItem = (navItem: NavigationItem) => {
90
+ const pathPrefix = `/${navItem.path}`;
91
+ const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
92
+ const isExternal = navItem.openIn === 'external';
93
+ const isActive =
94
+ !isOverlay &&
95
+ !isExternal &&
96
+ (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
97
+ const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
98
+ const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
99
+ const iconSrc = navItem.icon ?? faviconUrl ?? null;
100
+ const iconEl = iconSrc ? (
101
+ <img
102
+ src={iconSrc}
103
+ alt=""
104
+ className={cn('h-4 w-4', 'shrink-0')}
105
+ />
106
+ ) : hasAnyIcons ? (
107
+ <span className="h-4 w-4 shrink-0" />
108
+ ) : null;
109
+ const externalIcon = isExternal ? (
110
+ <img
111
+ src="/icons/external-link.svg"
112
+ alt=""
113
+ className="ml-auto h-4 w-4 shrink-0 opacity-70"
114
+ aria-hidden
115
+ />
116
+ ) : null;
117
+ const content = (
118
+ <>
119
+ {iconEl}
120
+ <span className="truncate">{itemLabel}</span>
121
+ {externalIcon}
122
+ </>
123
+ );
124
+ const linkOrTrigger =
125
+ navItem.openIn === 'modal' ? (
126
+ <button
127
+ type="button"
128
+ onClick={() => shellui.openModal(navItem.url)}
129
+ className="flex items-center gap-2 w-full cursor-pointer text-left"
130
+ >
131
+ {content}
132
+ </button>
133
+ ) : navItem.openIn === 'drawer' ? (
134
+ <button
135
+ type="button"
136
+ onClick={() => shellui.openDrawer({ url: navItem.url, position: navItem.drawerPosition })}
137
+ className="flex items-center gap-2 w-full cursor-pointer text-left"
138
+ >
139
+ {content}
140
+ </button>
141
+ ) : navItem.openIn === 'external' ? (
142
+ <a
143
+ href={navItem.url}
144
+ target="_blank"
145
+ rel="noopener noreferrer"
146
+ className="flex items-center gap-2 w-full"
147
+ >
148
+ {content}
149
+ </a>
150
+ ) : (
151
+ <Link
152
+ to={`/${navItem.path}`}
153
+ className="flex items-center gap-2 w-full"
154
+ >
155
+ {content}
156
+ </Link>
157
+ );
158
+ return (
159
+ <SidebarMenuButton
160
+ asChild
161
+ isActive={isActive}
162
+ className={cn('w-full', isActive && 'bg-sidebar-accent text-sidebar-accent-foreground')}
163
+ >
164
+ {linkOrTrigger}
165
+ </SidebarMenuButton>
166
+ );
167
+ };
168
+
169
+ // Render navigation items - handle both groups and standalone items
170
+ return (
171
+ <>
172
+ {navigation.map((item) => {
173
+ if (isGroup(item)) {
174
+ // Render as a group
175
+ const groupTitle = resolveLocalizedString(item.title, currentLanguage);
176
+ return (
177
+ <SidebarGroup
178
+ key={groupTitle}
179
+ className="mt-0"
180
+ >
181
+ <SidebarGroupLabel className="mb-1">{groupTitle}</SidebarGroupLabel>
182
+ <SidebarGroupContent>
183
+ <SidebarMenu className="gap-0.5">
184
+ {item.items.map((navItem) => (
185
+ <SidebarMenuItem key={navItem.path}>{renderNavItem(navItem)}</SidebarMenuItem>
186
+ ))}
187
+ </SidebarMenu>
188
+ </SidebarGroupContent>
189
+ </SidebarGroup>
190
+ );
191
+ } else {
192
+ // Render as a standalone item
193
+ return (
194
+ <SidebarMenu
195
+ key={item.path}
196
+ className="gap-0.5"
197
+ >
198
+ <SidebarMenuItem>{renderNavItem(item)}</SidebarMenuItem>
199
+ </SidebarMenu>
200
+ );
201
+ }
202
+ })}
203
+ </>
204
+ );
205
+ };
206
+
207
+ /** Reusable sidebar inner: header, main nav, footer. Used in desktop Sidebar and mobile Drawer. */
208
+ const SidebarInner = ({
209
+ title,
210
+ logo,
211
+ startNav,
212
+ endItems,
213
+ }: {
214
+ title?: string;
215
+ logo?: string;
216
+ startNav: (NavigationItem | NavigationGroup)[];
217
+ endItems: (NavigationItem | NavigationGroup)[];
218
+ }) => (
219
+ <>
220
+ <SidebarHeader className="border-b border-sidebar-border pb-4">
221
+ {(title || logo) && (
222
+ <Link
223
+ to="/"
224
+ className="flex items-center pl-1 pr-3 py-2 text-lg font-semibold text-sidebar-foreground hover:text-sidebar-foreground/80 transition-colors"
225
+ >
226
+ {logo && logo.trim() ? (
227
+ <img
228
+ src={logo}
229
+ alt={title || 'Logo'}
230
+ className="h-5 w-auto shrink-0 object-contain sidebar-logo"
231
+ />
232
+ ) : title ? (
233
+ <span className="leading-none">{title}</span>
234
+ ) : null}
235
+ </Link>
236
+ )}
237
+ </SidebarHeader>
238
+ <SidebarContent className="gap-1">
239
+ <NavigationContent navigation={startNav} />
240
+ </SidebarContent>
241
+ {endItems.length > 0 && (
242
+ <SidebarFooter>
243
+ <NavigationContent navigation={endItems} />
244
+ </SidebarFooter>
245
+ )}
246
+ </>
247
+ );
248
+
249
+ function resolveLocalizedLabel(
250
+ value: string | { en: string; fr: string; [key: string]: string },
251
+ lang: string,
252
+ ): string {
253
+ if (typeof value === 'string') return value;
254
+ return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
255
+ }
256
+
257
+ /** Approximate width per slot (icon + label + padding) and gap for dynamic slot count. */
258
+ const BOTTOM_NAV_SLOT_WIDTH = 64;
259
+ const BOTTOM_NAV_GAP = 4;
260
+ const BOTTOM_NAV_PX = 12;
261
+ /** Max slots in the row (Home + nav + optional More) to avoid overflow/duplicated wrap. */
262
+ const BOTTOM_NAV_MAX_SLOTS = 6;
263
+
264
+ /** True when the icon is a local app icon (/icons/); external images (avatars, favicons) are shown as-is. */
265
+ const isAppIcon = (src: string) => src.startsWith('/icons/');
266
+
267
+ /** Single nav item for bottom bar: icon + label, link or action. */
268
+ const BottomNavItem = ({
269
+ item,
270
+ label,
271
+ isActive,
272
+ iconSrc,
273
+ applyIconTheme,
274
+ }: {
275
+ item: NavigationItem;
276
+ label: string;
277
+ isActive: boolean;
278
+ iconSrc: string | null;
279
+ applyIconTheme: boolean;
280
+ }) => {
281
+ const pathPrefix = `/${item.path}`;
282
+ const content = (
283
+ <span className="flex flex-col items-center justify-center gap-1 w-full min-w-0 max-w-full overflow-hidden">
284
+ {iconSrc ? (
285
+ <img
286
+ src={iconSrc}
287
+ alt=""
288
+ className={cn(
289
+ 'size-4 shrink-0 rounded-sm object-cover',
290
+ applyIconTheme && 'opacity-90 dark:opacity-100 dark:invert',
291
+ )}
292
+ />
293
+ ) : (
294
+ <span className="size-4 shrink-0 rounded-sm bg-muted" />
295
+ )}
296
+ <span className="text-[11px] leading-tight truncate w-full min-w-0 text-center block">
297
+ {label}
298
+ </span>
299
+ </span>
300
+ );
301
+ const baseClass = cn(
302
+ 'flex flex-col items-center justify-center rounded-md py-1.5 px-2 min-w-0 max-w-full transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
303
+ isActive
304
+ ? 'bg-accent text-accent-foreground [&_span]:text-accent-foreground'
305
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground [&_span]:inherit',
306
+ );
307
+ if (item.openIn === 'modal') {
308
+ return (
309
+ <button
310
+ type="button"
311
+ onClick={() => shellui.openModal(item.url)}
312
+ className={baseClass}
313
+ >
314
+ {content}
315
+ </button>
316
+ );
317
+ }
318
+ if (item.openIn === 'drawer') {
319
+ return (
320
+ <button
321
+ type="button"
322
+ onClick={() => shellui.openDrawer({ url: item.url, position: item.drawerPosition })}
323
+ className={baseClass}
324
+ >
325
+ {content}
326
+ </button>
327
+ );
328
+ }
329
+ if (item.openIn === 'external') {
330
+ return (
331
+ <a
332
+ href={item.url}
333
+ target="_blank"
334
+ rel="noopener noreferrer"
335
+ className={baseClass}
336
+ >
337
+ {content}
338
+ </a>
339
+ );
340
+ }
341
+ return (
342
+ <Link
343
+ to={pathPrefix}
344
+ className={baseClass}
345
+ >
346
+ {content}
347
+ </Link>
348
+ );
349
+ };
350
+
351
+ /** Caret up: expand (show second line). */
352
+ const CaretUpIcon = ({ className }: { className?: string }) => (
353
+ <svg
354
+ xmlns="http://www.w3.org/2000/svg"
355
+ width="24"
356
+ height="24"
357
+ viewBox="0 0 24 24"
358
+ fill="none"
359
+ stroke="currentColor"
360
+ strokeWidth="2"
361
+ strokeLinecap="round"
362
+ strokeLinejoin="round"
363
+ className={cn('shrink-0', className)}
364
+ aria-hidden
365
+ >
366
+ <path d="m18 15-6-6-6 6" />
367
+ </svg>
368
+ );
369
+
370
+ /** Caret down: collapse (hide second line). */
371
+ const CaretDownIcon = ({ className }: { className?: string }) => (
372
+ <svg
373
+ xmlns="http://www.w3.org/2000/svg"
374
+ width="24"
375
+ height="24"
376
+ viewBox="0 0 24 24"
377
+ fill="none"
378
+ stroke="currentColor"
379
+ strokeWidth="2"
380
+ strokeLinecap="round"
381
+ strokeLinejoin="round"
382
+ className={cn('shrink-0', className)}
383
+ aria-hidden
384
+ >
385
+ <path d="m6 9 6 6 6-6" />
386
+ </svg>
387
+ );
388
+
389
+ /** Home icon for mobile bottom bar (same as sidebar logo action). */
390
+ const HomeIcon = ({ className }: { className?: string }) => (
391
+ <svg
392
+ xmlns="http://www.w3.org/2000/svg"
393
+ width="24"
394
+ height="24"
395
+ viewBox="0 0 24 24"
396
+ fill="none"
397
+ stroke="currentColor"
398
+ strokeWidth="2"
399
+ strokeLinecap="round"
400
+ strokeLinejoin="round"
401
+ className={cn('shrink-0', className)}
402
+ aria-hidden
403
+ >
404
+ <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
405
+ <polyline points="9 22 9 12 15 12 15 22" />
406
+ </svg>
407
+ );
408
+
409
+ /** Mobile bottom nav: Home + nav items; More only when not all fit. Dynamic from width. */
410
+ const MobileBottomNav = ({
411
+ items,
412
+ currentLanguage,
413
+ }: {
414
+ items: NavigationItem[];
415
+ currentLanguage: string;
416
+ }) => {
417
+ const location = useLocation();
418
+ const [expanded, setExpanded] = useState(false);
419
+ const navRef = useRef<HTMLElement>(null);
420
+ const [rowWidth, setRowWidth] = useState(0);
421
+
422
+ useLayoutEffect(() => {
423
+ const el = navRef.current;
424
+ if (!el) return;
425
+ const ro = new ResizeObserver((entries) => {
426
+ const w = entries[0]?.contentRect.width ?? 0;
427
+ setRowWidth(w);
428
+ });
429
+ ro.observe(el);
430
+ setRowWidth(el.getBoundingClientRect().width);
431
+ return () => ro.disconnect();
432
+ }, []);
433
+
434
+ const { rowItems, overflowItems, hasMore } = useMemo(() => {
435
+ const list = items.slice();
436
+ const contentWidth = Math.max(0, rowWidth - BOTTOM_NAV_PX * 2);
437
+ const slotTotal = BOTTOM_NAV_SLOT_WIDTH + BOTTOM_NAV_GAP;
438
+ const computedSlots =
439
+ rowWidth > 0 ? Math.floor((contentWidth + BOTTOM_NAV_GAP) / slotTotal) : 5;
440
+ const totalSlots = Math.min(Math.max(0, computedSlots), BOTTOM_NAV_MAX_SLOTS);
441
+ const slotsForNav = totalSlots - 1;
442
+ const allFit = list.length <= slotsForNav;
443
+ const maxInRow = allFit ? list.length : Math.max(0, totalSlots - 2);
444
+ const row = list.slice(0, maxInRow);
445
+ const rowPaths = new Set(row.map((i) => i.path));
446
+ const overflow = list.filter((item) => !rowPaths.has(item.path));
447
+ return {
448
+ rowItems: row,
449
+ overflowItems: overflow,
450
+ hasMore: overflow.length > 0,
451
+ };
452
+ }, [items, rowWidth]);
453
+
454
+ useEffect(() => {
455
+ setExpanded(false);
456
+ }, [location.pathname]);
457
+
458
+ const renderItem = (item: NavigationItem, index: number) => {
459
+ const pathPrefix = `/${item.path}`;
460
+ const isOverlayOrExternal =
461
+ item.openIn === 'modal' || item.openIn === 'drawer' || item.openIn === 'external';
462
+ const isActive =
463
+ !isOverlayOrExternal &&
464
+ (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
465
+ const label = resolveNavLabel(item.label, currentLanguage);
466
+ const faviconUrl =
467
+ item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item.url) : null;
468
+ const iconSrc = item.icon ?? faviconUrl ?? null;
469
+ const applyIconTheme = iconSrc ? isAppIcon(iconSrc) : false;
470
+ return (
471
+ <BottomNavItem
472
+ key={`${item.path}-${item.url}-${index}`}
473
+ item={item}
474
+ label={label}
475
+ isActive={isActive}
476
+ iconSrc={iconSrc}
477
+ applyIconTheme={applyIconTheme}
478
+ />
479
+ );
480
+ };
481
+
482
+ return (
483
+ <nav
484
+ ref={navRef}
485
+ className="fixed bottom-0 left-0 right-0 z-[9999] md:hidden border-t border-sidebar-border bg-sidebar-background overflow-hidden pt-2"
486
+ style={{
487
+ zIndex: Z_INDEX.SIDEBAR_TRIGGER,
488
+ paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
489
+ }}
490
+ >
491
+ {/* Top row: Home + nav items + More/Less — single row, no wrap */}
492
+ <div className="flex flex-row flex-nowrap items-center justify-center gap-1 px-3 overflow-x-hidden">
493
+ <Link
494
+ to="/"
495
+ className={cn(
496
+ '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',
497
+ location.pathname === '/' || location.pathname === ''
498
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground [&_span]:text-sidebar-accent-foreground'
499
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground [&_span]:inherit',
500
+ )}
501
+ aria-label="Home"
502
+ >
503
+ <span className="size-4 shrink-0 flex items-center justify-center [&_svg]:text-current">
504
+ <HomeIcon className="size-4" />
505
+ </span>
506
+ <span className="text-[11px] leading-tight">Home</span>
507
+ </Link>
508
+ {rowItems.map((item, i) => renderItem(item, i))}
509
+ {hasMore && (
510
+ <button
511
+ type="button"
512
+ onClick={() => setExpanded((e) => !e)}
513
+ className={cn(
514
+ 'flex flex-col items-center justify-center gap-1 rounded-md py-1.5 px-2 min-w-0 transition-colors cursor-pointer',
515
+ 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
516
+ )}
517
+ aria-expanded={expanded}
518
+ aria-label={expanded ? 'Show less' : 'Show more'}
519
+ >
520
+ <span className="size-4 shrink-0 flex items-center justify-center">
521
+ {expanded ? <CaretDownIcon className="size-4" /> : <CaretUpIcon className="size-4" />}
522
+ </span>
523
+ <span className="text-[11px] leading-tight">{expanded ? 'Less' : 'More'}</span>
524
+ </button>
525
+ )}
526
+ </div>
527
+
528
+ {/* Expanded: only overflow items — render list only when expanded so it clears when collapsed */}
529
+ <div
530
+ className={cn(
531
+ 'grid transition-[grid-template-rows] duration-300 ease-out',
532
+ expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
533
+ )}
534
+ >
535
+ <div className="min-h-0 overflow-hidden">
536
+ <div className="px-4 pt-3 pb-2 border-t border-sidebar-border/50 mt-1">
537
+ <div className="grid grid-cols-5 gap-2 justify-items-center max-w-xs mx-auto">
538
+ {expanded ? overflowItems.map((item, i) => renderItem(item, i)) : null}
539
+ </div>
540
+ </div>
541
+ </div>
542
+ </div>
543
+ </nav>
544
+ );
545
+ };
546
+
547
+ const DefaultLayoutContent = ({ title, logo, navigation }: DefaultLayoutProps) => {
548
+ const location = useLocation();
549
+ const { i18n } = useTranslation();
550
+ const currentLanguage = i18n.language || 'en';
551
+
552
+ const { startNav, endItems, navigationItems, mobileNavItems } = useMemo(() => {
553
+ const desktopNav = filterNavigationByViewport(navigation, 'desktop');
554
+ const mobileNav = filterNavigationByViewport(navigation, 'mobile');
555
+ const { start, end } = splitNavigationByPosition(desktopNav);
556
+ const flat = flattenNavigationItems(desktopNav);
557
+ const mobileFlat = flattenNavigationItems(mobileNav);
558
+ return {
559
+ startNav: filterNavigationForSidebar(start),
560
+ endItems: end,
561
+ navigationItems: flat,
562
+ mobileNavItems: mobileFlat,
563
+ };
564
+ }, [navigation]);
565
+
566
+ useEffect(() => {
567
+ if (!title) return;
568
+ const pathname = location.pathname.replace(/^\/+|\/+$/g, '') || '';
569
+ const segment = pathname.split('/')[0];
570
+ if (!segment) {
571
+ document.title = title;
572
+ return;
573
+ }
574
+ const navItem = navigationItems.find((item) => item.path === segment);
575
+ if (navItem) {
576
+ const label = resolveLocalizedLabel(navItem.label, currentLanguage);
577
+ document.title = `${label} | ${title}`;
578
+ } else {
579
+ document.title = title;
580
+ }
581
+ }, [location.pathname, title, navigationItems, currentLanguage]);
582
+
583
+ return (
584
+ <LayoutProviders>
585
+ <SidebarProvider>
586
+ <OverlayShell navigationItems={navigationItems}>
587
+ <div className="flex h-screen overflow-hidden">
588
+ {/* Desktop sidebar: visible from md up */}
589
+ <Sidebar className={cn('hidden md:flex shrink-0')}>
590
+ <SidebarInner
591
+ title={title}
592
+ logo={logo}
593
+ startNav={startNav}
594
+ endItems={endItems}
595
+ />
596
+ </Sidebar>
597
+
598
+ <main className="flex-1 flex flex-col overflow-hidden bg-background relative min-w-0">
599
+ <div className="flex-1 flex flex-col overflow-auto pb-16 md:pb-0">
600
+ <Outlet />
601
+ </div>
602
+ </main>
603
+ </div>
604
+
605
+ {/* Mobile bottom nav: visible only below md */}
606
+ <MobileBottomNav
607
+ items={mobileNavItems}
608
+ currentLanguage={currentLanguage}
609
+ />
610
+ </OverlayShell>
611
+ </SidebarProvider>
612
+ </LayoutProviders>
613
+ );
614
+ };
615
+
616
+ export const DefaultLayout = ({ title, appIcon, logo, navigation }: DefaultLayoutProps) => {
617
+ return (
618
+ <DefaultLayoutContent
619
+ title={title}
620
+ appIcon={appIcon}
621
+ logo={logo}
622
+ navigation={navigation}
623
+ />
624
+ );
625
+ };
@@ -0,0 +1,55 @@
1
+ import { useMemo, useEffect } from 'react';
2
+ import { Outlet, useLocation } from 'react-router';
3
+ import { useTranslation } from 'react-i18next';
4
+ import type { NavigationItem, NavigationGroup } from '../config/types';
5
+ import { flattenNavigationItems } from './utils';
6
+ import { LayoutProviders } from './LayoutProviders';
7
+ import { OverlayShell } from './OverlayShell';
8
+
9
+ interface FullscreenLayoutProps {
10
+ title?: string;
11
+ navigation: (NavigationItem | NavigationGroup)[];
12
+ }
13
+
14
+ function resolveLocalizedLabel(
15
+ value: string | { en: string; fr: string; [key: string]: string },
16
+ lang: string,
17
+ ): string {
18
+ if (typeof value === 'string') return value;
19
+ return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
20
+ }
21
+
22
+ /** Full-width layout with no sidebar or navigation; only content area. Modal, drawer and providers are still active. */
23
+ export function FullscreenLayout({ title, navigation }: FullscreenLayoutProps) {
24
+ const location = useLocation();
25
+ const { i18n } = useTranslation();
26
+ const currentLanguage = i18n.language || 'en';
27
+ const navigationItems = useMemo(() => flattenNavigationItems(navigation), [navigation]);
28
+
29
+ useEffect(() => {
30
+ if (!title) return;
31
+ const pathname = location.pathname.replace(/^\/+|\/+$/g, '') || '';
32
+ const segment = pathname.split('/')[0];
33
+ if (!segment) {
34
+ document.title = title;
35
+ return;
36
+ }
37
+ const navItem = navigationItems.find((item) => item.path === segment);
38
+ if (navItem) {
39
+ const label = resolveLocalizedLabel(navItem.label, currentLanguage);
40
+ document.title = `${label} | ${title}`;
41
+ } else {
42
+ document.title = title;
43
+ }
44
+ }, [location.pathname, title, navigationItems, currentLanguage]);
45
+
46
+ return (
47
+ <LayoutProviders>
48
+ <OverlayShell navigationItems={navigationItems}>
49
+ <main className="flex flex-col w-full h-screen overflow-hidden bg-background">
50
+ <Outlet />
51
+ </main>
52
+ </OverlayShell>
53
+ </LayoutProviders>
54
+ );
55
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react';
2
+ import { ModalProvider } from '../modal/ModalContext';
3
+ import { DrawerProvider } from '../drawer/DrawerContext';
4
+ import { SonnerProvider } from '../sonner/SonnerContext';
5
+
6
+ interface LayoutProvidersProps {
7
+ children: ReactNode;
8
+ }
9
+
10
+ /** Wraps layout content with Modal, Drawer and Sonner providers.
11
+ * Note: DialogProvider is now at the app level in app.tsx */
12
+ export function LayoutProviders({ children }: LayoutProvidersProps) {
13
+ return (
14
+ <ModalProvider>
15
+ <DrawerProvider>
16
+ <SonnerProvider>{children}</SonnerProvider>
17
+ </DrawerProvider>
18
+ </ModalProvider>
19
+ );
20
+ }