@everystack/admin 0.2.2 → 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/rpc/resource-dashboard-rpc.ts +59 -0
- 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 +31 -78
- package/src/screens/index.ts +6 -0
- package/src/types.ts +129 -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',
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { sql, type SQL } from 'drizzle-orm';
|
|
18
|
+
import { toCamelCaseKeys } from '../utils/naming';
|
|
18
19
|
import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig } from '../types';
|
|
19
20
|
|
|
20
21
|
interface DbExecutor {
|
|
@@ -413,6 +414,61 @@ function createEventListHandler(
|
|
|
413
414
|
};
|
|
414
415
|
}
|
|
415
416
|
|
|
417
|
+
// ────────────────────────────────────────────────────────────
|
|
418
|
+
// Event Detail RPC
|
|
419
|
+
//
|
|
420
|
+
// The show view's data source for detailVia: 'rpc' resources — one row
|
|
421
|
+
// by primary key, the static where still applied (an STI discriminator
|
|
422
|
+
// must gate detail exactly like it gates the list), keys camelCased to
|
|
423
|
+
// match field definitions. Keeps telemetry off the REST surface entirely.
|
|
424
|
+
// ────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
const MAX_ID_LENGTH = 256;
|
|
427
|
+
|
|
428
|
+
function createEventDetailHandler(
|
|
429
|
+
tableName: string,
|
|
430
|
+
config: ResourceDashboardConfig,
|
|
431
|
+
db: DbExecutor,
|
|
432
|
+
): (body: unknown) => Promise<unknown> {
|
|
433
|
+
const pkColumn = Array.isArray(config.primaryKey)
|
|
434
|
+
? config.primaryKey[0]
|
|
435
|
+
: config.primaryKey ?? 'id';
|
|
436
|
+
|
|
437
|
+
return async (body: unknown) => {
|
|
438
|
+
const id = (body as { id?: unknown } | null)?.id;
|
|
439
|
+
if (
|
|
440
|
+
(typeof id !== 'string' && typeof id !== 'number') ||
|
|
441
|
+
String(id).length === 0 ||
|
|
442
|
+
String(id).length > MAX_ID_LENGTH
|
|
443
|
+
) {
|
|
444
|
+
throw new Error('Invalid id');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const conditions: SQL[] = [sql`${sql.raw(pkColumn)} = ${String(id)}`];
|
|
448
|
+
for (const predicate of config.where ?? []) {
|
|
449
|
+
conditions.push(sql`${sql.raw(predicate.column)} = ${predicate.equals}`);
|
|
450
|
+
}
|
|
451
|
+
let where = conditions[0];
|
|
452
|
+
for (let i = 1; i < conditions.length; i++) {
|
|
453
|
+
where = sql`${where} AND ${conditions[i]}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const result = await db.execute(sql`
|
|
457
|
+
SELECT row_to_json(r) AS row
|
|
458
|
+
FROM (SELECT * FROM ${sql.raw(tableName)} WHERE ${where} LIMIT 1) r
|
|
459
|
+
`);
|
|
460
|
+
|
|
461
|
+
const rows = extractRows(result);
|
|
462
|
+
const first = rows[0] as { row: Record<string, unknown> | string } | undefined;
|
|
463
|
+
if (!first) {
|
|
464
|
+
throw new Error(`Record not found: ${id}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const raw = typeof first.row === 'string' ? JSON.parse(first.row) : first.row;
|
|
468
|
+
return toCamelCaseKeys(raw);
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
416
472
|
// ────────────────────────────────────────────────────────────
|
|
417
473
|
// Public API
|
|
418
474
|
// ────────────────────────────────────────────────────────────
|
|
@@ -420,6 +476,7 @@ function createEventListHandler(
|
|
|
420
476
|
export interface ResourceDashboardRpcs {
|
|
421
477
|
dashboardData: (body: unknown) => Promise<unknown>;
|
|
422
478
|
eventList: (body: unknown) => Promise<unknown>;
|
|
479
|
+
eventDetail: (body: unknown) => Promise<unknown>;
|
|
423
480
|
}
|
|
424
481
|
|
|
425
482
|
/**
|
|
@@ -438,6 +495,7 @@ export function createResourceDashboardRpcs(
|
|
|
438
495
|
return {
|
|
439
496
|
dashboardData: createDashboardDataHandler(tableName, config, db),
|
|
440
497
|
eventList: createEventListHandler(tableName, config, db),
|
|
498
|
+
eventDetail: createEventDetailHandler(tableName, config, db),
|
|
441
499
|
};
|
|
442
500
|
}
|
|
443
501
|
|
|
@@ -464,6 +522,7 @@ export function createAllDashboardRpcs(
|
|
|
464
522
|
const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
465
523
|
rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
|
|
466
524
|
rpcs[`${prefix}EventList`] = handlers.eventList;
|
|
525
|
+
rpcs[`${prefix}EventDetail`] = handlers.eventDetail;
|
|
467
526
|
}
|
|
468
527
|
|
|
469
528
|
return rpcs;
|
|
@@ -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
|
+
}
|