@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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResourceEdit
|
|
3
|
+
*
|
|
4
|
+
* Edit form for an existing resource record.
|
|
5
|
+
* Pre-populates form with existing data, submits via adapter.update().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
9
|
+
import { View, Text, ScrollView } from 'react-native';
|
|
10
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
|
+
import { useDataAdapter } from '../hooks/useAdminData';
|
|
12
|
+
import { FormField, TextInput, SelectInput, SwitchInput, Button, LoadingState, ErrorState } from '@everystack/ui';
|
|
13
|
+
import { ImageFieldInput } from '../components/ImageFieldInput';
|
|
14
|
+
import { AvatarFieldInput } from '../components/AvatarFieldInput';
|
|
15
|
+
import type { FieldDefinition, DataRecord } from '../types';
|
|
16
|
+
|
|
17
|
+
export interface ResourceEditProps {
|
|
18
|
+
/** Resource name */
|
|
19
|
+
resource: string;
|
|
20
|
+
/** Record ID */
|
|
21
|
+
id: string | number;
|
|
22
|
+
/** Called after successful update */
|
|
23
|
+
onSuccess?: (record: Record<string, unknown>) => void;
|
|
24
|
+
/** Called when the cancel button is pressed */
|
|
25
|
+
onCancel?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEditProps): React.ReactElement {
|
|
29
|
+
const config = useAdminConfig();
|
|
30
|
+
const adapter = useDataAdapter();
|
|
31
|
+
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
32
|
+
|
|
33
|
+
const [record, setRecord] = useState<DataRecord | null>(null);
|
|
34
|
+
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
35
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
36
|
+
const [loading, setLoading] = useState(true);
|
|
37
|
+
const [submitting, setSubmitting] = useState(false);
|
|
38
|
+
const [fetchError, setFetchError] = useState<Error | null>(null);
|
|
39
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
const fields = useMemo(() => {
|
|
42
|
+
if (!resourceConfig) return [];
|
|
43
|
+
const fieldNames = resourceConfig.editFields ?? resourceConfig.fields.map(f => f.name);
|
|
44
|
+
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
45
|
+
return fieldNames
|
|
46
|
+
.map(name => fieldMap.get(name))
|
|
47
|
+
.filter((f): f is FieldDefinition => f != null)
|
|
48
|
+
.filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
|
|
49
|
+
}, [resourceConfig]);
|
|
50
|
+
|
|
51
|
+
// Fetch existing record
|
|
52
|
+
const fetchRecord = useCallback(async () => {
|
|
53
|
+
setLoading(true);
|
|
54
|
+
setFetchError(null);
|
|
55
|
+
try {
|
|
56
|
+
const result = await adapter.fetchOne(resource, id);
|
|
57
|
+
setRecord(result);
|
|
58
|
+
// Pre-populate form data with existing values
|
|
59
|
+
const initial: Record<string, unknown> = {};
|
|
60
|
+
for (const field of fields) {
|
|
61
|
+
initial[field.name] = result[field.name];
|
|
62
|
+
}
|
|
63
|
+
setFormData(initial);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setFetchError(err instanceof Error ? err : new Error(String(err)));
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
}, [adapter, resource, id, fields]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
fetchRecord();
|
|
73
|
+
}, [fetchRecord]);
|
|
74
|
+
|
|
75
|
+
const setFieldValue = useCallback((name: string, value: unknown) => {
|
|
76
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
77
|
+
setErrors(prev => {
|
|
78
|
+
const next = { ...prev };
|
|
79
|
+
delete next[name];
|
|
80
|
+
return next;
|
|
81
|
+
});
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const validate = useCallback((): boolean => {
|
|
85
|
+
const newErrors: Record<string, string> = {};
|
|
86
|
+
for (const field of fields) {
|
|
87
|
+
const value = formData[field.name];
|
|
88
|
+
if (field.validation?.required && (value == null || value === '')) {
|
|
89
|
+
newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
setErrors(newErrors);
|
|
93
|
+
return Object.keys(newErrors).length === 0;
|
|
94
|
+
}, [fields, formData]);
|
|
95
|
+
|
|
96
|
+
const handleSubmit = useCallback(async () => {
|
|
97
|
+
if (!validate()) return;
|
|
98
|
+
|
|
99
|
+
setSubmitting(true);
|
|
100
|
+
setSubmitError(null);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const updated = await adapter.update(resource, id, formData);
|
|
104
|
+
onSuccess?.(updated);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
setSubmitError(err instanceof Error ? err.message : String(err));
|
|
107
|
+
} finally {
|
|
108
|
+
setSubmitting(false);
|
|
109
|
+
}
|
|
110
|
+
}, [adapter, resource, id, formData, validate, onSuccess]);
|
|
111
|
+
|
|
112
|
+
if (!resourceConfig) {
|
|
113
|
+
return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (loading) {
|
|
117
|
+
return <LoadingState message="Loading record..." />;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (fetchError) {
|
|
121
|
+
return <ErrorState message={fetchError.message} onRetry={fetchRecord} />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!record) {
|
|
125
|
+
return <ErrorState message="Record not found" />;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const label = resourceConfig.label ?? resourceConfig.name;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<ScrollView className="flex-1">
|
|
132
|
+
{/* Header */}
|
|
133
|
+
<View className="flex-row items-center justify-between px-4 py-4">
|
|
134
|
+
<Text className="text-xl font-bold text-gray-900">
|
|
135
|
+
Edit {label} #{String(id)}
|
|
136
|
+
</Text>
|
|
137
|
+
<View className="flex-row gap-2">
|
|
138
|
+
{onCancel && (
|
|
139
|
+
<Button label="Cancel" variant="ghost" onPress={onCancel} />
|
|
140
|
+
)}
|
|
141
|
+
<Button label="Save" onPress={handleSubmit} loading={submitting} />
|
|
142
|
+
</View>
|
|
143
|
+
</View>
|
|
144
|
+
|
|
145
|
+
{/* Error */}
|
|
146
|
+
{submitError && (
|
|
147
|
+
<View className="mx-4 mb-4 bg-red-50 border border-red-200 rounded-lg px-4 py-3">
|
|
148
|
+
<Text className="text-sm text-red-700">{submitError}</Text>
|
|
149
|
+
</View>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Form fields */}
|
|
153
|
+
<View className="px-4">
|
|
154
|
+
{fields.map(field => (
|
|
155
|
+
<EditFieldInput
|
|
156
|
+
key={field.name}
|
|
157
|
+
field={field}
|
|
158
|
+
value={formData[field.name]}
|
|
159
|
+
error={errors[field.name]}
|
|
160
|
+
onChange={value => setFieldValue(field.name, value)}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</View>
|
|
164
|
+
</ScrollView>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// =============================================================================
|
|
169
|
+
// EditFieldInput — maps FieldDefinition.type to input component
|
|
170
|
+
// =============================================================================
|
|
171
|
+
|
|
172
|
+
interface EditFieldInputProps {
|
|
173
|
+
field: FieldDefinition;
|
|
174
|
+
value: unknown;
|
|
175
|
+
error?: string;
|
|
176
|
+
onChange: (value: unknown) => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function EditFieldInput({ field, value, error, onChange }: EditFieldInputProps): React.ReactElement {
|
|
180
|
+
const label = field.label ?? field.name;
|
|
181
|
+
const required = field.validation?.required;
|
|
182
|
+
|
|
183
|
+
switch (field.type) {
|
|
184
|
+
case 'boolean':
|
|
185
|
+
return (
|
|
186
|
+
<FormField label={label} error={error} required={required}>
|
|
187
|
+
<SwitchInput value={Boolean(value)} onValueChange={onChange} />
|
|
188
|
+
</FormField>
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
case 'enum':
|
|
192
|
+
return (
|
|
193
|
+
<FormField label={label} error={error} required={required}>
|
|
194
|
+
<SelectInput
|
|
195
|
+
value={String(value ?? '')}
|
|
196
|
+
options={(field.enumValues ?? []).map(v => ({ label: v, value: v }))}
|
|
197
|
+
onValueChange={onChange}
|
|
198
|
+
placeholder={`Select ${label.toLowerCase()}`}
|
|
199
|
+
/>
|
|
200
|
+
</FormField>
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
case 'number':
|
|
204
|
+
return (
|
|
205
|
+
<FormField label={label} error={error} required={required}>
|
|
206
|
+
<TextInput
|
|
207
|
+
value={value != null ? String(value) : ''}
|
|
208
|
+
onChangeText={text => onChange(text ? Number(text) : null)}
|
|
209
|
+
keyboardType="numeric"
|
|
210
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
211
|
+
/>
|
|
212
|
+
</FormField>
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
case 'email':
|
|
216
|
+
return (
|
|
217
|
+
<FormField label={label} error={error} required={required}>
|
|
218
|
+
<TextInput
|
|
219
|
+
value={String(value ?? '')}
|
|
220
|
+
onChangeText={onChange}
|
|
221
|
+
keyboardType="email-address"
|
|
222
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
223
|
+
/>
|
|
224
|
+
</FormField>
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
case 'url':
|
|
228
|
+
return (
|
|
229
|
+
<FormField label={label} error={error} required={required}>
|
|
230
|
+
<TextInput
|
|
231
|
+
value={String(value ?? '')}
|
|
232
|
+
onChangeText={onChange}
|
|
233
|
+
keyboardType="url"
|
|
234
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
235
|
+
/>
|
|
236
|
+
</FormField>
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
case 'json':
|
|
240
|
+
return (
|
|
241
|
+
<FormField label={label} error={error} required={required}>
|
|
242
|
+
<TextInput
|
|
243
|
+
value={typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2)}
|
|
244
|
+
onChangeText={text => {
|
|
245
|
+
try { onChange(JSON.parse(text)); } catch { onChange(text); }
|
|
246
|
+
}}
|
|
247
|
+
multiline
|
|
248
|
+
placeholder="{}"
|
|
249
|
+
/>
|
|
250
|
+
</FormField>
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
case 'image':
|
|
254
|
+
if (field.renderer === 'avatar') {
|
|
255
|
+
return <AvatarFieldInput field={field} value={value} error={error} onChange={onChange} />;
|
|
256
|
+
}
|
|
257
|
+
return <ImageFieldInput field={field} value={value} error={error} onChange={onChange} />;
|
|
258
|
+
|
|
259
|
+
case 'file':
|
|
260
|
+
return (
|
|
261
|
+
<FormField label={label} error={error} required={required}>
|
|
262
|
+
<TextInput
|
|
263
|
+
value={String(value ?? '')}
|
|
264
|
+
onChangeText={onChange}
|
|
265
|
+
keyboardType="url"
|
|
266
|
+
placeholder="Enter file URL"
|
|
267
|
+
/>
|
|
268
|
+
</FormField>
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
default:
|
|
272
|
+
return (
|
|
273
|
+
<FormField label={label} error={error} required={required}>
|
|
274
|
+
<TextInput
|
|
275
|
+
value={String(value ?? '')}
|
|
276
|
+
onChangeText={onChange}
|
|
277
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
278
|
+
multiline={field.type === 'string' && (field.validation?.max ?? 0) > 255}
|
|
279
|
+
/>
|
|
280
|
+
</FormField>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResourceList
|
|
3
|
+
*
|
|
4
|
+
* List view for a resource with sortable table, search/filter, and pagination.
|
|
5
|
+
* Wires useAdminData() to DataTable, FilterBar, and PaginationBar components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useCallback, useMemo } from 'react';
|
|
9
|
+
import { View, Text } from 'react-native';
|
|
10
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
|
+
import { useAdminData } from '../hooks/useAdminData';
|
|
12
|
+
import { DataTable, FilterBar, PaginationBar, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
|
|
13
|
+
import type { ResourceConfig, FieldDefinition } from '../types';
|
|
14
|
+
|
|
15
|
+
export interface ResourceListProps {
|
|
16
|
+
/** Resource name (matches ResourceConfig.name) */
|
|
17
|
+
resource: string;
|
|
18
|
+
/** Called when a row is pressed */
|
|
19
|
+
onRowPress?: (record: Record<string, unknown>) => void;
|
|
20
|
+
/** Called when the create button is pressed */
|
|
21
|
+
onCreatePress?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceListProps): React.ReactElement {
|
|
25
|
+
const config = useAdminConfig();
|
|
26
|
+
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
27
|
+
|
|
28
|
+
const defaultSort = resourceConfig?.defaultSort;
|
|
29
|
+
const {
|
|
30
|
+
data,
|
|
31
|
+
total,
|
|
32
|
+
loading,
|
|
33
|
+
error,
|
|
34
|
+
refetch,
|
|
35
|
+
page,
|
|
36
|
+
perPage,
|
|
37
|
+
setPage,
|
|
38
|
+
setSort,
|
|
39
|
+
setFilter,
|
|
40
|
+
} = useAdminData(resource, {
|
|
41
|
+
sort: defaultSort?.field,
|
|
42
|
+
order: defaultSort?.order,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Build columns from field definitions
|
|
46
|
+
const columns = useMemo(() => {
|
|
47
|
+
if (!resourceConfig) return [];
|
|
48
|
+
return fieldsToColumns(resourceConfig, defaultSort?.field, defaultSort?.order);
|
|
49
|
+
}, [resourceConfig, defaultSort?.field, defaultSort?.order]);
|
|
50
|
+
|
|
51
|
+
// Build filter chips from filterable fields
|
|
52
|
+
const filterChips = useMemo(() => {
|
|
53
|
+
if (!resourceConfig) return [];
|
|
54
|
+
return resourceConfig.fields
|
|
55
|
+
.filter(f => f.filterable)
|
|
56
|
+
.map(f => ({ key: f.name, label: f.label ?? f.name }));
|
|
57
|
+
}, [resourceConfig]);
|
|
58
|
+
|
|
59
|
+
const handleSort = useCallback((key: string) => {
|
|
60
|
+
setSort(key, 'asc');
|
|
61
|
+
}, [setSort]);
|
|
62
|
+
|
|
63
|
+
const handleSearch = useCallback((query: string) => {
|
|
64
|
+
if (!query) {
|
|
65
|
+
setFilter({});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Search across searchable fields using ilike
|
|
69
|
+
const searchableFields = resourceConfig?.fields.filter(f => f.searchable) ?? [];
|
|
70
|
+
if (searchableFields.length > 0) {
|
|
71
|
+
setFilter({ [searchableFields[0].name]: `ilike.*${query}*` });
|
|
72
|
+
}
|
|
73
|
+
}, [resourceConfig, setFilter]);
|
|
74
|
+
|
|
75
|
+
if (!resourceConfig) {
|
|
76
|
+
return (
|
|
77
|
+
<ErrorState message={`Resource "${resource}" not found in admin configuration`} />
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const label = resourceConfig.labelPlural ?? resourceConfig.label ?? resourceConfig.name;
|
|
82
|
+
const canCreate = resourceConfig.actions?.create !== false;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<View className="flex-1">
|
|
86
|
+
{/* Header */}
|
|
87
|
+
<View className="flex-row items-center justify-between px-4 py-4">
|
|
88
|
+
<Text className="text-xl font-bold text-gray-900">{label}</Text>
|
|
89
|
+
{canCreate && onCreatePress && (
|
|
90
|
+
<Button label="Create" onPress={onCreatePress} />
|
|
91
|
+
)}
|
|
92
|
+
</View>
|
|
93
|
+
|
|
94
|
+
{/* Filter bar */}
|
|
95
|
+
<FilterBar
|
|
96
|
+
placeholder={`Search ${label.toLowerCase()}...`}
|
|
97
|
+
onSearch={handleSearch}
|
|
98
|
+
filters={filterChips}
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
{/* Content */}
|
|
102
|
+
{loading ? (
|
|
103
|
+
<LoadingState message={`Loading ${label.toLowerCase()}...`} />
|
|
104
|
+
) : error ? (
|
|
105
|
+
<ErrorState message={error.message} onRetry={refetch} />
|
|
106
|
+
) : data.length === 0 ? (
|
|
107
|
+
<EmptyState
|
|
108
|
+
title={`No ${label.toLowerCase()}`}
|
|
109
|
+
message={`No ${label.toLowerCase()} found. ${canCreate ? 'Create one to get started.' : ''}`}
|
|
110
|
+
/>
|
|
111
|
+
) : (
|
|
112
|
+
<DataTable
|
|
113
|
+
columns={columns}
|
|
114
|
+
data={data}
|
|
115
|
+
onSort={handleSort}
|
|
116
|
+
onRowPress={onRowPress}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Pagination */}
|
|
121
|
+
{total > 0 && (
|
|
122
|
+
<PaginationBar
|
|
123
|
+
page={page}
|
|
124
|
+
perPage={perPage}
|
|
125
|
+
total={total}
|
|
126
|
+
onPageChange={setPage}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// Helpers
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
function fieldsToColumns(
|
|
138
|
+
resourceConfig: ResourceConfig,
|
|
139
|
+
_sortField?: string,
|
|
140
|
+
_sortOrder?: 'asc' | 'desc'
|
|
141
|
+
): DataTableColumn[] {
|
|
142
|
+
const fieldNames = resourceConfig.listFields ?? resourceConfig.fields.map(f => f.name);
|
|
143
|
+
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
144
|
+
|
|
145
|
+
return fieldNames
|
|
146
|
+
.map(name => fieldMap.get(name))
|
|
147
|
+
.filter((f): f is FieldDefinition => f != null)
|
|
148
|
+
.map(field => ({
|
|
149
|
+
key: field.name,
|
|
150
|
+
label: field.label ?? field.name,
|
|
151
|
+
sortable: field.sortable,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResourceShow
|
|
3
|
+
*
|
|
4
|
+
* Detail view for a single resource record.
|
|
5
|
+
* Fetches via adapter.fetchOne() and displays field values.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
9
|
+
import { View, Text, ScrollView } from 'react-native';
|
|
10
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
|
+
import { useDataAdapter } from '../hooks/useAdminData';
|
|
12
|
+
import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
|
|
13
|
+
import { ImageDisplay } from '../components/ImageDisplay';
|
|
14
|
+
import type { FieldDefinition, DataRecord } from '../types';
|
|
15
|
+
|
|
16
|
+
export interface ResourceShowProps {
|
|
17
|
+
/** Resource name */
|
|
18
|
+
resource: string;
|
|
19
|
+
/** Record ID */
|
|
20
|
+
id: string | number;
|
|
21
|
+
/** Called when the edit button is pressed */
|
|
22
|
+
onEditPress?: () => void;
|
|
23
|
+
/** Called when the delete button is pressed */
|
|
24
|
+
onDeletePress?: () => void;
|
|
25
|
+
/** Called when the back/list button is pressed */
|
|
26
|
+
onBackPress?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ResourceShow({
|
|
30
|
+
resource,
|
|
31
|
+
id,
|
|
32
|
+
onEditPress,
|
|
33
|
+
onDeletePress,
|
|
34
|
+
onBackPress,
|
|
35
|
+
}: ResourceShowProps): React.ReactElement {
|
|
36
|
+
const config = useAdminConfig();
|
|
37
|
+
const adapter = useDataAdapter();
|
|
38
|
+
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
39
|
+
|
|
40
|
+
const [record, setRecord] = useState<DataRecord | null>(null);
|
|
41
|
+
const [loading, setLoading] = useState(true);
|
|
42
|
+
const [error, setError] = useState<Error | null>(null);
|
|
43
|
+
|
|
44
|
+
const fetchRecord = useCallback(async () => {
|
|
45
|
+
setLoading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
try {
|
|
48
|
+
const result = await adapter.fetchOne(resource, id);
|
|
49
|
+
setRecord(result);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
52
|
+
} finally {
|
|
53
|
+
setLoading(false);
|
|
54
|
+
}
|
|
55
|
+
}, [adapter, resource, id]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
fetchRecord();
|
|
59
|
+
}, [fetchRecord]);
|
|
60
|
+
|
|
61
|
+
if (!resourceConfig) {
|
|
62
|
+
return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (loading) {
|
|
66
|
+
return <LoadingState message="Loading record..." />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (error) {
|
|
70
|
+
return <ErrorState message={error.message} onRetry={fetchRecord} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!record) {
|
|
74
|
+
return <ErrorState message="Record not found" />;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const label = resourceConfig.label ?? resourceConfig.name;
|
|
78
|
+
const showFields = resourceConfig.showFields ?? resourceConfig.fields.map(f => f.name);
|
|
79
|
+
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
80
|
+
const canEdit = resourceConfig.actions?.edit !== false;
|
|
81
|
+
const canDelete = resourceConfig.actions?.delete !== false;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<ScrollView className="flex-1">
|
|
85
|
+
{/* Header */}
|
|
86
|
+
<View className="flex-row items-center justify-between px-4 py-4">
|
|
87
|
+
<View className="flex-row items-center">
|
|
88
|
+
{onBackPress && (
|
|
89
|
+
<Button label="\u2190 Back" variant="ghost" onPress={onBackPress} style={{ marginRight: 8 }} />
|
|
90
|
+
)}
|
|
91
|
+
<Text className="text-xl font-bold text-gray-900">
|
|
92
|
+
{label} #{String(id)}
|
|
93
|
+
</Text>
|
|
94
|
+
</View>
|
|
95
|
+
<View className="flex-row gap-2">
|
|
96
|
+
{canEdit && onEditPress && (
|
|
97
|
+
<Button label="Edit" variant="outline" onPress={onEditPress} />
|
|
98
|
+
)}
|
|
99
|
+
{canDelete && onDeletePress && (
|
|
100
|
+
<Button label="Delete" variant="destructive" onPress={onDeletePress} />
|
|
101
|
+
)}
|
|
102
|
+
</View>
|
|
103
|
+
</View>
|
|
104
|
+
|
|
105
|
+
{/* Fields */}
|
|
106
|
+
<Card style={{ marginHorizontal: 16, marginBottom: 16 }}>
|
|
107
|
+
{showFields.map((fieldName, idx) => {
|
|
108
|
+
const fieldDef = fieldMap.get(fieldName);
|
|
109
|
+
if (!fieldDef) return null;
|
|
110
|
+
const value = record[fieldName];
|
|
111
|
+
return (
|
|
112
|
+
<React.Fragment key={fieldName}>
|
|
113
|
+
{idx > 0 && <Divider />}
|
|
114
|
+
<FieldRow field={fieldDef} value={value} />
|
|
115
|
+
</React.Fragment>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</Card>
|
|
119
|
+
</ScrollView>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// FieldRow
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
interface FieldRowProps {
|
|
128
|
+
field: FieldDefinition;
|
|
129
|
+
value: unknown;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function FieldRow({ field, value }: FieldRowProps): React.ReactElement {
|
|
133
|
+
return (
|
|
134
|
+
<View className="py-3">
|
|
135
|
+
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
|
|
136
|
+
{field.label ?? field.name}
|
|
137
|
+
</Text>
|
|
138
|
+
{field.type === 'image' ? (
|
|
139
|
+
<ImageDisplay field={field} value={value} />
|
|
140
|
+
) : (
|
|
141
|
+
<Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
|
|
142
|
+
)}
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatFieldValue(value: unknown, field: FieldDefinition): string {
|
|
148
|
+
if (value == null) return '\u2014';
|
|
149
|
+
|
|
150
|
+
switch (field.type) {
|
|
151
|
+
case 'boolean':
|
|
152
|
+
return value ? 'Yes' : 'No';
|
|
153
|
+
case 'date':
|
|
154
|
+
return new Date(String(value)).toLocaleDateString();
|
|
155
|
+
case 'datetime':
|
|
156
|
+
return new Date(String(value)).toLocaleString();
|
|
157
|
+
case 'json':
|
|
158
|
+
return JSON.stringify(value, null, 2);
|
|
159
|
+
case 'url':
|
|
160
|
+
case 'email':
|
|
161
|
+
case 'image':
|
|
162
|
+
return String(value);
|
|
163
|
+
case 'file': {
|
|
164
|
+
const str = String(value);
|
|
165
|
+
const basename = str.split('/').pop() ?? str;
|
|
166
|
+
return basename;
|
|
167
|
+
}
|
|
168
|
+
default:
|
|
169
|
+
return String(value);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin screen components.
|
|
3
|
+
*
|
|
4
|
+
* These components wire admin hooks to UI components to provide
|
|
5
|
+
* ready-made screens for admin dashboards.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { AdminLayout, type AdminLayoutProps } from './AdminLayout';
|
|
9
|
+
export { AdminDashboard, type AdminDashboardProps } from './AdminDashboard';
|
|
10
|
+
export { ResourceList, type ResourceListProps } from './ResourceList';
|
|
11
|
+
export { ResourceShow, type ResourceShowProps } from './ResourceShow';
|
|
12
|
+
export { ResourceCreate, type ResourceCreateProps } from './ResourceCreate';
|
|
13
|
+
export { ResourceEdit, type ResourceEditProps } from './ResourceEdit';
|
|
14
|
+
export { ResourceDashboard, type ResourceDashboardProps } from './ResourceDashboard';
|