@everystack/admin 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 (38) hide show
  1. package/README.md +282 -0
  2. package/package.json +53 -0
  3. package/src/AdminRoot.tsx +34 -0
  4. package/src/adapters/EverystackAdapter.ts +218 -0
  5. package/src/adapters/types.ts +78 -0
  6. package/src/components/AvatarFieldInput.tsx +100 -0
  7. package/src/components/ImageDisplay.tsx +57 -0
  8. package/src/components/ImageFieldInput.tsx +140 -0
  9. package/src/components/dashboard/Brush.tsx +139 -0
  10. package/src/components/dashboard/DimensionBars.tsx +67 -0
  11. package/src/components/dashboard/DimensionDonut.tsx +98 -0
  12. package/src/components/dashboard/EventTable.tsx +248 -0
  13. package/src/components/dashboard/StatDials.tsx +67 -0
  14. package/src/components/dashboard/TimeHistogram.tsx +158 -0
  15. package/src/context.ts +75 -0
  16. package/src/hooks/useAdminConfig.ts +22 -0
  17. package/src/hooks/useAdminData.ts +132 -0
  18. package/src/hooks/useAggregates.ts +290 -0
  19. package/src/hooks/useRpc.ts +124 -0
  20. package/src/hooks/useStorageConfig.ts +16 -0
  21. package/src/index.ts +138 -0
  22. package/src/presets/twitter.ts +267 -0
  23. package/src/providers/AdminConfigProvider.tsx +18 -0
  24. package/src/providers/DataAdapterProvider.tsx +18 -0
  25. package/src/rpc/resource-dashboard-rpc.ts +416 -0
  26. package/src/screens/AdminDashboard.tsx +217 -0
  27. package/src/screens/AdminLayout.tsx +97 -0
  28. package/src/screens/ResourceCreate.tsx +243 -0
  29. package/src/screens/ResourceDashboard.tsx +278 -0
  30. package/src/screens/ResourceEdit.tsx +283 -0
  31. package/src/screens/ResourceList.tsx +153 -0
  32. package/src/screens/ResourceShow.tsx +171 -0
  33. package/src/screens/index.ts +14 -0
  34. package/src/types.ts +358 -0
  35. package/src/utils/naming.ts +105 -0
  36. package/src/utils/pick-image-web.ts +81 -0
  37. package/src/utils/resolve-image-url.ts +43 -0
  38. package/src/utils/upload-file.ts +93 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * useAggregates hook
