@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "Declarative admin dashboard framework for PostgREST APIs",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -22,6 +22,10 @@
22
22
  "./presets/twitter": {
23
23
  "types": "./src/presets/twitter.ts",
24
24
  "default": "./src/presets/twitter.ts"
25
+ },
26
+ "./editors/tiptap": {
27
+ "types": "./src/editors/tiptap.tsx",
28
+ "default": "./src/editors/tiptap.tsx"
25
29
  }
26
30
  },
27
31
  "scripts": {
@@ -36,12 +40,23 @@
36
40
  "peerDependencies": {
37
41
  "@everystack/api": ">=0.2.4",
38
42
  "@everystack/ui": ">=0.2.2",
43
+ "@tiptap/pm": ">=2.0.0",
44
+ "@tiptap/react": ">=2.0.0",
45
+ "@tiptap/starter-kit": ">=2.0.0",
39
46
  "react": "19.2.6",
40
47
  "react-native": "0.83.6"
41
48
  },
49
+ "peerDependenciesMeta": {
50
+ "@tiptap/pm": { "optional": true },
51
+ "@tiptap/react": { "optional": true },
52
+ "@tiptap/starter-kit": { "optional": true }
53
+ },
42
54
  "devDependencies": {
43
55
  "@everystack/api": "workspace:*",
44
56
  "@everystack/ui": "workspace:*",
57
+ "@tiptap/pm": "^2.0.0",
58
+ "@tiptap/react": "^2.0.0",
59
+ "@tiptap/starter-kit": "^2.0.0",
45
60
  "@types/d3-scale": "4.0.9",
46
61
  "@types/d3-time-format": "4.0.3",
47
62
  "@types/jest": "29.5.14",
package/src/AdminRoot.tsx CHANGED
@@ -6,27 +6,32 @@
6
6
  */
7
7
 
8
8
  import React from 'react';
9
- import type { AdminConfig } from './types';
9
+ import type { AdminConfig, EditorRegistry } from './types';
10
10
  import type { DataAdapter } from './adapters/types';
11
11
  import { AdminConfigProvider } from './providers/AdminConfigProvider';
12
12
  import { DataAdapterProvider } from './providers/DataAdapterProvider';
13
13
  import { StorageConfigContext } from './hooks/useStorageConfig';
14
+ import { EditorRegistryContext } from './hooks/useEditors';
14
15
 
15
16
  export interface AdminRootProps {
16
17
  /** Admin configuration */
17
18
  config: AdminConfig;
18
19
  /** Data adapter for backend communication */
19
20
  adapter: DataAdapter;
21
+ /** Editor adapters available to fields via `field.editor` (opt-in plugins). */
22
+ editors?: EditorRegistry;
20
23
  /** Children to render (typically expo-router's Slot or navigator) */
21
24
  children?: React.ReactNode;
22
25
  }
23
26
 
24
- export function AdminRoot({ config, adapter, children }: AdminRootProps) {
27
+ export function AdminRoot({ config, adapter, editors, children }: AdminRootProps) {
25
28
  return (
26
29
  <AdminConfigProvider config={config}>
27
30
  <DataAdapterProvider adapter={adapter}>
28
31
  <StorageConfigContext.Provider value={config.storage ?? null}>
29
- {children}
32
+ <EditorRegistryContext.Provider value={editors ?? {}}>
33
+ {children}
34
+ </EditorRegistryContext.Provider>
30
35
  </StorageConfigContext.Provider>
31
36
  </DataAdapterProvider>
32
37
  </AdminConfigProvider>
@@ -22,13 +22,35 @@ const POSTGREST_OPERATORS = new Set([
22
22
  'sl', 'sr', 'nxl', 'nxr', 'adj', 'ov', 'fts',
23
23
  ]);
24
24
 
25
- export interface EverystackAdapterOptions extends ClientOptions {}
25
+ export interface EverystackAdapterOptions extends ClientOptions {
26
+ /**
27
+ * Resources whose list rows come from an RPC instead of the PostgREST table.
28
+ * Maps resource name → RPC function name. The RPC speaks the same list
29
+ * protocol — body `{ limit, offset, sort, dir, filter }`, result
30
+ * `{ data, total }` — so the list UI is identical to the REST path. Used to
31
+ * serve DB-free observability tables (logs, crashes) from the S3 archive.
32
+ */
33
+ listRpcs?: Record<string, string>;
34
+ /**
35
+ * Resources whose single-record reads + writes go through an RPC instead of
36
+ * the PostgREST table. Maps resource name → RPC function name. The RPC is
37
+ * action-dispatched: body `{ action: 'get'|'create'|'update'|'delete', id?,
38
+ * data? }` → the record (get/create/update) or `{}` (delete). Used to serve
39
+ * DB-free mutable config (alert rules) from an S3 object with CAS. Pair with
40
+ * `listRpcs` (same RPC name) for the list view.
41
+ */
42
+ crudRpcs?: Record<string, string>;
43
+ }
26
44
 
27
45
  export class EverystackAdapter implements DataAdapter {
28
46
  private client: Client;
47
+ private listRpcs: Record<string, string>;
48
+ private crudRpcs: Record<string, string>;
29
49
 
30
50
  constructor(options: EverystackAdapterOptions) {
31
51
  this.client = createClient(options);
52
+ this.listRpcs = options.listRpcs ?? {};
53
+ this.crudRpcs = options.crudRpcs ?? {};
32
54
  }
33
55
 
34
56
  async fetchList<T extends DataRecord = DataRecord>(
@@ -39,6 +61,24 @@ export class EverystackAdapter implements DataAdapter {
39
61
  const perPage = params.perPage ?? 25;
40
62
  const offset = (page - 1) * perPage;
41
63
 
64
+ // RPC-backed list (e.g. DB-free observability tables over the S3 archive).
65
+ const listRpc = this.listRpcs[resource];
66
+ if (listRpc) {
67
+ const result = await this.rpc<{ data: T[]; total: number }>(listRpc, {
68
+ limit: perPage,
69
+ offset,
70
+ sort: params.sort,
71
+ dir: params.order ?? 'asc',
72
+ filter: params.filter,
73
+ });
74
+ return {
75
+ data: result?.data ?? [],
76
+ total: result?.total ?? 0,
77
+ page,
78
+ perPage,
79
+ };
80
+ }
81
+
42
82
  let builder = this.client.from(resource);
43
83
 
44
84
  // Apply filters
@@ -70,6 +110,14 @@ export class EverystackAdapter implements DataAdapter {
70
110
  resource: string,
71
111
  id: string | number,
72
112
  ): Promise<T> {
113
+ // RPC-backed single read (e.g. DB-free alert rules from the S3 config object).
114
+ const crudRpc = this.crudRpcs[resource];
115
+ if (crudRpc) {
116
+ const record = await this.rpc<T | null>(crudRpc, { action: 'get', id: String(id) });
117
+ if (!record) throw new Error(`${resource}/${id} not found`);
118
+ return record;
119
+ }
120
+
73
121
  const result = await this.client.from(resource).eq('id', String(id)).single();
74
122
 
75
123
  if (result.error) {
@@ -85,6 +133,11 @@ export class EverystackAdapter implements DataAdapter {
85
133
  resource: string,
86
134
  data: Omit<T, 'id'>,
87
135
  ): Promise<T> {
136
+ const crudRpc = this.crudRpcs[resource];
137
+ if (crudRpc) {
138
+ return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, { action: 'create', data }));
139
+ }
140
+
88
141
  const result = await this.client.from(resource).insert(data as Record<string, unknown>);
89
142
 
90
143
  if (result.error) {
@@ -99,6 +152,11 @@ export class EverystackAdapter implements DataAdapter {
99
152
  id: string | number,
100
153
  data: Partial<T>,
101
154
  ): Promise<T> {
155
+ const crudRpc = this.crudRpcs[resource];
156
+ if (crudRpc) {
157
+ return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, { action: 'update', id: String(id), data }));
158
+ }
159
+
102
160
  const result = await this.client
103
161
  .from(resource)
104
162
  .eq('id', String(id))
@@ -113,6 +171,12 @@ export class EverystackAdapter implements DataAdapter {
113
171
  }
114
172
 
115
173
  async delete(resource: string, id: string | number): Promise<void> {
174
+ const crudRpc = this.crudRpcs[resource];
175
+ if (crudRpc) {
176
+ this.rpcResult(await this.rpc<{ error?: string }>(crudRpc, { action: 'delete', id: String(id) }));
177
+ return;
178
+ }
179
+
116
180
  const result = await this.client.from(resource).eq('id', String(id)).remove();
117
181
 
118
182
  if (result.error) {
@@ -120,6 +184,14 @@ export class EverystackAdapter implements DataAdapter {
120
184
  }
121
185
  }
122
186
 
187
+ /** Unwrap an action RPC result, surfacing a `{ error }` payload as a throw. */
188
+ private rpcResult<T>(result: T | { error: string }): T {
189
+ if (result && typeof result === 'object' && 'error' in result && (result as { error?: unknown }).error) {
190
+ throw new Error(String((result as { error: unknown }).error));
191
+ }
192
+ return result as T;
193
+ }
194
+
123
195
  async rpc<T = unknown>(fn: string, body?: unknown): Promise<T> {
124
196
  const result = await this.client.rpc<T>(fn, body);
125
197
 
@@ -180,43 +252,50 @@ function applyFilters(builder: QueryBuilder, filter?: Record<string, unknown>):
180
252
 
181
253
  for (const [key, value] of Object.entries(filter)) {
182
254
  if (value == null) continue;
183
-
184
- const strValue = String(value);
185
-
186
- if (isPostgrestFilter(strValue)) {
187
- // Already formatted — extract operator and value
188
- const dotIndex = strValue.indexOf('.');
189
- const op = strValue.substring(0, dotIndex);
190
- const val = strValue.substring(dotIndex + 1);
191
-
192
- switch (op) {
193
- case 'eq': builder = builder.eq(key, val); break;
194
- case 'neq': builder = builder.neq(key, val); break;
195
- case 'gt': builder = builder.gt(key, val); break;
196
- case 'gte': builder = builder.gte(key, val); break;
197
- case 'lt': builder = builder.lt(key, val); break;
198
- case 'lte': builder = builder.lte(key, val); break;
199
- case 'like': builder = builder.like(key, val); break;
200
- case 'ilike': builder = builder.ilike(key, val); break;
201
- case 'is': builder = builder.is(key, val); break;
202
- case 'in': {
203
- const values = val.replace(/^\(/, '').replace(/\)$/, '').split(',');
204
- builder = builder.in(key, values);
205
- break;
206
- }
207
- default:
208
- // Unknown operator — use eq with full value
209
- builder = builder.eq(key, strValue);
210
- }
255
+ // An array applies several conditions to the same column (PostgREST ANDs
256
+ // them) — e.g. a time range as ['gte.<from>', 'lte.<to>'].
257
+ if (Array.isArray(value)) {
258
+ for (const v of value) builder = applyOneFilter(builder, key, v);
211
259
  } else {
212
- // Plain value — wrap with eq
213
- builder = builder.eq(key, strValue);
260
+ builder = applyOneFilter(builder, key, value);
214
261
  }
215
262
  }
216
263
 
217
264
  return builder;
218
265
  }
219
266
 
267
+ /** Apply one condition: a PostgREST-formatted value (`op.value`) or a plain eq. */
268
+ function applyOneFilter(builder: QueryBuilder, key: string, value: unknown): QueryBuilder {
269
+ if (value == null) return builder;
270
+ const strValue = String(value);
271
+
272
+ if (!isPostgrestFilter(strValue)) {
273
+ return builder.eq(key, strValue); // plain value → eq
274
+ }
275
+
276
+ const dotIndex = strValue.indexOf('.');
277
+ const op = strValue.substring(0, dotIndex);
278
+ const val = strValue.substring(dotIndex + 1);
279
+
280
+ switch (op) {
281
+ case 'eq': return builder.eq(key, val);
282
+ case 'neq': return builder.neq(key, val);
283
+ case 'gt': return builder.gt(key, val);
284
+ case 'gte': return builder.gte(key, val);
285
+ case 'lt': return builder.lt(key, val);
286
+ case 'lte': return builder.lte(key, val);
287
+ case 'like': return builder.like(key, val);
288
+ case 'ilike': return builder.ilike(key, val);
289
+ case 'is': return builder.is(key, val);
290
+ case 'in': {
291
+ const values = val.replace(/^\(/, '').replace(/\)$/, '').split(',');
292
+ return builder.in(key, values);
293
+ }
294
+ default:
295
+ return builder.eq(key, strValue); // unknown operator → eq with full value
296
+ }
297
+ }
298
+
220
299
  /**
221
300
  * Check if a filter value is already in PostgREST format (operator.value).
222
301
  */
@@ -0,0 +1,134 @@
1
+ /**
2
+ * AnalyticsHeader — the analytics chrome that sits atop a CRUD ResourceList.
3
+ *
4
+ * Stats + counts-over-time (brushable) + per-dimension breakdowns for any
5
+ * resource that declares a `dashboard` config. Controlled: the parent owns the
6
+ * filter state (time range + dimension selections) so the same brush/segment
7
+ * that updates these charts also filters the table below. Data comes from the
8
+ * resource's `{prefix}DashboardData` RPC.
9
+ *
10
+ * This replaces ResourceDashboard's role: the table is now the CRUD list, not a
11
+ * read-only event table — the header is the analytics layer over it.
12
+ */
13
+
14
+ import React, { useMemo } from 'react';
15
+ import { View, useWindowDimensions } from 'react-native';
16
+ import { useContainerWidth } from '@everystack/ui/dashboard';
17
+ import { useRpc } from '../hooks/useRpc';
18
+ import { StatDials } from './dashboard/StatDials';
19
+ import { TimeHistogram } from './dashboard/TimeHistogram';
20
+ import { DimensionBars } from './dashboard/DimensionBars';
21
+ import { DimensionDonut } from './dashboard/DimensionDonut';
22
+ import type { ResourceDashboardConfig } from '../types';
23
+ import type { DashboardFilters, DashboardAggregates } from '../rpc/resource-dashboard-rpc';
24
+
25
+ export interface AnalyticsHeaderProps {
26
+ /** Resource name — determines the RPC prefix ({prefix}DashboardData). */
27
+ resource: string;
28
+ /** The resource's dashboard/analytics config. */
29
+ dashboard: ResourceDashboardConfig;
30
+ /** API base for the DashboardData RPC. */
31
+ baseUrl: string;
32
+ /** Auth token provider. */
33
+ getToken?: () => string | null | Promise<string | null>;
34
+ /** Controlled filter state (time range + dimension selections). */
35
+ filters: DashboardFilters;
36
+ /** Emitted when the brush or a dimension chip changes the filters. */
37
+ onFiltersChange: (filters: DashboardFilters) => void;
38
+ }
39
+
40
+ function toCamelCase(snake: string): string {
41
+ return snake.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
42
+ }
43
+
44
+ export function AnalyticsHeader({
45
+ resource,
46
+ dashboard,
47
+ baseUrl,
48
+ getToken,
49
+ filters,
50
+ onFiltersChange,
51
+ }: AnalyticsHeaderProps): React.ReactElement {
52
+ const { width: windowWidth } = useWindowDimensions();
53
+ const chart = useContainerWidth(Math.min(windowWidth - 48, 800));
54
+
55
+ const dashboardDataFn = `${toCamelCase(resource)}DashboardData`;
56
+ const hours = dashboard.defaultHours ?? 168;
57
+
58
+ const body = useMemo(() => ({ hours, filters }), [hours, filters]);
59
+ const { data } = useRpc<DashboardAggregates>({ baseUrl, fn: dashboardDataFn, body, getToken });
60
+
61
+ const handleTimeBrush = (extent: [string, string] | null) => {
62
+ const next = { ...filters };
63
+ if (extent) next.time = extent;
64
+ else delete next.time;
65
+ onFiltersChange(next);
66
+ };
67
+
68
+ const handleDimensionFilter = (column: string, value: string) => {
69
+ const next = { ...filters };
70
+ const current = (next[column] as string[] | undefined) ?? [];
71
+ const updated = current.includes(value)
72
+ ? current.filter(v => v !== value)
73
+ : [...current, value];
74
+ if (updated.length === 0) delete next[column];
75
+ else next[column] = updated;
76
+ onFiltersChange(next);
77
+ };
78
+
79
+ const dials = useMemo(() => {
80
+ const stats = data?.stats;
81
+ const total = stats?.total ?? stats?.[dashboard.stats[0]?.id] ?? 1;
82
+ return dashboard.stats.map(stat => {
83
+ const value = stats?.[stat.id] ?? 0;
84
+ let ratio: number | undefined;
85
+ if (stat.ratio === 'of_total' && total > 0 && stat.id !== 'total') ratio = value / total;
86
+ else if (typeof stat.ratio === 'number') ratio = stat.ratio;
87
+ return { label: stat.label, value, color: stat.color, ratio };
88
+ });
89
+ }, [data?.stats, dashboard.stats]);
90
+
91
+ return (
92
+ <View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
93
+ <StatDials dials={dials} />
94
+
95
+ <View onLayout={chart.onLayout}>
96
+ <TimeHistogram
97
+ data={data?.timeHistogram ?? []}
98
+ width={chart.width}
99
+ title={dashboard.title ? `${dashboard.title} Over Time` : undefined}
100
+ onBrush={handleTimeBrush}
101
+ />
102
+ </View>
103
+
104
+ {dashboard.dimensions.length > 0 && (
105
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, marginTop: 16 }}>
106
+ {dashboard.dimensions.map(dim => {
107
+ const dimData = data?.dimensions?.[dim.column] ?? [];
108
+ const activeFilters = (filters[dim.column] as string[] | undefined) ?? [];
109
+ return dim.chartType === 'donut' ? (
110
+ <DimensionDonut
111
+ key={dim.column}
112
+ title={dim.label}
113
+ data={dimData}
114
+ colors={dim.colors}
115
+ order={dim.order}
116
+ activeFilters={activeFilters}
117
+ onFilter={value => handleDimensionFilter(dim.column, value)}
118
+ />
119
+ ) : (
120
+ <DimensionBars
121
+ key={dim.column}
122
+ title={dim.label}
123
+ data={dimData}
124
+ colors={dim.colors}
125
+ activeFilters={activeFilters}
126
+ onFilter={value => handleDimensionFilter(dim.column, value)}
127
+ />
128
+ );
129
+ })}
130
+ </View>
131
+ )}
132
+ </View>
133
+ );
134
+ }
@@ -11,14 +11,17 @@ import { View, Text, Image } from 'react-native';
11
11
  import { Avatar } from '@everystack/ui';
12
12
  import { useStorageConfig } from '../hooks/useStorageConfig';
13
13
  import { resolveImageUrl } from '../utils/resolve-image-url';
14
+ import { imageRenderSpec, type ImageView } from '../utils/render-resolution';
14
15
  import type { FieldDefinition } from '../types';
15
16
 
16
17
  interface ImageDisplayProps {
17
18
  field: FieldDefinition;
18
19
  value: unknown;
20
+ /** Which view is rendering \u2014 drives default sizing. Defaults to 'show'. */
21
+ view?: ImageView;
19
22
  }
20
23
 
21
- export function ImageDisplay({ field, value }: ImageDisplayProps): React.ReactElement {
24
+ export function ImageDisplay({ field, value, view = 'show' }: ImageDisplayProps): React.ReactElement {
22
25
  const storage = useStorageConfig();
23
26
 
24
27
  if (value == null) {
@@ -26,15 +29,14 @@ export function ImageDisplay({ field, value }: ImageDisplayProps): React.ReactEl
26
29
  }
27
30
 
28
31
  const stringValue = String(value);
32
+ const spec = imageRenderSpec(field, view);
29
33
  const uri = resolveImageUrl(stringValue, {
30
34
  cdnUrl: storage?.cdnUrl,
31
35
  endpoint: storage?.endpoint,
32
- transform: field.renderer === 'avatar'
33
- ? { width: 128, height: 128, fit: 'cover' }
34
- : { width: 400, height: 300, fit: 'cover' },
36
+ transform: spec.transform,
35
37
  });
36
38
 
37
- if (field.renderer === 'avatar') {
39
+ if (spec.isAvatar) {
38
40
  return (
39
41
  <View className="flex-row items-center gap-2">
40
42
  <Avatar uri={uri} size="lg" />
@@ -46,12 +48,14 @@ export function ImageDisplay({ field, value }: ImageDisplayProps): React.ReactEl
46
48
  <View>
47
49
  <Image
48
50
  source={{ uri }}
49
- className="w-48 h-36 rounded-lg"
50
- style={{ resizeMode: 'cover' }}
51
+ className="rounded-lg"
52
+ style={{ width: spec.width, height: spec.height, resizeMode: 'cover' }}
51
53
  />
52
- <Text className="text-xs text-gray-500 mt-1" numberOfLines={1}>
53
- {stringValue}
54
- </Text>
54
+ {spec.showCaption && (
55
+ <Text className="text-xs text-gray-500 mt-1" numberOfLines={1}>
56
+ {stringValue}
57
+ </Text>
58
+ )}
55
59
  </View>
56
60
  );
57
61
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * RowActionButton — a declarative record action, shared by the list and show
3
+ * views. Behavior resolves render → onPress → rpc (see actionBehavior):
4
+ *
5
+ * - render : the app owns the element; we render it as-is.
6
+ * - onPress : custom behavior, default button chrome + two-tap confirm.
7
+ * - rpc : adapter.rpc(action.rpc, { id: recordId }), same chrome.
8
+ *
9
+ * Two-tap confirm: actions with `confirm` swap their label on first press and
10
+ * execute on the second. Cross-platform (no Alert/window.confirm); resets after
11
+ * 3 seconds without the confirming press.
12
+ */
13
+
14
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
15
+ import { View, Text } from 'react-native';
16
+ import { Button } from '@everystack/ui';
17
+ import { actionBehavior } from '../utils/render-resolution';
18
+ import type { RowActionConfig, ActionContext, DataRecord } from '../types';
19
+ import type { DataAdapter } from '../adapters/types';
20
+
21
+ export interface RowActionButtonProps {
22
+ action: RowActionConfig;
23
+ /** The full record the action operates on. */
24
+ record: DataRecord;
25
+ /** Value sent as `{ id }` to an rpc action (record[primaryKey]). */
26
+ recordId: string | number;
27
+ adapter: DataAdapter;
28
+ /** Re-fetch the current view after the action completes. */
29
+ refetch: () => Promise<void> | void;
30
+ /** Navigate to an admin path, when the route layer provides it. */
31
+ navigate?: (path: string) => void;
32
+ }
33
+
34
+ export function RowActionButton({
35
+ action,
36
+ record,
37
+ recordId,
38
+ adapter,
39
+ refetch,
40
+ navigate,
41
+ }: RowActionButtonProps): React.ReactElement | null {
42
+ const [confirming, setConfirming] = useState(false);
43
+ const [busy, setBusy] = useState(false);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
46
+
47
+ useEffect(() => {
48
+ return () => {
49
+ if (resetTimer.current) clearTimeout(resetTimer.current);
50
+ };
51
+ }, []);
52
+
53
+ const behavior = actionBehavior(action);
54
+
55
+ const execute = useCallback(async () => {
56
+ setBusy(true);
57
+ setError(null);
58
+ try {
59
+ if (action.onPress) {
60
+ await action.onPress({ record, adapter, refetch, navigate });
61
+ } else if (action.rpc) {
62
+ if (!adapter.rpc) throw new Error('Adapter does not support RPC actions');
63
+ await adapter.rpc(action.rpc, { id: recordId });
64
+ }
65
+ await refetch();
66
+ } catch (err) {
67
+ setError(err instanceof Error ? err.message : String(err));
68
+ } finally {
69
+ setBusy(false);
70
+ setConfirming(false);
71
+ }
72
+ }, [action, record, recordId, adapter, refetch, navigate]);
73
+
74
+ const handlePress = useCallback(() => {
75
+ if (busy) return;
76
+ if (action.confirm && !confirming) {
77
+ setConfirming(true);
78
+ if (resetTimer.current) clearTimeout(resetTimer.current);
79
+ resetTimer.current = setTimeout(() => setConfirming(false), 3000);
80
+ return;
81
+ }
82
+ if (resetTimer.current) clearTimeout(resetTimer.current);
83
+ execute();
84
+ }, [busy, action.confirm, confirming, execute]);
85
+
86
+ // Fully custom element — the app owns chrome and behavior.
87
+ if (behavior === 'render') {
88
+ return <>{action.render!({ record, adapter, refetch, navigate })}</>;
89
+ }
90
+ // Misconfigured action (no rpc/onPress/render) renders nothing.
91
+ if (behavior === 'none') return null;
92
+
93
+ const buttonLabel = busy ? '…' : confirming ? (action.confirm ?? action.label) : action.label;
94
+
95
+ return (
96
+ <View>
97
+ <Button
98
+ label={buttonLabel}
99
+ variant={action.variant ?? 'outline'}
100
+ onPress={handlePress}
101
+ />
102
+ {error && (
103
+ <Text className="text-xs text-red-600 mt-1" numberOfLines={2}>
104
+ {error}
105
+ </Text>
106
+ )}
107
+ </View>
108
+ );
109
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * TipTap editor adapter — a reference EditorRegistry plugin (HTML).
3
+ *
4
+ * Opt-in and web-only. The core admin never imports this; tiptap is an
5
+ * optional peer dependency. To use:
6
+ *
7
+ * pnpm add @tiptap/react @tiptap/starter-kit @tiptap/pm
8
+ * import { TiptapEditor } from '@everystack/admin/editors/tiptap';
9
+ * <AdminShell editors={{ tiptap: TiptapEditor }} ... />
10
+ * // field: { name: 'body', type: 'html', editor: 'tiptap' }
11
+ *
12
+ * Reads and writes an HTML string via the EditorProps contract. The toolbar
13
+ * uses React Native primitives; the editable surface is tiptap's EditorContent
14
+ * (a DOM contenteditable, which is why this is web-only). See
15
+ * docs/plans/admin-editors.md.
16
+ */
17
+
18
+ import React from 'react';
19
+ import { View, Text, Pressable } from 'react-native';
20
+ import { useEditor, EditorContent } from '@tiptap/react';
21
+ import StarterKit from '@tiptap/starter-kit';
22
+ import type { EditorProps } from '../types';
23
+
24
+ interface ToolbarButtonProps {
25
+ label: string;
26
+ active?: boolean;
27
+ onPress: () => void;
28
+ }
29
+
30
+ function ToolbarButton({ label, active, onPress }: ToolbarButtonProps): React.ReactElement {
31
+ return (
32
+ <Pressable
33
+ onPress={onPress}
34
+ className={active ? 'px-2 py-1 rounded bg-blue-100' : 'px-2 py-1 rounded border border-gray-200'}
35
+ >
36
+ <Text className={active ? 'text-sm text-blue-700 font-semibold' : 'text-sm text-gray-700'}>
37
+ {label}
38
+ </Text>
39
+ </Pressable>
40
+ );
41
+ }
42
+
43
+ export function TiptapEditor({ value, onChange, error }: EditorProps): React.ReactElement {
44
+ const editor = useEditor({
45
+ extensions: [StarterKit],
46
+ content: typeof value === 'string' ? value : '',
47
+ // Render on the client only — the admin shell SSRs, and a contenteditable
48
+ // can't hydrate from server output.
49
+ immediatelyRender: false,
50
+ onUpdate: ({ editor }) => onChange(editor.getHTML()),
51
+ });
52
+
53
+ return (
54
+ <View>
55
+ <View className="flex-row flex-wrap gap-1 mb-2">
56
+ <ToolbarButton label="B" active={editor?.isActive('bold')} onPress={() => editor?.chain().focus().toggleBold().run()} />
57
+ <ToolbarButton label="I" active={editor?.isActive('italic')} onPress={() => editor?.chain().focus().toggleItalic().run()} />
58
+ <ToolbarButton label="H2" active={editor?.isActive('heading', { level: 2 })} onPress={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
59
+ <ToolbarButton label="List" active={editor?.isActive('bulletList')} onPress={() => editor?.chain().focus().toggleBulletList().run()} />
60
+ <ToolbarButton label="1." active={editor?.isActive('orderedList')} onPress={() => editor?.chain().focus().toggleOrderedList().run()} />
61
+ <ToolbarButton label="Quote" active={editor?.isActive('blockquote')} onPress={() => editor?.chain().focus().toggleBlockquote().run()} />
62
+ </View>
63
+ <View className="border border-gray-200 rounded-lg px-3 py-2">
64
+ <EditorContent editor={editor} />
65
+ </View>
66
+ {error ? <Text className="text-xs text-red-600 mt-1">{error}</Text> : null}
67
+ </View>
68
+ );
69
+ }