@modern-admin/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,195 @@
1
+ // UI Extension Registry — lets Pro plugins add sidebar items, settings
2
+ // sections, custom property renderers, and full-page routes without
3
+ // modifying `packages/react` directly.
4
+ //
5
+ // Usage: call `register*` functions during module initialisation (before
6
+ // `<AdminApp>` renders). Every consumer of `@modern-admin/react` can import
7
+ // and call these helpers; the registry is a process-global singleton.
8
+ //
9
+ // Design notes:
10
+ // • Module-global (not React-context-based) so registration can happen in
11
+ // plain JS outside any component tree.
12
+ // • Idempotent: re-registering the same `key` is silently ignored so hot-
13
+ // reload doesn't produce duplicates.
14
+ // • Zero external runtime deps — only imports prop-type interfaces from
15
+ // `./types.ts` which is already bundled.
16
+
17
+ import type * as React from 'react'
18
+ import type { PropertyDisplayProps, PropertyEditorProps } from './types.js'
19
+
20
+ // ─── Extension shapes ─────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Extra navigation item rendered in the sidebar below the built-in entries
24
+ * (Home, Audit Log) and above the resource list.
25
+ */
26
+ export interface SidebarExtension {
27
+ /** Stable unique key (e.g. `'rbac'`). Duplicate keys are silently ignored. */
28
+ key: string
29
+ /** Label rendered next to the icon in the sidebar. */
30
+ label: string
31
+ /** Lucide-compatible icon component. */
32
+ icon: React.ComponentType<{ className?: string }>
33
+ /**
34
+ * Extension route key — navigates to `/ext/<extensionKey>` within the
35
+ * admin shell (i.e., `{ name: 'extension', key: extensionKey }`).
36
+ * Must match the `key` of a corresponding `registerExtensionRoute` call
37
+ * so the router can render the right component.
38
+ */
39
+ extensionKey: string
40
+ /**
41
+ * Optional capability gate. When set, the item is rendered only when
42
+ * `features[featureGate] === true` in the admin config (i.e. the backend
43
+ * has explicitly enabled the subsystem). Leave unset to always show.
44
+ */
45
+ featureGate?: string
46
+ }
47
+
48
+ /**
49
+ * Extra section added to the Settings page navigation. The consumer's
50
+ * `component` is rendered as the main content area when the section is active.
51
+ */
52
+ export interface SettingsExtension {
53
+ /**
54
+ * Unique section key (appears in the URL as `/settings/<key>`). Must be
55
+ * URL-safe (no slashes, encodes cleanly). Duplicate keys are ignored.
56
+ */
57
+ key: string
58
+ /**
59
+ * i18n translation key for the section label shown in the settings nav
60
+ * (e.g. `'rbac:settings.title'`). Falls back to `key` if the key is missing
61
+ * from the active locale.
62
+ */
63
+ labelKey: string
64
+ /** Lucide-compatible icon component shown next to the label. */
65
+ icon: React.ComponentType<{ className?: string }>
66
+ /**
67
+ * Component rendered as the main content when this section is active.
68
+ * It receives no props — read what you need via hooks or internal state.
69
+ */
70
+ component: React.ComponentType
71
+ }
72
+
73
+ /**
74
+ * Custom property type renderer pair. When registered for `type`, the
75
+ * built-in `PropertyDisplay` and `PropertyEditor` switches fall through
76
+ * to this extension instead of rendering the default plain-text fallback.
77
+ */
78
+ export interface PropertyExtension {
79
+ /** Renders a read-only value cell (list / show view). */
80
+ display: React.ComponentType<PropertyDisplayProps>
81
+ /** Renders an editable form field (edit / new view). */
82
+ editor: React.ComponentType<PropertyEditorProps>
83
+ }
84
+
85
+ /**
86
+ * Full-page route rendered inside the authenticated admin shell at the
87
+ * reserved path `/ext/<key>`.
88
+ */
89
+ export interface RouteExtension {
90
+ /**
91
+ * URL-safe key (no slashes). The route becomes `/ext/<key>`. Must be
92
+ * unique — duplicate keys are silently ignored.
93
+ */
94
+ key: string
95
+ /** Component rendered as the page content inside the shell layout. */
96
+ component: React.ComponentType
97
+ }
98
+
99
+ // ─── Internal registry ────────────────────────────────────────────────────────
100
+
101
+ interface RegistryData {
102
+ sidebarItems: SidebarExtension[]
103
+ settingsSections: SettingsExtension[]
104
+ propertyEditors: Map<string, PropertyExtension>
105
+ routes: RouteExtension[]
106
+ }
107
+
108
+ const registry: RegistryData = {
109
+ sidebarItems: [],
110
+ settingsSections: [],
111
+ propertyEditors: new Map(),
112
+ routes: [],
113
+ }
114
+
115
+ // ─── Registration API ─────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Register a sidebar navigation item for an extension page.
119
+ * Call this during module initialisation, before `<AdminApp>` renders.
120
+ * Re-registering the same `key` is a no-op.
121
+ */
122
+ export function registerSidebarItem(ext: SidebarExtension): void {
123
+ if (!registry.sidebarItems.find((e) => e.key === ext.key)) {
124
+ registry.sidebarItems.push(ext)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Register a custom section in the Settings page.
130
+ * Call this during module initialisation, before `<AdminApp>` renders.
131
+ * Re-registering the same `key` is a no-op.
132
+ */
133
+ export function registerSettingsSection(ext: SettingsExtension): void {
134
+ if (!registry.settingsSections.find((e) => e.key === ext.key)) {
135
+ registry.settingsSections.push(ext)
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Register a custom property type renderer (display + editor pair).
141
+ * When `property.type === type`, the built-in switch delegates to this
142
+ * extension instead of the plain-text fallback.
143
+ * Re-registering the same `type` overwrites the previous entry.
144
+ */
145
+ export function registerPropertyExtension(type: string, ext: PropertyExtension): void {
146
+ registry.propertyEditors.set(type, ext)
147
+ }
148
+
149
+ /**
150
+ * Register a full-page extension route at `/ext/<key>` inside the admin
151
+ * shell. A matching `SidebarExtension` with `extensionKey === key` provides
152
+ * the navigation entry.
153
+ * Re-registering the same `key` is a no-op.
154
+ */
155
+ export function registerExtensionRoute(ext: RouteExtension): void {
156
+ if (!registry.routes.find((r) => r.key === ext.key)) {
157
+ registry.routes.push(ext)
158
+ }
159
+ }
160
+
161
+ // ─── Accessors (consumed by shell components at render time) ──────────────────
162
+
163
+ /** Returns all registered sidebar extensions in registration order. */
164
+ export function getSidebarExtensions(): SidebarExtension[] {
165
+ return registry.sidebarItems
166
+ }
167
+
168
+ /** Returns all registered settings sections in registration order. */
169
+ export function getSettingsSectionExtensions(): SettingsExtension[] {
170
+ return registry.settingsSections
171
+ }
172
+
173
+ /**
174
+ * Returns the registered property extension for `type`, or `undefined` if
175
+ * no extension covers it (fall through to plain-text).
176
+ */
177
+ export function getPropertyExtension(type: string): PropertyExtension | undefined {
178
+ return registry.propertyEditors.get(type)
179
+ }
180
+
181
+ /** Returns the registered route extension for `key`, or `undefined`. */
182
+ export function getRouteExtension(key: string): RouteExtension | undefined {
183
+ return registry.routes.find((r) => r.key === key)
184
+ }
185
+
186
+ /**
187
+ * Clears all registrations.
188
+ * @internal For unit tests only — do not call in production code.
189
+ */
190
+ export function _resetExtensionRegistry(): void {
191
+ registry.sidebarItems = []
192
+ registry.settingsSections = []
193
+ registry.propertyEditors.clear()
194
+ registry.routes = []
195
+ }
@@ -0,0 +1,125 @@
1
+ // Theme + language switchers for the admin header. Both render as
2
+ // dropdown menus using shadcn primitives and reflect persistent state
3
+ // (theme via @modern-admin/ui's lib/theme; locale via I18nProvider).
4
+
5
+ import * as React from 'react'
6
+ import {
7
+ Button,
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ ScrollArea,
15
+ readThemeMode,
16
+ setThemeMode,
17
+ type ThemeMode,
18
+ } from '@modern-admin/ui'
19
+ import { Check, Languages, Monitor, Moon, Sun } from 'lucide-react'
20
+ import { useI18n } from './i18n.js'
21
+
22
+ /** Single dropdown row with a leading icon, label, and trailing check when active. */
23
+ function MenuOption({
24
+ icon,
25
+ label,
26
+ active,
27
+ onSelect,
28
+ }: {
29
+ icon?: React.ReactNode
30
+ label: React.ReactNode
31
+ active: boolean
32
+ onSelect(): void
33
+ }): React.ReactElement {
34
+ return (
35
+ <DropdownMenuItem
36
+ onSelect={(e) => {
37
+ e.preventDefault()
38
+ onSelect()
39
+ }}
40
+ className="gap-3 px-3 py-2"
41
+ >
42
+ {icon && <span className="flex size-4 items-center justify-center text-muted-foreground">{icon}</span>}
43
+ <span className="flex-1 truncate">{label}</span>
44
+ {active && <Check className="size-4 text-primary" />}
45
+ </DropdownMenuItem>
46
+ )
47
+ }
48
+
49
+ export function ThemeToggle(): React.ReactElement {
50
+ const { t } = useI18n()
51
+ const [mode, setMode] = React.useState<ThemeMode>(() => readThemeMode())
52
+ const apply = (next: ThemeMode): void => {
53
+ setThemeMode(next)
54
+ setMode(next)
55
+ }
56
+ const Icon = mode === 'dark' ? Moon : mode === 'light' ? Sun : Monitor
57
+ return (
58
+ <DropdownMenu>
59
+ <DropdownMenuTrigger asChild>
60
+ <Button variant="ghost" size="icon" aria-label={t('common:toggleTheme')}>
61
+ <Icon className="size-4" />
62
+ </Button>
63
+ </DropdownMenuTrigger>
64
+ <DropdownMenuContent align="end" sideOffset={8} className="w-44 p-1">
65
+ <DropdownMenuLabel className="px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground">
66
+ {t('common:theme')}
67
+ </DropdownMenuLabel>
68
+ <DropdownMenuSeparator />
69
+ <MenuOption
70
+ icon={<Sun className="size-4" />}
71
+ label={t('common:themeLight')}
72
+ active={mode === 'light'}
73
+ onSelect={() => apply('light')}
74
+ />
75
+ <MenuOption
76
+ icon={<Moon className="size-4" />}
77
+ label={t('common:themeDark')}
78
+ active={mode === 'dark'}
79
+ onSelect={() => apply('dark')}
80
+ />
81
+ <MenuOption
82
+ icon={<Monitor className="size-4" />}
83
+ label={t('common:themeSystem')}
84
+ active={mode === 'system'}
85
+ onSelect={() => apply('system')}
86
+ />
87
+ </DropdownMenuContent>
88
+ </DropdownMenu>
89
+ )
90
+ }
91
+
92
+ export function LanguageSwitcher(): React.ReactElement | null {
93
+ const { locale, setLocale, availableLocales, t } = useI18n()
94
+ const locales = availableLocales()
95
+ // With a single registered locale there is nothing to switch between —
96
+ // hide the control entirely (consumers control the set via I18nProvider's
97
+ // `locales` prop, which the standalone shell wires from runtime config).
98
+ if (locales.length <= 1) return null
99
+ return (
100
+ <DropdownMenu>
101
+ <DropdownMenuTrigger asChild>
102
+ <Button variant="ghost" size="sm" className="gap-2">
103
+ <Languages className="size-4" />
104
+ <span className="text-xs uppercase">{locale}</span>
105
+ </Button>
106
+ </DropdownMenuTrigger>
107
+ <DropdownMenuContent align="end" sideOffset={8} className="w-52 p-1">
108
+ <DropdownMenuLabel className="px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground">
109
+ {t('common:language')}
110
+ </DropdownMenuLabel>
111
+ <DropdownMenuSeparator />
112
+ <ScrollArea className="max-h-72">
113
+ {locales.map((l) => (
114
+ <MenuOption
115
+ key={l.code}
116
+ label={l.name}
117
+ active={l.code === locale}
118
+ onSelect={() => setLocale(l.code)}
119
+ />
120
+ ))}
121
+ </ScrollArea>
122
+ </DropdownMenuContent>
123
+ </DropdownMenu>
124
+ )
125
+ }