@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.
- package/package.json +16 -1
- package/src/AdminRoot.tsx +8 -3
- package/src/adapters/EverystackAdapter.ts +110 -31
- package/src/components/AnalyticsHeader.tsx +134 -0
- package/src/components/ImageDisplay.tsx +14 -10
- package/src/components/RowActionButton.tsx +109 -0
- package/src/editors/tiptap.tsx +69 -0
- package/src/hooks/useAdminData.ts +58 -20
- package/src/hooks/useEditors.ts +17 -0
- package/src/hooks/useViewState.ts +51 -0
- package/src/index.ts +35 -0
- package/src/presets/twitter.ts +4 -4
- package/src/screens/AdminLayout.tsx +166 -23
- package/src/screens/AdminRouter.tsx +137 -0
- package/src/screens/AdminShell.tsx +66 -0
- package/src/screens/ResourceCreate.tsx +13 -0
- package/src/screens/ResourceEdit.tsx +16 -1
- package/src/screens/ResourceList.tsx +86 -15
- package/src/screens/ResourceListView.tsx +82 -0
- package/src/screens/ResourceShow.tsx +13 -84
- package/src/screens/index.ts +6 -0
- package/src/types.ts +123 -6
- package/src/utils/admin-routing.ts +66 -0
- package/src/utils/breadcrumbs.ts +81 -0
- package/src/utils/render-resolution.ts +91 -0
- package/src/utils/standard-actions.ts +48 -0
- package/src/utils/view-filters.ts +75 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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';
|
package/src/presets/twitter.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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({
|
|
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
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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={
|
|
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
|
+
}
|