@everystack/admin 0.1.0
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/README.md +282 -0
- package/package.json +53 -0
- package/src/AdminRoot.tsx +34 -0
- package/src/adapters/EverystackAdapter.ts +218 -0
- package/src/adapters/types.ts +78 -0
- package/src/components/AvatarFieldInput.tsx +100 -0
- package/src/components/ImageDisplay.tsx +57 -0
- package/src/components/ImageFieldInput.tsx +140 -0
- package/src/components/dashboard/Brush.tsx +139 -0
- package/src/components/dashboard/DimensionBars.tsx +67 -0
- package/src/components/dashboard/DimensionDonut.tsx +98 -0
- package/src/components/dashboard/EventTable.tsx +248 -0
- package/src/components/dashboard/StatDials.tsx +67 -0
- package/src/components/dashboard/TimeHistogram.tsx +158 -0
- package/src/context.ts +75 -0
- package/src/hooks/useAdminConfig.ts +22 -0
- package/src/hooks/useAdminData.ts +132 -0
- package/src/hooks/useAggregates.ts +290 -0
- package/src/hooks/useRpc.ts +124 -0
- package/src/hooks/useStorageConfig.ts +16 -0
- package/src/index.ts +138 -0
- package/src/presets/twitter.ts +267 -0
- package/src/providers/AdminConfigProvider.tsx +18 -0
- package/src/providers/DataAdapterProvider.tsx +18 -0
- package/src/rpc/resource-dashboard-rpc.ts +416 -0
- package/src/screens/AdminDashboard.tsx +217 -0
- package/src/screens/AdminLayout.tsx +97 -0
- package/src/screens/ResourceCreate.tsx +243 -0
- package/src/screens/ResourceDashboard.tsx +278 -0
- package/src/screens/ResourceEdit.tsx +283 -0
- package/src/screens/ResourceList.tsx +153 -0
- package/src/screens/ResourceShow.tsx +171 -0
- package/src/screens/index.ts +14 -0
- package/src/types.ts +358 -0
- package/src/utils/naming.ts +105 -0
- package/src/utils/pick-image-web.ts +81 -0
- package/src/utils/resolve-image-url.ts +43 -0
- package/src/utils/upload-file.ts +93 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core type definitions for everystack/admin
|
|
3
|
+
*
|
|
4
|
+
* These types define the DSL (Domain-Specific Language) for configuring
|
|
5
|
+
* admin dashboards declaratively.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Field Types
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
export type FieldType =
|
|
13
|
+
| 'string'
|
|
14
|
+
| 'number'
|
|
15
|
+
| 'boolean'
|
|
16
|
+
| 'date'
|
|
17
|
+
| 'datetime'
|
|
18
|
+
| 'email'
|
|
19
|
+
| 'url'
|
|
20
|
+
| 'json'
|
|
21
|
+
| 'enum'
|
|
22
|
+
| 'relation'
|
|
23
|
+
| 'image'
|
|
24
|
+
| 'file';
|
|
25
|
+
|
|
26
|
+
export interface FieldDefinition {
|
|
27
|
+
/** Field name (matches data key) */
|
|
28
|
+
name: string;
|
|
29
|
+
/** Display label */
|
|
30
|
+
label?: string;
|
|
31
|
+
/** Field type for rendering */
|
|
32
|
+
type: FieldType;
|
|
33
|
+
/** Whether field is sortable in tables */
|
|
34
|
+
sortable?: boolean;
|
|
35
|
+
/** Whether field is searchable */
|
|
36
|
+
searchable?: boolean;
|
|
37
|
+
/** Whether field is filterable */
|
|
38
|
+
filterable?: boolean;
|
|
39
|
+
/** For enum types: allowed values */
|
|
40
|
+
enumValues?: string[];
|
|
41
|
+
/** For relation types: related resource name */
|
|
42
|
+
relation?: {
|
|
43
|
+
resource: string;
|
|
44
|
+
field: string;
|
|
45
|
+
displayField?: string;
|
|
46
|
+
};
|
|
47
|
+
/** Validation rules */
|
|
48
|
+
validation?: {
|
|
49
|
+
required?: boolean;
|
|
50
|
+
min?: number;
|
|
51
|
+
max?: number;
|
|
52
|
+
pattern?: string;
|
|
53
|
+
message?: string;
|
|
54
|
+
};
|
|
55
|
+
/** Renderer variant for this field type (e.g., 'avatar' for image fields) */
|
|
56
|
+
renderer?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// Resource Configuration
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
export interface ResourceConfig {
|
|
64
|
+
/** Unique resource identifier (e.g., 'users', 'orders') */
|
|
65
|
+
name: string;
|
|
66
|
+
/** Display label (singular) */
|
|
67
|
+
label?: string;
|
|
68
|
+
/** Display label (plural) */
|
|
69
|
+
labelPlural?: string;
|
|
70
|
+
/** Icon name for navigation */
|
|
71
|
+
icon?: string;
|
|
72
|
+
/** Field definitions */
|
|
73
|
+
fields: FieldDefinition[];
|
|
74
|
+
/** Default sort field */
|
|
75
|
+
defaultSort?: {
|
|
76
|
+
field: string;
|
|
77
|
+
order: 'asc' | 'desc';
|
|
78
|
+
};
|
|
79
|
+
/** Available actions */
|
|
80
|
+
actions?: {
|
|
81
|
+
list?: boolean;
|
|
82
|
+
show?: boolean;
|
|
83
|
+
create?: boolean;
|
|
84
|
+
edit?: boolean;
|
|
85
|
+
delete?: boolean;
|
|
86
|
+
};
|
|
87
|
+
/** Fields to display in list view */
|
|
88
|
+
listFields?: string[];
|
|
89
|
+
/** Fields to display in detail/show view */
|
|
90
|
+
showFields?: string[];
|
|
91
|
+
/** Fields to display in create form */
|
|
92
|
+
createFields?: string[];
|
|
93
|
+
/** Fields to display in edit form */
|
|
94
|
+
editFields?: string[];
|
|
95
|
+
/** Primary key field name */
|
|
96
|
+
primaryKey?: string;
|
|
97
|
+
/** Optional observability-style dashboard configuration */
|
|
98
|
+
dashboard?: ResourceDashboardConfig;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// =============================================================================
|
|
102
|
+
// Resource Dashboard
|
|
103
|
+
// =============================================================================
|
|
104
|
+
|
|
105
|
+
/** How to compute a stat dial value. */
|
|
106
|
+
export type StatDialValue =
|
|
107
|
+
| { type: 'count' }
|
|
108
|
+
| { type: 'count_where'; column: string; equals: string }
|
|
109
|
+
| { type: 'distinct'; column: string }
|
|
110
|
+
| { type: 'table_count'; table: string; timestampColumn?: string };
|
|
111
|
+
|
|
112
|
+
/** Configuration for a single stat dial in the dashboard header. */
|
|
113
|
+
export interface StatDialConfig {
|
|
114
|
+
/** Unique identifier */
|
|
115
|
+
id: string;
|
|
116
|
+
/** Display label */
|
|
117
|
+
label: string;
|
|
118
|
+
/** Ring color (hex) */
|
|
119
|
+
color: string;
|
|
120
|
+
/** How to compute the value */
|
|
121
|
+
value: StatDialValue;
|
|
122
|
+
/** Ring fill: 'of_total' computes value/total, number is a fixed ratio. Default: 1. */
|
|
123
|
+
ratio?: 'of_total' | number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A filterable dimension (renders as bars or donut chart). */
|
|
127
|
+
export interface DimensionConfig {
|
|
128
|
+
/** Column name in the database table */
|
|
129
|
+
column: string;
|
|
130
|
+
/** Display label */
|
|
131
|
+
label: string;
|
|
132
|
+
/** Chart type for this dimension */
|
|
133
|
+
chartType: 'bars' | 'donut';
|
|
134
|
+
/** Allowed values — whitelist for SQL safety. Falls back to field enumValues. */
|
|
135
|
+
allowedValues?: string[];
|
|
136
|
+
/** Color map: { value: hexColor } */
|
|
137
|
+
colors?: Record<string, string>;
|
|
138
|
+
/** Display order for values (donut chart). */
|
|
139
|
+
order?: string[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Dashboard configuration attached to a ResourceConfig. */
|
|
143
|
+
export interface ResourceDashboardConfig {
|
|
144
|
+
/** Title displayed at the top */
|
|
145
|
+
title?: string;
|
|
146
|
+
/** The datetime column for the time histogram */
|
|
147
|
+
timestampColumn: string;
|
|
148
|
+
/** Time window in hours (default: 168 = 7 days) */
|
|
149
|
+
defaultHours?: number;
|
|
150
|
+
/** Time bucket granularity (default: 'hour') */
|
|
151
|
+
bucketSize?: 'hour' | 'day';
|
|
152
|
+
/** Stat dials to show above the histogram */
|
|
153
|
+
stats: StatDialConfig[];
|
|
154
|
+
/** Filterable dimensions (columns → bar/donut charts) */
|
|
155
|
+
dimensions: DimensionConfig[];
|
|
156
|
+
/** Primary key column(s) for the event list (default: 'id'). Use array for composite keys. */
|
|
157
|
+
primaryKey?: string | string[];
|
|
158
|
+
/** Columns to display in the event list */
|
|
159
|
+
listColumns: string[];
|
|
160
|
+
/** Columns searchable via ILIKE in the event list */
|
|
161
|
+
searchColumns?: string[];
|
|
162
|
+
/** Navigate to detail on row press (default: true) */
|
|
163
|
+
linkToDetail?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// =============================================================================
|
|
167
|
+
// Dashboard & Widgets
|
|
168
|
+
// =============================================================================
|
|
169
|
+
|
|
170
|
+
export type WidgetType = 'stat' | 'chart' | 'table' | 'list' | 'custom';
|
|
171
|
+
export type ChartType = 'line' | 'bar' | 'area' | 'pie' | 'donut';
|
|
172
|
+
|
|
173
|
+
export interface WidgetConfig {
|
|
174
|
+
/** Unique widget identifier */
|
|
175
|
+
id: string;
|
|
176
|
+
/** Widget type */
|
|
177
|
+
type: WidgetType;
|
|
178
|
+
/** Display title */
|
|
179
|
+
title: string;
|
|
180
|
+
/** Grid position and size */
|
|
181
|
+
grid?: {
|
|
182
|
+
x: number;
|
|
183
|
+
y: number;
|
|
184
|
+
width: number;
|
|
185
|
+
height: number;
|
|
186
|
+
};
|
|
187
|
+
/** Data source configuration */
|
|
188
|
+
data?: {
|
|
189
|
+
resource?: string;
|
|
190
|
+
query?: Record<string, unknown>;
|
|
191
|
+
aggregation?: 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
192
|
+
field?: string;
|
|
193
|
+
groupBy?: string;
|
|
194
|
+
};
|
|
195
|
+
/** Chart-specific options */
|
|
196
|
+
chart?: {
|
|
197
|
+
type: ChartType;
|
|
198
|
+
xField?: string;
|
|
199
|
+
yField?: string;
|
|
200
|
+
colorField?: string;
|
|
201
|
+
};
|
|
202
|
+
/** Stat-specific options */
|
|
203
|
+
stat?: {
|
|
204
|
+
format?: 'number' | 'currency' | 'percent';
|
|
205
|
+
trend?: {
|
|
206
|
+
field: string;
|
|
207
|
+
comparison: 'previous_period' | 'previous_year';
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface DashboardConfig {
|
|
213
|
+
/** Unique dashboard identifier */
|
|
214
|
+
id: string;
|
|
215
|
+
/** Display title */
|
|
216
|
+
title: string;
|
|
217
|
+
/** Dashboard description */
|
|
218
|
+
description?: string;
|
|
219
|
+
/** Widget configurations */
|
|
220
|
+
widgets: WidgetConfig[];
|
|
221
|
+
/** Auto-refresh interval in seconds */
|
|
222
|
+
refreshInterval?: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// =============================================================================
|
|
226
|
+
// Navigation & Menu
|
|
227
|
+
// =============================================================================
|
|
228
|
+
|
|
229
|
+
export interface MenuItem {
|
|
230
|
+
/** Menu item identifier */
|
|
231
|
+
id: string;
|
|
232
|
+
/** Display label */
|
|
233
|
+
label: string;
|
|
234
|
+
/** Icon name */
|
|
235
|
+
icon?: string;
|
|
236
|
+
/** Navigation path */
|
|
237
|
+
path?: string;
|
|
238
|
+
/** Resource name (for resource links) */
|
|
239
|
+
resource?: string;
|
|
240
|
+
/** Dashboard ID (for dashboard links) */
|
|
241
|
+
dashboard?: string;
|
|
242
|
+
/** Nested menu items */
|
|
243
|
+
children?: MenuItem[];
|
|
244
|
+
/** Badge content */
|
|
245
|
+
badge?: string | number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// =============================================================================
|
|
249
|
+
// Admin Configuration
|
|
250
|
+
// =============================================================================
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// Storage Configuration
|
|
254
|
+
// =============================================================================
|
|
255
|
+
|
|
256
|
+
export interface StorageConfig {
|
|
257
|
+
/** Storage API endpoint (e.g., '/api/storage') */
|
|
258
|
+
endpoint: string;
|
|
259
|
+
/** CDN base URL for image transforms (e.g., 'https://d15vjr4un7lj63.cloudfront.net') */
|
|
260
|
+
cdnUrl?: string;
|
|
261
|
+
/** Auth token getter for upload requests */
|
|
262
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface AdminConfig {
|
|
266
|
+
/** Application title */
|
|
267
|
+
title: string;
|
|
268
|
+
/** Application subtitle/description */
|
|
269
|
+
subtitle?: string;
|
|
270
|
+
/** Resource configurations */
|
|
271
|
+
resources: ResourceConfig[];
|
|
272
|
+
/** Dashboard configurations */
|
|
273
|
+
dashboards?: DashboardConfig[];
|
|
274
|
+
/** Navigation menu */
|
|
275
|
+
menu?: MenuItem[];
|
|
276
|
+
/** Theme configuration */
|
|
277
|
+
theme?: {
|
|
278
|
+
primaryColor?: string;
|
|
279
|
+
backgroundColor?: string;
|
|
280
|
+
textColor?: string;
|
|
281
|
+
accentColor?: string;
|
|
282
|
+
};
|
|
283
|
+
/** Base path for admin routes */
|
|
284
|
+
basePath?: string;
|
|
285
|
+
/** Storage configuration for image/file upload fields */
|
|
286
|
+
storage?: StorageConfig;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// =============================================================================
|
|
290
|
+
// Data Types
|
|
291
|
+
// =============================================================================
|
|
292
|
+
|
|
293
|
+
export interface ListParams {
|
|
294
|
+
/** Page number (1-indexed) */
|
|
295
|
+
page?: number;
|
|
296
|
+
/** Items per page */
|
|
297
|
+
perPage?: number;
|
|
298
|
+
/** Sort field */
|
|
299
|
+
sort?: string;
|
|
300
|
+
/** Sort order */
|
|
301
|
+
order?: 'asc' | 'desc';
|
|
302
|
+
/** Filter conditions */
|
|
303
|
+
filter?: Record<string, unknown>;
|
|
304
|
+
/** Search query */
|
|
305
|
+
search?: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface ListResult<T = Record<string, unknown>> {
|
|
309
|
+
/** Data items */
|
|
310
|
+
data: T[];
|
|
311
|
+
/** Total count (for pagination) */
|
|
312
|
+
total: number;
|
|
313
|
+
/** Current page */
|
|
314
|
+
page: number;
|
|
315
|
+
/** Items per page */
|
|
316
|
+
perPage: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface DataRecord {
|
|
320
|
+
id: string | number;
|
|
321
|
+
[key: string]: unknown;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// =============================================================================
|
|
325
|
+
// Aggregate Types
|
|
326
|
+
// =============================================================================
|
|
327
|
+
|
|
328
|
+
export type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
329
|
+
|
|
330
|
+
export interface AggregateQuery {
|
|
331
|
+
/** Aggregate function to apply */
|
|
332
|
+
function: AggregateFunction;
|
|
333
|
+
/** Column to aggregate (optional for count()) */
|
|
334
|
+
column?: string;
|
|
335
|
+
/** Alias for the result */
|
|
336
|
+
alias?: string;
|
|
337
|
+
/** Use DISTINCT (for count) */
|
|
338
|
+
distinct?: boolean;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface AggregateParams {
|
|
342
|
+
/** Aggregate functions to compute */
|
|
343
|
+
aggregates: AggregateQuery[];
|
|
344
|
+
/** Columns to group by */
|
|
345
|
+
groupBy?: string[];
|
|
346
|
+
/** Filter conditions */
|
|
347
|
+
filter?: Record<string, unknown>;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface AggregateResult {
|
|
351
|
+
/** Aggregated data rows */
|
|
352
|
+
data: Record<string, unknown>[];
|
|
353
|
+
/** Metadata about the query */
|
|
354
|
+
meta?: {
|
|
355
|
+
aggregate: boolean;
|
|
356
|
+
groupBy?: string[];
|
|
357
|
+
};
|
|
358
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column Naming Utilities
|
|
3
|
+
*
|
|
4
|
+
* Utilities for resolving field values from records that may use
|
|
5
|
+
* either snake_case (database convention) or camelCase (JavaScript convention).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convert snake_case to camelCase
|
|
10
|
+
*/
|
|
11
|
+
export function toCamelCase(str: string): string {
|
|
12
|
+
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert camelCase to snake_case
|
|
17
|
+
*/
|
|
18
|
+
export function toSnakeCase(str: string): string {
|
|
19
|
+
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get a value from a record, trying both snake_case and camelCase keys
|
|
24
|
+
*/
|
|
25
|
+
export function getFieldValue<T = unknown>(
|
|
26
|
+
record: Record<string, unknown>,
|
|
27
|
+
fieldName: string
|
|
28
|
+
): T | undefined {
|
|
29
|
+
// Try exact match first
|
|
30
|
+
if (fieldName in record) {
|
|
31
|
+
return record[fieldName] as T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// If fieldName is snake_case, try camelCase
|
|
35
|
+
if (fieldName.includes('_')) {
|
|
36
|
+
const camelCase = toCamelCase(fieldName);
|
|
37
|
+
if (camelCase in record) {
|
|
38
|
+
return record[camelCase] as T;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If fieldName is camelCase, try snake_case
|
|
43
|
+
const snakeCase = toSnakeCase(fieldName);
|
|
44
|
+
if (snakeCase !== fieldName && snakeCase in record) {
|
|
45
|
+
return record[snakeCase] as T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a field exists in a record (using both naming conventions)
|
|
53
|
+
*/
|
|
54
|
+
export function hasField(record: Record<string, unknown>, fieldName: string): boolean {
|
|
55
|
+
return getFieldValue(record, fieldName) !== undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the actual key name used in a record for a given field
|
|
60
|
+
*/
|
|
61
|
+
export function resolveFieldKey(record: Record<string, unknown>, fieldName: string): string {
|
|
62
|
+
if (fieldName in record) {
|
|
63
|
+
return fieldName;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (fieldName.includes('_')) {
|
|
67
|
+
const camelCase = toCamelCase(fieldName);
|
|
68
|
+
if (camelCase in record) {
|
|
69
|
+
return camelCase;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const snakeCase = toSnakeCase(fieldName);
|
|
74
|
+
if (snakeCase !== fieldName && snakeCase in record) {
|
|
75
|
+
return snakeCase;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return fieldName;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Transform a record's keys from camelCase to snake_case
|
|
83
|
+
*/
|
|
84
|
+
export function toSnakeCaseKeys<T extends Record<string, unknown>>(
|
|
85
|
+
record: T
|
|
86
|
+
): Record<string, unknown> {
|
|
87
|
+
const result: Record<string, unknown> = {};
|
|
88
|
+
for (const [key, value] of Object.entries(record)) {
|
|
89
|
+
result[toSnakeCase(key)] = value;
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Transform a record's keys from snake_case to camelCase
|
|
96
|
+
*/
|
|
97
|
+
export function toCamelCaseKeys<T extends Record<string, unknown>>(
|
|
98
|
+
record: T
|
|
99
|
+
): Record<string, unknown> {
|
|
100
|
+
const result: Record<string, unknown> = {};
|
|
101
|
+
for (const [key, value] of Object.entries(record)) {
|
|
102
|
+
result[toCamelCase(key)] = value;
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pickImageWeb
|
|
3
|
+
*
|
|
4
|
+
* Web-only image picker using the native file input API.
|
|
5
|
+
* Creates a hidden <input type="file">, triggers click, and returns
|
|
6
|
+
* both the File object (for upload) and a PickResult (for preview).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PickResult } from '@everystack/ui';
|
|
10
|
+
|
|
11
|
+
export interface PickImageWebResult {
|
|
12
|
+
file: File;
|
|
13
|
+
preview: PickResult;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function pickImageWeb(accept?: string): Promise<PickImageWebResult | null> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const input = document.createElement('input');
|
|
19
|
+
input.type = 'file';
|
|
20
|
+
input.accept = accept ?? 'image/*';
|
|
21
|
+
input.style.display = 'none';
|
|
22
|
+
document.body.appendChild(input);
|
|
23
|
+
|
|
24
|
+
let resolved = false;
|
|
25
|
+
|
|
26
|
+
input.onchange = () => {
|
|
27
|
+
resolved = true;
|
|
28
|
+
const file = input.files?.[0];
|
|
29
|
+
document.body.removeChild(input);
|
|
30
|
+
|
|
31
|
+
if (!file) {
|
|
32
|
+
resolve(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const uri = URL.createObjectURL(file);
|
|
37
|
+
|
|
38
|
+
// Read image dimensions
|
|
39
|
+
const img = new window.Image();
|
|
40
|
+
img.onload = () => {
|
|
41
|
+
resolve({
|
|
42
|
+
file,
|
|
43
|
+
preview: {
|
|
44
|
+
uri,
|
|
45
|
+
width: img.naturalWidth,
|
|
46
|
+
height: img.naturalHeight,
|
|
47
|
+
type: file.type,
|
|
48
|
+
fileName: file.name,
|
|
49
|
+
fileSize: file.size,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
img.onerror = () => {
|
|
54
|
+
// Non-image or unreadable — still return with what we have
|
|
55
|
+
resolve({
|
|
56
|
+
file,
|
|
57
|
+
preview: {
|
|
58
|
+
uri,
|
|
59
|
+
type: file.type,
|
|
60
|
+
fileName: file.name,
|
|
61
|
+
fileSize: file.size,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
img.src = uri;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Detect cancel: focus returns to window without onchange firing
|
|
69
|
+
window.addEventListener('focus', function onFocus() {
|
|
70
|
+
window.removeEventListener('focus', onFocus);
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
if (!resolved) {
|
|
73
|
+
try { document.body.removeChild(input); } catch { /* already removed */ }
|
|
74
|
+
resolve(null);
|
|
75
|
+
}
|
|
76
|
+
}, 300);
|
|
77
|
+
}, { once: true });
|
|
78
|
+
|
|
79
|
+
input.click();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveImageUrl
|
|
3
|
+
*
|
|
4
|
+
* Resolves a stored value (storage key or full URL) to a displayable image URL.
|
|
5
|
+
*
|
|
6
|
+
* - Full URLs (http:// or https://) are returned as-is (legacy data).
|
|
7
|
+
* - Storage keys are routed through CDN (with optional transforms) or
|
|
8
|
+
* the storage handler's /files/:key endpoint.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { buildImageUrl, type ImageTransformOptions } from '@everystack/ui/image-url';
|
|
12
|
+
|
|
13
|
+
export interface ResolveImageUrlOptions {
|
|
14
|
+
/** CDN base URL for image transforms */
|
|
15
|
+
cdnUrl?: string;
|
|
16
|
+
/** Storage API endpoint for direct file serving */
|
|
17
|
+
endpoint?: string;
|
|
18
|
+
/** Image transform options (only used with cdnUrl) */
|
|
19
|
+
transform?: ImageTransformOptions;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveImageUrl(
|
|
23
|
+
value: string,
|
|
24
|
+
options: ResolveImageUrlOptions,
|
|
25
|
+
): string {
|
|
26
|
+
// Full URL — return unchanged
|
|
27
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// CDN URL — build transform URL
|
|
32
|
+
if (options.cdnUrl) {
|
|
33
|
+
return buildImageUrl(options.cdnUrl, value, options.transform);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Storage endpoint — direct file serving
|
|
37
|
+
if (options.endpoint) {
|
|
38
|
+
return `${options.endpoint}/files/${encodeURIComponent(value)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// No options — return raw value
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uploadFile
|
|
3
|
+
*
|
|
4
|
+
* Self-contained presigned URL upload utility.
|
|
5
|
+
* Step 1: POST /presign with file metadata + auth token → { url, key }
|
|
6
|
+
* Step 2: XHR PUT to presigned URL with progress tracking.
|
|
7
|
+
*
|
|
8
|
+
* No external dependencies — avoids coupling everystack/admin to @everystack/storage.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface UploadFileOptions {
|
|
12
|
+
/** Storage API endpoint (e.g., '/api/storage') */
|
|
13
|
+
endpoint: string;
|
|
14
|
+
/** File to upload */
|
|
15
|
+
file: File;
|
|
16
|
+
/** Auth token getter */
|
|
17
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
18
|
+
/** Upload progress callback (0-100) */
|
|
19
|
+
onProgress?: (percentage: number) => void;
|
|
20
|
+
/** AbortSignal for cancellation */
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UploadFileResult {
|
|
25
|
+
key: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function uploadFile(options: UploadFileOptions): Promise<UploadFileResult> {
|
|
29
|
+
const { endpoint, file, getToken, onProgress, signal } = options;
|
|
30
|
+
|
|
31
|
+
// Build auth headers
|
|
32
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
33
|
+
if (getToken) {
|
|
34
|
+
const token = await getToken();
|
|
35
|
+
if (token) headers['authorization'] = `Bearer ${token}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Step 1: Get presigned URL
|
|
39
|
+
const presignRes = await fetch(`${endpoint}/presign`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers,
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
name: file.name,
|
|
44
|
+
type: file.type,
|
|
45
|
+
size: file.size,
|
|
46
|
+
}),
|
|
47
|
+
signal,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!presignRes.ok) {
|
|
51
|
+
const body = await presignRes.json().catch(() => ({ error: 'Presign failed' }));
|
|
52
|
+
throw new Error((body as Record<string, unknown>).error as string || `Presign failed: ${presignRes.status}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { url, key } = await presignRes.json() as { url: string; key: string };
|
|
56
|
+
|
|
57
|
+
// Step 2: XHR PUT to presigned URL with progress
|
|
58
|
+
await new Promise<void>((resolve, reject) => {
|
|
59
|
+
const xhr = new XMLHttpRequest();
|
|
60
|
+
|
|
61
|
+
if (signal) {
|
|
62
|
+
if (signal.aborted) {
|
|
63
|
+
reject(new Error('Upload cancelled'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
signal.addEventListener('abort', () => xhr.abort());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
xhr.upload.onprogress = (e: ProgressEvent) => {
|
|
70
|
+
if (e.lengthComputable && onProgress) {
|
|
71
|
+
onProgress(Math.round((e.loaded / e.total) * 100));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
xhr.onload = () => {
|
|
76
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
77
|
+
resolve();
|
|
78
|
+
} else {
|
|
79
|
+
reject(new Error(`Upload failed: ${xhr.status}`));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
xhr.onerror = () => reject(new Error('Upload failed: network error'));
|
|
84
|
+
xhr.onabort = () => reject(new Error('Upload cancelled'));
|
|
85
|
+
|
|
86
|
+
xhr.open('PUT', url);
|
|
87
|
+
xhr.setRequestHeader('content-type', file.type);
|
|
88
|
+
xhr.send(file);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
onProgress?.(100);
|
|
92
|
+
return { key };
|
|
93
|
+
}
|