@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,860 @@
1
+ import {
2
+ useMemo,
3
+ useState,
4
+ useCallback,
5
+ useRef,
6
+ useEffect,
7
+ type PointerEvent as ReactPointerEvent,
8
+ } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ import { shellui } from '@shellui/sdk';
11
+ import type { NavigationItem, NavigationGroup } from '../config/types';
12
+ import {
13
+ flattenNavigationItems,
14
+ resolveLocalizedString as resolveNavLabel,
15
+ splitNavigationByPosition,
16
+ } from './utils';
17
+ import { useSettings } from '../settings/hooks/useSettings';
18
+ import { LayoutProviders } from './LayoutProviders';
19
+ import { OverlayShell } from './OverlayShell';
20
+ import { ContentView } from '@/components/ContentView';
21
+ import { cn } from '@/lib/utils';
22
+ import { Z_INDEX } from '@/lib/z-index';
23
+
24
+ interface WindowsLayoutProps {
25
+ title?: string;
26
+ appIcon?: string;
27
+ logo?: string;
28
+ navigation: (NavigationItem | NavigationGroup)[];
29
+ }
30
+
31
+ const getExternalFaviconUrl = (url: string): string | null => {
32
+ try {
33
+ const parsed = new URL(url);
34
+ const hostname = parsed.hostname;
35
+ if (!hostname) return null;
36
+ return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
37
+ } catch {
38
+ return null;
39
+ }
40
+ };
41
+
42
+ /** True when the icon is a local app icon (/icons/); apply theme (dark invert) so it matches foreground. */
43
+ const isAppIcon = (src: string) => src.startsWith('/icons/');
44
+
45
+ const genId = () => `win-${Date.now()}-${Math.random().toString(36).slice(2)}`;
46
+
47
+ export interface WindowState {
48
+ id: string;
49
+ path: string;
50
+ pathname: string;
51
+ baseUrl: string;
52
+ label: string;
53
+ icon: string | null;
54
+ bounds: { x: number; y: number; w: number; h: number };
55
+ }
56
+
57
+ const MIN_WIDTH = 280;
58
+ const MIN_HEIGHT = 200;
59
+ const DEFAULT_WIDTH = 720;
60
+ const DEFAULT_HEIGHT = 480;
61
+ const TASKBAR_HEIGHT = 48;
62
+
63
+ function getMaximizedBounds(): WindowState['bounds'] {
64
+ return {
65
+ x: 0,
66
+ y: 0,
67
+ w: typeof window !== 'undefined' ? window.innerWidth : 800,
68
+ h: typeof window !== 'undefined' ? window.innerHeight - TASKBAR_HEIGHT : 600,
69
+ };
70
+ }
71
+
72
+ function buildFinalUrl(baseUrl: string, path: string, pathname: string): string {
73
+ const pathPrefix = `/${path}`;
74
+ const subPath = pathname.length > pathPrefix.length ? pathname.slice(pathPrefix.length + 1) : '';
75
+ if (!subPath) return baseUrl;
76
+ const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
77
+ return `${base}${subPath}`;
78
+ }
79
+
80
+ /** Single draggable/resizable window */
81
+ function AppWindow({
82
+ win,
83
+ navItem,
84
+ currentLanguage,
85
+ isFocused,
86
+ onFocus,
87
+ onClose,
88
+ onBoundsChange,
89
+ maxZIndex,
90
+ zIndex,
91
+ }: {
92
+ win: WindowState;
93
+ navItem: NavigationItem;
94
+ currentLanguage: string;
95
+ isFocused: boolean;
96
+ onFocus: () => void;
97
+ onClose: () => void;
98
+ onBoundsChange: (bounds: WindowState['bounds']) => void;
99
+ maxZIndex: number;
100
+ zIndex: number;
101
+ }) {
102
+ const windowLabel = resolveNavLabel(navItem.label, currentLanguage);
103
+ const [bounds, setBounds] = useState(win.bounds);
104
+ const [isMaximized, setIsMaximized] = useState(false);
105
+ const boundsBeforeMaximizeRef = useRef<WindowState['bounds']>(bounds);
106
+ const containerRef = useRef<HTMLDivElement>(null);
107
+ const dragRef = useRef<{
108
+ startX: number;
109
+ startY: number;
110
+ startBounds: WindowState['bounds'];
111
+ lastDx: number;
112
+ lastDy: number;
113
+ } | null>(null);
114
+ const resizeRef = useRef<{
115
+ edge: string;
116
+ startX: number;
117
+ startY: number;
118
+ startBounds: WindowState['bounds'];
119
+ } | null>(null);
120
+ const resizeRafRef = useRef<number | null>(null);
121
+ const pendingResizeBoundsRef = useRef<WindowState['bounds'] | null>(null);
122
+
123
+ useEffect(() => {
124
+ setBounds(win.bounds);
125
+ }, [win.bounds]);
126
+
127
+ useEffect(() => {
128
+ onBoundsChange(bounds);
129
+ }, [bounds, onBoundsChange]);
130
+
131
+ // When maximized, keep filling the viewport on window resize
132
+ useEffect(() => {
133
+ if (!isMaximized) return;
134
+ const onResize = () => setBounds(getMaximizedBounds());
135
+ window.addEventListener('resize', onResize);
136
+ return () => window.removeEventListener('resize', onResize);
137
+ }, [isMaximized]);
138
+
139
+ const onPointerMove = useCallback((e: PointerEvent) => {
140
+ if (!dragRef.current) return;
141
+ const d = dragRef.current;
142
+ const dx = e.clientX - d.startX;
143
+ const dy = e.clientY - d.startY;
144
+ d.lastDx = dx;
145
+ d.lastDy = dy;
146
+ const el = containerRef.current;
147
+ if (el) {
148
+ el.style.willChange = 'transform';
149
+ el.style.transform = `translate(${dx}px, ${dy}px)`;
150
+ }
151
+ }, []);
152
+
153
+ const onPointerUp = useCallback(
154
+ (e: PointerEvent) => {
155
+ const el = containerRef.current;
156
+ if (el) {
157
+ el.removeEventListener('pointermove', onPointerMove);
158
+ el.removeEventListener('pointerup', onPointerUp as (e: Event) => void);
159
+ el.releasePointerCapture(e.pointerId);
160
+ }
161
+ if (dragRef.current) {
162
+ const d = dragRef.current;
163
+ if (el) {
164
+ el.style.transform = '';
165
+ el.style.willChange = '';
166
+ }
167
+ const finalBounds: WindowState['bounds'] = {
168
+ ...d.startBounds,
169
+ x: Math.max(0, d.startBounds.x + d.lastDx),
170
+ y: Math.max(0, d.startBounds.y + d.lastDy),
171
+ };
172
+ setBounds(finalBounds);
173
+ dragRef.current = null;
174
+ }
175
+ },
176
+ [onPointerMove],
177
+ );
178
+
179
+ const handleTitlePointerDown = useCallback(
180
+ (e: ReactPointerEvent) => {
181
+ if (e.button !== 0 || isMaximized) return;
182
+ // Don't start drag when clicking a button (close, maximize) so their click handlers run
183
+ if ((e.target as Element).closest('button')) return;
184
+ e.preventDefault();
185
+ onFocus();
186
+ dragRef.current = {
187
+ startX: e.clientX,
188
+ startY: e.clientY,
189
+ startBounds: { ...bounds },
190
+ lastDx: 0,
191
+ lastDy: 0,
192
+ };
193
+ const el = containerRef.current;
194
+ if (el) {
195
+ el.setPointerCapture(e.pointerId);
196
+ el.addEventListener('pointermove', onPointerMove, { passive: true });
197
+ el.addEventListener('pointerup', onPointerUp as (e: Event) => void);
198
+ }
199
+ },
200
+ [bounds, isMaximized, onFocus, onPointerMove, onPointerUp],
201
+ );
202
+
203
+ const handleMaximizeToggle = useCallback(() => {
204
+ if (isMaximized) {
205
+ setBounds(boundsBeforeMaximizeRef.current);
206
+ setIsMaximized(false);
207
+ } else {
208
+ boundsBeforeMaximizeRef.current = { ...bounds };
209
+ setBounds(getMaximizedBounds());
210
+ setIsMaximized(true);
211
+ }
212
+ }, [isMaximized, bounds]);
213
+
214
+ const onResizePointerMove = useCallback((e: PointerEvent) => {
215
+ if (!resizeRef.current) return;
216
+ const { edge, startX, startY, startBounds } = resizeRef.current;
217
+ const dx = e.clientX - startX;
218
+ const dy = e.clientY - startY;
219
+ const next: WindowState['bounds'] = { ...startBounds };
220
+ if (edge.includes('e')) next.w = Math.max(MIN_WIDTH, startBounds.w + dx);
221
+ if (edge.includes('w')) {
222
+ const newW = Math.max(MIN_WIDTH, startBounds.w - dx);
223
+ next.x = startBounds.x + startBounds.w - newW;
224
+ next.w = newW;
225
+ }
226
+ if (edge.includes('s')) next.h = Math.max(MIN_HEIGHT, startBounds.h + dy);
227
+ if (edge.includes('n')) {
228
+ const newH = Math.max(MIN_HEIGHT, startBounds.h - dy);
229
+ next.y = startBounds.y + startBounds.h - newH;
230
+ next.h = newH;
231
+ }
232
+ pendingResizeBoundsRef.current = next;
233
+ if (resizeRafRef.current === null) {
234
+ resizeRafRef.current = requestAnimationFrame(() => {
235
+ const pending = pendingResizeBoundsRef.current;
236
+ resizeRafRef.current = null;
237
+ pendingResizeBoundsRef.current = null;
238
+ if (pending) setBounds(pending);
239
+ });
240
+ }
241
+ }, []);
242
+
243
+ const onResizePointerUp = useCallback(
244
+ (e: PointerEvent) => {
245
+ const el = containerRef.current;
246
+ if (el) {
247
+ el.removeEventListener('pointermove', onResizePointerMove as (e: Event) => void);
248
+ el.removeEventListener('pointerup', onResizePointerUp as (e: Event) => void);
249
+ el.releasePointerCapture(e.pointerId);
250
+ }
251
+ resizeRef.current = null;
252
+ },
253
+ [onResizePointerMove],
254
+ );
255
+
256
+ const handleResizePointerDown = useCallback(
257
+ (e: ReactPointerEvent, edge: string) => {
258
+ if (e.button !== 0) return;
259
+ e.preventDefault();
260
+ e.stopPropagation();
261
+ onFocus();
262
+ resizeRef.current = {
263
+ edge,
264
+ startX: e.clientX,
265
+ startY: e.clientY,
266
+ startBounds: { ...bounds },
267
+ };
268
+ const el = containerRef.current;
269
+ if (el) {
270
+ el.setPointerCapture(e.pointerId);
271
+ el.addEventListener('pointermove', onResizePointerMove as (e: Event) => void, {
272
+ passive: true,
273
+ });
274
+ el.addEventListener('pointerup', onResizePointerUp as (e: Event) => void);
275
+ }
276
+ },
277
+ [bounds, onFocus, onResizePointerMove, onResizePointerUp],
278
+ );
279
+
280
+ const finalUrl = useMemo(
281
+ () => buildFinalUrl(win.baseUrl, win.path, win.pathname),
282
+ [win.baseUrl, win.path, win.pathname],
283
+ );
284
+
285
+ const z = isFocused ? maxZIndex : zIndex;
286
+
287
+ return (
288
+ <div
289
+ ref={containerRef}
290
+ className="absolute flex flex-col rounded-lg border border-border bg-card shadow-lg overflow-hidden"
291
+ style={{
292
+ left: bounds.x,
293
+ top: bounds.y,
294
+ width: bounds.w,
295
+ height: bounds.h,
296
+ zIndex: z,
297
+ }}
298
+ onClick={onFocus}
299
+ onMouseDown={onFocus}
300
+ >
301
+ {/* Title bar: pointer capture so drag continues when cursor is over iframe or outside window */}
302
+ <div
303
+ className="flex items-center gap-2 pl-2 pr-1 py-1 bg-muted/80 border-b border-border cursor-move select-none shrink-0"
304
+ onPointerDown={handleTitlePointerDown}
305
+ >
306
+ {win.icon && (
307
+ <img
308
+ src={win.icon}
309
+ alt=""
310
+ className={cn(
311
+ 'h-4 w-4 shrink-0 rounded-sm object-cover',
312
+ isAppIcon(win.icon) && 'opacity-90 dark:opacity-100 dark:invert',
313
+ )}
314
+ />
315
+ )}
316
+ <span className="flex-1 text-sm font-medium truncate min-w-0">{windowLabel}</span>
317
+ <button
318
+ type="button"
319
+ onClick={(e) => {
320
+ e.stopPropagation();
321
+ handleMaximizeToggle();
322
+ }}
323
+ className="p-1 rounded cursor-pointer text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
324
+ aria-label={isMaximized ? 'Restore' : 'Maximize'}
325
+ >
326
+ {isMaximized ? <RestoreIcon className="h-4 w-4" /> : <MaximizeIcon className="h-4 w-4" />}
327
+ </button>
328
+ <button
329
+ type="button"
330
+ onClick={(e) => {
331
+ e.stopPropagation();
332
+ onClose();
333
+ }}
334
+ className="p-1 rounded cursor-pointer text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
335
+ aria-label="Close"
336
+ >
337
+ <CloseIcon className="h-4 w-4" />
338
+ </button>
339
+ </div>
340
+ {/* Content */}
341
+ <div className="flex-1 min-h-0 relative bg-background">
342
+ {/* When not focused, overlay captures clicks to bring window to front */}
343
+ {!isFocused && (
344
+ <div
345
+ className="absolute inset-0 z-10 cursor-pointer"
346
+ onClick={onFocus}
347
+ onMouseDown={(e) => {
348
+ e.stopPropagation();
349
+ onFocus();
350
+ }}
351
+ aria-hidden
352
+ />
353
+ )}
354
+ <ContentView
355
+ url={finalUrl}
356
+ pathPrefix={win.path}
357
+ ignoreMessages={true}
358
+ navItem={navItem}
359
+ />
360
+ </div>
361
+ {/* Resize handles (hidden when maximized) */}
362
+ {!isMaximized &&
363
+ (['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'] as const).map((edge) => (
364
+ <div
365
+ key={edge}
366
+ className={cn(
367
+ 'absolute bg-transparent',
368
+ edge.includes('n') && 'top-0 h-2 cursor-n-resize',
369
+ edge.includes('s') && 'bottom-0 h-2 cursor-s-resize',
370
+ edge.includes('e') && 'right-0 w-2 cursor-e-resize',
371
+ edge.includes('w') && 'left-0 w-2 cursor-w-resize',
372
+ edge === 'n' && 'left-2 right-2',
373
+ edge === 's' && 'left-2 right-2',
374
+ edge === 'e' && 'top-2 bottom-2',
375
+ edge === 'w' && 'top-2 bottom-2',
376
+ edge === 'ne' && 'top-0 right-0 w-2 h-2 cursor-ne-resize',
377
+ edge === 'nw' && 'top-0 left-0 w-2 h-2 cursor-nw-resize',
378
+ edge === 'se' && 'bottom-0 right-0 w-2 h-2 cursor-se-resize',
379
+ edge === 'sw' && 'bottom-0 left-0 w-2 h-2 cursor-sw-resize',
380
+ )}
381
+ style={
382
+ edge === 'n'
383
+ ? { left: 8, right: 8 }
384
+ : edge === 's'
385
+ ? { left: 8, right: 8 }
386
+ : edge === 'e'
387
+ ? { top: 8, bottom: 8 }
388
+ : edge === 'w'
389
+ ? { top: 8, bottom: 8 }
390
+ : undefined
391
+ }
392
+ onPointerDown={(e) => handleResizePointerDown(e, edge)}
393
+ />
394
+ ))}
395
+ </div>
396
+ );
397
+ }
398
+
399
+ function MaximizeIcon({ className }: { className?: string }) {
400
+ return (
401
+ <svg
402
+ xmlns="http://www.w3.org/2000/svg"
403
+ width="24"
404
+ height="24"
405
+ viewBox="0 0 24 24"
406
+ fill="none"
407
+ stroke="currentColor"
408
+ strokeWidth="2"
409
+ strokeLinecap="round"
410
+ strokeLinejoin="round"
411
+ className={className}
412
+ aria-hidden
413
+ >
414
+ <path d="M8 3H5a2 2 0 0 0-2 2v3" />
415
+ <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
416
+ <path d="M3 16v3a2 2 0 0 0 2 2h3" />
417
+ <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
418
+ </svg>
419
+ );
420
+ }
421
+
422
+ function RestoreIcon({ className }: { className?: string }) {
423
+ return (
424
+ <svg
425
+ xmlns="http://www.w3.org/2000/svg"
426
+ width="24"
427
+ height="24"
428
+ viewBox="0 0 24 24"
429
+ fill="none"
430
+ stroke="currentColor"
431
+ strokeWidth="2"
432
+ strokeLinecap="round"
433
+ strokeLinejoin="round"
434
+ className={className}
435
+ aria-hidden
436
+ >
437
+ <rect
438
+ x="3"
439
+ y="3"
440
+ width="10"
441
+ height="10"
442
+ rx="1"
443
+ />
444
+ <rect
445
+ x="11"
446
+ y="11"
447
+ width="10"
448
+ height="10"
449
+ rx="1"
450
+ />
451
+ </svg>
452
+ );
453
+ }
454
+
455
+ function CloseIcon({ className }: { className?: string }) {
456
+ return (
457
+ <svg
458
+ xmlns="http://www.w3.org/2000/svg"
459
+ width="24"
460
+ height="24"
461
+ viewBox="0 0 24 24"
462
+ fill="none"
463
+ stroke="currentColor"
464
+ strokeWidth="2"
465
+ strokeLinecap="round"
466
+ strokeLinejoin="round"
467
+ className={className}
468
+ aria-hidden
469
+ >
470
+ <path d="M18 6 6 18" />
471
+ <path d="m6 6 12 12" />
472
+ </svg>
473
+ );
474
+ }
475
+
476
+ /** Start menu icon (Windows-style) */
477
+ function StartIcon({ className }: { className?: string }) {
478
+ return (
479
+ <svg
480
+ xmlns="http://www.w3.org/2000/svg"
481
+ width="24"
482
+ height="24"
483
+ viewBox="0 0 24 24"
484
+ fill="currentColor"
485
+ className={className}
486
+ aria-hidden
487
+ >
488
+ <path d="M3 3h8v8H3V3zm10 0h8v8h-8V3zM3 13h8v8H3v-8zm10 0h8v8h-8v-8z" />
489
+ </svg>
490
+ );
491
+ }
492
+
493
+ function getBrowserTimezone(): string {
494
+ if (typeof window !== 'undefined' && Intl.DateTimeFormat) {
495
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
496
+ }
497
+ return 'UTC';
498
+ }
499
+
500
+ export function WindowsLayout({
501
+ title,
502
+ appIcon: _appIcon,
503
+ logo: _logo,
504
+ navigation,
505
+ }: WindowsLayoutProps) {
506
+ const { i18n } = useTranslation();
507
+ const { settings } = useSettings();
508
+ const currentLanguage = i18n.language || 'en';
509
+ const timeZone = settings.region?.timezone ?? getBrowserTimezone();
510
+ const { startNavItems, endNavItems, navigationItems } = useMemo(() => {
511
+ const { start, end } = splitNavigationByPosition(navigation);
512
+ return {
513
+ startNavItems: flattenNavigationItems(start),
514
+ endNavItems: end,
515
+ navigationItems: flattenNavigationItems(navigation),
516
+ };
517
+ }, [navigation]);
518
+
519
+ const [windows, setWindows] = useState<WindowState[]>([]);
520
+ /** Id of the window that is on top (first plan). Clicking a window or its taskbar button sets this. */
521
+ const [frontWindowId, setFrontWindowId] = useState<string | null>(null);
522
+ const [startMenuOpen, setStartMenuOpen] = useState(false);
523
+ const [now, setNow] = useState(() => new Date());
524
+ const startPanelRef = useRef<HTMLDivElement>(null);
525
+
526
+ // Update date/time every second for taskbar clock
527
+ useEffect(() => {
528
+ const interval = setInterval(() => setNow(new Date()), 1000);
529
+ return () => clearInterval(interval);
530
+ }, []);
531
+
532
+ /** Highest z-index: front window always gets this so it stays on top. */
533
+ const maxZIndex = useMemo(
534
+ () => Z_INDEX.WINDOWS_WINDOW_BASE + Math.max(windows.length, 1),
535
+ [windows.length],
536
+ );
537
+
538
+ const openWindow = useCallback(
539
+ (item: NavigationItem) => {
540
+ const label =
541
+ typeof item.label === 'string' ? item.label : resolveNavLabel(item.label, currentLanguage);
542
+ const faviconUrl =
543
+ item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item.url) : null;
544
+ const icon = item.icon ?? faviconUrl ?? null;
545
+ const id = genId();
546
+ const bounds = {
547
+ x: 60 + windows.length * 24,
548
+ y: 60 + windows.length * 24,
549
+ w: DEFAULT_WIDTH,
550
+ h: DEFAULT_HEIGHT,
551
+ };
552
+ setWindows((prev) => [
553
+ ...prev,
554
+ {
555
+ id,
556
+ path: item.path,
557
+ pathname: `/${item.path}`,
558
+ baseUrl: item.url,
559
+ label,
560
+ icon,
561
+ bounds,
562
+ },
563
+ ]);
564
+ setFrontWindowId(id);
565
+ setStartMenuOpen(false);
566
+ },
567
+ [currentLanguage, windows.length],
568
+ );
569
+
570
+ const closeWindow = useCallback((id: string) => {
571
+ setWindows((prev) => prev.filter((w) => w.id !== id));
572
+ setFrontWindowId((current) => (current === id ? null : current));
573
+ }, []);
574
+
575
+ // When front window is closed or missing, bring first window to front
576
+ useEffect(() => {
577
+ if (windows.length === 0) {
578
+ setFrontWindowId(null);
579
+ return;
580
+ }
581
+ const frontStillExists = frontWindowId !== null && windows.some((w) => w.id === frontWindowId);
582
+ if (!frontStillExists) {
583
+ setFrontWindowId(windows[0].id);
584
+ }
585
+ }, [windows, frontWindowId]);
586
+
587
+ /** Bring the window to front (first plan). Used when clicking the window or its taskbar button. */
588
+ const focusWindow = useCallback((id: string) => {
589
+ setFrontWindowId(id);
590
+ }, []);
591
+
592
+ const updateWindowBounds = useCallback((id: string, bounds: WindowState['bounds']) => {
593
+ setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, bounds } : w)));
594
+ }, []);
595
+
596
+ // Close start menu on click outside
597
+ useEffect(() => {
598
+ if (!startMenuOpen) return;
599
+ const onDocClick = (e: MouseEvent) => {
600
+ if (startPanelRef.current && !startPanelRef.current.contains(e.target as Node)) {
601
+ setStartMenuOpen(false);
602
+ }
603
+ };
604
+ document.addEventListener('mousedown', onDocClick);
605
+ return () => document.removeEventListener('mousedown', onDocClick);
606
+ }, [startMenuOpen]);
607
+
608
+ const handleNavClick = useCallback(
609
+ (item: NavigationItem) => {
610
+ if (item.openIn === 'modal') {
611
+ shellui.openModal(item.url);
612
+ setStartMenuOpen(false);
613
+ return;
614
+ }
615
+ if (item.openIn === 'drawer') {
616
+ shellui.openDrawer({ url: item.url, position: item.drawerPosition });
617
+ setStartMenuOpen(false);
618
+ return;
619
+ }
620
+ if (item.openIn === 'external') {
621
+ window.open(item.url, '_blank', 'noopener,noreferrer');
622
+ setStartMenuOpen(false);
623
+ return;
624
+ }
625
+ openWindow(item);
626
+ },
627
+ [openWindow],
628
+ );
629
+
630
+ return (
631
+ <LayoutProviders>
632
+ <OverlayShell navigationItems={navigationItems}>
633
+ <div
634
+ className="fixed inset-0 bg-muted/30"
635
+ style={{ paddingBottom: TASKBAR_HEIGHT }}
636
+ >
637
+ {/* Desktop area: windows */}
638
+ {windows.map((win, index) => {
639
+ const navItem = navigationItems.find((n) => n.path === win.path);
640
+ if (!navItem) return null;
641
+ const isFocused = win.id === frontWindowId;
642
+ const zIndex = Z_INDEX.WINDOWS_WINDOW_BASE + index;
643
+ return (
644
+ <AppWindow
645
+ key={win.id}
646
+ win={win}
647
+ navItem={navItem}
648
+ currentLanguage={currentLanguage}
649
+ isFocused={isFocused}
650
+ onFocus={() => focusWindow(win.id)}
651
+ onClose={() => closeWindow(win.id)}
652
+ onBoundsChange={(bounds) => updateWindowBounds(win.id, bounds)}
653
+ maxZIndex={maxZIndex}
654
+ zIndex={zIndex}
655
+ />
656
+ );
657
+ })}
658
+ </div>
659
+
660
+ {/* Taskbar */}
661
+ <div
662
+ className="fixed left-0 right-0 bottom-0 flex items-center gap-1 px-2 border-t border-border bg-sidebar-background"
663
+ style={{
664
+ height: TASKBAR_HEIGHT,
665
+ zIndex: Z_INDEX.WINDOWS_TASKBAR,
666
+ paddingBottom: 'env(safe-area-inset-bottom, 0px)',
667
+ }}
668
+ >
669
+ {/* Start button */}
670
+ <div
671
+ className="relative shrink-0"
672
+ ref={startPanelRef}
673
+ >
674
+ <button
675
+ type="button"
676
+ onClick={() => setStartMenuOpen((o) => !o)}
677
+ className={cn(
678
+ 'flex items-center gap-2 h-9 px-3 rounded cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
679
+ startMenuOpen
680
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
681
+ : 'text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground',
682
+ )}
683
+ aria-expanded={startMenuOpen}
684
+ aria-haspopup="true"
685
+ aria-label="Start"
686
+ >
687
+ <StartIcon className="h-5 w-5" />
688
+ <span className="font-semibold text-sm hidden sm:inline">{title || 'Start'}</span>
689
+ </button>
690
+ {/* Start menu panel */}
691
+ {startMenuOpen && (
692
+ <div
693
+ className="absolute bottom-full left-0 mb-1 w-64 max-h-[70vh] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg py-2 z-[10001]"
694
+ style={{ zIndex: Z_INDEX.MODAL_CONTENT }}
695
+ >
696
+ <div className="px-2 pb-2 border-b border-border mb-2">
697
+ <span className="text-sm font-semibold text-popover-foreground">
698
+ {title || 'Applications'}
699
+ </span>
700
+ </div>
701
+ <div className="grid gap-0.5">
702
+ {startNavItems
703
+ .filter((item) => !item.hidden)
704
+ .map((item) => {
705
+ const label =
706
+ typeof item.label === 'string'
707
+ ? item.label
708
+ : resolveNavLabel(item.label, currentLanguage);
709
+ const icon =
710
+ item.icon ??
711
+ (item.openIn === 'external' ? getExternalFaviconUrl(item.url) : null);
712
+ return (
713
+ <button
714
+ key={item.path}
715
+ type="button"
716
+ onClick={() => handleNavClick(item)}
717
+ className="flex items-center gap-3 w-full px-3 py-2 text-left text-sm cursor-pointer text-popover-foreground hover:bg-accent hover:text-accent-foreground rounded-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
718
+ >
719
+ {icon ? (
720
+ <img
721
+ src={icon}
722
+ alt=""
723
+ className={cn(
724
+ 'h-5 w-5 shrink-0 rounded-sm object-cover',
725
+ isAppIcon(icon) && 'opacity-90 dark:opacity-100 dark:invert',
726
+ )}
727
+ />
728
+ ) : (
729
+ <span className="h-5 w-5 shrink-0 rounded-sm bg-muted" />
730
+ )}
731
+ <span className="truncate">{label}</span>
732
+ </button>
733
+ );
734
+ })}
735
+ </div>
736
+ </div>
737
+ )}
738
+ </div>
739
+
740
+ {/* Window list */}
741
+ <div className="flex-1 flex items-center gap-1 min-w-0 overflow-x-auto">
742
+ {windows.map((win) => {
743
+ const navItem = navigationItems.find((n) => n.path === win.path);
744
+ const windowLabel = navItem
745
+ ? resolveNavLabel(navItem.label, currentLanguage)
746
+ : win.label;
747
+ const isFocused = win.id === frontWindowId;
748
+ return (
749
+ <button
750
+ key={win.id}
751
+ type="button"
752
+ onClick={() => focusWindow(win.id)}
753
+ onContextMenu={(e) => {
754
+ e.preventDefault();
755
+ closeWindow(win.id);
756
+ }}
757
+ className={cn(
758
+ 'flex items-center gap-2 h-8 px-2 rounded min-w-0 max-w-[140px] shrink-0 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
759
+ isFocused
760
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
761
+ : 'text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground',
762
+ )}
763
+ title={windowLabel}
764
+ >
765
+ {win.icon ? (
766
+ <img
767
+ src={win.icon}
768
+ alt=""
769
+ className={cn(
770
+ 'h-4 w-4 shrink-0 rounded-sm object-cover',
771
+ isAppIcon(win.icon) && 'opacity-90 dark:opacity-100 dark:invert',
772
+ )}
773
+ />
774
+ ) : (
775
+ <span className="h-4 w-4 shrink-0 rounded-sm bg-muted" />
776
+ )}
777
+ <span className="text-xs truncate">{windowLabel}</span>
778
+ </button>
779
+ );
780
+ })}
781
+ </div>
782
+
783
+ {/* End navigation items (right side of taskbar) */}
784
+ {endNavItems.length > 0 && (
785
+ <div className="flex items-center gap-0.5 shrink-0 border-l border-sidebar-border pl-2 ml-1">
786
+ {endNavItems.map((item) => {
787
+ const label =
788
+ typeof item.label === 'string'
789
+ ? item.label
790
+ : resolveNavLabel(item.label, currentLanguage);
791
+ const icon =
792
+ item.icon ??
793
+ (item.openIn === 'external' ? getExternalFaviconUrl(item.url) : null);
794
+ return (
795
+ <button
796
+ key={item.path}
797
+ type="button"
798
+ onClick={() => handleNavClick(item)}
799
+ className="flex items-center gap-2 h-8 px-2 rounded cursor-pointer text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
800
+ title={label}
801
+ >
802
+ {icon ? (
803
+ <img
804
+ src={icon}
805
+ alt=""
806
+ className={cn(
807
+ 'h-4 w-4 shrink-0 rounded-sm object-cover',
808
+ isAppIcon(icon) && 'opacity-90 dark:opacity-100 dark:invert',
809
+ )}
810
+ />
811
+ ) : (
812
+ <span className="h-4 w-4 shrink-0 rounded-sm bg-muted" />
813
+ )}
814
+ <span className="text-xs truncate max-w-[100px]">{label}</span>
815
+ </button>
816
+ );
817
+ })}
818
+ </div>
819
+ )}
820
+
821
+ {/* Date and time (extreme bottom right, OS-style); uses region timezone from settings */}
822
+ <div
823
+ className="flex flex-col items-end justify-center shrink-0 px-3 py-1 text-sidebar-foreground border-l border-sidebar-border ml-1 min-w-0"
824
+ style={{ paddingRight: 'max(0.75rem, env(safe-area-inset-right))' }}
825
+ role="timer"
826
+ aria-live="off"
827
+ aria-label={new Intl.DateTimeFormat(currentLanguage, {
828
+ timeZone,
829
+ dateStyle: 'full',
830
+ timeStyle: 'medium',
831
+ }).format(now)}
832
+ >
833
+ <time
834
+ dateTime={now.toISOString()}
835
+ className="text-xs leading-tight tabular-nums whitespace-nowrap"
836
+ >
837
+ {new Intl.DateTimeFormat(currentLanguage, {
838
+ timeZone,
839
+ hour: '2-digit',
840
+ minute: '2-digit',
841
+ second: '2-digit',
842
+ }).format(now)}
843
+ </time>
844
+ <time
845
+ dateTime={now.toISOString()}
846
+ className="text-[10px] leading-tight whitespace-nowrap text-sidebar-foreground/90"
847
+ >
848
+ {new Intl.DateTimeFormat(currentLanguage, {
849
+ timeZone,
850
+ weekday: 'short',
851
+ day: 'numeric',
852
+ month: 'short',
853
+ }).format(now)}
854
+ </time>
855
+ </div>
856
+ </div>
857
+ </OverlayShell>
858
+ </LayoutProviders>
859
+ );
860
+ }