@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.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- 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
|
+
}
|