@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,125 @@
1
+ // Time-series UI helpers — bucket fill, tick formatting, label formatting.
2
+ // All zero-fill happens here on the client side (req: "Заполнение нулями
3
+ // должно быть только в ui, вне зависимости от адаптеров"). Adapters return
4
+ // only buckets that have data; the UI fabricates the rest at value 0 so
5
+ // charts don't have visual gaps.
6
+
7
+ import type {
8
+ TimeSeriesPoint,
9
+ TimeSeriesSeries,
10
+ TimeSeriesStep,
11
+ } from '../client.js'
12
+
13
+ /** Generate a continuous sequence of `YYYY-MM-DD` bucket keys spanning [from, to]. */
14
+ export function generateBuckets(
15
+ fromIso: string,
16
+ toIso: string,
17
+ step: TimeSeriesStep,
18
+ ): string[] {
19
+ if (step === 'all') return [fromIso.slice(0, 10)]
20
+ const out: string[] = []
21
+ const cur = startOf(new Date(fromIso), step)
22
+ const end = startOf(new Date(toIso), step)
23
+ while (cur.getTime() <= end.getTime()) {
24
+ out.push(ymd(cur))
25
+ advance(cur, step)
26
+ }
27
+ return out
28
+ }
29
+
30
+ /**
31
+ * Zero-fill every series so each one has a value for every bucket in the
32
+ * resolved date range. Missing buckets become `value: 0` rather than gaps,
33
+ * which Recharts renders as flat segments instead of breaks.
34
+ */
35
+ export function fillTimeSeries(
36
+ series: TimeSeriesSeries[],
37
+ fromIso: string,
38
+ toIso: string,
39
+ step: TimeSeriesStep,
40
+ ): TimeSeriesSeries[] {
41
+ const buckets = generateBuckets(fromIso, toIso, step)
42
+ return series.map((s) => {
43
+ const map = new Map(s.points.map((p) => [p.date, p.value]))
44
+ const points: TimeSeriesPoint[] = buckets.map((date) => ({
45
+ date,
46
+ value: map.get(date) ?? 0,
47
+ }))
48
+ return { key: s.key, points }
49
+ })
50
+ }
51
+
52
+ /**
53
+ * X-axis tick formatter — short label format depending on bucket size.
54
+ * day/week → DD.MM, month → MM.YYYY, year → YYYY.
55
+ */
56
+ export function makeTickFormatter(
57
+ step: TimeSeriesStep,
58
+ locale = 'en-US',
59
+ ): (iso: string) => string {
60
+ const dayMonth = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit' })
61
+ const monthYear = new Intl.DateTimeFormat(locale, { month: '2-digit', year: 'numeric' })
62
+ const year = new Intl.DateTimeFormat(locale, { year: 'numeric' })
63
+ return (iso: string): string => {
64
+ const d = new Date(iso)
65
+ if (step === 'year') return year.format(d)
66
+ if (step === 'month') return monthYear.format(d)
67
+ return dayMonth.format(d)
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Tooltip label formatter — long, human-friendly form. For week buckets
73
+ * we render `Mon DD – Sun DD` (the bucket plus 6 days).
74
+ */
75
+ export function makeLabelFormatter(
76
+ step: TimeSeriesStep,
77
+ locale = 'en-US',
78
+ ): (iso: string) => string {
79
+ const fullDay = new Intl.DateTimeFormat(locale, {
80
+ day: '2-digit',
81
+ month: 'long',
82
+ year: 'numeric',
83
+ })
84
+ const monthYear = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
85
+ const year = new Intl.DateTimeFormat(locale, { year: 'numeric' })
86
+ const shortDay = new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short' })
87
+ return (iso: string): string => {
88
+ const d = new Date(iso)
89
+ if (step === 'year') return year.format(d)
90
+ if (step === 'month') return monthYear.format(d)
91
+ if (step === 'week') {
92
+ const end = new Date(d)
93
+ end.setDate(end.getDate() + 6)
94
+ return `${shortDay.format(d)} – ${shortDay.format(end)}`
95
+ }
96
+ return fullDay.format(d)
97
+ }
98
+ }
99
+
100
+ // ─── Internals ────────────────────────────────────────────────────────────
101
+
102
+ function ymd(d: Date): string {
103
+ return d.toISOString().slice(0, 10)
104
+ }
105
+
106
+ function startOf(d: Date, step: TimeSeriesStep): Date {
107
+ const out = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
108
+ if (step === 'week') {
109
+ // Monday-anchored week.
110
+ const dow = (out.getUTCDay() + 6) % 7
111
+ out.setUTCDate(out.getUTCDate() - dow)
112
+ } else if (step === 'month') {
113
+ out.setUTCDate(1)
114
+ } else if (step === 'year') {
115
+ out.setUTCMonth(0, 1)
116
+ }
117
+ return out
118
+ }
119
+
120
+ function advance(d: Date, step: TimeSeriesStep): void {
121
+ if (step === 'day') d.setUTCDate(d.getUTCDate() + 1)
122
+ else if (step === 'week') d.setUTCDate(d.getUTCDate() + 7)
123
+ else if (step === 'month') d.setUTCMonth(d.getUTCMonth() + 1)
124
+ else if (step === 'year') d.setUTCFullYear(d.getUTCFullYear() + 1)
125
+ }
@@ -0,0 +1,265 @@
1
+ // Imperative modal dialogs.
2
+ //
3
+ // `<DialogsProvider>` owns a small stack of active dialogs and renders them
4
+ // through shadcn's purpose-built primitives: `<AlertDialog>` for confirm/alert
5
+ // (focus trap + Esc-to-cancel + accessible role), and `<Dialog>` for arbitrary
6
+ // content opened via `open()`. `useDialogs()` exposes a promise-flavoured API
7
+ // (`confirm`, `alert`, `open`) so callers can `await` user choices instead of
8
+ // weaving open/close state through their components. All built-in dialogs go
9
+ // through `useI18n()` for labels — captions stay localized without callers
10
+ // writing translations on the call site.
11
+ //
12
+ // Custom dialogs use `open({ render })`, where `render` receives a `close`
13
+ // callback. The promise resolves with whatever value the caller passes to
14
+ // `close`, defaulting to `undefined` when the user dismisses the dialog
15
+ // (Esc / overlay click).
16
+
17
+ import * as React from 'react'
18
+ import {
19
+ AlertDialog,
20
+ AlertDialogAction,
21
+ AlertDialogCancel,
22
+ AlertDialogContent,
23
+ AlertDialogDescription,
24
+ AlertDialogFooter,
25
+ AlertDialogHeader,
26
+ AlertDialogTitle,
27
+ Dialog,
28
+ DialogContent,
29
+ } from '@modern-admin/ui'
30
+ import { useI18n } from './i18n.js'
31
+
32
+ export interface ConfirmOptions {
33
+ title?: string
34
+ description?: string
35
+ confirmLabel?: string
36
+ cancelLabel?: string
37
+ /** Style the confirm button as destructive (red). Useful for delete dialogs. */
38
+ destructive?: boolean
39
+ }
40
+
41
+ export interface AlertOptions {
42
+ title?: string
43
+ description?: string
44
+ okLabel?: string
45
+ }
46
+
47
+ export interface OpenOptions<T> {
48
+ render: (api: { close: (value?: T) => void }) => React.ReactNode
49
+ /** Optional max-width override; defaults to `sm:max-w-lg`. */
50
+ className?: string
51
+ /** Disable closing on overlay click + Esc. */
52
+ modal?: boolean
53
+ }
54
+
55
+ export interface DialogsApi {
56
+ confirm(opts?: ConfirmOptions): Promise<boolean>
57
+ alert(opts?: AlertOptions): Promise<void>
58
+ open<T = unknown>(opts: OpenOptions<T>): Promise<T | undefined>
59
+ }
60
+
61
+ interface BaseEntry {
62
+ id: number
63
+ open: boolean
64
+ resolve(value: unknown): void
65
+ }
66
+
67
+ interface ConfirmEntry extends BaseEntry {
68
+ kind: 'confirm'
69
+ opts: ConfirmOptions
70
+ }
71
+
72
+ interface AlertEntry extends BaseEntry {
73
+ kind: 'alert'
74
+ opts: AlertOptions
75
+ }
76
+
77
+ interface CustomEntry extends BaseEntry {
78
+ kind: 'custom'
79
+ className?: string
80
+ modal?: boolean
81
+ render(api: { close: (value?: unknown) => void }): React.ReactNode
82
+ }
83
+
84
+ type DialogEntry = ConfirmEntry | AlertEntry | CustomEntry
85
+
86
+ const DialogsContext = React.createContext<DialogsApi | null>(null)
87
+
88
+ let nextId = 1
89
+
90
+ export interface DialogsProviderProps {
91
+ children: React.ReactNode
92
+ }
93
+
94
+ export function DialogsProvider({ children }: DialogsProviderProps): React.ReactElement {
95
+ const [entries, setEntries] = React.useState<DialogEntry[]>([])
96
+ const { t } = useI18n()
97
+
98
+ const removeEntry = React.useCallback((id: number) => {
99
+ setEntries((prev) => prev.filter((e) => e.id !== id))
100
+ }, [])
101
+
102
+ const closeEntry = React.useCallback(
103
+ (id: number, value: unknown) => {
104
+ setEntries((prev) => {
105
+ const target = prev.find((e) => e.id === id)
106
+ if (target) target.resolve(value)
107
+ // Mark closed so Radix can play the leave animation, then remove.
108
+ return prev.map((e) => (e.id === id ? { ...e, open: false } : e))
109
+ })
110
+ // Cleanup after the close animation ~200ms.
111
+ window.setTimeout(() => removeEntry(id), 250)
112
+ },
113
+ [removeEntry],
114
+ )
115
+
116
+ const api = React.useMemo<DialogsApi>(() => {
117
+ const push = <T,>(
118
+ build: (id: number, resolve: (value: T | undefined) => void) => DialogEntry,
119
+ ): Promise<T | undefined> =>
120
+ new Promise<T | undefined>((resolve) => {
121
+ const id = nextId++
122
+ const entry = build(id, resolve)
123
+ setEntries((prev) => [...prev, entry])
124
+ })
125
+
126
+ return {
127
+ confirm: (opts = {}) =>
128
+ push<boolean>((id, resolve) => ({
129
+ kind: 'confirm',
130
+ id,
131
+ open: true,
132
+ opts,
133
+ resolve: (value) => resolve(value as boolean | undefined),
134
+ })).then((v) => v === true),
135
+ alert: (opts = {}) =>
136
+ push<void>((id, resolve) => ({
137
+ kind: 'alert',
138
+ id,
139
+ open: true,
140
+ opts,
141
+ resolve: () => resolve(undefined),
142
+ })).then(() => undefined),
143
+ open: <T,>(opts: OpenOptions<T>) =>
144
+ push<T>((id, resolve) => ({
145
+ kind: 'custom',
146
+ id,
147
+ open: true,
148
+ className: opts.className,
149
+ modal: opts.modal,
150
+ render: ({ close }) => opts.render({ close: (value?: T) => close(value as unknown) }),
151
+ resolve: (value) => resolve(value as T | undefined),
152
+ })),
153
+ }
154
+ }, [])
155
+
156
+ return (
157
+ <DialogsContext.Provider value={api}>
158
+ {children}
159
+ {entries.map((entry) => {
160
+ if (entry.kind === 'confirm') {
161
+ const title = entry.opts.title ?? t('common:confirmDelete')
162
+ const confirmLabel =
163
+ entry.opts.confirmLabel ?? t(entry.opts.destructive ? 'common:delete' : 'common:save')
164
+ const cancelLabel = entry.opts.cancelLabel ?? t('common:cancel')
165
+ return (
166
+ <AlertDialog
167
+ key={entry.id}
168
+ open={entry.open}
169
+ onOpenChange={(next) => {
170
+ if (!next) closeEntry(entry.id, false)
171
+ }}
172
+ >
173
+ <AlertDialogContent>
174
+ <AlertDialogHeader>
175
+ <AlertDialogTitle>{title}</AlertDialogTitle>
176
+ {entry.opts.description && (
177
+ <AlertDialogDescription>{entry.opts.description}</AlertDialogDescription>
178
+ )}
179
+ </AlertDialogHeader>
180
+ <AlertDialogFooter>
181
+ <AlertDialogCancel onClick={() => closeEntry(entry.id, false)}>
182
+ {cancelLabel}
183
+ </AlertDialogCancel>
184
+ <AlertDialogAction
185
+ variant={entry.opts.destructive ? 'destructive' : 'default'}
186
+ onClick={() => closeEntry(entry.id, true)}
187
+ >
188
+ {confirmLabel}
189
+ </AlertDialogAction>
190
+ </AlertDialogFooter>
191
+ </AlertDialogContent>
192
+ </AlertDialog>
193
+ )
194
+ }
195
+ if (entry.kind === 'alert') {
196
+ return (
197
+ <AlertDialog
198
+ key={entry.id}
199
+ open={entry.open}
200
+ onOpenChange={(next) => {
201
+ if (!next) closeEntry(entry.id, undefined)
202
+ }}
203
+ >
204
+ <AlertDialogContent>
205
+ <AlertDialogHeader>
206
+ <AlertDialogTitle>{entry.opts.title ?? ''}</AlertDialogTitle>
207
+ {entry.opts.description && (
208
+ <AlertDialogDescription>{entry.opts.description}</AlertDialogDescription>
209
+ )}
210
+ </AlertDialogHeader>
211
+ <AlertDialogFooter>
212
+ <AlertDialogAction onClick={() => closeEntry(entry.id, undefined)}>
213
+ {entry.opts.okLabel ?? t('common:ok')}
214
+ </AlertDialogAction>
215
+ </AlertDialogFooter>
216
+ </AlertDialogContent>
217
+ </AlertDialog>
218
+ )
219
+ }
220
+ // custom
221
+ return (
222
+ <Dialog
223
+ key={entry.id}
224
+ open={entry.open}
225
+ onOpenChange={(next) => {
226
+ if (!next) closeEntry(entry.id, undefined)
227
+ }}
228
+ >
229
+ <DialogContent
230
+ className={entry.className}
231
+ onInteractOutside={(e) => {
232
+ if (entry.modal) e.preventDefault()
233
+ }}
234
+ onEscapeKeyDown={(e) => {
235
+ if (entry.modal) e.preventDefault()
236
+ }}
237
+ >
238
+ {entry.render({ close: (value) => closeEntry(entry.id, value) })}
239
+ </DialogContent>
240
+ </Dialog>
241
+ )
242
+ })}
243
+ </DialogsContext.Provider>
244
+ )
245
+ }
246
+
247
+ /** Imperative dialog API. Falls back to a no-op when no provider is mounted. */
248
+ export function useDialogs(): DialogsApi {
249
+ const ctx = React.useContext(DialogsContext)
250
+ if (ctx) return ctx
251
+ // No provider — fall back to native confirm/alert so basic flows still work
252
+ // when a host app forgets to mount <DialogsProvider>.
253
+ return {
254
+ confirm: async (opts) =>
255
+ typeof window === 'undefined'
256
+ ? false
257
+ : window.confirm([opts?.title, opts?.description].filter(Boolean).join('\n\n') || ''),
258
+ alert: async (opts) => {
259
+ if (typeof window !== 'undefined') {
260
+ window.alert([opts?.title, opts?.description].filter(Boolean).join('\n\n') || '')
261
+ }
262
+ },
263
+ open: async () => undefined,
264
+ }
265
+ }
package/src/export.ts ADDED
@@ -0,0 +1,140 @@
1
+ // Client-side export helpers.
2
+ //
3
+ // Pulls every record matching the current list query by paginating with a
4
+ // large pageSize, then serializes to CSV or JSON and triggers a browser
5
+ // download. Pure functions live here so they're trivially unit-testable;
6
+ // the dialog UI in pages/export-dialog.tsx wires them together.
7
+
8
+ import type { AdminClient } from './client.js'
9
+ import type { ListQuery, PropertyJSON, RecordJSON } from './types.js'
10
+
11
+ export type ExportFormat = 'csv' | 'json'
12
+
13
+ export interface FetchAllOptions {
14
+ /** Page size used for each list request. */
15
+ batchSize?: number
16
+ /** Optional progress callback: `(loaded, total)`. */
17
+ onProgress?(loaded: number, total: number): void
18
+ /** AbortSignal — drop pagination loop on cancel. */
19
+ signal?: AbortSignal
20
+ }
21
+
22
+ /**
23
+ * Page through `client.list()` until exhausted, returning every record that
24
+ * matches the same filters/sorting as the current list view. `query.page`
25
+ * and `query.perPage` are overwritten — pass the user's filters/sorting only.
26
+ */
27
+ export async function fetchAllRecords(
28
+ client: AdminClient,
29
+ resourceId: string,
30
+ query: ListQuery | undefined,
31
+ opts: FetchAllOptions = {},
32
+ ): Promise<RecordJSON[]> {
33
+ // Backend caps `perPage` at 200 (see listQueryZ in @modern-admin/nest).
34
+ const batchSize = opts.batchSize ?? 200
35
+ const baseQuery: ListQuery = { ...(query ?? {}), perPage: batchSize, page: 1 }
36
+ const all: RecordJSON[] = []
37
+ let total = 0
38
+ for (let page = 1; ; page++) {
39
+ if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError')
40
+ const res = await client.list(resourceId, { ...baseQuery, page })
41
+ total = res.meta.total
42
+ all.push(...res.records)
43
+ opts.onProgress?.(all.length, total)
44
+ if (res.records.length < batchSize) break
45
+ if (all.length >= total) break
46
+ }
47
+ return all
48
+ }
49
+
50
+ /** Escape a single CSV field per RFC 4180 (wrap in quotes when needed). */
51
+ export function csvEscape(value: unknown): string {
52
+ if (value == null) return ''
53
+ let str: string
54
+ if (typeof value === 'string') str = value
55
+ else if (typeof value === 'number' || typeof value === 'boolean') str = String(value)
56
+ else if (value instanceof Date) str = value.toISOString()
57
+ else str = JSON.stringify(value)
58
+ // Quote if it contains comma, quote, CR or LF.
59
+ if (/[",\r\n]/.test(str)) {
60
+ return `"${str.replace(/"/g, '""')}"`
61
+ }
62
+ return str
63
+ }
64
+
65
+ export interface SerializeOptions {
66
+ /** Properties to export in this order. Defaults to union of keys in `records`. */
67
+ properties?: PropertyJSON[]
68
+ /** When provided, the active list query is embedded as a comment at the top
69
+ * of the exported file so the export is self-documenting:
70
+ * – CSV: `# Query: {...}` line before the header row
71
+ * – JSON: `// Query: {...}` line before the JSON array */
72
+ query?: ListQuery
73
+ }
74
+
75
+ /** Build a CSV document for the given records. UTF-8 BOM for Excel friendliness. */
76
+ export function recordsToCsv(records: RecordJSON[], opts: SerializeOptions = {}): string {
77
+ const columns = opts.properties
78
+ ? opts.properties.map((p) => ({ path: p.path, label: p.label }))
79
+ : columnsFromRecords(records)
80
+ const header = columns.map((c) => csvEscape(c.label)).join(',')
81
+ const lines = records.map((r) =>
82
+ columns.map((c) => csvEscape(r.params[c.path])).join(','),
83
+ )
84
+ const queryComment = opts.query
85
+ ? `# Query: ${JSON.stringify(opts.query)}\r\n`
86
+ : ''
87
+ return `\uFEFF${queryComment}${[header, ...lines].join('\r\n')}`
88
+ }
89
+
90
+ /** Build a pretty-printed JSON document for the given records.
91
+ * When `opts.query` is set, a `// Query: ...` comment is prepended so the
92
+ * export is self-documenting (JSONC — understood by VS Code, TypeScript, etc.) */
93
+ export function recordsToJson(records: RecordJSON[], opts: SerializeOptions = {}): string {
94
+ const paths = opts.properties?.map((p) => p.path)
95
+ const items = records.map((r) => {
96
+ if (!paths) return { id: r.id, ...r.params }
97
+ const row: Record<string, unknown> = { id: r.id }
98
+ for (const p of paths) row[p] = r.params[p]
99
+ return row
100
+ })
101
+ const json = JSON.stringify(items, null, 2)
102
+ return opts.query ? `// Query: ${JSON.stringify(opts.query)}\n${json}` : json
103
+ }
104
+
105
+ function columnsFromRecords(records: RecordJSON[]): { path: string; label: string }[] {
106
+ const seen = new Set<string>()
107
+ const out: { path: string; label: string }[] = []
108
+ for (const r of records) {
109
+ for (const k of Object.keys(r.params)) {
110
+ if (seen.has(k)) continue
111
+ seen.add(k)
112
+ out.push({ path: k, label: k })
113
+ }
114
+ }
115
+ return out
116
+ }
117
+
118
+ /** Trigger a browser download for the given text payload. */
119
+ export function downloadText(filename: string, mime: string, body: string): void {
120
+ if (typeof window === 'undefined') return
121
+ const blob = new Blob([body], { type: `${mime};charset=utf-8` })
122
+ const url = URL.createObjectURL(blob)
123
+ const a = document.createElement('a')
124
+ a.href = url
125
+ a.download = filename
126
+ document.body.appendChild(a)
127
+ a.click()
128
+ a.remove()
129
+ // Revoke after a tick so Safari has time to start the download.
130
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000)
131
+ }
132
+
133
+ /** Build a stable filename like `users-20260506-143015.csv`. */
134
+ export function exportFilename(resourceId: string, format: ExportFormat, now: Date = new Date()): string {
135
+ const pad = (n: number) => String(n).padStart(2, '0')
136
+ const stamp =
137
+ `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
138
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
139
+ return `${resourceId}-${stamp}.${format}`
140
+ }