@everystack/admin 0.2.3 → 0.2.6

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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * AdminShell
3
+ *
4
+ * The admin chrome, router-agnostic. Wraps the providers (AdminRoot) and the
5
+ * sidebar layout (AdminLayout), wiring the menu's navigation to the app's
6
+ * router port and highlighting the active item from the current path. The app
7
+ * renders this once in its admin _layout with `<Slot/>` as children.
8
+ */
9
+
10
+ import React from 'react';
11
+ import type { AdminConfig, MenuItem, EditorRegistry } from '../types';
12
+ import type { DataAdapter } from '../adapters/types';
13
+ import { AdminRoot } from '../AdminRoot';
14
+ import { AdminLayout } from './AdminLayout';
15
+ import { adminHref, pathToSegments, resolveAdminRoute } from '../utils/admin-routing';
16
+ import { buildBreadcrumbs } from '../utils/breadcrumbs';
17
+ import type { AdminNavigator } from './AdminRouter';
18
+
19
+ export interface AdminShellProps {
20
+ config: AdminConfig;
21
+ adapter: DataAdapter;
22
+ /** Navigation port — the app's router (expo-router's useRouter() fits). */
23
+ router: AdminNavigator;
24
+ /** Current path, e.g. expo-router's usePathname(). */
25
+ pathname: string;
26
+ /** Editor adapters available to fields via `field.editor` (opt-in plugins). */
27
+ editors?: EditorRegistry;
28
+ /** Mount point of the admin. Default '/admin'. */
29
+ basePath?: string;
30
+ children?: React.ReactNode;
31
+ }
32
+
33
+ export function AdminShell({
34
+ config,
35
+ adapter,
36
+ router,
37
+ pathname,
38
+ editors,
39
+ basePath = '/admin',
40
+ children,
41
+ }: AdminShellProps): React.ReactElement {
42
+ const segments = pathToSegments(pathname, basePath);
43
+ const activeItem = segments[0] ?? config.dashboards?.[0]?.id ?? config.menu?.[0]?.id;
44
+ const match = resolveAdminRoute(segments);
45
+ const breadcrumbs = buildBreadcrumbs(config, match, activeItem, basePath);
46
+
47
+ const onNavigate = (item: MenuItem) => {
48
+ if (item.path === '#') return; // divider / non-navigable
49
+ if (item.dashboard) router.push(basePath);
50
+ else if (item.resource) router.push(adminHref(basePath, { resource: item.resource }));
51
+ else if (item.path) router.push(item.path);
52
+ };
53
+
54
+ return (
55
+ <AdminRoot config={config} adapter={adapter} editors={editors}>
56
+ <AdminLayout
57
+ activeItem={activeItem}
58
+ onNavigate={onNavigate}
59
+ breadcrumbs={breadcrumbs}
60
+ onNavigatePath={path => router.push(path)}
61
+ >
62
+ {children}
63
+ </AdminLayout>
64
+ </AdminRoot>
65
+ );
66
+ }
@@ -9,6 +9,8 @@ import React, { useState, useCallback, useMemo } from 'react';
9
9
  import { View, Text, ScrollView } from 'react-native';
10
10
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import { useDataAdapter } from '../hooks/useAdminData';
12
+ import { useEditors } from '../hooks/useEditors';
13
+ import { editorFor } from '../utils/render-resolution';
12
14
  import { FormField, TextInput, SelectInput, SwitchInput, Button, ErrorState } from '@everystack/ui';
13
15
  import { ImageFieldInput } from '../components/ImageFieldInput';
14
16
  import { AvatarFieldInput } from '../components/AvatarFieldInput';
@@ -134,6 +136,17 @@ interface FieldInputProps {
134
136
  }
135
137
 
