@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,248 @@
1
+ /**
2
+ * EventTable — Config-driven paginated, searchable, sortable table.
3
+ *
4
+ * Generalized from EventList. Columns, RPC function name, and navigation
5
+ * are all injected via props.
6
+ */
7
+
8
+ import React, { useState, useMemo, useCallback } from 'react';
9
+ import { View, Text, TextInput, Pressable, ActivityIndicator } from 'react-native';
10
+ import { useRpc } from '../../hooks/useRpc';
11
+ import type { DashboardFilters, EventListResult } from '../../rpc/resource-dashboard-rpc';
12
+
13
+ export interface EventTableColumn {
14
+ /** Column key (matches data field) */
15
+ key: string;
16
+ /** Display label */
17
+ label: string;
18
+ /** Flex ratio (default: 1) */
19
+ flex?: number;
20
+ /** Format function */
21
+ format?: (value: unknown) => string;
22
+ }
23
+
24
+ export interface EventTableProps {
25
+ baseUrl: string;
26
+ rpcFn: string;
27
+ columns: EventTableColumn[];
28
+ filters: DashboardFilters;
29
+ getToken?: () => string | null | Promise<string | null>;
30
+ onRowPress?: (row: Record<string, unknown>) => void;
31
+ /** Default sort column */
32
+ defaultSort?: string;
33
+ }
34
+
35
+ const PAGE_SIZE = 25;
36
+
37
+ interface SortState {
38
+ column: string;
39
+ dir: 'asc' | 'desc';
40
+ }
41
+
42
+ function SortHeader({
43
+ label,
44
+ column,
45
+ sort,
46
+ onSort,
47
+ flex,
48
+ }: {
49
+ label: string;
50
+ column: string;
51
+ sort: SortState;
52
+ onSort: (col: string) => void;
53
+ flex: number;
54
+ }): React.ReactElement {
55
+ const active = sort.column === column;
56
+ const arrow = active ? (sort.dir === 'asc' ? ' \u25B2' : ' \u25BC') : '';
57
+ return (
58
+ <Pressable onPress={() => onSort(column)} style={{ flex }}>
59
+ <Text style={{
60
+ fontSize: 11, fontWeight: '600', color: active ? '#0f172a' : '#6b7280',
61
+ textTransform: 'uppercase', letterSpacing: 0.5,
62
+ }}>
63
+ {label}{arrow}
64
+ </Text>
65
+ </Pressable>
66
+ );
67
+ }
68
+
69
+ export function EventTable({
70
+ baseUrl, rpcFn, columns, filters, getToken, onRowPress, defaultSort,
71
+ }: EventTableProps): React.ReactElement {
72
+ const [search, setSearch] = useState('');
73
+ const [page, setPage] = useState(0);
74
+ const [sort, setSort] = useState<SortState>({
75
+ column: defaultSort ?? columns[0]?.key ?? 'id',
76
+ dir: 'desc',
77
+ });
78
+
79
+ const searchTrimmed = search.trim();
80
+
81
+ const body = useMemo(() => ({
82
+ hours: 168,
83
+ filters,
84
+ search: searchTrimmed || undefined,
85
+ sortBy: sort.column,
86
+ sortDir: sort.dir,
87
+ limit: PAGE_SIZE,
88
+ offset: page * PAGE_SIZE,
89
+ }), [filters, searchTrimmed, sort.column, sort.dir, page]);
90
+
91
+ const { data, loading } = useRpc<EventListResult>({
92
+ baseUrl,
93
+ fn: rpcFn,
94
+ body,
95
+ getToken,
96
+ });
97
+
98
+ const handleSort = useCallback((col: string) => {
99
+ setSort(prev => ({
100
+ column: col,
101
+ dir: prev.column === col && prev.dir === 'desc' ? 'asc' : 'desc',
102
+ }));
103
+ setPage(0);
104
+ }, []);
105
+
106
+ const handleSearch = useCallback((text: string) => {
107
+ setSearch(text);
108
+ setPage(0);
109
+ }, []);
110
+
111
+ const total = data?.total ?? 0;
112
+ const rows = data?.rows ?? [];
113
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
114
+
115
+ const defaultFormat = (value: unknown): string => {
116
+ if (value == null) return '\u2014';
117
+ if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
118
+ try {
119
+ const d = new Date(value);
120
+ return d.toLocaleString('en-US', {
121
+ month: 'short', day: 'numeric',
122
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
123
+ hour12: false,
124
+ });
125
+ } catch { /* fallthrough */ }
126
+ }
127
+ return String(value);
128
+ };
129
+
130
+ return (
131
+ <View style={{ marginTop: 32 }}>
132
+ {/* Header + Search */}
133
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
134
+ <Text style={{ fontSize: 14, fontWeight: '600', color: '#0f172a' }}>
135
+ Events {total > 0 ? `(${total.toLocaleString()})` : ''}
136
+ </Text>
137
+ <TextInput
138
+ value={search}
139
+ onChangeText={handleSearch}
140
+ placeholder="Search..."
141
+ placeholderTextColor="#9ca3af"
142
+ style={{
143
+ width: 220, height: 32, paddingHorizontal: 10,
144
+ borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 6,
145
+ fontSize: 12, color: '#0f172a', backgroundColor: '#ffffff',
146
+ }}
147
+ />
148
+ </View>
149
+
150
+ {/* Column headers */}
151
+ <View style={{
152
+ flexDirection: 'row', paddingVertical: 8, paddingHorizontal: 12,
153
+ backgroundColor: '#f8fafc', borderRadius: 6, marginBottom: 2,
154
+ }}>
155
+ {columns.map(col => (
156
+ <SortHeader
157
+ key={col.key}
158
+ label={col.label}
159
+ column={col.key}
160
+ sort={sort}
161
+ onSort={handleSort}
162
+ flex={col.flex ?? 1}
163
+ />
164
+ ))}
165
+ </View>
166
+
167
+ {/* Loading */}
168
+ {loading && rows.length === 0 && (
169
+ <View style={{ padding: 24, alignItems: 'center' }}>
170
+ <ActivityIndicator size="small" color="#6366f1" />
171
+ </View>
172
+ )}
173
+
174
+ {/* Rows */}
175
+ {rows.map((row, idx) => (
176
+ <Pressable
177
+ key={row.id as string ?? idx}
178
+ onPress={onRowPress ? () => onRowPress(row) : undefined}
179
+ style={{
180
+ flexDirection: 'row', paddingVertical: 8, paddingHorizontal: 12,
181
+ borderBottomWidth: 1, borderBottomColor: '#f1f5f9',
182
+ opacity: loading ? 0.6 : 1,
183
+ }}
184
+ >
185
+ {columns.map(col => {
186
+ const format = col.format ?? defaultFormat;
187
+ return (
188
+ <Text
189
+ key={col.key}
190
+ style={{ flex: col.flex ?? 1, fontSize: 11, color: '#0f172a' }}
191
+ numberOfLines={2}
192
+ >
193
+ {format(row[col.key])}
194
+ </Text>
195
+ );
196
+ })}
197
+ </Pressable>
198
+ ))}
199
+
200
+ {/* Empty state */}
201
+ {!loading && rows.length === 0 && (
202
+ <View style={{ padding: 24, alignItems: 'center' }}>
203
+ <Text style={{ color: '#9ca3af', fontSize: 12 }}>
204
+ {searchTrimmed ? 'No events match your search' : 'No events found'}
205
+ </Text>
206
+ </View>
207
+ )}
208
+
209
+ {/* Pagination */}
210
+ {total > PAGE_SIZE && (
211
+ <View style={{
212
+ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
213
+ paddingTop: 12, paddingHorizontal: 4,
214
+ }}>
215
+ <Text style={{ fontSize: 11, color: '#6b7280' }}>
216
+ {page * PAGE_SIZE + 1}\u2013{Math.min((page + 1) * PAGE_SIZE, total)} of {total.toLocaleString()}
217
+ </Text>
218
+ <View style={{ flexDirection: 'row', gap: 8 }}>
219
+ <Pressable
220
+ onPress={() => setPage(p => Math.max(0, p - 1))}
221
+ disabled={page === 0}
222
+ style={{
223
+ paddingVertical: 4, paddingHorizontal: 10, borderRadius: 4,
224
+ backgroundColor: page === 0 ? '#f1f5f9' : '#e0e7ff',
225
+ }}
226
+ >
227
+ <Text style={{ fontSize: 11, color: page === 0 ? '#9ca3af' : '#6366f1', fontWeight: '500' }}>
228
+ Prev
229
+ </Text>
230
+ </Pressable>
231
+ <Pressable
232
+ onPress={() => setPage(p => Math.min(totalPages - 1, p + 1))}
233
+ disabled={page >= totalPages - 1}
234
+ style={{
235
+ paddingVertical: 4, paddingHorizontal: 10, borderRadius: 4,
236
+ backgroundColor: page >= totalPages - 1 ? '#f1f5f9' : '#e0e7ff',
237
+ }}
238
+ >
239
+ <Text style={{ fontSize: 11, color: page >= totalPages - 1 ? '#9ca3af' : '#6366f1', fontWeight: '500' }}>
240
+ Next
241
+ </Text>
242
+ </Pressable>
243
+ </View>
244
+ </View>
245
+ )}
246
+ </View>
247
+ );
248
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * StatDials — Config-driven circular dials for dashboard KPIs.
3
+ *
4
+ * Renders N dials from an array of { label, value, color, ratio }.
5
+ * Each dial shows a colored ring proportional to ratio (0-1).
6
+ */
7
+
8
+ import React from 'react';
9
+ import { View, Text } from 'react-native';
10
+
11
+ export interface DialData {
12
+ label: string;
13
+ value: number;
14
+ color: string;
15
+ /** 0-1 ratio for ring fill. undefined = full ring. */
16
+ ratio?: number;
17
+ }
18
+
19
+ export interface StatDialsProps {
20
+ dials: DialData[];
21
+ }
22
+
23
+ function Dial({ label, value, color, ratio }: DialData): React.ReactElement {
24
+ const size = 64;
25
+ const borderWidth = 4;
26
+ const fillPercent = Math.min(1, Math.max(0, ratio ?? 1));
27
+
28
+ return (
29
+ <View style={{ alignItems: 'center', flex: 1 }}>
30
+ <View style={{
31
+ width: size, height: size, borderRadius: size / 2,
32
+ borderWidth,
33
+ borderColor: `${color}20`,
34
+ backgroundColor: '#ffffff',
35
+ alignItems: 'center', justifyContent: 'center',
36
+ }}>
37
+ {/* Overlay ring */}
38
+ <View style={{
39
+ position: 'absolute', top: -borderWidth, left: -borderWidth,
40
+ width: size, height: size, borderRadius: size / 2,
41
+ borderWidth,
42
+ borderLeftColor: fillPercent > 0 ? color : 'transparent',
43
+ borderBottomColor: fillPercent >= 0.25 ? color : 'transparent',
44
+ borderRightColor: fillPercent >= 0.5 ? color : 'transparent',
45
+ borderTopColor: fillPercent >= 0.75 ? color : 'transparent',
46
+ transform: [{ rotate: '-90deg' }],
47
+ }} />
48
+ <Text style={{ color, fontSize: value > 999 ? 14 : 18, fontWeight: '700' }}>
49
+ {value.toLocaleString()}
50
+ </Text>
51
+ </View>
52
+ <Text style={{ marginTop: 6, fontSize: 11, color: '#6b7280', fontWeight: '500' }}>
53
+ {label}
54
+ </Text>
55
+ </View>
56
+ );
57
+ }
58
+
59
+ export function StatDials({ dials }: StatDialsProps): React.ReactElement {
60
+ return (
61
+ <View style={{ flexDirection: 'row', gap: 8, paddingVertical: 12 }}>
62
+ {dials.map((dial) => (
63
+ <Dial key={dial.label} {...dial} />
64
+ ))}
65
+ </View>
66
+ );
67
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * TimeHistogram — Brushable hourly event histogram.
3
+ *
4
+ * Receives pre-aggregated time buckets from the server. Brushing
5
+ * calls onBrush with [startISO, endISO] to update the parent's
6
+ * filter state, which triggers a new server query.
7
+ *
8
+ * Renders bars as react-native Views (no SVG dependency).
9
+ */
10
+
11
+ import React, { useMemo } from 'react';
12
+ import { View, Text } from 'react-native';
13
+ import { Brush } from './Brush';
14
+
15
+ export interface TimeHistogramProps {
16
+ data: { bucket: string; count: number }[];
17
+ width: number;
18
+ height?: number;
19
+ title?: string;
20
+ onBrush: (extent: [string, string] | null) => void;
21
+ }
22
+
23
+ export function TimeHistogram({ data, width, height = 120, title, onBrush }: TimeHistogramProps): React.ReactElement {
24
+ const { bins, maxCount, xScale, barWidth } = useMemo(() => {
25
+ if (!data || data.length === 0) {
26
+ return { bins: [] as { bucket: string; ts: number; count: number }[], maxCount: 0, xScale: null, barWidth: 0 };
27
+ }
28
+
29
+ const sorted = data
30
+ .map(d => ({ bucket: d.bucket, ts: new Date(d.bucket).getTime(), count: d.count }))
31
+ .filter(d => d.count > 0 && !isNaN(d.ts))
32
+ .sort((a, b) => a.ts - b.ts);
33
+
34
+ if (sorted.length === 0) {
35
+ return { bins: [] as { bucket: string; ts: number; count: number }[], maxCount: 0, xScale: null, barWidth: 0 };
36
+ }
37
+
38
+ const minTime = sorted[0].ts;
39
+ const maxTime = sorted[sorted.length - 1].ts;
40
+ const mc = Math.max(...sorted.map(e => e.count), 1);
41
+
42
+ try {
43
+ const { scaleLinear } = require('d3-scale');
44
+ const padding = 16;
45
+ const chartWidth = width - padding * 2;
46
+ const scale = scaleLinear()
47
+ .domain([minTime, maxTime + 3_600_000])
48
+ .range([padding, padding + chartWidth]);
49
+ const bw = Math.max(2, chartWidth / Math.max(sorted.length, 1) - 1);
50
+ return { bins: sorted, maxCount: mc, xScale: scale, barWidth: bw };
51
+ } catch {
52
+ return { bins: sorted, maxCount: mc, xScale: null, barWidth: 0 };
53
+ }
54
+ }, [data, width]);
55
+
56
+ if (bins.length === 0) {
57
+ return (
58
+ <View style={{ height, justifyContent: 'center', alignItems: 'center' }}>
59
+ <Text style={{ color: '#9ca3af', fontSize: 12 }}>No event data</Text>
60
+ </View>
61
+ );
62
+ }
63
+
64
+ const barHeight = height - 32;
65
+
66
+ const handleBrush = (extent: [unknown, unknown] | null) => {
67
+ if (!extent) {
68
+ onBrush(null);
69
+ return;
70
+ }
71
+ // Convert epoch ms back to ISO strings for the server
72
+ const start = new Date(extent[0] as number).toISOString();
73
+ const end = new Date(extent[1] as number).toISOString();
74
+ onBrush([start, end]);
75
+ };
76
+
77
+ // Format time labels
78
+ const firstTime = new Date(bins[0].ts);
79
+ const lastTime = new Date(bins[bins.length - 1].ts);
80
+ const formatTime = (d: Date) => {
81
+ try {
82
+ const { timeFormat } = require('d3-time-format');
83
+ return timeFormat('%b %d %H:%M')(d);
84
+ } catch {
85
+ return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
86
+ }
87
+ };
88
+
89
+ return (
90
+ <View style={{ marginTop: 16 }}>
91
+ <Text style={{ fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 8 }}>
92
+ {title ?? 'Events Over Time'}
93
+ </Text>
94
+ <View style={{ position: 'relative', height, width }}>
95
+ {/* Bars */}
96
+ {xScale ? (
97
+ <View style={{ position: 'absolute', top: 0, left: 0, width, height: barHeight }}>
98
+ {bins.map((bin) => {
99
+ const x = xScale(bin.ts);
100
+ const h = Math.max(2, (bin.count / maxCount) * barHeight);
101
+ return (
102
+ <View
103
+ key={bin.bucket}
104
+ style={{
105
+ position: 'absolute',
106
+ left: x,
107
+ bottom: 0,
108
+ width: barWidth,
109
+ height: h,
110
+ backgroundColor: bin.count > maxCount * 0.7 ? '#ef4444' : '#6366f1',
111
+ borderRadius: 2,
112
+ }}
113
+ />
114
+ );
115
+ })}
116
+ </View>
117
+ ) : (
118
+ <View style={{
119
+ flexDirection: 'row', alignItems: 'flex-end', height: barHeight, gap: 1,
120
+ paddingHorizontal: 16,
121
+ }}>
122
+ {bins.map((bin) => (
123
+ <View
124
+ key={bin.bucket}
125
+ style={{
126
+ flex: 1,
127
+ height: Math.max(2, (bin.count / maxCount) * barHeight),
128
+ backgroundColor: bin.count > maxCount * 0.7 ? '#ef4444' : '#6366f1',
129
+ borderRadius: 2,
130
+ }}
131
+ />
132
+ ))}
133
+ </View>
134
+ )}
135
+
136
+ {/* Brush overlay */}
137
+ {xScale && (
138
+ <Brush
139
+ xScale={xScale}
140
+ width={width}
141
+ height={barHeight}
142
+ onBrush={handleBrush}
143
+ />
144
+ )}
145
+
146
+ {/* Time labels */}
147
+ <View style={{
148
+ flexDirection: 'row', justifyContent: 'space-between',
149
+ paddingHorizontal: 16, marginTop: 4,
150
+ position: 'absolute', bottom: 0, left: 0, right: 0,
151
+ }}>
152
+ <Text style={{ fontSize: 10, color: '#9ca3af' }}>{formatTime(firstTime)}</Text>
153
+ <Text style={{ fontSize: 10, color: '#9ca3af' }}>{formatTime(lastTime)}</Text>
154
+ </View>
155
+ </View>
156
+ </View>
157
+ );
158
+ }
package/src/context.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * createAdminContext
3
+ *
4
+ * Creates a RequireContext-compatible object for expo-router's virtual routing.
5
+ * This allows the admin package to define routes without filesystem files.
6
+ *
7
+ * Based on expo-router's testing-library implementation of inMemoryContext.
8
+ */
9
+
10
+ import type { AdminConfig } from './types';
11
+ import React from 'react';
12
+ import { AdminLayout } from './screens/AdminLayout';
13
+ import { AdminDashboard } from './screens/AdminDashboard';
14
+ import { ResourceList } from './screens/ResourceList';
15
+ import { ResourceShow } from './screens/ResourceShow';
16
+ import { ResourceCreate } from './screens/ResourceCreate';
17
+ import { ResourceEdit } from './screens/ResourceEdit';
18
+
19
+ // =============================================================================
20
+ // RequireContext Types
21
+ // =============================================================================
22
+
23
+ /**
24
+ * Webpack/Metro RequireContext interface
25
+ * This is what expo-router expects for route resolution
26
+ */
27
+ export interface RequireContext {
28
+ (id: string): { default: React.ComponentType<unknown> } | undefined;
29
+ keys(): string[];
30
+ resolve(id: string): string;
31
+ id: string;
32
+ }
33
+
34
+ // =============================================================================
35
+ // Context Creation
36
+ // =============================================================================
37
+
38
+ /**
39
+ * Creates a RequireContext from an AdminConfig
40
+ *
41
+ * The context generates routes for:
42
+ * - _layout.js - Main admin layout
43
+ * - index.js - Dashboard
44
+ * - [resource]/index.js - Resource list (dynamic route)
45
+ * - [resource]/[id].js - Resource show (dynamic route)
46
+ * - [resource]/create.js - Resource create
47
+ * - [resource]/[id]/edit.js - Resource edit
48
+ */
49
+ export function createAdminContext(config: AdminConfig): RequireContext {
50
+ // Build the module map
51
+ const modules: Record<string, { default: React.ComponentType<unknown> }> = {
52
+ './_layout.js': { default: AdminLayout as React.ComponentType<unknown> },
53
+ './index.js': { default: AdminDashboard as React.ComponentType<unknown> },
54
+ './[resource]/index.js': { default: ResourceList as React.ComponentType<unknown> },
55
+ './[resource]/[id].js': { default: ResourceShow as React.ComponentType<unknown> },
56
+ './[resource]/create.js': { default: ResourceCreate as React.ComponentType<unknown> },
57
+ './[resource]/[id]/edit.js': { default: ResourceEdit as React.ComponentType<unknown> },
58
+ };
59
+
60
+ // Create the context function
61
+ const context = ((id: string) => {
62
+ return modules[id];
63
+ }) as RequireContext;
64
+
65
+ // Add keys() - returns all module paths
66
+ context.keys = () => Object.keys(modules);
67
+
68
+ // Add resolve() - returns the resolved path
69
+ context.resolve = (id: string) => id;
70
+
71
+ // Add id - unique identifier for this context
72
+ context.id = `admin-context-${config.basePath || '/admin'}`;
73
+
74
+ return context;
75
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * useAdminConfig hook
3
+ *
4
+ * Provides access to the admin configuration from context.
5
+ */
6
+
7
+ import { createContext, useContext } from 'react';
8
+ import type { AdminConfig } from '../types';
9
+
10
+ export interface AdminConfigContextValue {
11
+ config: AdminConfig;
12
+ }
13
+
14
+ export const AdminConfigContext = createContext<AdminConfigContextValue | null>(null);
15
+
16
+ export function useAdminConfig(): AdminConfig {
17
+ const context = useContext(AdminConfigContext);
18
+ if (!context) {
19
+ throw new Error('useAdminConfig must be used within an AdminConfigProvider');
20
+ }
21
+ return context.config;
22
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * useAdminData hook
3
+ *
4
+ * Provides data fetching capabilities using the configured DataAdapter.
5
+ */
6
+
7
+ import { createContext, useContext, useState, useEffect, useCallback } from 'react';
8
+ import type { DataAdapter } from '../adapters/types';
9
+ import type { ListParams, DataRecord } from '../types';
10
+
11
+ // =============================================================================
12
+ // Context
13
+ // =============================================================================
14
+
15
+ export interface DataAdapterContextValue {
16
+ adapter: DataAdapter;
17
+ }
18
+
19
+ export const DataAdapterContext = createContext<DataAdapterContextValue | null>(null);
20
+
21
+ export function useDataAdapter(): DataAdapter {
22
+ const context = useContext(DataAdapterContext);
23
+ if (!context) {
24
+ throw new Error('useDataAdapter must be used within a DataAdapterProvider');
25
+ }
26
+ return context.adapter;
27
+ }
28
+
29
+ // =============================================================================
30
+ // useAdminData Hook
31
+ // =============================================================================
32
+
33
+ export interface UseAdminDataOptions extends ListParams {
34
+ /** Skip initial fetch */
35
+ skip?: boolean;
36
+ }
37
+
38
+ export interface UseAdminDataResult<T extends DataRecord = DataRecord> {
39
+ /** Fetched data */
40
+ data: T[];
41
+ /** Total record count */
42
+ total: number;
43
+ /** Loading state */
44
+ loading: boolean;
45
+ /** Error state */
46
+ error: Error | null;
47
+ /** Refetch data */
48
+ refetch: () => Promise<void>;
49
+ /** Current page */
50
+ page: number;
51
+ /** Items per page */
52
+ perPage: number;
53
+ /** Set page */
54
+ setPage: (page: number) => void;
55
+ /** Set items per page */
56
+ setPerPage: (perPage: number) => void;
57
+ /** Set sort */
58
+ setSort: (field: string, order?: 'asc' | 'desc') => void;
59
+ /** Set filter */
60
+ setFilter: (filter: Record<string, unknown>) => void;
61
+ }
62
+
63
+ export function useAdminData<T extends DataRecord = DataRecord>(
64
+ resource: string,
65
+ options: UseAdminDataOptions = {}
66
+ ): UseAdminDataResult<T> {
67
+ const adapter = useDataAdapter();
68
+ const [data, setData] = useState<T[]>([]);
69
+ const [total, setTotal] = useState(0);
70
+ const [loading, setLoading] = useState(!options.skip);
71
+ const [error, setError] = useState<Error | null>(null);
72
+
73
+ // Params state
74
+ const [page, setPage] = useState(options.page ?? 1);
75
+ const [perPage, setPerPage] = useState(options.perPage ?? 10);
76
+ const [sort, setSortState] = useState(options.sort);
77
+ const [order, setOrder] = useState(options.order ?? 'asc');
78
+ const [filter, setFilterState] = useState(options.filter ?? {});
79
+
80
+ const fetchData = useCallback(async () => {
81
+ setLoading(true);
82
+ setError(null);
83
+
84
+ try {
85
+ const result = await adapter.fetchList<T>(resource, {
86
+ page,
87
+ perPage,
88
+ sort,
89
+ order,
90
+ filter,
91
+ search: options.search,
92
+ });
93
+
94
+ setData(result.data);
95
+ setTotal(result.total);
96
+ } catch (err) {
97
+ setError(err instanceof Error ? err : new Error(String(err)));
98
+ } finally {
99
+ setLoading(false);
100
+ }
101
+ }, [adapter, resource, page, perPage, sort, order, filter, options.search]);
102
+
103
+ useEffect(() => {
104
+ if (!options.skip) {
105
+ fetchData();
106
+ }
107
+ }, [fetchData, options.skip]);
108
+
109
+ const setSort = useCallback((field: string, newOrder?: 'asc' | 'desc') => {
110
+ setSortState(field);
111
+ setOrder(newOrder ?? 'asc');
112
+ }, []);
113
+
114
+ const setFilter = useCallback((newFilter: Record<string, unknown>) => {
115
+ setFilterState(newFilter);
116
+ setPage(1); // Reset to first page when filter changes
117
+ }, []);
118
+
119
+ return {
120
+ data,
121
+ total,
122
+ loading,
123
+ error,
124
+ refetch: fetchData,
125
+ page,
126
+ perPage,
127
+ setPage,
128
+ setPerPage,
129
+ setSort,
130
+ setFilter,
131
+ };
132
+ }