@everystack/admin 0.2.3 → 0.2.5

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.
@@ -4,7 +4,7 @@
4
4
  * Provides data fetching capabilities using the configured DataAdapter.
5
5
  */
6
6
 
7
- import { createContext, useContext, useState, useEffect, useCallback } from 'react';
7
+ import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
8
8
  import type { DataAdapter } from '../adapters/types';
9
9
  import type { ListParams, DataRecord } from '../types';
10
10
 
@@ -33,6 +33,12 @@ export function useDataAdapter(): DataAdapter {
33
33
  export interface UseAdminDataOptions extends ListParams {
34
34
  /** Skip initial fetch */
35
35
  skip?: boolean;
36
+ /**
37
+ * Reactive filters merged into every fetch beneath the in-view search filter
38
+ * (e.g. an analytics-header time range or segment). Changing it refetches.
39
+ * Memoize it at the call site to keep identity stable across renders.
40
+ */
41
+ baseFilter?: Record<string, unknown>;
36
42
  }
37
43
 
38
44
  export interface UseAdminDataResult<T extends DataRecord = DataRecord> {
@@ -77,34 +83,66 @@ export function useAdminData<T extends DataRecord = DataRecord>(
77
83
  const [order, setOrder] = useState(options.order ?? 'asc');
78
84
  const [filter, setFilterState] = useState(options.filter ?? {});
79
85
 
80
- const fetchData = useCallback(async () => {
86
+ // Manual-refetch trigger (bumped by refetch()).
87
+ const [refetchIndex, setRefetchIndex] = useState(0);
88
+
89
+ // A stable, value-based key for the whole request. The effect keys on this
90
+ // string rather than on object identity, so a re-render that produces a
91
+ // new-but-equal `filter`/`baseFilter` (e.g. FilterBar's initial empty search,
92
+ // or the auth refresh re-rendering the tree) does NOT re-fire the fetch.
93
+ // This is what collapses the per-load double request.
94
+ const reqKey = JSON.stringify({
95
+ resource,
96
+ page,
97
+ perPage,
98
+ sort: sort ?? null,
99
+ order,
100
+ baseFilter: options.baseFilter ?? {},
101
+ filter,
102
+ search: options.search ?? null,
103
+ refetchIndex,
104
+ });
105
+
106
+ // Latest-request guard: only the most recent fetch may write state, so a
107
+ // superseded in-flight response can't clobber newer data (out-of-order races).
108
+ const seqRef = useRef(0);
109
+
110
+ useEffect(() => {
111
+ if (options.skip) return;
112
+ const seq = ++seqRef.current;
81
113
  setLoading(true);
82
114
  setError(null);
83
115
 
84
- try {
85
- const result = await adapter.fetchList<T>(resource, {
116
+ adapter
117
+ .fetchList<T>(resource, {
86
118
  page,
87
119
  perPage,
88
120
  sort,
89
121
  order,
90
- filter,
122
+ // Base filters (time range, segment) sit beneath the in-view search filter.
123
+ filter: { ...(options.baseFilter ?? {}), ...filter },
91
124
  search: options.search,
125
+ })
126
+ .then(result => {
127
+ if (seq !== seqRef.current) return;
128
+ setData(result.data);
129
+ setTotal(result.total);
130
+ })
131
+ .catch(err => {
132
+ if (seq !== seqRef.current) return;
133
+ setError(err instanceof Error ? err : new Error(String(err)));
134
+ })
135
+ .finally(() => {
136
+ if (seq === seqRef.current) setLoading(false);
92
137
  });
138
+ // reqKey encodes every input; the per-field deps are folded into it.
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, [adapter, reqKey, options.skip]);
93
141
 
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]);
142
+ // Refetch by bumping the trigger — runs through the same effect/guard path.
143
+ const refetch = useCallback(async () => {
144
+ setRefetchIndex(i => i + 1);
145
+ }, []);
108
146
 
109
147
  const setSort = useCallback((field: string, newOrder?: 'asc' | 'desc') => {
110
148
  setSortState(field);
@@ -121,7 +159,7 @@ export function useAdminData<T extends DataRecord = DataRecord>(
121
159
  total,
122
160
  loading,
123
161
  error,
124
- refetch: fetchData,
162
+ refetch,
125
163
  page,
126
164
  perPage,
127
165
  setPage,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Editor registry context.
3
+ *
4
+ * Apps register editor adapters (TipTap, MDXEditor, a GrapesJS email builder,
5
+ * …) on AdminRoot/AdminShell; fields select one via `field.editor`. The core
6
+ * admin depends on no editor library — the registry is the plug socket.
7
+ * See docs/plans/admin-editors.md.
8
+ */
9
+
10
+ import { createContext, useContext } from 'react';
11
+ import type { EditorRegistry } from '../types';
12
+
13
+ export const EditorRegistryContext = createContext<EditorRegistry>({});
14
+
15
+ export function useEditors(): EditorRegistry {
16
+ return useContext(EditorRegistryContext);
17
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * useViewState — read/write the admin's in-view state as URL query params.
3
+ *
4
+ * Router-agnostic: AdminRouter populates the context from the `searchParams` it
5
+ * receives (e.g. expo-router's useLocalSearchParams) and a setter built on the
6
+ * navigation port. Screens read the current params and push updates, so every
7
+ * filtered/scrubbed view is a direct link and drill-downs (`?level=error`) work.
8
+ */
9
+
10
+ import { createContext, useContext } from 'react';
11
+
12
+ /** Current query params, normalized to single strings (first value wins). */
13
+ export type ViewParams = Record<string, string>;
14
+
15
+ export interface ViewStateContextValue {
16
+ params: ViewParams;
17
+ /**
18
+ * Merge `next` into the current params and navigate. Keys set to undefined are
19
+ * removed from the URL. The path is preserved; only the query changes.
20
+ */
21
+ setParams: (next: Record<string, string | undefined>) => void;
22
+ }
23
+
24
+ export const ViewStateContext = createContext<ViewStateContextValue | null>(null);
25
+
26
+ /** Safe outside a provider: returns empty params and a no-op setter. */
27
+ export function useViewState(): ViewStateContextValue {
28
+ return useContext(ViewStateContext) ?? { params: {}, setParams: () => {} };
29
+ }
30
+
31
+ /** Normalize a raw searchParams object (values may be arrays) to single strings. */
32
+ export function normalizeParams(raw?: Record<string, string | string[] | undefined>): ViewParams {
33
+ const out: ViewParams = {};
34
+ if (!raw) return out;
35
+ for (const [k, v] of Object.entries(raw)) {
36
+ if (v == null) continue;
37
+ out[k] = Array.isArray(v) ? String(v[0] ?? '') : String(v);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Build a `?a=1&b=2` query string from params; empty → ''. Undefined dropped. */
43
+ export function buildQueryString(params: Record<string, string | undefined>): string {
44
+ const usp = new URLSearchParams();
45
+ for (const [k, v] of Object.entries(params)) {
46
+ if (v == null || v === '') continue;
47
+ usp.set(k, v);
48
+ }
49
+ const s = usp.toString();
50
+ return s ? `?${s}` : '';
51
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,14 @@
10
10
  export type {
11
11
  FieldType,
12
12
  FieldDefinition,
13
+ ImageSize,
14
+ CellContext,
15
+ ActionContext,
16
+ InputContext,
17
+ ContentFormat,
18
+ EditorProps,
19
+ EditorComponent,
20
+ EditorRegistry,
13
21
  ResourceConfig,
14
22
  StorageConfig,
15
23
  WidgetType,
@@ -69,6 +77,14 @@ export {
69
77
 
70
78
  // useRpc hook
71
79
  export { useRpc, type UseRpcOptions, type UseRpcResult } from './hooks/useRpc';
80
+ export {
81
+ useViewState,
82
+ ViewStateContext,
83
+ normalizeParams,
84
+ buildQueryString,
85
+ type ViewParams,
86
+ type ViewStateContextValue,
87
+ } from './hooks/useViewState';
72
88
 
73
89
  // RPC Factory
74
90
  export {
@@ -104,6 +120,8 @@ export {
104
120
  ResourceCreate,
105
121
  ResourceEdit,
106
122
  ResourceDashboard,
123
+ AdminRouter,
124
+ AdminShell,
107
125
  type AdminLayoutProps,
108
126
  type AdminDashboardProps,
109
127
  type ResourceListProps,
@@ -111,18 +129,35 @@ export {
111
129
  type ResourceCreateProps,
112
130
  type ResourceEditProps,
113
131
  type ResourceDashboardProps,
132
+ type AdminRouterProps,
133
+ type AdminShellProps,
134
+ type AdminNavigator,
114
135
  } from './screens';
115
136
 
137
+ // Routing (router-agnostic path↔route mapping behind AdminRouter)
138
+ export {
139
+ resolveAdminRoute,
140
+ adminHref,
141
+ pathToSegments,
142
+ type AdminScreen,
143
+ type AdminRouteMatch,
144
+ type AdminHrefParts,
145
+ } from './utils/admin-routing';
146
+
116
147
  // Context (virtual routing for expo-router)
117
148
  export { createAdminContext, type RequireContext } from './context';
118
149
 
119
150
  // Storage hooks
120
151
  export { useStorageConfig, StorageConfigContext } from './hooks/useStorageConfig';
121
152
 
153
+ // Editor registry (pluggable rich-text/content editors)
154
+ export { useEditors, EditorRegistryContext } from './hooks/useEditors';
155
+
122
156
  // Image field components
123
157
  export { ImageFieldInput } from './components/ImageFieldInput';
124
158
  export { AvatarFieldInput } from './components/AvatarFieldInput';
125
159
  export { ImageDisplay } from './components/ImageDisplay';
160
+ export { RowActionButton, type RowActionButtonProps } from './components/RowActionButton';
126
161
 
127
162
  // Image utilities
128
163
  export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
@@ -35,7 +35,7 @@ const profilesResource: ResourceConfig = {
35
35
  labelPlural: 'Profiles',
36
36
  icon: 'person',
37
37
  primaryKey: 'id',
38
- actions: { list: true, show: true, create: false, edit: true, delete: false },
38
+ actions: { list: true, show: true, create: true, edit: true, delete: true },
39
39
  fields: [
40
40
  { name: 'id', type: 'string', label: 'ID' },
41
41
  { name: 'username', type: 'string', label: 'Username', sortable: true, searchable: true },
@@ -72,7 +72,7 @@ const postsResource: ResourceConfig = {
72
72
  labelPlural: 'Posts',
73
73
  icon: 'chatbubble',
74
74
  primaryKey: 'id',
75
- actions: { list: true, show: true, create: false, edit: false, delete: true },
75
+ actions: { list: true, show: true, create: true, edit: true, delete: true },
76
76
  fields: [
77
77
  { name: 'id', type: 'string', label: 'ID' },
78
78
  {
@@ -111,7 +111,7 @@ const likesResource: ResourceConfig = {
111
111
  labelPlural: 'Likes',
112
112
  icon: 'heart',
113
113
  primaryKey: 'user_id',
114
- actions: { list: true, show: false, create: false, edit: false, delete: false },
114
+ actions: { list: true, show: true, create: true, edit: false, delete: true },
115
115
  fields: [
116
116
  {
117
117
  name: 'user_id',
@@ -154,7 +154,7 @@ const followsResource: ResourceConfig = {
154
154
  labelPlural: 'Follows',
155
155
  icon: 'people',
156
156
  primaryKey: 'follower_id',
157
- actions: { list: true, show: false, create: false, edit: false, delete: false },
157
+ actions: { list: true, show: true, create: true, edit: false, delete: true },
158
158
  fields: [
159
159
  {
160
160
  name: 'follower_id',
@@ -5,10 +5,24 @@
5
5
  * Reads menu items from AdminConfig via useAdminConfig().
6
6
  */
7
7
 
8
- import React, { useState } from 'react';
8
+ import React, { useEffect, useState } from 'react';
9
9
  import { View, Text, Pressable, ScrollView } from 'react-native';
10
10
  import { useAdminConfig } from '../hooks/useAdminConfig';
11
11
  import type { MenuItem } from '../types';
12
+ import type { Crumb } from '../utils/breadcrumbs';
13
+
14
+ /** The group id whose children contain the active item, if any. */
15
+ function activeGroupId(menu: MenuItem[] | undefined, activeItem: string | undefined): string | null {
16
+ for (const top of menu ?? []) {
17
+ if (top.children?.some(c => c.id === activeItem)) return top.id;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ /** First top-level group (the default-open one \u2014 e.g. "Content"). */
23
+ function firstGroupId(menu: MenuItem[] | undefined): string | null {
24
+ return menu?.find(m => m.children && m.children.length > 0)?.id ?? null;
25
+ }
12
26
 
13
27
  export interface AdminLayoutProps {
14
28
  children?: React.ReactNode;
@@ -16,34 +30,73 @@ export interface AdminLayoutProps {
16
30
  activeItem?: string;
17
31
  /** Called when a menu item is pressed */
18
32
  onNavigate?: (item: MenuItem) => void;
33
+ /** Breadcrumb trail for the content top bar. */
34
+ breadcrumbs?: Crumb[];
35
+ /** Navigate to a breadcrumb's admin path. */
36
+ onNavigatePath?: (path: string) => void;
19
37
  }
20
38
 
21
- export function AdminLayout({ children, activeItem, onNavigate }: AdminLayoutProps): React.ReactElement {
39
+ export function AdminLayout({
40
+ children,
41
+ activeItem,
42
+ onNavigate,
43
+ breadcrumbs,
44
+ onNavigatePath,
45
+ }: AdminLayoutProps): React.ReactElement {
22
46
  const config = useAdminConfig();
23
47
  const [sidebarOpen, setSidebarOpen] = useState(true);
24
48
 
49
+ // Accordion: at most one group open. Default to the active group, else the
50
+ // first group ("Content"). Navigating into a group opens it (and closes the
51
+ // others); the active item is always visible.
52
+ const activeGroup = activeGroupId(config.menu, activeItem);
53
+ const [openGroup, setOpenGroup] = useState<string | null>(activeGroup ?? firstGroupId(config.menu));
54
+ useEffect(() => {
55
+ if (activeGroup) setOpenGroup(activeGroup);
56
+ }, [activeGroup]);
57
+
25
58
  return (
26
59
  <View className="flex-1 flex-row bg-gray-50">
27
60
  {/* Sidebar */}
28
61
  {sidebarOpen && (
29
62
  <View className="w-64 bg-white border-r border-gray-200">
30
- {/* Header */}
31
- <View className="px-4 py-5 border-b border-gray-200">
32
- <Text className="text-lg font-bold text-gray-900">{config.title}</Text>
33
- {config.subtitle && (
34
- <Text className="text-xs text-gray-500 mt-0.5">{config.subtitle}</Text>
35
- )}
63
+ {/* Header \u2014 collapse toggle sits to the LEFT of the title. */}
64
+ <View className="flex-row items-center px-4 py-5 border-b border-gray-200">
65
+ <Pressable
66
+ className="p-2 -ml-2 mr-1 rounded-lg"
67
+ accessibilityLabel="Collapse menu"
68
+ onPress={() => setSidebarOpen(false)}
69
+ >
70
+ <Text className="text-lg text-gray-600">{'\u2630'}</Text>
71
+ </Pressable>
72
+ <View className="flex-1">
73
+ <Text className="text-lg font-bold text-gray-900">{config.title}</Text>
74
+ {config.subtitle && (
75
+ <Text className="text-xs text-gray-500 mt-0.5">{config.subtitle}</Text>
76
+ )}
77
+ </View>
36
78
  </View>
37
79
 
38
80
  {/* Menu */}
39
81
  <ScrollView className="flex-1 py-2">
40
82
  {config.menu?.map(item => (
41
- <MenuItemRow
42
- key={item.id}
43
- item={item}
44
- active={activeItem === item.id}
45
- onPress={onNavigate}
46
- />
83
+ item.children && item.children.length > 0 ? (
84
+ <MenuGroup
85
+ key={item.id}
86
+ item={item}
87
+ activeItem={activeItem}
88
+ open={openGroup === item.id}
89
+ onToggle={() => setOpenGroup(g => (g === item.id ? null : item.id))}
90
+ onPress={onNavigate}
91
+ />
92
+ ) : (
93
+ <MenuItemRow
94
+ key={item.id}
95
+ item={item}
96
+ active={activeItem === item.id}
97
+ onPress={onNavigate}
98
+ />
99
+ )
47
100
  ))}
48
101
  </ScrollView>
49
102
  </View>
@@ -51,14 +104,18 @@ export function AdminLayout({ children, activeItem, onNavigate }: AdminLayoutPro
51
104
 
52
105
  {/* Main content */}
53
106
  <View className="flex-1">
54
- {/* Top bar */}
107
+ {/* Top bar \u2014 breadcrumbs (and a menu-open button when collapsed). */}
55
108
  <View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-200">
56
- <Pressable
57
- className="p-2 rounded-lg mr-2"
58
- onPress={() => setSidebarOpen(!sidebarOpen)}
59
- >
60
- <Text className="text-lg text-gray-600">{sidebarOpen ? '\u2190' : '\u2192'}</Text>
61
- </Pressable>
109
+ {!sidebarOpen && (
110
+ <Pressable
111
+ className="p-2 -ml-2 mr-2 rounded-lg"
112
+ accessibilityLabel="Open menu"
113
+ onPress={() => setSidebarOpen(true)}
114
+ >
115
+ <Text className="text-lg text-gray-600">{'\u2630'}</Text>
116
+ </Pressable>
117
+ )}
118
+ <Breadcrumbs crumbs={breadcrumbs} onNavigatePath={onNavigatePath} />
62
119
  </View>
63
120
 
64
121
  {/* Content */}
@@ -68,6 +125,42 @@ export function AdminLayout({ children, activeItem, onNavigate }: AdminLayoutPro
68
125
  );
69
126
  }
70
127
 
128
+ // =============================================================================
129
+ // Breadcrumbs
130
+ // =============================================================================
131
+
132
+ function Breadcrumbs({
133
+ crumbs,
134
+ onNavigatePath,
135
+ }: {
136
+ crumbs?: Crumb[];
137
+ onNavigatePath?: (path: string) => void;
138
+ }): React.ReactElement | null {
139
+ if (!crumbs || crumbs.length === 0) return null;
140
+ return (
141
+ <View className="flex-row items-center flex-wrap">
142
+ {crumbs.map((crumb, i) => {
143
+ const last = i === crumbs.length - 1;
144
+ const navigable = crumb.href != null && !last;
145
+ return (
146
+ <View key={`${crumb.label}-${i}`} className="flex-row items-center">
147
+ {i > 0 && <Text className="text-sm text-gray-300 mx-2">{'/'}</Text>}
148
+ {navigable ? (
149
+ <Pressable onPress={() => onNavigatePath?.(crumb.href!)}>
150
+ <Text className="text-sm text-blue-600">{crumb.label}</Text>
151
+ </Pressable>
152
+ ) : (
153
+ <Text className={last ? 'text-sm font-semibold text-gray-900' : 'text-sm text-gray-500'}>
154
+ {crumb.label}
155
+ </Text>
156
+ )}
157
+ </View>
158
+ );
159
+ })}
160
+ </View>
161
+ );
162
+ }
163
+
71
164
  // =============================================================================
72
165
  // MenuItemRow
73
166
  // =============================================================================
@@ -76,12 +169,24 @@ interface MenuItemRowProps {
76
169
  item: MenuItem;
77
170
  active: boolean;
78
171
  onPress?: (item: MenuItem) => void;
172
+ indent?: boolean;
79
173
  }
80
174
 
81
- function MenuItemRow({ item, active, onPress }: MenuItemRowProps): React.ReactElement {
175
+ function MenuItemRow({ item, active, onPress, indent }: MenuItemRowProps): React.ReactElement {
176
+ // react-native-tailwind can't resolve dynamic/template-literal classNames — it
177
+ // falls back to passing the string as `style`, which crashes react-dom. Keep
178
+ // every className a static string literal (a ternary of literals is fine).
82
179
  return (
83
180
  <Pressable
84
- className={active ? "flex-row items-center px-4 py-2.5 mx-2 rounded-lg bg-blue-50" : "flex-row items-center px-4 py-2.5 mx-2 rounded-lg"}
181
+ className={
182
+ indent
183
+ ? (active
184
+ ? 'flex-row items-center pl-7 pr-4 py-2 mx-2 rounded-lg bg-blue-50'
185
+ : 'flex-row items-center pl-7 pr-4 py-2 mx-2 rounded-lg')
186
+ : (active
187
+ ? 'flex-row items-center px-4 py-2.5 mx-2 rounded-lg bg-blue-50'
188
+ : 'flex-row items-center px-4 py-2.5 mx-2 rounded-lg')
189
+ }
85
190
  onPress={() => onPress?.(item)}
86
191
  >
87
192
  <Text className={active ? "text-sm text-blue-700 font-semibold" : "text-sm text-gray-700"}>
@@ -95,3 +200,41 @@ function MenuItemRow({ item, active, onPress }: MenuItemRowProps): React.ReactEl
95
200
  </Pressable>
96
201
  );
97
202
  }
203
+
204
+ // =============================================================================
205
+ // MenuGroup — collapsible section. The group header toggles; children navigate.
206
+ // =============================================================================
207
+
208
+ interface MenuGroupProps {
209
+ item: MenuItem;
210
+ activeItem?: string;
211
+ /** Controlled open state (accordion — at most one group open). */
212
+ open: boolean;
213
+ onToggle: () => void;
214
+ onPress?: (item: MenuItem) => void;
215
+ }
216
+
217
+ function MenuGroup({ item, activeItem, open, onToggle, onPress }: MenuGroupProps): React.ReactElement {
218
+ return (
219
+ <View className="mt-2">
220
+ <Pressable
221
+ className="flex-row items-center px-4 py-1.5"
222
+ onPress={onToggle}
223
+ >
224
+ <Text className="text-xs font-semibold uppercase tracking-wide text-gray-400">
225
+ {item.label}
226
+ </Text>
227
+ <Text className="ml-auto text-xs text-gray-400">{open ? '⌄' : '›'}</Text>
228
+ </Pressable>
229
+ {open && item.children?.map(child => (
230
+ <MenuItemRow
231
+ key={child.id}
232
+ item={child}
233
+ active={activeItem === child.id}
234
+ onPress={onPress}
235
+ indent
236
+ />
237
+ ))}
238
+ </View>
239
+ );
240
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * AdminRouter
3
+ *
4
+ * Router-agnostic CRUD dispatcher. The app's catch-all route hands it the
5
+ * current `pathname` and a `{ push }` port (e.g. expo-router's useRouter()),
6
+ * and AdminRouter renders the right screen and wires its navigation — so apps
7
+ * no longer hand-write a route file per screen. The package never imports a
8
+ * router. See docs/plans/admin-routing.md.
9
+ */
10
+
11
+ import React, { useMemo } from 'react';
12
+ import { useAdminConfig } from '../hooks/useAdminConfig';
13
+ import { useDataAdapter } from '../hooks/useAdminData';
14
+ import { ViewStateContext, normalizeParams, buildQueryString } from '../hooks/useViewState';
15
+ import { AdminDashboard } from './AdminDashboard';
16
+ import { ResourceListView } from './ResourceListView';
17
+ import { ResourceShow } from './ResourceShow';
18
+ import { ResourceCreate } from './ResourceCreate';
19
+ import { ResourceEdit } from './ResourceEdit';
20
+ import { resolveAdminRoute, adminHref, pathToSegments, type AdminHrefParts } from '../utils/admin-routing';
21
+
22
+ /** Minimal navigation port. expo-router's useRouter() satisfies it as-is. */
23
+ export interface AdminNavigator {
24
+ push: (href: string) => void;
25
+ back?: () => void;
26
+ replace?: (href: string) => void;
27
+ }
28
+
29
+ export interface AdminRouterProps {
30
+ /** Navigation port — the app's router (expo-router's useRouter() fits). */
31
+ router: AdminNavigator;
32
+ /** Current path, e.g. expo-router's usePathname(). */
33
+ pathname: string;
34
+ /** Current query params, e.g. expo-router's useLocalSearchParams(). */
35
+ searchParams?: Record<string, string | string[] | undefined>;
36
+ /** Mount point of the admin. Default '/admin'. */
37
+ basePath?: string;
38
+ /** API base for resources with a `dashboard` config (observability screens). */
39
+ baseUrl?: string;
40
+ /** Token getter for those dashboard RPCs. */
41
+ getToken?: () => string | null | Promise<string | null>;
42
+ }
43
+
44
+ export function AdminRouter({
45
+ router,
46
+ pathname,
47
+ searchParams,
48
+ basePath = '/admin',
49
+ baseUrl,
50
+ getToken,
51
+ }: AdminRouterProps): React.ReactElement {
52
+ const config = useAdminConfig();
53
+ const adapter = useDataAdapter();
54
+
55
+ const match = resolveAdminRoute(pathToSegments(pathname, basePath));
56
+ const go = (parts: AdminHrefParts) => router.push(adminHref(basePath, parts));
57
+
58
+ // View state ⇄ URL query: screens read the current params and push merged
59
+ // updates, preserving the path. Powers shareable filtered/scrubbed views.
60
+ const viewState = useMemo(() => {
61
+ const params = normalizeParams(searchParams);
62
+ return {
63
+ params,
64
+ setParams: (next: Record<string, string | undefined>) => {
65
+ const merged: Record<string, string | undefined> = { ...params, ...next };
66
+ (router.replace ?? router.push)(`${pathname}${buildQueryString(merged)}`);
67
+ },
68
+ };
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [JSON.stringify(searchParams), pathname, router]);
71
+
72
+ return (
73
+ <ViewStateContext.Provider value={viewState}>
74
+ {renderScreen()}
75
+ </ViewStateContext.Provider>
76
+ );
77
+
78
+ function renderScreen(): React.ReactElement {
79
+
80
+ switch (match.screen) {
81
+ case 'dashboard':
82
+ return <AdminDashboard />;
83
+
84
+ case 'create':
85
+ return (
86
+ <ResourceCreate
87
+ resource={match.resource!}
88
+ onSuccess={record => go({ resource: match.resource, id: record.id as string | number })}
89
+ onCancel={() => go({ resource: match.resource })}
90
+ />
91
+ );
92
+
93
+ case 'edit':
94
+ return (
95
+ <ResourceEdit
96
+ resource={match.resource!}
97
+ id={match.id!}
98
+ onSuccess={() => go({ resource: match.resource, id: match.id })}
99
+ onCancel={() => go({ resource: match.resource, id: match.id })}
100
+ />
101
+ );
102
+
103
+ case 'show':
104
+ return (
105
+ <ResourceShow
106
+ resource={match.resource!}
107
+ id={match.id!}
108
+ onEditPress={() => go({ resource: match.resource, id: match.id, action: 'edit' })}
109
+ onBackPress={() => go({ resource: match.resource })}
110
+ />
111
+ );
112
+
113
+ case 'list':
114
+ default: {
115
+ const resourceConfig = config.resources.find(r => r.name === match.resource);
116
+ const primaryKey = resourceConfig?.primaryKey ?? 'id';
117
+ const idOf = (record: Record<string, unknown>) =>
118
+ (record[primaryKey] as string | number) ?? (record.id as string | number);
119
+ const toShow = (record: Record<string, unknown>) =>
120
+ go({ resource: match.resource, id: idOf(record) });
121
+
122
+ // One unified view: always a CRUD list, with an optional analytics header
123
+ // when the resource declares a `dashboard` config and a baseUrl is supplied.
124
+ return (
125
+ <ResourceListView
126
+ resource={match.resource!}
127
+ baseUrl={baseUrl}
128
+ getToken={getToken}
129
+ onRowPress={toShow}
130
+ onEditPress={record => go({ resource: match.resource, id: idOf(record), action: 'edit' })}
131
+ onCreatePress={() => go({ resource: match.resource, action: 'create' })}
132
+ />
133
+ );
134
+ }
135
+ }
136
+ }
137
+ }