136
138
  function FieldInput({ field, value, error, onChange }: FieldInputProps): React.ReactElement {
139
+ const editors = useEditors();
140
+
141
+ // Resolution: inline input → registered editor → type-based default.
142
+ if (field.input) {
143
+ return <>{field.input({ value, onChange, field, error })}</>;
144
+ }
145
+ const Editor = editorFor(field, editors);
146
+ if (Editor) {
147
+ return <>{Editor({ value, onChange, field, format: field.format ?? 'html', error })}</>;
148
+ }
149
+
137
150
  const label = field.label ?? field.name;
138
151
  const required = field.validation?.required;
139
152
 
@@ -9,6 +9,8 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
9
9
  import { View, Text, ScrollView } from 'react-native';
10
10
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import { useDataAdapter } from '../hooks/useAdminData';
12
+ import { useEditors } from '../hooks/useEditors';
13
+ import { editorFor } from '../utils/render-resolution';
12
14
  import { FormField, TextInput, SelectInput, SwitchInput, Button, LoadingState, ErrorState } from '@everystack/ui';
13
15
  import { ImageFieldInput } from '../components/ImageFieldInput';
14
16
  import { AvatarFieldInput } from '../components/AvatarFieldInput';
@@ -158,6 +160,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
158
160
  value={formData[field.name]}
159
161
  error={errors[field.name]}
160
162
  onChange={value => setFieldValue(field.name, value)}
163
+ record={record}
161
164
  />
162
165
  ))}
163
166
  </View>
@@ -174,9 +177,21 @@ interface EditFieldInputProps {
174
177
  value: unknown;
175
178
  error?: string;
176
179
  onChange: (value: unknown) => void;
180
+ record?: DataRecord;
177
181
  }
178
182
 
179
- function EditFieldInput({ field, value, error, onChange }: EditFieldInputProps): React.ReactElement {
183
+ function EditFieldInput({ field, value, error, onChange, record }: EditFieldInputProps): React.ReactElement {
184
+ const editors = useEditors();
185
+
186
+ // Resolution: inline input → registered editor → type-based default.
187
+ if (field.input) {
188
+ return <>{field.input({ value, onChange, field, record, error })}</>;
189
+ }
190
+ const Editor = editorFor(field, editors);
191
+ if (Editor) {
192
+ return <>{Editor({ value, onChange, field, format: field.format ?? 'html', error })}</>;
193
+ }
194
+
180
195
  const label = field.label ?? field.name;
181
196
  const required = field.validation?.required;
182
197
 
@@ -8,22 +8,36 @@
8
8
  import React, { useCallback, useMemo } from 'react';
9
9
  import { View, Text } from 'react-native';
10
10
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
- import { useAdminData } from '../hooks/useAdminData';
11
+ import { useAdminData, useDataAdapter } from '../hooks/useAdminData';
12
12
  import { DataTable, FilterBar, PaginationBar, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
13
13
  import { formatCellValue } from '../utils/format-cell-value';
14
- import type { ResourceConfig, FieldDefinition } from '../types';
14
+ import { cellRenderKind } from '../utils/render-resolution';
15
+ import { visibleRowActions } from '../utils/row-actions';
16
+ import { standardRowActions } from '../utils/standard-actions';
17
+ import { ImageDisplay } from '../components/ImageDisplay';
18
+ import { RowActionButton } from '../components/RowActionButton';
19
+ import type { DataAdapter } from '../adapters/types';
20
+ import type { ResourceConfig, FieldDefinition, DataRecord, RowActionConfig } from '../types';
15
21
 
16
22
  export interface ResourceListProps {
17
23
  /** Resource name (matches ResourceConfig.name) */
18
24
  resource: string;
19
- /** Called when a row is pressed */
25
+ /** Called when a row is pressed — also the built-in "Show" action target */
20
26
  onRowPress?: (record: Record<string, unknown>) => void;
27
+ /** Called when a row's built-in "Edit" action is pressed */
28
+ onEditPress?: (record: Record<string, unknown>) => void;
21
29
  /** Called when the create button is pressed */
22
30
  onCreatePress?: () => void;
31
+ /**
32
+ * Reactive filters merged beneath the search filter (e.g. an analytics-header
33
+ * time range or segment). Memoize at the call site.
34
+ */
35
+ baseFilter?: Record<string, unknown>;
23
36
  }
24
37
 
25
- export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceListProps): React.ReactElement {
38
+ export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress, baseFilter }: ResourceListProps): React.ReactElement {
26
39
  const config = useAdminConfig();
40
+ const adapter = useDataAdapter();
27
41
  const resourceConfig = config.resources.find(r => r.name === resource);
28
42
 
29
43
  const defaultSort = resourceConfig?.defaultSort;
@@ -41,13 +55,23 @@ export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceLi
41
55
  } = useAdminData(resource, {
42
56
  sort: defaultSort?.field,
43
57
  order: defaultSort?.order,
58
+ baseFilter,
44
59
  });
45
60
 
46
- // Build columns from field definitions
61
+ // Build columns from field definitions, plus an actions column combining the
62
+ // built-in Show/Edit/Delete (gated by actions.*) with any custom rowActions.
47
63
  const columns = useMemo(() => {
48
64
  if (!resourceConfig) return [];
49
- return fieldsToColumns(resourceConfig, defaultSort?.field, defaultSort?.order);
50
- }, [resourceConfig, defaultSort?.field, defaultSort?.order]);
65
+ const cols = fieldsToColumns(resourceConfig);
66
+ const actions = [
67
+ ...standardRowActions(resourceConfig, { onShow: onRowPress, onEdit: onEditPress }),
68
+ ...(resourceConfig.rowActions ?? []),
69
+ ];
70
+ if (actions.length) {
71
+ cols.push(actionsColumn(resourceConfig, actions, adapter, refetch));
72
+ }
73
+ return cols;
74
+ }, [resourceConfig, adapter, refetch, onRowPress, onEditPress]);
51
75
 