3
+ *
4
+ * Provides aggregate query capabilities for dashboard charts and statistics.
5
+ * Uses the configured DataAdapter's aggregate method for PostgREST-style queries.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from 'react';
9
+ import { useDataAdapter } from './useAdminData';
10
+ import type { AggregateParams, AggregateResult, AggregateQuery } from '../types';
11
+
12
+ // =============================================================================
13
+ // Types
14
+ // =============================================================================
15
+
16
+ export interface UseAggregatesOptions extends Omit<AggregateParams, 'aggregates'> {
17
+ /** Aggregate functions to compute */
18
+ aggregates: AggregateQuery[];
19
+ /** Skip initial fetch */
20
+ skip?: boolean;
21
+ /** Polling interval in milliseconds (0 = disabled) */
22
+ pollInterval?: number;
23
+ /** Callback when data is fetched successfully */
24
+ onSuccess?: (result: AggregateResult) => void;
25
+ /** Callback when fetch fails */
26
+ onError?: (error: Error) => void;
27
+ }
28
+
29
+ export interface UseAggregatesResult {
30
+ /** Aggregated data rows */
31
+ data: Record<string, unknown>[];
32
+ /** Loading state */
33
+ loading: boolean;
34
+ /** Error state */
35
+ error: Error | null;
36
+ /** Refetch data manually */
37
+ refetch: () => Promise<void>;
38
+ /** Update filter and refetch */
39
+ setFilter: (filter: Record<string, unknown>) => void;
40
+ /** Whether the adapter supports aggregates */
41
+ isSupported: boolean;
42
+ }
43
+
44
+ // =============================================================================
45
+ // Hook Implementation
46
+ // =============================================================================
47
+
48
+ export function useAggregates(
49
+ resource: string,
50
+ options: UseAggregatesOptions
51
+ ): UseAggregatesResult {
52
+ const adapter = useDataAdapter();
53
+
54
+ const [data, setData] = useState<Record<string, unknown>[]>([]);
55
+ const [loading, setLoading] = useState(!options.skip);
56
+ const [error, setError] = useState<Error | null>(null);
57
+ const [filter, setFilterState] = useState(options.filter ?? {});
58
+
59
+ // Track if adapter supports aggregates
60
+ const isSupported = typeof adapter.aggregate === 'function';
61
+
62
+ // Use ref to track mounted state for cleanup
63
+ const isMounted = useRef(true);
64
+
65
+ // Stable reference for options
66
+ const aggregatesRef = useRef(options.aggregates);
67
+ const groupByRef = useRef(options.groupBy);
68
+ aggregatesRef.current = options.aggregates;
69
+ groupByRef.current = options.groupBy;
70
+
71
+ const fetchData = useCallback(async () => {
72
+ if (!isSupported) {
73
+ setError(new Error('DataAdapter does not support aggregate queries'));
74
+ return;
75
+ }
76
+
77
+ setLoading(true);
78
+ setError(null);
79
+
80
+ try {
81
+ const result = await adapter.aggregate!(resource, {
82
+ aggregates: aggregatesRef.current,
83
+ groupBy: groupByRef.current,
84
+ filter,
85
+ });
86
+
87
+ if (isMounted.current) {
88
+ setData(result.data);
89
+ options.onSuccess?.(result);
90
+ }
91
+ } catch (err) {
92
+ if (isMounted.current) {
93
+ const error = err instanceof Error ? err : new Error(String(err));
94
+ setError(error);
95
+ options.onError?.(error);
96
+ }
97
+ } finally {
98
+ if (isMounted.current) {
99
+ setLoading(false);
100
+ }
101
+ }
102
+ }, [adapter, resource, filter, isSupported, options.onSuccess, options.onError]);
103
+
104
+ // Set error if adapter doesn't support aggregates
105
+ useEffect(() => {
106
+ if (!isSupported && !options.skip) {
107
+ setError(new Error('DataAdapter does not support aggregate queries'));
108
+ setLoading(false);
109
+ }
110
+ }, [isSupported, options.skip]);
111
+
112
+ // Initial fetch
113
+ useEffect(() => {
114
+ if (!options.skip && isSupported) {
115
+ fetchData();
116
+ }
117
+ }, [fetchData, options.skip, isSupported]);
118
+
119
+ // Polling
120
+ useEffect(() => {
121
+ if (options.pollInterval && options.pollInterval > 0 && isSupported) {
122
+ const intervalId = setInterval(fetchData, options.pollInterval);
123
+ return () => clearInterval(intervalId);
124
+ }
125
+ }, [fetchData, options.pollInterval, isSupported]);
126
+
127
+ // Cleanup on unmount
128
+ useEffect(() => {
129
+ isMounted.current = true;
130
+ return () => {
131
+ isMounted.current = false;
132
+ };
133
+ }, []);
134
+
135
+ const setFilter = useCallback((newFilter: Record<string, unknown>) => {
136
+ setFilterState(newFilter);
137
+ }, []);
138
+
139
+ return {
140
+ data,
141
+ loading,
142
+ error,
143
+ refetch: fetchData,
144
+ setFilter,
145
+ isSupported,
146
+ };
147
+ }
148
+
149
+ // =============================================================================
150
+ // Convenience Hooks
151
+ // =============================================================================
152
+
153
+ export interface UseCountOptions {
154
+ column?: string;
155
+ distinct?: boolean;
156
+ filter?: Record<string, unknown>;
157
+ skip?: boolean;
158
+ pollInterval?: number;
159
+ }
160
+
161
+ export interface UseCountResult {
162
+ count: number | null;
163
+ loading: boolean;
164
+ error: Error | null;
165
+ refetch: () => Promise<void>;
166
+ }
167
+
168
+ export function useCount(resource: string, options: UseCountOptions = {}): UseCountResult {
169
+ const result = useAggregates(resource, {
170
+ aggregates: [
171
+ {
172
+ function: 'count',
173
+ column: options.column,
174
+ distinct: options.distinct,
175
+ alias: 'count',
176
+ },
177
+ ],
178
+ filter: options.filter,
179
+ skip: options.skip,
180
+ pollInterval: options.pollInterval,
181
+ });
182
+
183
+ const count = (result.data[0]?.count as number | null) ?? null;
184
+
185
+ return {
186
+ count,
187
+ loading: result.loading,
188
+ error: result.error,
189
+ refetch: result.refetch,
190
+ };
191
+ }
192
+
193
+ export interface UseGroupedCountOptions {
194
+ filter?: Record<string, unknown>;
195
+ skip?: boolean;
196
+ pollInterval?: number;
197
+ }
198
+
199
+ export interface UseGroupedCountResult {
200
+ data: { [key: string]: unknown; count: number }[];
201
+ loading: boolean;
202
+ error: Error | null;
203
+ refetch: () => Promise<void>;
204
+ }
205
+
206
+ export function useGroupedCount(
207
+ resource: string,
208
+ groupBy: string,
209
+ options: UseGroupedCountOptions = {}
210
+ ): UseGroupedCountResult {
211
+ const result = useAggregates(resource, {
212
+ aggregates: [{ function: 'count', alias: 'count' }],
213
+ groupBy: [groupBy],
214
+ filter: options.filter,
215
+ skip: options.skip,
216
+ pollInterval: options.pollInterval,
217
+ });
218
+
219
+ return {
220
+ data: result.data as { [key: string]: unknown; count: number }[],
221
+ loading: result.loading,
222
+ error: result.error,
223
+ refetch: result.refetch,
224
+ };
225
+ }
226
+
227
+ export interface UseStatsOptions {
228
+ count?: boolean;
229
+ sum?: string;
230
+ avg?: string;
231
+ min?: string;
232
+ max?: string;
233
+ filter?: Record<string, unknown>;
234
+ skip?: boolean;
235
+ pollInterval?: number;
236
+ }
237
+
238
+ export interface UseStatsResult {
239
+ stats: {
240
+ count?: number;
241
+ sum?: number;
242
+ avg?: number;
243
+ min?: number;
244
+ max?: number;
245
+ };
246
+ loading: boolean;
247
+ error: Error | null;
248
+ refetch: () => Promise<void>;
249
+ }
250
+
251
+ export function useStats(resource: string, options: UseStatsOptions = {}): UseStatsResult {
252
+ const aggregates: AggregateQuery[] = [];
253
+
254
+ if (options.count) {
255
+ aggregates.push({ function: 'count', alias: 'count' });
256
+ }
257
+ if (options.sum) {
258
+ aggregates.push({ function: 'sum', column: options.sum, alias: 'sum' });
259
+ }
260
+ if (options.avg) {
261
+ aggregates.push({ function: 'avg', column: options.avg, alias: 'avg' });
262
+ }
263
+ if (options.min) {
264
+ aggregates.push({ function: 'min', column: options.min, alias: 'min' });
265
+ }
266
+ if (options.max) {
267
+ aggregates.push({ function: 'max', column: options.max, alias: 'max' });
268
+ }
269
+
270
+ // Default to count if no options specified
271
+ if (aggregates.length === 0) {
272
+ aggregates.push({ function: 'count', alias: 'count' });
273
+ }
274
+
275
+ const result = useAggregates(resource, {
276
+ aggregates,
277
+ filter: options.filter,
278
+ skip: options.skip,
279
+ pollInterval: options.pollInterval,
280
+ });
281
+
282
+ const stats = (result.data[0] ?? {}) as UseStatsResult['stats'];
283
+
284
+ return {
285
+ stats,
286
+ loading: result.loading,
287
+ error: result.error,
288
+ refetch: result.refetch,
289
+ };
290
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * useRpc — Lightweight hook for calling RPC endpoints.
3
+ *
4
+ * Calls POST {baseUrl}/rpc/{fn} with JSON body.
5
+ * Manages loading/error state and supports manual refetch.
6
+ *
7
+ * Resilience features:
8
+ * - Debounces rapid body changes (150ms) to prevent request storms
9
+ * - AbortController cancels stale in-flight requests
10
+ * - Keeps previous data visible during refetch (no flash to loading state)
11
+ */
12
+
13
+ import { useState, useEffect, useCallback, useRef } from 'react';
14
+
15
+ export interface UseRpcOptions {
16
+ baseUrl: string;
17
+ fn: string;
18
+ body?: unknown;
19
+ skip?: boolean;
20
+ /** Debounce delay in ms before firing request on body change. Default: 150 */
21
+ debounce?: number;
22
+ getToken?: () => string | null | Promise<string | null>;
23
+ }
24
+
25
+ export interface UseRpcResult<T> {
26
+ data: T | null;
27
+ loading: boolean;
28
+ error: Error | null;
29
+ refetch: () => Promise<void>;
30
+ }
31
+
32
+ export function useRpc<T = unknown>(options: UseRpcOptions): UseRpcResult<T> {
33
+ const { baseUrl, fn, body, skip, debounce = 150, getToken } = options;
34
+ const [data, setData] = useState<T | null>(null);
35
+ const [loading, setLoading] = useState(!skip);
36
+ const [error, setError] = useState<Error | null>(null);
37
+
38
+ const serializedBody = JSON.stringify(body) ?? '{}';
39
+ const abortRef = useRef<AbortController | null>(null);
40
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
41
+
42
+ const fetchData = useCallback(async (signal?: AbortSignal) => {
43
+ setLoading(true);
44
+ setError(null);
45
+ try {
46
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
47
+ if (getToken) {
48
+ const token = await getToken();
49
+ if (token) headers['Authorization'] = `Bearer ${token}`;
50
+ }
51
+ const res = await fetch(`${baseUrl}/rpc/${fn}`, {
52
+ method: 'POST',
53
+ headers,
54
+ body: serializedBody,
55
+ signal,
56
+ });
57
+ if (signal?.aborted) return;
58
+ if (!res.ok) {
59
+ throw new Error(`RPC ${fn} failed: ${res.status}`);
60
+ }
61
+ const json = await res.json();
62
+ if (!signal?.aborted) {
63
+ setData(json as T);
64
+ }
65
+ } catch (err) {
66
+ if (err instanceof DOMException && err.name === 'AbortError') return;
67
+ if (!signal?.aborted) {
68
+ setError(err instanceof Error ? err : new Error(String(err)));
69
+ }
70
+ } finally {
71
+ if (!signal?.aborted) {
72
+ setLoading(false);
73
+ }
74
+ }
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [baseUrl, fn, getToken, serializedBody]);
77
+
78
+ useEffect(() => {
79
+ if (skip) return;
80
+
81
+ // Clear any pending debounce
82
+ if (debounceRef.current) {
83
+ clearTimeout(debounceRef.current);
84
+ }
85
+
86
+ // Abort previous in-flight request
87
+ if (abortRef.current) {
88
+ abortRef.current.abort();
89
+ }
90
+
91
+ // Debounce the fetch (skip debounce for initial load when no data yet)
92
+ const delay = data === null ? 0 : debounce;
93
+ debounceRef.current = setTimeout(() => {
94
+ const controller = new AbortController();
95
+ abortRef.current = controller;
96
+ fetchData(controller.signal);
97
+ }, delay);
98
+
99
+ return () => {
100
+ if (debounceRef.current) {
101
+ clearTimeout(debounceRef.current);
102
+ }
103
+ };
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [fetchData, skip]);
106
+
107
+ // Manual refetch (bypasses debounce)
108
+ const refetch = useCallback(async () => {
109
+ if (abortRef.current) abortRef.current.abort();
110
+ const controller = new AbortController();
111
+ abortRef.current = controller;
112
+ await fetchData(controller.signal);
113
+ }, [fetchData]);
114
+
115
+ // Cleanup on unmount
116
+ useEffect(() => {
117
+ return () => {
118
+ if (abortRef.current) abortRef.current.abort();
119
+ if (debounceRef.current) clearTimeout(debounceRef.current);
120
+ };
121
+ }, []);
122
+
123
+ return { data, loading, error, refetch };
124
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * useStorageConfig
3
+ *
4
+ * Provides storage configuration (endpoint, CDN URL, auth) to admin
5
+ * image/file field components via React context. Nullable by design —
6
+ * when storage is not configured, image fields degrade to plain TextInput.
7
+ */
8
+
9
+ import { createContext, useContext } from 'react';
10
+ import type { StorageConfig } from '../types';
11
+
12
+ export const StorageConfigContext = createContext<StorageConfig | null>(null);
13
+
14
+ export function useStorageConfig(): StorageConfig | null {
15
+ return useContext(StorageConfigContext);
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * everystack/admin — Declarative admin dashboard framework.
3
+ *
4
+ * Provides a configuration-driven admin UI with hooks for data access,
5
+ * an EverystackAdapter that bridges to everystack/client, and utilities
6
+ * for building admin dashboards.
7
+ */
8
+
9
+ // Types
10
+ export type {
11
+ FieldType,
12
+ FieldDefinition,
13
+ ResourceConfig,
14
+ StorageConfig,
15
+ WidgetType,
16
+ ChartType,
17
+ WidgetConfig,
18
+ DashboardConfig,
19
+ MenuItem,
20
+ AdminConfig,
21
+ ListParams,
22
+ ListResult,
23
+ DataRecord,
24
+ AggregateFunction,
25
+ AggregateQuery,
26
+ AggregateParams,
27
+ AggregateResult,
28
+ // Resource Dashboard types
29
+ ResourceDashboardConfig,
30
+ StatDialConfig,
31
+ StatDialValue,
32
+ DimensionConfig,
33
+ } from './types';
34
+
35
+ // Adapter types
36
+ export type {
37
+ DataAdapter,
38
+ SubscriptionEventType,
39
+ SubscriptionEvent,
40
+ } from './adapters/types';
41
+
42
+ // EverystackAdapter
43
+ export { EverystackAdapter, type EverystackAdapterOptions } from './adapters/EverystackAdapter';
44
+
45
+ // Hooks
46
+ export { useAdminConfig, type AdminConfigContextValue } from './hooks/useAdminConfig';
47
+ export {
48
+ useDataAdapter,
49
+ useAdminData,
50
+ type UseAdminDataOptions,
51
+ type UseAdminDataResult,
52
+ } from './hooks/useAdminData';
53
+ export {
54
+ useAggregates,
55
+ useCount,
56
+ useGroupedCount,
57
+ useStats,
58
+ type UseAggregatesOptions,
59
+ type UseAggregatesResult,
60
+ type UseCountOptions,
61
+ type UseCountResult,
62
+ type UseGroupedCountOptions,
63
+ type UseGroupedCountResult,
64
+ type UseStatsOptions,
65
+ type UseStatsResult,
66
+ } from './hooks/useAggregates';
67
+
68
+ // useRpc hook
69
+ export { useRpc, type UseRpcOptions, type UseRpcResult } from './hooks/useRpc';
70
+
71
+ // RPC Factory
72
+ export {
73
+ createResourceDashboardRpcs,
74
+ createAllDashboardRpcs,
75
+ type ResourceDashboardRpcs,
76
+ type DashboardFilters,
77
+ type DashboardAggregates,
78
+ type EventListResult,
79
+ } from './rpc/resource-dashboard-rpc';
80
+
81
+ // Dashboard Components
82
+ export { StatDials, type DialData, type StatDialsProps } from './components/dashboard/StatDials';
83
+ export { DimensionBars, type DimensionBarsProps } from './components/dashboard/DimensionBars';
84
+ export { DimensionDonut, type DimensionDonutProps } from './components/dashboard/DimensionDonut';
85
+ export { EventTable, type EventTableProps, type EventTableColumn } from './components/dashboard/EventTable';
86
+ export { TimeHistogram, type TimeHistogramProps } from './components/dashboard/TimeHistogram';
87
+ export { Brush, type BrushProps, clamp, computeBrushExtent } from './components/dashboard/Brush';
88
+
89
+ // Providers
90
+ export { AdminConfigProvider, type AdminConfigProviderProps } from './providers/AdminConfigProvider';
91
+ export { DataAdapterProvider, type DataAdapterProviderProps } from './providers/DataAdapterProvider';
92
+
93
+ // AdminRoot
94
+ export { AdminRoot, type AdminRootProps } from './AdminRoot';
95
+
96
+ // Screens
97
+ export {
98
+ AdminLayout,
99
+ AdminDashboard,
100
+ ResourceList,
101
+ ResourceShow,
102
+ ResourceCreate,
103
+ ResourceEdit,
104
+ ResourceDashboard,
105
+ type AdminLayoutProps,
106
+ type AdminDashboardProps,
107
+ type ResourceListProps,
108
+ type ResourceShowProps,
109
+ type ResourceCreateProps,
110
+ type ResourceEditProps,
111
+ type ResourceDashboardProps,
112
+ } from './screens';
113
+
114
+ // Context (virtual routing for expo-router)
115
+ export { createAdminContext, type RequireContext } from './context';
116
+
117
+ // Storage hooks
118
+ export { useStorageConfig, StorageConfigContext } from './hooks/useStorageConfig';
119
+
120
+ // Image field components
121
+ export { ImageFieldInput } from './components/ImageFieldInput';
122
+ export { AvatarFieldInput } from './components/AvatarFieldInput';
123
+ export { ImageDisplay } from './components/ImageDisplay';
124
+
125
+ // Image utilities
126
+ export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
127
+ export { uploadFile, type UploadFileOptions, type UploadFileResult } from './utils/upload-file';
128
+
129
+ // Utilities
130
+ export {
131
+ toCamelCase,
132
+ toSnakeCase,
133
+ getFieldValue,
134
+ hasField,
135
+ resolveFieldKey,
136
+ toSnakeCaseKeys,
137
+ toCamelCaseKeys,
138
+ } from './utils/naming';