@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.
- package/README.md +282 -0
- package/package.json +53 -0
- package/src/AdminRoot.tsx +34 -0
- package/src/adapters/EverystackAdapter.ts +218 -0
- package/src/adapters/types.ts +78 -0
- package/src/components/AvatarFieldInput.tsx +100 -0
- package/src/components/ImageDisplay.tsx +57 -0
- package/src/components/ImageFieldInput.tsx +140 -0
- package/src/components/dashboard/Brush.tsx +139 -0
- package/src/components/dashboard/DimensionBars.tsx +67 -0
- package/src/components/dashboard/DimensionDonut.tsx +98 -0
- package/src/components/dashboard/EventTable.tsx +248 -0
- package/src/components/dashboard/StatDials.tsx +67 -0
- package/src/components/dashboard/TimeHistogram.tsx +158 -0
- package/src/context.ts +75 -0
- package/src/hooks/useAdminConfig.ts +22 -0
- package/src/hooks/useAdminData.ts +132 -0
- package/src/hooks/useAggregates.ts +290 -0
- package/src/hooks/useRpc.ts +124 -0
- package/src/hooks/useStorageConfig.ts +16 -0
- package/src/index.ts +138 -0
- package/src/presets/twitter.ts +267 -0
- package/src/providers/AdminConfigProvider.tsx +18 -0
- package/src/providers/DataAdapterProvider.tsx +18 -0
- package/src/rpc/resource-dashboard-rpc.ts +416 -0
- package/src/screens/AdminDashboard.tsx +217 -0
- package/src/screens/AdminLayout.tsx +97 -0
- package/src/screens/ResourceCreate.tsx +243 -0
- package/src/screens/ResourceDashboard.tsx +278 -0
- package/src/screens/ResourceEdit.tsx +283 -0
- package/src/screens/ResourceList.tsx +153 -0
- package/src/screens/ResourceShow.tsx +171 -0
- package/src/screens/index.ts +14 -0
- package/src/types.ts +358 -0
- package/src/utils/naming.ts +105 -0
- package/src/utils/pick-image-web.ts +81 -0
- package/src/utils/resolve-image-url.ts +43 -0
- 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
|
+
}
|