@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
package/src/hooks.ts ADDED
@@ -0,0 +1,509 @@
1
+ // TanStack Query hooks that wrap AdminClient. Query keys are
2
+ // `[resourceId, action, params?]` so cache invalidation is precise.
3
+
4
+ import * as React from 'react'
5
+ import {
6
+ useInfiniteQuery,
7
+ useMutation,
8
+ useQuery,
9
+ useQueryClient,
10
+ type InfiniteData,
11
+ type UseInfiniteQueryResult,
12
+ type UseMutationResult,
13
+ type UseQueryResult,
14
+ } from '@tanstack/react-query'
15
+ import { useAdminClient } from './provider.js'
16
+ import type {
17
+ AdminConfig,
18
+ AdminFeatures,
19
+ CustomActionResponse,
20
+ CurrentUser,
21
+ ListQuery,
22
+ ListResponse,
23
+ RecordResponse,
24
+ ResourceJSON,
25
+ } from './types.js'
26
+ import { resolveFeatures } from './types.js'
27
+ import {
28
+ AdminApiError,
29
+ type AuthUiProps,
30
+ type AuditLogQuery,
31
+ type AuditLogResponse,
32
+ type GlobalSearchResponse,
33
+ type HistoryListResponse,
34
+ type HistoryRevisionResponse,
35
+ type TimeSeriesQuery,
36
+ type TimeSeriesResponse,
37
+ } from './client.js'
38
+ import { useI18n } from './i18n.js'
39
+
40
+ const KEY_CONFIG = ['modern-admin', 'config'] as const
41
+ const keyList = (resourceId: string, query?: ListQuery) =>
42
+ ['modern-admin', resourceId, 'list', query ?? null] as const
43
+ const keyShow = (resourceId: string, recordId: string) =>
44
+ ['modern-admin', resourceId, 'show', recordId] as const
45
+ const keyHistory = (resourceId: string, recordId: string) =>
46
+ ['modern-admin', resourceId, 'history', recordId] as const
47
+ const keyHistoryRevision = (resourceId: string, recordId: string, revisionId: string) =>
48
+ ['modern-admin', resourceId, 'history', recordId, revisionId] as const
49
+ const keyAuditLog = (query?: AuditLogQuery) =>
50
+ ['modern-admin', 'audit-log', query ?? null] as const
51
+
52
+ export const useAdminConfig = (): UseQueryResult<AdminConfig> => {
53
+ const client = useAdminClient()
54
+ return useQuery({ queryKey: KEY_CONFIG, queryFn: () => client.config(), staleTime: 60_000 })
55
+ }
56
+
57
+ export const useResource = (resourceId: string | undefined): ResourceJSON | undefined => {
58
+ const { data } = useAdminConfig()
59
+ const { localizeResource } = useI18n()
60
+ return React.useMemo(() => {
61
+ const resource = data?.resources.find((r) => r.id === resourceId)
62
+ return resource ? localizeResource(resource) : undefined
63
+ }, [data?.resources, localizeResource, resourceId])
64
+ }
65
+
66
+ /**
67
+ * Capability flags advertised by the backend via `/admin/api/config`.
68
+ * Use to gate optional UI surfaces (audit-log link, settings sections,
69
+ * revisions button, AI assistant widget) — every flag is `false` until
70
+ * the bootstrap config is loaded, so consumers can render unconditionally
71
+ * and the gating logic short-circuits during the initial paint.
72
+ */
73
+ export const useFeatures = (): AdminFeatures => {
74
+ const { data } = useAdminConfig()
75
+ return React.useMemo(() => resolveFeatures(data?.features), [data?.features])
76
+ }
77
+
78
+ export const useResources = (): ResourceJSON[] => {
79
+ const { data } = useAdminConfig()
80
+ const { localizeResource } = useI18n()
81
+ return React.useMemo(
82
+ () => (data?.resources ?? []).map((resource) => localizeResource(resource)),
83
+ [data?.resources, localizeResource],
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Fetch distinct values for a field, cached for 5 minutes.
89
+ * Used by the filter value picker to offer multi-select when cardinality is low.
90
+ * The `enabled` flag allows lazy loading only when the filter UI is open.
91
+ */
92
+ export const useDistinctValues = (
93
+ resourceId: string,
94
+ field: string,
95
+ options?: { search?: string; limit?: number; enabled?: boolean },
96
+ ): UseQueryResult<{ values: string[]; hasMore: boolean }> => {
97
+ const client = useAdminClient()
98
+ return useQuery({
99
+ queryKey: ['modern-admin', resourceId, 'values', field, options?.search ?? '', options?.limit ?? 100] as const,
100
+ queryFn: () => client.distinctValues(resourceId, field, {
101
+ search: options?.search,
102
+ limit: options?.limit,
103
+ }),
104
+ staleTime: 5 * 60_000, // 5 min cache
105
+ enabled: options?.enabled !== false,
106
+ })
107
+ }
108
+
109
+ export const useRecords = (
110
+ resourceId: string,
111
+ query?: ListQuery,
112
+ ): UseQueryResult<ListResponse> => {
113
+ const client = useAdminClient()
114
+ return useQuery({
115
+ queryKey: keyList(resourceId, query),
116
+ queryFn: () => client.list(resourceId, query),
117
+ })
118
+ }
119
+
120
+ export const useRecord = (
121
+ resourceId: string,
122
+ recordId: string | undefined,
123
+ ): UseQueryResult<RecordResponse> => {
124
+ const client = useAdminClient()
125
+ return useQuery({
126
+ queryKey: keyShow(resourceId, recordId ?? ''),
127
+ queryFn: () => client.show(resourceId, recordId!),
128
+ enabled: !!recordId,
129
+ })
130
+ }
131
+
132
+ export const useCreateRecord = (
133
+ resourceId: string,
134
+ ): UseMutationResult<RecordResponse, Error, Record<string, unknown>> => {
135
+ const client = useAdminClient()
136
+ const qc = useQueryClient()
137
+ return useMutation({
138
+ mutationFn: (payload) => client.create(resourceId, payload),
139
+ onSuccess: () => {
140
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
141
+ },
142
+ })
143
+ }
144
+
145
+ export const useUpdateRecord = (
146
+ resourceId: string,
147
+ ): UseMutationResult<
148
+ RecordResponse,
149
+ Error,
150
+ { id: string; payload: Record<string, unknown> }
151
+ > => {
152
+ const client = useAdminClient()
153
+ const qc = useQueryClient()
154
+ return useMutation({
155
+ mutationFn: ({ id, payload }) => client.update(resourceId, id, payload),
156
+ onSuccess: (_data, { id }) => {
157
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
158
+ qc.invalidateQueries({ queryKey: keyShow(resourceId, id) })
159
+ },
160
+ })
161
+ }
162
+
163
+ export const useDeleteRecord = (
164
+ resourceId: string,
165
+ ): UseMutationResult<void, Error, string> => {
166
+ const client = useAdminClient()
167
+ const qc = useQueryClient()
168
+ return useMutation({
169
+ mutationFn: (id) => client.delete(resourceId, id),
170
+ onSuccess: () => {
171
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
172
+ },
173
+ })
174
+ }
175
+
176
+ export const useBulkDeleteRecords = (
177
+ resourceId: string,
178
+ ): UseMutationResult<unknown, Error, ReadonlyArray<string>> => {
179
+ const client = useAdminClient()
180
+ const qc = useQueryClient()
181
+ return useMutation({
182
+ mutationFn: (ids) => client.bulkDelete(resourceId, ids),
183
+ onSuccess: () => {
184
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
185
+ },
186
+ })
187
+ }
188
+
189
+ const keySearch = (resourceId: string, query: string) =>
190
+ ['modern-admin', resourceId, 'search', query] as const
191
+
192
+ /**
193
+ * Live-search hook against a resource's `search` action. Used by reference
194
+ * comboboxes — debounce the input on the call site.
195
+ */
196
+ // ─── Auth ─────────────────────────────────────────────────────────────────
197
+
198
+ const KEY_ME = ['modern-admin', 'auth', 'me'] as const
199
+ const KEY_AUTH_UI = ['modern-admin', 'auth', 'ui-props'] as const
200
+
201
+ export interface CurrentUserResult {
202
+ user: CurrentUser | null
203
+ isLoading: boolean
204
+ isAuthenticated: boolean
205
+ error: Error | null
206
+ }
207
+
208
+ /** Resolve the current admin via /admin/api/auth/me. A 401 response surfaces
209
+ * as `user: null` (rather than an error) so callers can branch on it to
210
+ * render the login screen. */
211
+ export const useCurrentUser = (): CurrentUserResult => {
212
+ const client = useAdminClient()
213
+ const query = useQuery<{ user: CurrentUser } | null, Error>({
214
+ queryKey: KEY_ME,
215
+ queryFn: async () => {
216
+ try {
217
+ return await client.me()
218
+ } catch (err) {
219
+ if (err instanceof AdminApiError && err.status === 401) return null
220
+ throw err
221
+ }
222
+ },
223
+ staleTime: 30_000,
224
+ retry: (failureCount, err) => {
225
+ if (err instanceof AdminApiError && err.status === 401) return false
226
+ return failureCount < 1
227
+ },
228
+ })
229
+ return {
230
+ user: query.data?.user ?? null,
231
+ isLoading: query.isLoading,
232
+ isAuthenticated: !!query.data?.user,
233
+ error: query.error,
234
+ }
235
+ }
236
+
237
+ /** Fetch public auth UI metadata (enabled social providers, email/password flag).
238
+ * Cached indefinitely — the provider list is static for a given deployment. */
239
+ export const useAuthUiProps = (): UseQueryResult<AuthUiProps> => {
240
+ const client = useAdminClient()
241
+ return useQuery({
242
+ queryKey: KEY_AUTH_UI,
243
+ queryFn: () => client.getAuthUiProps(),
244
+ staleTime: Infinity,
245
+ })
246
+ }
247
+
248
+ /** Initiate OAuth social login. Navigates the browser away to the provider;
249
+ * `isPending` is true while the redirect URL is being fetched. */
250
+ export const useSocialLogin = (): UseMutationResult<void, Error, string> => {
251
+ const client = useAdminClient()
252
+ return useMutation({
253
+ mutationFn: (provider: string) => client.loginSocial(provider),
254
+ })
255
+ }
256
+
257
+ export const useLogin = (): UseMutationResult<
258
+ void,
259
+ Error,
260
+ { email: string; password: string }
261
+ > => {
262
+ const client = useAdminClient()
263
+ const qc = useQueryClient()
264
+ return useMutation({
265
+ mutationFn: ({ email, password }) => client.login(email, password),
266
+ onSuccess: () => {
267
+ qc.invalidateQueries({ queryKey: KEY_ME })
268
+ qc.invalidateQueries({ queryKey: KEY_CONFIG })
269
+ },
270
+ })
271
+ }
272
+
273
+ export const useLogout = (): UseMutationResult<void, Error, void> => {
274
+ const client = useAdminClient()
275
+ const qc = useQueryClient()
276
+ return useMutation({
277
+ mutationFn: () => client.logout(),
278
+ onSuccess: async () => {
279
+ // Cancel any in-flight `me` refetch so it cannot overwrite the
280
+ // optimistic null below and bounce the gate back to authenticated.
281
+ await qc.cancelQueries({ queryKey: KEY_ME })
282
+ // Flip the auth gate to "logged out" immediately. We deliberately
283
+ // do NOT invalidate KEY_ME here — Better Auth has already deleted
284
+ // the server-side session, but the Set-Cookie header may not be
285
+ // applied to outgoing requests for a tick, and a refetch in that
286
+ // window would return the still-valid user and cancel the logout
287
+ // visually. The next mount/refresh will re-check freshly.
288
+ qc.setQueryData(KEY_ME, null)
289
+ // Drop every other cached resource so list/show data doesn't
290
+ // linger behind the login form.
291
+ qc.removeQueries({
292
+ predicate: (q) => {
293
+ const k = q.queryKey as readonly unknown[]
294
+ return !(k[0] === 'modern-admin' && k[1] === 'auth' && k[2] === 'me')
295
+ },
296
+ })
297
+ },
298
+ })
299
+ }
300
+
301
+ export const useInvokeRecordAction = (
302
+ resourceId: string,
303
+ ): UseMutationResult<CustomActionResponse, Error, { recordId: string; actionName: string }> => {
304
+ const client = useAdminClient()
305
+ const qc = useQueryClient()
306
+ return useMutation({
307
+ mutationFn: ({ recordId, actionName }) =>
308
+ client.invokeRecordAction(resourceId, recordId, actionName),
309
+ onSuccess: (_data, { recordId }) => {
310
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
311
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId, 'show', recordId] })
312
+ },
313
+ })
314
+ }
315
+
316
+ export const useInvokeBulkAction = (
317
+ resourceId: string,
318
+ ): UseMutationResult<CustomActionResponse, Error, { actionName: string; ids: string[] }> => {
319
+ const client = useAdminClient()
320
+ const qc = useQueryClient()
321
+ return useMutation({
322
+ mutationFn: ({ actionName, ids }) => client.invokeBulkAction(resourceId, actionName, ids),
323
+ onSuccess: () => {
324
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
325
+ },
326
+ })
327
+ }
328
+
329
+ export const useInvokeResourceAction = (
330
+ resourceId: string,
331
+ ): UseMutationResult<CustomActionResponse, Error, { actionName: string; payload?: Record<string, unknown> }> => {
332
+ const client = useAdminClient()
333
+ const qc = useQueryClient()
334
+ return useMutation({
335
+ mutationFn: ({ actionName, payload }) => client.invokeResourceAction(resourceId, actionName, payload),
336
+ onSuccess: () => {
337
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
338
+ },
339
+ })
340
+ }
341
+
342
+ /**
343
+ * Cross-resource search hook. Fires a single batched request that fans out
344
+ * to every registered resource's `search` action; results are grouped by
345
+ * resource. The empty-query case is handled by the caller (skip render);
346
+ * `enabled` allows lazy activation while the dialog is closed.
347
+ */
348
+ export const useGlobalSearch = (
349
+ query: string,
350
+ enabled = true,
351
+ ): UseQueryResult<GlobalSearchResponse> => {
352
+ const client = useAdminClient()
353
+ return useQuery({
354
+ queryKey: ['modern-admin', 'global-search', query] as const,
355
+ // Forward the AbortSignal TanStack Query attaches to each invocation.
356
+ // The signal fires when the query key changes (next keystroke) or the
357
+ // component unmounts, letting the server short-circuit stale work.
358
+ queryFn: ({ signal }) => client.globalSearch(query, undefined, { signal }),
359
+ enabled: enabled && query.trim().length > 0,
360
+ staleTime: 30_000,
361
+ })
362
+ }
363
+
364
+ export const useSearchRecords = (
365
+ resourceId: string | undefined,
366
+ query: string,
367
+ enabled = true,
368
+ ): UseQueryResult<ListResponse> => {
369
+ const client = useAdminClient()
370
+ return useQuery({
371
+ queryKey: keySearch(resourceId ?? '', query),
372
+ queryFn: () => client.search(resourceId!, query),
373
+ enabled: !!resourceId && enabled,
374
+ staleTime: 30_000,
375
+ })
376
+ }
377
+
378
+ /**
379
+ * Distinct (deduplicated, sorted) values pulled from a single field of a
380
+ * resource — the data source for the autocomplete `suggestionsResource +
381
+ * suggestionsField` binding on `KeyValueFieldSpec`. Loads up to `perPage`
382
+ * records and projects `field` client-side; for typical admin resources
383
+ * (hundreds–low thousands of rows) this is plenty cheap. For very large
384
+ * tables, reach for a dedicated `distinct` endpoint.
385
+ */
386
+ export const useFieldSuggestions = (
387
+ resourceId: string | undefined,
388
+ field: string | undefined,
389
+ perPage = 200,
390
+ ): UseQueryResult<string[]> => {
391
+ const client = useAdminClient()
392
+ return useQuery({
393
+ queryKey: ['modern-admin', 'fieldSuggestions', resourceId ?? '', field ?? '', perPage],
394
+ queryFn: async (): Promise<string[]> => {
395
+ const res = await client.list(resourceId!, { perPage })
396
+ const seen = new Set<string>()
397
+ const out: string[] = []
398
+ for (const r of res.records) {
399
+ const raw = r.params?.[field!]
400
+ if (raw == null || raw === '') continue
401
+ const v = String(raw)
402
+ if (seen.has(v)) continue
403
+ seen.add(v)
404
+ out.push(v)
405
+ }
406
+ out.sort((a, b) => a.localeCompare(b))
407
+ return out
408
+ },
409
+ enabled: !!resourceId && !!field,
410
+ staleTime: 60_000,
411
+ })
412
+ }
413
+
414
+ export const useTimeSeries = (
415
+ query: TimeSeriesQuery | null,
416
+ ): UseQueryResult<TimeSeriesResponse> => {
417
+ const client = useAdminClient()
418
+ return useQuery({
419
+ queryKey: ['modern-admin', 'timeseries', query],
420
+ queryFn: () => client.timeseries(query!),
421
+ enabled: query !== null && !!query.resource && !!query.dateField,
422
+ staleTime: 60_000,
423
+ })
424
+ }
425
+
426
+ export const useRecordHistory = (
427
+ resourceId: string,
428
+ recordId: string | undefined,
429
+ options: { limit?: number; offset?: number } = {},
430
+ ): UseQueryResult<HistoryListResponse> => {
431
+ const client = useAdminClient()
432
+ return useQuery({
433
+ queryKey: [...keyHistory(resourceId, recordId ?? ''), options] as const,
434
+ queryFn: () => client.listHistory(resourceId, recordId!, options),
435
+ enabled: !!resourceId && !!recordId,
436
+ staleTime: 30_000,
437
+ })
438
+ }
439
+
440
+ export const useHistoryRevision = (
441
+ resourceId: string,
442
+ recordId: string | undefined,
443
+ revisionId: string | undefined,
444
+ ): UseQueryResult<HistoryRevisionResponse> => {
445
+ const client = useAdminClient()
446
+ return useQuery({
447
+ queryKey: keyHistoryRevision(resourceId, recordId ?? '', revisionId ?? ''),
448
+ queryFn: () => client.getHistoryRevision(resourceId, recordId!, revisionId!),
449
+ enabled: !!resourceId && !!recordId && !!revisionId,
450
+ staleTime: 30_000,
451
+ })
452
+ }
453
+
454
+ export const useRevertRevision = (
455
+ resourceId: string,
456
+ recordId: string,
457
+ ): UseMutationResult<RecordResponse, Error, { revisionId: string; reason?: string }> => {
458
+ const client = useAdminClient()
459
+ const qc = useQueryClient()
460
+ return useMutation({
461
+ mutationFn: ({ revisionId, reason }) =>
462
+ client.revertHistoryRevision(resourceId, recordId, revisionId, { reason }),
463
+ onSuccess: (_data, { revisionId }) => {
464
+ qc.invalidateQueries({ queryKey: ['modern-admin', resourceId] })
465
+ qc.invalidateQueries({ queryKey: keyShow(resourceId, recordId) })
466
+ qc.invalidateQueries({ queryKey: keyHistory(resourceId, recordId) })
467
+ qc.invalidateQueries({ queryKey: keyHistoryRevision(resourceId, recordId, revisionId) })
468
+ },
469
+ })
470
+ }
471
+
472
+ export const useAuditLog = (
473
+ query: AuditLogQuery = {},
474
+ ): UseQueryResult<AuditLogResponse> => {
475
+ const client = useAdminClient()
476
+ return useQuery({
477
+ queryKey: keyAuditLog(query),
478
+ queryFn: () => client.listAuditLog(query),
479
+ staleTime: 30_000,
480
+ })
481
+ }
482
+
483
+ /**
484
+ * Cursor-based infinite scroll variant of `useAuditLog`.
485
+ * Each page passes the `at` timestamp of the last entry as the `before` cursor.
486
+ * `pageSize` entries are requested; if the response is full, there are more pages.
487
+ */
488
+ export const useInfiniteAuditLog = (
489
+ filters: Omit<AuditLogQuery, 'before' | 'offset' | 'limit'>,
490
+ pageSize: number,
491
+ ): UseInfiniteQueryResult<InfiniteData<AuditLogResponse>, Error> => {
492
+ const client = useAdminClient()
493
+ return useInfiniteQuery({
494
+ queryKey: ['modern-admin', 'audit-log-infinite', filters],
495
+ queryFn: ({ pageParam }) =>
496
+ client.listAuditLog({
497
+ ...filters,
498
+ limit: pageSize + 1,
499
+ before: pageParam as number | undefined,
500
+ }),
501
+ initialPageParam: undefined as number | undefined,
502
+ getNextPageParam: (lastPage) => {
503
+ const events = lastPage.events
504
+ if (events.length <= pageSize) return undefined
505
+ return events[pageSize - 1]!.at
506
+ },
507
+ staleTime: 30_000,
508
+ })
509
+ }
@@ -0,0 +1,56 @@
1
+ // Header button + dialog that surfaces every <useHotkey> entry that opted
2
+ // into the registry by passing `description`. Pressing `?` toggles it.
3
+
4
+ import * as React from 'react'
5
+ import {
6
+ Button,
7
+ Kbd,
8
+ KeyboardShortcutsHelp,
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipTrigger,
12
+ } from '@modern-admin/ui'
13
+ import { Keyboard } from 'lucide-react'
14
+ import { useHotkey } from './use-hotkey.js'
15
+ import { useRegisteredHotkeys } from './hotkey-registry.js'
16
+ import { useI18n } from './i18n.js'
17
+
18
+ export function HotkeyHelpButton(): React.ReactElement {
19
+ const [open, setOpen] = React.useState(false)
20
+ const items = useRegisteredHotkeys()
21
+ const { t } = useI18n()
22
+ const label = t('common:shortcutsHelp')
23
+
24
+ useHotkey('?', () => setOpen((v) => !v), {
25
+ description: label,
26
+ })
27
+
28
+ return (
29
+ <>
30
+ <Tooltip>
31
+ <TooltipTrigger asChild>
32
+ <Button
33
+ variant="ghost"
34
+ size="icon"
35
+ onClick={() => setOpen(true)}
36
+ aria-label={label}
37
+ className="hidden md:inline-flex"
38
+ >
39
+ <Keyboard className="size-4" />
40
+ </Button>
41
+ </TooltipTrigger>
42
+ <TooltipContent className="flex items-center gap-1.5">
43
+ <span>{label}</span>
44
+ <Kbd>?</Kbd>
45
+ </TooltipContent>
46
+ </Tooltip>
47
+ <KeyboardShortcutsHelp
48
+ open={open}
49
+ onOpenChange={setOpen}
50
+ items={items}
51
+ title={label}
52
+ emptyMessage={t('common:shortcutsEmpty')}
53
+ />
54
+ </>
55
+ )
56
+ }
@@ -0,0 +1,60 @@
1
+ // Process-local registry of currently-mounted hotkeys. `useHotkey` opts
2
+ // into registration by passing `description` in its options; the entry
3
+ // shows up in <KeyboardShortcutsHelp> until its component unmounts.
4
+ //
5
+ // Without a surrounding <HotkeyRegistryProvider> the registry is a
6
+ // no-op so plain `useHotkey` stays usable in isolation (tests etc.).
7
+
8
+ import * as React from 'react'
9
+
10
+ export interface HotkeyDescriptor {
11
+ /** Combo string in `useHotkey` syntax, e.g. `mod+s`, `shift+/`, `esc`. */
12
+ keys: string
13
+ description: string
14
+ group?: string
15
+ }
16
+
17
+ interface HotkeyRegistryApi {
18
+ register(d: HotkeyDescriptor): () => void
19
+ list: HotkeyDescriptor[]
20
+ }
21
+
22
+ const NOOP_REGISTER: HotkeyRegistryApi['register'] = () => () => {}
23
+
24
+ const HotkeyRegistryContext = React.createContext<HotkeyRegistryApi>({
25
+ register: NOOP_REGISTER,
26
+ list: [],
27
+ })
28
+
29
+ export function HotkeyRegistryProvider({
30
+ children,
31
+ }: {
32
+ children: React.ReactNode
33
+ }): React.ReactElement {
34
+ const [list, setList] = React.useState<HotkeyDescriptor[]>([])
35
+ // Stable register: setList is stable from useState, and the closure
36
+ // is captured once via useRef so dependent effects don't re-fire.
37
+ const register = React.useRef<HotkeyRegistryApi['register']>((d) => {
38
+ setList((prev) => [...prev, d])
39
+ return () => {
40
+ setList((prev) => prev.filter((x) => x !== d))
41
+ }
42
+ }).current
43
+ const value = React.useMemo<HotkeyRegistryApi>(
44
+ () => ({ register, list }),
45
+ [register, list],
46
+ )
47
+ return (
48
+ <HotkeyRegistryContext.Provider value={value}>
49
+ {children}
50
+ </HotkeyRegistryContext.Provider>
51
+ )
52
+ }
53
+
54
+ export function useRegisteredHotkeys(): HotkeyDescriptor[] {
55
+ return React.useContext(HotkeyRegistryContext).list
56
+ }
57
+
58
+ export function useHotkeyRegister(): HotkeyRegistryApi['register'] {
59
+ return React.useContext(HotkeyRegistryContext).register
60
+ }