52
76
  // Build filter chips from filterable fields
53
77
  const filterChips = useMemo(() => {
@@ -114,7 +138,10 @@ export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceLi
114
138
  columns={columns}
115
139
  data={data}
116
140
  onSort={handleSort}
117
- onRowPress={onRowPress}
141
+ // A list-only resource (actions.show === false) has no detail screen —
142
+ // don't make rows navigate to a fetchOne that 404s. The row-action menu
143
+ // already hides "Show" via standardRowActions; this gates the whole-row tap.
144
+ onRowPress={resourceConfig?.actions?.show === false ? undefined : onRowPress}
118
145
  />
119
146
  )}
120
147
 
@@ -135,11 +162,7 @@ export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceLi
135
162
  // Helpers
136
163
  // =============================================================================
137
164
 
138
- function fieldsToColumns(
139
- resourceConfig: ResourceConfig,
140
- _sortField?: string,
141
- _sortOrder?: 'asc' | 'desc'
142
- ): DataTableColumn[] {
165
+ function fieldsToColumns(resourceConfig: ResourceConfig): DataTableColumn[] {
143
166
  const fieldNames = resourceConfig.listFields ?? resourceConfig.fields.map(f => f.name);
144
167
  const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
145
168
 
@@ -150,10 +173,61 @@ function fieldsToColumns(
150
173
  key: field.name,
151
174
  label: field.label ?? field.name,
152
175
  sortable: field.sortable,
153
- render: (value: unknown) => (
176
+ render: cellRenderer(field),
177
+ }));
178
+ }
179
+
180
+ /**
181
+ * Cell rendering precedence: custom render → image thumbnail → type text.
182
+ * (See render-resolution.cellRenderKind.)
183
+ */
184
+ function cellRenderer(field: FieldDefinition): DataTableColumn['render'] {
185
+ switch (cellRenderKind(field)) {
186
+ case 'custom':
187
+ return (value, record) => field.render!(value, record as DataRecord, { view: 'list' });
188
+ case 'image':
189
+ return (value) => <ImageDisplay field={field} value={value} view="list" />;
190
+ default:
191
+ return (value) => (
154
192
  <Text className="text-sm text-gray-900" numberOfLines={1}>
155
193
  {formatCellValue(field, value)}
156
194
  </Text>
157
- ),
158
- }));
195
+ );
196
+ }
197
+ }
198
+
199
+ /**
200
+ * A trailing column of per-row actions, each gated by its `when` clause.
201
+ * The cell claims the press so tapping an action doesn't also fire the row's
202
+ * onRowPress (navigate-to-detail).
203
+ */
204
+ function actionsColumn(
205
+ resourceConfig: ResourceConfig,
206
+ actions: RowActionConfig[],
207
+ adapter: DataAdapter,
208
+ refetch: () => Promise<void> | void,
209
+ ): DataTableColumn {
210
+ const primaryKey = resourceConfig.primaryKey ?? 'id';
211
+ return {
212
+ key: '__actions',
213
+ label: 'Actions',
214
+ render: (_value, record) => {
215
+ const visible = visibleRowActions(actions, record);
216
+ if (!visible.length) return null;
217
+ return (
218
+ <View className="flex-row gap-2" onStartShouldSetResponder={() => true}>
219
+ {visible.map(action => (
220
+ <RowActionButton
221
+ key={action.id}
222
+ action={action}
223
+ record={record as DataRecord}
224
+ recordId={(record[primaryKey] as string | number) ?? (record.id as string | number)}
225
+ adapter={adapter}
226
+ refetch={refetch}
227
+ />
228
+ ))}
229
+ </View>
230
+ );
231
+ },
232
+ };
159
233
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ResourceListView — the unified resource screen.
3
+ *
4
+ * Always a CRUD ResourceList (all records, paginated, Add/Edit/Delete). When the
5
+ * resource declares a `dashboard` config and the app supplied a baseUrl, an
6
+ * AnalyticsHeader (stats + counts-over-time + segments) renders on top, and its
7
+ * brush/segment selections filter the table beneath — one view, not two.
8
+ *
9
+ * Filter state lives in the URL query, so a filtered/scrubbed view is a direct
10
+ * link and drill-downs work (`/admin/log_events?level=error`, `?from=&to=`).
11
+ */
12
+
13
+ import React, { useMemo } from 'react';
14
+ import { View } from 'react-native';
15
+ import { useAdminConfig } from '../hooks/useAdminConfig';
16
+ import { useViewState } from '../hooks/useViewState';
17
+ import { ResourceList } from './ResourceList';
18
+ import { AnalyticsHeader } from '../components/AnalyticsHeader';
19
+ import { paramsToFilters, filtersToParams, filtersToBaseFilter, paramsToColumnFilters } from '../utils/view-filters';
20
+
21
+ export interface ResourceListViewProps {
22
+ resource: string;
23
+ baseUrl?: string;
24
+ getToken?: () => string | null | Promise<string | null>;
25
+ onRowPress?: (record: Record<string, unknown>) => void;
26
+ onEditPress?: (record: Record<string, unknown>) => void;
27
+ onCreatePress?: () => void;
28
+ }
29
+
30
+ export function ResourceListView({
31
+ resource,
32
+ baseUrl,
33
+ getToken,
34
+ onRowPress,
35
+ onEditPress,
36
+ onCreatePress,
37
+ }: ResourceListViewProps): React.ReactElement {
38
+ const config = useAdminConfig();
39
+ const resourceConfig = config.resources.find(r => r.name === resource);
40
+ const dashboard = resourceConfig?.dashboard;
41
+ const { params, setParams } = useViewState();
42
+
43
+ const dimensions = dashboard?.dimensions ?? [];
44
+ const filters = useMemo(() => paramsToFilters(params, dimensions), [params, dimensions]);
45
+
46
+ // Table filters = analytics filters (time + dimensions) plus pass-through
47
+ // column filters from the URL (drill-downs by any resource field).
48
+ const baseFilter = useMemo(() => {
49
+ const fromAnalytics = dashboard ? filtersToBaseFilter(filters, dashboard) : undefined;
50
+ const fieldNames = new Set((resourceConfig?.fields ?? []).map(f => f.name));
51
+ const exclude = new Set(dimensions.map(d => d.column));
52
+ const passthrough = paramsToColumnFilters(params, fieldNames, exclude);
53
+ const merged = { ...fromAnalytics, ...passthrough };
54
+ return Object.keys(merged).length ? merged : undefined;
55
+ }, [filters, dashboard, params, resourceConfig, dimensions]);
56
+
57
+ const showHeader = Boolean(dashboard && baseUrl);
58
+
59
+ return (
60
+ <View className="flex-1">
61
+ {showHeader && (
62
+ <AnalyticsHeader
63
+ resource={resource}
64
+ dashboard={dashboard!}
65
+ baseUrl={baseUrl!}
66
+ getToken={getToken}
67
+ filters={filters}
68
+ onFiltersChange={next => setParams(filtersToParams(next, dimensions))}
69
+ />
70
+ )}
71
+ <View className="flex-1">
72
+ <ResourceList
73
+ resource={resource}
74
+ baseFilter={baseFilter}
75
+ onRowPress={onRowPress}
76
+ onEditPress={onEditPress}
77
+ onCreatePress={onCreatePress}
78
+ />
79
+ </View>
80
+ </View>
81
+ );
82
+ }
@@ -5,15 +5,16 @@
5
5
  * Fetches via adapter.fetchOne() and displays field values.
6
6
  */
7
7
 
8
- import React, { useState, useEffect, useCallback, useRef } from 'react';
8
+ import React, { useState, useEffect, useCallback } from 'react';
9
9
  import { View, Text, ScrollView } from 'react-native';
10
10
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import { useDataAdapter } from '../hooks/useAdminData';
12
12
  import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
13
13
  import { ImageDisplay } from '../components/ImageDisplay';
14
+ import { RowActionButton } from '../components/RowActionButton';
14
15
  import { visibleRowActions } from '../utils/row-actions';
15
16
  import { toCamelCase } from '../utils/naming';
16
- import type { FieldDefinition, DataRecord, ResourceConfig, RowActionConfig } from '../types';
17
+ import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
17
18
  import type { DataAdapter } from '../adapters/types';
18
19
 
19
20
  export interface ResourceShowProps {
@@ -89,7 +90,7 @@ export function ResourceShow({
89
90
  <View className="flex-row items-center justify-between px-4 py-4">
90
91
  <View className="flex-row items-center">
91
92
  {onBackPress && (
92
- <Button label="\u2190 Back" variant="ghost" onPress={onBackPress} style={{ marginRight: 8 }} />
93
+ <Button label="← Back" variant="ghost" onPress={onBackPress} style={{ marginRight: 8 }} />
93
94
  )}
94
95
  <Text className="text-xl font-bold text-gray-900">
95
96
  {label} #{String(id)}
@@ -100,9 +101,10 @@ export function ResourceShow({
100
101
  <RowActionButton
101
102
  key={action.id}
102
103
  action={action}
104
+ record={record}
103
105
  recordId={id}
104
106
  adapter={adapter}
105
- onDone={fetchRecord}
107
+ refetch={fetchRecord}
106
108
  />
107
109
  ))}
108
110
  {canEdit && onEditPress && (
@@ -123,7 +125,7 @@ export function ResourceShow({
123
125
  return (
124
126
  <React.Fragment key={fieldName}>
125
127
  {idx > 0 && <Divider />}
126
- <FieldRow field={fieldDef} value={value} />
128
+ <FieldRow field={fieldDef} value={value} record={record} />
127
129
  </React.Fragment>
128
130
  );
129
131
  })}
@@ -147,7 +149,9 @@ async function fetchDetail(
147
149
  id: string | number,
148
150
  resourceConfig: ResourceConfig | undefined,
149
151
  ): Promise<DataRecord> {
150
- if (resourceConfig?.dashboard?.detailVia === 'rpc') {
152
+ // detailVia may sit on the resource directly (a plain list with no dashboard,
153
+ // e.g. the audit log) or inside its dashboard (the observability screens).
154
+ if (resourceConfig?.detailVia === 'rpc' || resourceConfig?.dashboard?.detailVia === 'rpc') {
151
155
  if (!adapter.rpc) {
152
156
  throw new Error(`Resource "${resource}" uses detailVia: 'rpc' but the adapter has no rpc()`);
153
157
  }
@@ -156,82 +160,6 @@ async function fetchDetail(
156
160
  return adapter.fetchOne(resource, id);
157
161
  }
158
162
 
159
- // =============================================================================
160
- // RowActionButton
161
- // =============================================================================
162
-
163
- interface RowActionButtonProps {
164
- action: RowActionConfig;
165
- recordId: string | number;
166
- adapter: DataAdapter;
167
- onDone: () => Promise<void> | void;
168
- }
169
-
170
- /**
171
- * Two-tap confirm: actions with `confirm` swap their label on first press
172
- * and execute on the second. Cross-platform (no Alert/window.confirm),
173
- * resets after 3 seconds without the confirming press.
174
- */
175
- function RowActionButton({ action, recordId, adapter, onDone }: RowActionButtonProps): React.ReactElement {
176
- const [confirming, setConfirming] = useState(false);
177
- const [busy, setBusy] = useState(false);
178
- const [error, setError] = useState<string | null>(null);
179
- const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
180
-
181
- useEffect(() => {
182
- return () => {
183
- if (resetTimer.current) clearTimeout(resetTimer.current);
184
- };
185
- }, []);
186
-
187
- const execute = useCallback(async () => {
188
- if (!adapter.rpc) {
189
- setError('Adapter does not support RPC actions');
190
- return;
191
- }
192
- setBusy(true);
193
- setError(null);
194
- try {
195
- await adapter.rpc(action.rpc, { id: recordId });
196
- await onDone();
197
- } catch (err) {
198
- setError(err instanceof Error ? err.message : String(err));
199
- } finally {
200
- setBusy(false);
201
- setConfirming(false);
202
- }
203
- }, [adapter, action.rpc, recordId, onDone]);
204
-
205
- const handlePress = useCallback(() => {
206
- if (busy) return;
207
- if (action.confirm && !confirming) {
208
- setConfirming(true);
209
- if (resetTimer.current) clearTimeout(resetTimer.current);
210
- resetTimer.current = setTimeout(() => setConfirming(false), 3000);
211
- return;
212
- }
213
- if (resetTimer.current) clearTimeout(resetTimer.current);
214
- execute();
215
- }, [busy, action.confirm, confirming, execute]);
216
-
217
- const buttonLabel = busy ? '…' : confirming ? (action.confirm ?? action.label) : action.label;
218
-
219
- return (
220
- <View>
221
- <Button
222
- label={buttonLabel}
223
- variant={action.variant ?? 'outline'}
224
- onPress={handlePress}
225
- />
226
- {error && (
227
- <Text className="text-xs text-red-600 mt-1" numberOfLines={2}>
228
- {error}
229
- </Text>
230
- )}
231
- </View>
232
- );
233
- }
234
-
235
163
  // =============================================================================
236
164
  // FieldRow
237
165
  // =============================================================================
@@ -239,16 +167,19 @@ function RowActionButton({ action, recordId, adapter, onDone }: RowActionButtonP
239
167
  interface FieldRowProps {
240
168
  field: FieldDefinition;
241
169
  value: unknown;
170
+ record: DataRecord;
242
171
  }
243
172
 
244
- function FieldRow({ field, value }: FieldRowProps): React.ReactElement {
173
+ function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
245
174
  return (
246
175
  <View className="py-3">
247
176
  <Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
248
177
  {field.label ?? field.name}
249
178
  </Text>
250
- {field.type === 'image' ? (
251
- <ImageDisplay field={field} value={value} />
179
+ {field.render ? (
180
+ <>{field.render(value, record, { view: 'show' })}</>
181
+ ) : field.type === 'image' ? (
182
+ <ImageDisplay field={field} value={value} view="show" />
252
183
  ) : (
253
184
  <Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
254
185
  )}
@@ -8,7 +8,13 @@
8
8
  export { AdminLayout, type AdminLayoutProps } from './AdminLayout';
9
9
  export { AdminDashboard, type AdminDashboardProps } from './AdminDashboard';
10
10
  export { ResourceList, type ResourceListProps } from './ResourceList';
11
+ export { ResourceListView, type ResourceListViewProps } from './ResourceListView';
11
12
  export { ResourceShow, type ResourceShowProps } from './ResourceShow';
12
13
  export { ResourceCreate, type ResourceCreateProps } from './ResourceCreate';
13
14
  export { ResourceEdit, type ResourceEditProps } from './ResourceEdit';
15
+ // ResourceDashboard is retained for backward-compat / direct use, but the router
16
+ // now composes AnalyticsHeader + ResourceList via ResourceListView instead.
14
17
  export { ResourceDashboard, type ResourceDashboardProps } from './ResourceDashboard';
18
+ export { AnalyticsHeader, type AnalyticsHeaderProps } from '../components/AnalyticsHeader';
19
+ export { AdminRouter, type AdminRouterProps, type AdminNavigator } from './AdminRouter';
20
+ export { AdminShell, type AdminShellProps } from './AdminShell';