@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,362 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import {
3
+ EMPTY_DASHBOARD,
4
+ dashboardBlobZ,
5
+ uuidv7,
6
+ type ChartDef,
7
+ type ChartDefInput,
8
+ type ChartGroup,
9
+ type DashboardBlob,
10
+ type IDashboardStore,
11
+ type TimeRange,
12
+ } from '@modern-admin/core'
13
+ import type { AdminClient, TimeSeriesMetric, TimeSeriesStep } from './client.js'
14
+
15
+ const STORAGE_PREFIX = 'modern-admin:dashboard:v1:'
16
+ const ANON_USER = '__anon__'
17
+
18
+ /**
19
+ * localStorage-backed `IDashboardStore`. Persists one blob per user under
20
+ * `modern-admin:dashboard:v1:<userId>` so multiple admins on the same
21
+ * browser do not see each other's charts.
22
+ *
23
+ * SSR-safe: `typeof window` checks gate every access; on the server load()
24
+ * returns `EMPTY_DASHBOARD` and save() is a no-op.
25
+ */
26
+ export class LocalStorageDashboardStore implements IDashboardStore {
27
+ load(userId: string): DashboardBlob {
28
+ if (typeof window === 'undefined') return EMPTY_DASHBOARD
29
+ try {
30
+ const raw = window.localStorage.getItem(STORAGE_PREFIX + (userId || ANON_USER))
31
+ if (!raw) return EMPTY_DASHBOARD
32
+ const parsed = JSON.parse(raw) as unknown
33
+ const result = dashboardBlobZ.safeParse(parsed)
34
+ // Legacy bare-array shapes and other malformed blobs reset to empty
35
+ // rather than crashing the dashboard.
36
+ return result.success ? result.data : EMPTY_DASHBOARD
37
+ } catch {
38
+ return EMPTY_DASHBOARD
39
+ }
40
+ }
41
+
42
+ save(userId: string, blob: DashboardBlob): void {
43
+ if (typeof window === 'undefined') return
44
+ try {
45
+ window.localStorage.setItem(
46
+ STORAGE_PREFIX + (userId || ANON_USER),
47
+ JSON.stringify(blob),
48
+ )
49
+ } catch {
50
+ // Quota exceeded / private mode — silently drop.
51
+ }
52
+ }
53
+ }
54
+
55
+ const defaultStore = new LocalStorageDashboardStore()
56
+
57
+ /**
58
+ * Server-backed `IDashboardStore` that persists per-user dashboard layouts
59
+ * via `GET/PUT /admin/api/dashboard`. Requires `configStore` to be wired in
60
+ * `ModernAdminModule.forRoot()`. Falls back gracefully when the endpoint
61
+ * returns an empty dashboard (e.g. first load or missing configStore).
62
+ */
63
+ export class ServerDashboardStore implements IDashboardStore {
64
+ constructor(private readonly client: AdminClient) {}
65
+
66
+ async load(_userId: string): Promise<DashboardBlob> {
67
+ try {
68
+ const res = await this.client.loadDashboard()
69
+ return res.dashboard
70
+ } catch {
71
+ return EMPTY_DASHBOARD
72
+ }
73
+ }
74
+
75
+ async save(_userId: string, blob: DashboardBlob): Promise<void> {
76
+ try {
77
+ await this.client.saveDashboard(blob)
78
+ } catch {
79
+ // Server unavailable — silently drop. The next save attempt will retry.
80
+ }
81
+ }
82
+ }
83
+
84
+ // ─── Time-range helpers ──────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Resolve a `TimeRange` (preset or explicit custom) into concrete
88
+ * inclusive `from`/`to` `YYYY-MM-DD` strings. Presets are anchored to
89
+ * `now` so cards always reflect "the last N days" without re-saving.
90
+ *
91
+ * `'all'` resolves to a 10-year window ending at `now`, which is wide
92
+ * enough for any realistic admin dataset while keeping the server's
93
+ * date-range constraint satisfied.
94
+ */
95
+ export function resolveRange(
96
+ range: TimeRange,
97
+ now: Date = new Date(),
98
+ ): { from: string; to: string } {
99
+ if (range.preset === 'custom') return { from: range.from, to: range.to }
100
+ const days =
101
+ range.preset === '7d' ? 7
102
+ : range.preset === '30d' ? 30
103
+ : range.preset === '90d' ? 90
104
+ : range.preset === '1y' ? 365
105
+ : 3650 // 'all' → 10 years
106
+ const to = new Date(now)
107
+ const from = new Date(now)
108
+ from.setDate(from.getDate() - days)
109
+ return { from: ymd(from), to: ymd(to) }
110
+ }
111
+
112
+ /**
113
+ * Equal-length window immediately preceding `[from, to]`.
114
+ */
115
+ export function previousRangeOf(
116
+ range: { from: string; to: string },
117
+ ): { from: string; to: string } | null {
118
+ const f = new Date(range.from).getTime()
119
+ const t = new Date(range.to).getTime()
120
+ if (isNaN(f) || isNaN(t) || t < f) return null
121
+ const span = t - f
122
+ const prevTo = new Date(f - 86_400_000)
123
+ const prevFrom = new Date(prevTo.getTime() - span)
124
+ return { from: ymd(prevFrom), to: ymd(prevTo) }
125
+ }
126
+
127
+ const ymd = (d: Date): string => d.toISOString().slice(0, 10)
128
+
129
+ // ─── Reload signal ───────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Module-scoped pub/sub so external code (e.g. AI assistant widget after a
133
+ * chart mutation) can force the dashboard to reload its blob from the store
134
+ * without lifting state up. Subscribers are notified asynchronously.
135
+ */
136
+ const dashboardReloadListeners = new Set<() => void>()
137
+
138
+ /** Signal every mounted `useDashboardCharts` hook to reload from its store. */
139
+ export function emitDashboardReload(): void {
140
+ for (const listener of dashboardReloadListeners) {
141
+ try {
142
+ listener()
143
+ } catch {
144
+ // Listener errors must not block sibling listeners.
145
+ }
146
+ }
147
+ }
148
+
149
+ // ─── Hook ────────────────────────────────────────────────────────────────
150
+
151
+ export interface UseDashboardChartsOptions {
152
+ /** Used to scope the storage key. `null`/`undefined` defers loading. */
153
+ userId: string | null | undefined
154
+ /** Override the default localStorage store (e.g. server-backed in future). */
155
+ store?: IDashboardStore
156
+ }
157
+
158
+ export interface UseDashboardChartsResult {
159
+ charts: ChartDef[]
160
+ /** Groups defined on the dashboard, sorted by `order` ascending. */
161
+ groups: ChartGroup[]
162
+ /** True until the initial load has resolved (relevant for async stores). */
163
+ isLoading: boolean
164
+ /** Append a new chart. Auto-assigns to the first group when groups exist and no `groupId` is provided. */
165
+ addChart(input: ChartDefInput): void
166
+ updateChart(id: string, input: ChartDefInput): void
167
+ removeChart(id: string): void
168
+ /**
169
+ * Create a new group. When this is the very first group, every existing
170
+ * (ungrouped) chart is moved into it so the user keeps the same view.
171
+ * Returns the new group id so callers can switch to it.
172
+ */
173
+ addGroup(input: { name: string; order?: number }): string
174
+ updateGroup(id: string, patch: { name?: string; order?: number }): void
175
+ /** Remove a group AND every chart assigned to it. */
176
+ removeGroup(id: string): void
177
+ }
178
+
179
+ /**
180
+ * Per-user dashboard chart registry backed by `IDashboardStore` (default:
181
+ * `LocalStorageDashboardStore`). When `userId` is null/undefined the hook
182
+ * returns an empty list and ignores writes — used while
183
+ * `useCurrentUser()` is still loading.
184
+ */
185
+ export function useDashboardCharts(
186
+ options: UseDashboardChartsOptions,
187
+ ): UseDashboardChartsResult {
188
+ const { userId, store = defaultStore } = options
189
+ const [charts, setCharts] = useState<ChartDef[]>([])
190
+ const [groups, setGroups] = useState<ChartGroup[]>([])
191
+ const [isLoading, setIsLoading] = useState(true)
192
+ // Bumping this counter re-runs the load effect; used by external
193
+ // `emitDashboardReload()` callers (e.g. AI assistant widget) so the hook
194
+ // can pick up changes other actors made to the underlying store.
195
+ const [reloadTick, setReloadTick] = useState(0)
196
+
197
+ // Reload whenever `userId` flips (login / logout / switch) or an external
198
+ // reload is signalled.
199
+ useEffect(() => {
200
+ if (!userId) {
201
+ setCharts([])
202
+ setIsLoading(false)
203
+ return
204
+ }
205
+ let cancelled = false
206
+ setIsLoading(true)
207
+ Promise.resolve(store.load(userId)).then((blob) => {
208
+ if (cancelled) return
209
+ setCharts(blob.charts)
210
+ setGroups([...blob.groups].sort(byOrderThenCreated))
211
+ setIsLoading(false)
212
+ })
213
+ return () => {
214
+ cancelled = true
215
+ }
216
+ }, [userId, store, reloadTick])
217
+
218
+ // Subscribe to module-scoped reload signal.
219
+ useEffect(() => {
220
+ const listener = (): void => setReloadTick((tick) => tick + 1)
221
+ dashboardReloadListeners.add(listener)
222
+ return () => {
223
+ dashboardReloadListeners.delete(listener)
224
+ }
225
+ }, [])
226
+
227
+ // Single persist that writes BOTH charts and groups so a partial update
228
+ // never strands one in localStorage while overwriting the other.
229
+ const persist = useCallback(
230
+ (nextCharts: ChartDef[], nextGroups: ChartGroup[]): void => {
231
+ setCharts(nextCharts)
232
+ setGroups([...nextGroups].sort(byOrderThenCreated))
233
+ if (!userId) return
234
+ void Promise.resolve(
235
+ store.save(userId, { version: 1, charts: nextCharts, groups: nextGroups }),
236
+ )
237
+ },
238
+ [userId, store],
239
+ )
240
+
241
+ const addChart = useCallback(
242
+ (input: ChartDefInput): void => {
243
+ const now = new Date().toISOString()
244
+ // When groups exist and the caller didn't pick one, fall back to the
245
+ // first-ordered group so the new chart is visible somewhere.
246
+ const fallbackGroupId = input.groupId ?? groups[0]?.id
247
+ const def = {
248
+ ...input,
249
+ id: uuidv7(),
250
+ title: input.title ?? '',
251
+ filters: input.filters ?? {},
252
+ ...(fallbackGroupId ? { groupId: fallbackGroupId } : {}),
253
+ createdAt: now,
254
+ updatedAt: now,
255
+ } as ChartDef
256
+ persist([...charts, def], groups)
257
+ },
258
+ [charts, groups, persist],
259
+ )
260
+
261
+ const updateChart = useCallback(
262
+ (id: string, input: ChartDefInput): void => {
263
+ persist(
264
+ charts.map((c) =>
265
+ c.id === id
266
+ ? ({
267
+ ...c,
268
+ ...input,
269
+ id,
270
+ title: input.title ?? '',
271
+ filters: input.filters ?? {},
272
+ createdAt: c.createdAt,
273
+ updatedAt: new Date().toISOString(),
274
+ } as ChartDef)
275
+ : c,
276
+ ),
277
+ groups,
278
+ )
279
+ },
280
+ [charts, groups, persist],
281
+ )
282
+
283
+ const removeChart = useCallback(
284
+ (id: string): void => {
285
+ persist(charts.filter((c) => c.id !== id), groups)
286
+ },
287
+ [charts, groups, persist],
288
+ )
289
+
290
+ const addGroup = useCallback(
291
+ (input: { name: string; order?: number }): string => {
292
+ const now = new Date().toISOString()
293
+ const id = uuidv7()
294
+ const next: ChartGroup = {
295
+ id,
296
+ name: input.name,
297
+ order: input.order ?? groups.length,
298
+ createdAt: now,
299
+ updatedAt: now,
300
+ }
301
+ // First-group rule: existing ungrouped charts join this group so the
302
+ // user does not lose their current dashboard view.
303
+ const isFirstGroup = groups.length === 0
304
+ const nextCharts = isFirstGroup
305
+ ? charts.map((c) => (c.groupId ? c : { ...c, groupId: id, updatedAt: now }))
306
+ : charts
307
+ persist(nextCharts, [...groups, next])
308
+ return id
309
+ },
310
+ [charts, groups, persist],
311
+ )
312
+
313
+ const updateGroup = useCallback(
314
+ (id: string, patch: { name?: string; order?: number }): void => {
315
+ const now = new Date().toISOString()
316
+ persist(
317
+ charts,
318
+ groups.map((g) =>
319
+ g.id === id
320
+ ? {
321
+ ...g,
322
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
323
+ ...(patch.order !== undefined ? { order: patch.order } : {}),
324
+ updatedAt: now,
325
+ }
326
+ : g,
327
+ ),
328
+ )
329
+ },
330
+ [charts, groups, persist],
331
+ )
332
+
333
+ const removeGroup = useCallback(
334
+ (id: string): void => {
335
+ // Cascading delete: every chart assigned to the group disappears with it.
336
+ persist(charts.filter((c) => c.groupId !== id), groups.filter((g) => g.id !== id))
337
+ },
338
+ [charts, groups, persist],
339
+ )
340
+
341
+ return {
342
+ charts,
343
+ groups,
344
+ isLoading,
345
+ addChart,
346
+ updateChart,
347
+ removeChart,
348
+ addGroup,
349
+ updateGroup,
350
+ removeGroup,
351
+ }
352
+ }
353
+
354
+ /** Stable sort: by `order` ascending, then by `createdAt` for tie-breaking. */
355
+ function byOrderThenCreated(a: { order: number; createdAt: string }, b: { order: number; createdAt: string }): number {
356
+ if (a.order !== b.order) return a.order - b.order
357
+ return a.createdAt.localeCompare(b.createdAt)
358
+ }
359
+
360
+ // Re-export types from client for convenience so consumers import from one place.
361
+ export type { TimeSeriesMetric, TimeSeriesStep }
362
+ export type { ChartDef, ChartDefInput, ChartGroup } from '@modern-admin/core'
@@ -0,0 +1,128 @@
1
+ // Tiny keyboard-shortcut hook. Each combo is a `+`-separated string,
2
+ // e.g. `mod+s`, `ctrl+shift+k`, `esc`. `mod` matches Ctrl on
3
+ // Windows/Linux and Cmd on macOS so Ctrl+S / ⌘S map to the same handler.
4
+ //
5
+ // By default a chord with a modifier (mod / alt) fires anywhere; a
6
+ // modifier-less chord is suppressed when focus is inside an input,
7
+ // textarea, select, or contenteditable element so plain `n`/`r` keys
8
+ // don't hijack typing. Override per-call with `allowInInput`.
9
+
10
+ import * as React from 'react'
11
+ import { useHotkeyRegister } from './hotkey-registry.js'
12
+
13
+ export interface HotkeyOptions {
14
+ enabled?: boolean
15
+ /** Override input-suppression. `true` always fires, `false` never. */
16
+ allowInInput?: boolean
17
+ /** Call `preventDefault()` on match. Default `true`. */
18
+ preventDefault?: boolean
19
+ /**
20
+ * Human-readable label shown in <KeyboardShortcutsHelp>. When set, the
21
+ * hotkey registers itself with the surrounding HotkeyRegistryProvider
22
+ * for the duration it's mounted (and `enabled`).
23
+ */
24
+ description?: string
25
+ /** Optional group label used to bucket entries in the help dialog. */
26
+ group?: string
27
+ }
28
+
29
+ interface ParsedCombo {
30
+ key: string
31
+ code: string | null
32
+ mod: boolean
33
+ shift: boolean
34
+ alt: boolean
35
+ hasModifier: boolean
36
+ }
37
+
38
+ const KEY_ALIASES: Record<string, string> = {
39
+ esc: 'escape',
40
+ space: ' ',
41
+ spacebar: ' ',
42
+ return: 'enter',
43
+ del: 'delete',
44
+ }
45
+
46
+ function keyToCode(key: string): string | null {
47
+ if (/^[a-z]$/.test(key)) return `Key${key.toUpperCase()}`
48
+ if (/^[0-9]$/.test(key)) return `Digit${key}`
49
+ return null
50
+ }
51
+
52
+ function parseCombo(s: string): ParsedCombo {
53
+ const parts = s.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean)
54
+ const last = parts[parts.length - 1] ?? ''
55
+ const key = KEY_ALIASES[last] ?? last
56
+ const code = keyToCode(key)
57
+ const mod = parts.some((p) => p === 'mod' || p === 'ctrl' || p === 'meta' || p === 'cmd')
58
+ const shift = parts.includes('shift')
59
+ const alt = parts.includes('alt') || parts.includes('option')
60
+ return { key, code, mod, shift, alt, hasModifier: mod || alt }
61
+ }
62
+
63
+ function normalizeEventKey(e: KeyboardEvent): string | null {
64
+ return typeof e.key === 'string' ? e.key.toLowerCase() : null
65
+ }
66
+
67
+ function normalizeEventCode(e: KeyboardEvent): string | null {
68
+ return typeof e.code === 'string' && e.code.length > 0 ? e.code : null
69
+ }
70
+
71
+ function matches(c: ParsedCombo, e: KeyboardEvent): boolean {
72
+ if (c.code) {
73
+ const code = normalizeEventCode(e)
74
+ if (!code || code !== c.code) return false
75
+ } else {
76
+ const k = normalizeEventKey(e)
77
+ if (!k || k !== c.key) return false
78
+ }
79
+ const hasMod = e.ctrlKey || e.metaKey
80
+ if (c.mod !== hasMod) return false
81
+ if (c.alt !== e.altKey) return false
82
+ // Require shift only when explicitly asked. Letter keys may have shift
83
+ // accidentally engaged (caps lock, etc.) — allow that case.
84
+ if (c.shift && !e.shiftKey) return false
85
+ return true
86
+ }
87
+
88
+ function isEditableTarget(target: EventTarget | null): boolean {
89
+ if (!(target instanceof HTMLElement)) return false
90
+ const tag = target.tagName
91
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
92
+ return target.isContentEditable
93
+ }
94
+
95
+ export function useHotkey(
96
+ combo: string | string[],
97
+ handler: (e: KeyboardEvent) => void,
98
+ options: HotkeyOptions = {},
99
+ ): void {
100
+ const { enabled = true, allowInInput, preventDefault = true, description, group } = options
101
+ const handlerRef = React.useRef(handler)
102
+ React.useEffect(() => {
103
+ handlerRef.current = handler
104
+ }, [handler])
105
+
106
+ const comboKey = Array.isArray(combo) ? combo.join('|') : combo
107
+ const register = useHotkeyRegister()
108
+
109
+ React.useEffect(() => {
110
+ if (!enabled) return
111
+ const parsed = comboKey.split('|').map(parseCombo)
112
+ const onKeyDown = (e: KeyboardEvent): void => {
113
+ const hit = parsed.find((c) => matches(c, e))
114
+ if (!hit) return
115
+ const allow = allowInInput ?? hit.hasModifier
116
+ if (!allow && isEditableTarget(e.target)) return
117
+ if (preventDefault) e.preventDefault()
118
+ handlerRef.current(e)
119
+ }
120
+ document.addEventListener('keydown', onKeyDown)
121
+ return () => document.removeEventListener('keydown', onKeyDown)
122
+ }, [comboKey, enabled, allowInInput, preventDefault])
123
+
124
+ React.useEffect(() => {
125
+ if (!enabled || !description) return
126
+ return register({ keys: comboKey, description, group })
127
+ }, [enabled, description, group, comboKey, register])
128
+ }
@@ -0,0 +1,56 @@
1
+ import { useQueries } from '@tanstack/react-query'
2
+ import { useAdminClient } from './provider.js'
3
+ import type { RecordJSON } from './types.js'
4
+
5
+ /** Convention: the host app exposes panel administrators under the
6
+ * `admins` resource id (backed by Better Auth's `ma_user` table). When
7
+ * absent or the lookup fails we fall back to the raw id string. */
8
+ export const USERS_RESOURCE_ID = 'admins'
9
+
10
+ /** Resolve admin user records by id via the conventional `users` resource.
11
+ * Failed lookups (404 / no users resource) cache as `null` so we don't keep
12
+ * retrying them on every re-render.
13
+ *
14
+ * NOTE: uses the `'user-dir'` segment (not `'show'`) so the cached value
15
+ * (`RecordJSON | null`) doesn't collide with `useRecord`'s cache which stores
16
+ * the full `RecordResponse` shape under `['modern-admin', id, 'show', ...]`. */
17
+ export function useUserDirectory(
18
+ userIds: ReadonlyArray<string>,
19
+ ): Map<string, RecordJSON | null> {
20
+ const client = useAdminClient()
21
+ const queries = useQueries({
22
+ queries: userIds.map((id) => ({
23
+ queryKey: ['modern-admin', 'user-dir', USERS_RESOURCE_ID, id] as const,
24
+ queryFn: async (): Promise<RecordJSON | null> => {
25
+ try {
26
+ const res = await client.show(USERS_RESOURCE_ID, id)
27
+ return res.record
28
+ } catch {
29
+ return null
30
+ }
31
+ },
32
+ staleTime: 60_000,
33
+ retry: false,
34
+ })),
35
+ })
36
+ const map = new Map<string, RecordJSON | null>()
37
+ userIds.forEach((id, i) => map.set(id, queries[i]?.data ?? null))
38
+ return map
39
+ }
40
+
41
+ /** Pick a human-readable label for an admin record. Checks explicit name
42
+ * fields first — `record.title` may be the id fallback when the resource
43
+ * has no matching TITLE_COLUMN_NAMES property. */
44
+ export function userLabelOf(
45
+ record: RecordJSON | null | undefined,
46
+ fallback: string,
47
+ ): string {
48
+ if (!record) return fallback
49
+ const params = record.params ?? {}
50
+ const candidates = [params.name, params.fullName, params.email, record.title]
51
+ for (const candidate of candidates) {
52
+ const s = typeof candidate === 'string' ? candidate.trim() : ''
53
+ if (s && s !== record.id) return s
54
+ }
55
+ return fallback
56
+ }