@opensaas/stack-ui 0.1.0 → 0.1.1

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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +4 -2
  2. package/CHANGELOG.md +12 -0
  3. package/CLAUDE.md +311 -0
  4. package/LICENSE +21 -0
  5. package/dist/components/ItemFormClient.d.ts.map +1 -1
  6. package/dist/components/ItemFormClient.js +3 -1
  7. package/dist/components/fields/FileField.d.ts +21 -0
  8. package/dist/components/fields/FileField.d.ts.map +1 -0
  9. package/dist/components/fields/FileField.js +78 -0
  10. package/dist/components/fields/ImageField.d.ts +23 -0
  11. package/dist/components/fields/ImageField.d.ts.map +1 -0
  12. package/dist/components/fields/ImageField.js +107 -0
  13. package/dist/components/fields/JsonField.d.ts +15 -0
  14. package/dist/components/fields/JsonField.d.ts.map +1 -0
  15. package/dist/components/fields/JsonField.js +57 -0
  16. package/dist/components/fields/index.d.ts +6 -0
  17. package/dist/components/fields/index.d.ts.map +1 -1
  18. package/dist/components/fields/index.js +3 -0
  19. package/dist/components/fields/registry.d.ts.map +1 -1
  20. package/dist/components/fields/registry.js +6 -0
  21. package/dist/primitives/index.d.ts +1 -0
  22. package/dist/primitives/index.d.ts.map +1 -1
  23. package/dist/primitives/index.js +1 -0
  24. package/dist/primitives/textarea.d.ts +6 -0
  25. package/dist/primitives/textarea.d.ts.map +1 -0
  26. package/dist/primitives/textarea.js +8 -0
  27. package/dist/styles/globals.css +89 -0
  28. package/package.json +5 -3
  29. package/src/components/ItemFormClient.tsx +3 -1
  30. package/src/components/fields/FileField.tsx +223 -0
  31. package/src/components/fields/ImageField.tsx +328 -0
  32. package/src/components/fields/JsonField.tsx +114 -0
  33. package/src/components/fields/index.ts +6 -0
  34. package/src/components/fields/registry.ts +6 -0
  35. package/src/primitives/index.ts +1 -0
  36. package/src/primitives/textarea.tsx +24 -0
  37. package/vitest.config.ts +1 -1
@@ -1,8 +1,10 @@
1
1
 
2
- > @opensaas/stack-ui@0.1.0 build /home/runner/work/stack/stack/packages/ui
2
+ > @opensaas/stack-ui@0.1.1 build /home/runner/work/stack/stack/packages/ui
3
3
  > tsc && npm run build:css
4
4
 
5
+ npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm.
6
+ npm warn Unknown env config "_jsr-registry". This will stop working in the next major version of npm.
5
7
 
6
- > @opensaas/stack-ui@0.1.0 build:css
8
+ > @opensaas/stack-ui@0.1.1 build:css
7
9
  > mkdir -p dist/styles && postcss ./src/styles/globals.css -o ./dist/styles/globals.css
8
10
 
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # @opensaas/stack-ui
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 9a3fda5: Add JSON field
8
+ - 045c071: Add field and image upload
9
+ - Updated dependencies [9a3fda5]
10
+ - Updated dependencies [f8ebc0e]
11
+ - Updated dependencies [045c071]
12
+ - @opensaas/stack-core@0.1.1
package/CLAUDE.md ADDED
@@ -0,0 +1,311 @@
1
+ # @opensaas/stack-ui
2
+
3
+ Composable React UI components for OpenSaas Stack admin interfaces, built on shadcn/ui primitives.
4
+
5
+ ## Purpose
6
+
7
+ Provides multiple levels of UI abstraction:
8
+
9
+ 1. **Full AdminUI** - Complete admin interface with routing
10
+ 2. **Standalone Components** - Drop-in CRUD components (forms, tables)
11
+ 3. **Field Components** - Individual field inputs
12
+ 4. **Primitives** - Low-level shadcn/ui components for custom UIs
13
+
14
+ ## Key Exports
15
+
16
+ ### Main Export (`src/index.ts`)
17
+
18
+ - `AdminUI` - Complete admin interface
19
+ - `registerFieldComponent(type, Component)` - Register custom field components
20
+ - Primitives re-exported
21
+
22
+ ### Primitives (`/primitives`)
23
+
24
+ shadcn/ui components:
25
+
26
+ - `Button`, `Input`, `Label`, `Checkbox`, `Select`
27
+ - `Card`, `CardHeader`, `CardContent`, `CardFooter`
28
+ - `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableCell`
29
+ - `Dialog`, `DialogContent`, `DialogHeader`, `DialogFooter`
30
+ - `Popover`, `Calendar`, `DatetimePicker`, `TimePicker`
31
+ - `Combobox` - Search and select component
32
+
33
+ ### Fields (`/fields`)
34
+
35
+ Field components for forms:
36
+
37
+ - `TextField`, `IntegerField`, `CheckboxField`, `TimestampField`
38
+ - `PasswordField`, `SelectField`, `RelationshipField`
39
+ - `FieldRenderer` - Renders field based on config (uses registry)
40
+
41
+ ### Standalone (`/standalone`)
42
+
43
+ Composable CRUD components:
44
+
45
+ - `ItemCreateForm` - Create new item
46
+ - `ItemEditForm` - Edit existing item
47
+ - `ListTable` - Display list of items
48
+ - `SearchBar` - Search and filter
49
+ - `DeleteButton` - Delete with confirmation
50
+
51
+ ### Server (`/server`)
52
+
53
+ - `getAdminContext(headers)` - Get context with session from request headers
54
+
55
+ ## Architecture Patterns
56
+
57
+ ### Component Registry
58
+
59
+ Field components are registered by type, avoiding switch statements:
60
+
61
+ ```typescript
62
+ // Default registry
63
+ registerFieldComponent('text', TextField)
64
+ registerFieldComponent('integer', IntegerField)
65
+ // etc.
66
+
67
+ // Custom registration
68
+ registerFieldComponent('color', ColorPickerField)
69
+ ```
70
+
71
+ ### Component Resolution Priority
72
+
73
+ `FieldRenderer` resolves components in order:
74
+
75
+ 1. `field.ui.component` - Per-field override (highest priority)
76
+ 2. `field.ui.fieldType` - Custom type lookup in registry
77
+ 3. `field.type` - Default type lookup in registry
78
+
79
+ ### Composability Levels
80
+
81
+ **Level 1: Full AdminUI**
82
+
83
+ ```typescript
84
+ import { AdminUI } from '@opensaas/stack-ui'
85
+ <AdminUI context={context} config={config} />
86
+ ```
87
+
88
+ **Level 2: Standalone Components**
89
+
90
+ ```typescript
91
+ import { ItemCreateForm, ListTable } from '@opensaas/stack-ui/standalone'
92
+
93
+ <ItemCreateForm
94
+ listKey="Post"
95
+ context={context}
96
+ onSuccess={(item) => router.push(`/posts/${item.id}`)}
97
+ />
98
+
99
+ <ListTable
100
+ listKey="Post"
101
+ context={context}
102
+ columns={['title', 'author', 'createdAt']}
103
+ />
104
+ ```
105
+
106
+ **Level 3: Field Components**
107
+
108
+ ```typescript
109
+ import { TextField, SelectField } from '@opensaas/stack-ui/fields'
110
+
111
+ <form>
112
+ <TextField
113
+ name="title"
114
+ value={title}
115
+ onChange={setTitle}
116
+ label="Title"
117
+ required
118
+ />
119
+ </form>
120
+ ```
121
+
122
+ **Level 4: Primitives**
123
+
124
+ ```typescript
125
+ import { Button, Card, Input } from '@opensaas/stack-ui/primitives'
126
+
127
+ <Card>
128
+ <Input placeholder="Custom input" />
129
+ <Button onClick={handleClick}>Submit</Button>
130
+ </Card>
131
+ ```
132
+
133
+ ### UI Options Pass-Through
134
+
135
+ Field config `ui` options automatically pass to components:
136
+
137
+ ```typescript
138
+ // Config
139
+ content: richText({
140
+ ui: {
141
+ placeholder: 'Write content...',
142
+ minHeight: 300,
143
+ customOption: 'value',
144
+ },
145
+ })
146
+
147
+ // Component receives all ui options as props
148
+ export function RichTextField({ placeholder, minHeight, customOption, ...baseProps }) {
149
+ // Use options
150
+ }
151
+ ```
152
+
153
+ `FieldRenderer` extracts `component` and `fieldType`, passes rest as props.
154
+
155
+ ### Server/Client Boundaries
156
+
157
+ - `AdminUI` is server component (uses `getAdminContext`)
158
+ - Forms and interactive components are client components
159
+ - Data serialization via props (no functions, only JSON-serializable data)
160
+
161
+ ## Integration Points
162
+
163
+ ### With @opensaas/stack-core
164
+
165
+ - Reads config to generate UI
166
+ - Uses context for all data operations
167
+ - Field components map to field types via registry
168
+
169
+ ### With @opensaas/stack-auth
170
+
171
+ - `getAdminContext` uses Better-auth to get session
172
+ - Session flows through context to access control
173
+ - Auth UI components imported separately from `@opensaas/stack-auth/ui`
174
+
175
+ ### With Third-Party Field Packages
176
+
177
+ Third-party fields register components on client side:
178
+
179
+ ```typescript
180
+ // lib/register-fields.ts
181
+ 'use client'
182
+ import { registerFieldComponent } from '@opensaas/stack-ui'
183
+ import { RichTextField } from '@opensaas/stack-tiptap'
184
+ registerFieldComponent('richText', RichTextField)
185
+
186
+ // app/admin/[[...admin]]/page.tsx
187
+ import '../../../lib/register-fields' // Side-effect import
188
+ ```
189
+
190
+ ## Common Patterns
191
+
192
+ ### Basic Admin Setup
193
+
194
+ ```typescript
195
+ // app/admin/[[...admin]]/page.tsx
196
+ import { AdminUI } from '@opensaas/stack-ui'
197
+ import { getAdminContext } from '@opensaas/stack-ui/server'
198
+ import config from '@/opensaas.config'
199
+
200
+ export default async function AdminPage() {
201
+ const context = await getAdminContext()
202
+ return <AdminUI context={context} config={config} />
203
+ }
204
+ ```
205
+
206
+ ### Custom Field Component (Global Registration)
207
+
208
+ ```typescript
209
+ // lib/register-fields.ts
210
+ 'use client'
211
+ import { registerFieldComponent } from '@opensaas/stack-ui'
212
+ import { ColorPickerField } from './components/ColorPickerField'
213
+
214
+ registerFieldComponent('color', ColorPickerField)
215
+
216
+ // opensaas.config.ts
217
+ fields: {
218
+ themeColor: text({ ui: { fieldType: 'color' } })
219
+ }
220
+ ```
221
+
222
+ ### Custom Field Component (Per-Field Override)
223
+
224
+ ```typescript
225
+ // opensaas.config.ts
226
+ import { SlugField } from './components/SlugField'
227
+
228
+ fields: {
229
+ slug: text({ ui: { component: SlugField } })
230
+ }
231
+ ```
232
+
233
+ ### Composable Dashboard
234
+
235
+ ```typescript
236
+ import { ItemCreateForm, ListTable } from '@opensaas/stack-ui/standalone'
237
+ import { Card, Button } from '@opensaas/stack-ui/primitives'
238
+
239
+ export default function CustomDashboard() {
240
+ return (
241
+ <div className="grid gap-4">
242
+ <Card>
243
+ <h2>Recent Posts</h2>
244
+ <ListTable
245
+ listKey="Post"
246
+ context={context}
247
+ columns={['title', 'status', 'createdAt']}
248
+ />
249
+ </Card>
250
+
251
+ <Card>
252
+ <h2>Create Post</h2>
253
+ <ItemCreateForm
254
+ listKey="Post"
255
+ context={context}
256
+ onSuccess={(item) => router.push(`/posts/${item.id}`)}
257
+ />
258
+ </Card>
259
+ </div>
260
+ )
261
+ }
262
+ ```
263
+
264
+ ### Standalone Form with Custom Actions
265
+
266
+ ```typescript
267
+ import { ItemEditForm } from '@opensaas/stack-ui/standalone'
268
+
269
+ <ItemEditForm
270
+ listKey="Post"
271
+ itemId={postId}
272
+ context={context}
273
+ onSuccess={(item) => {
274
+ // Custom success handling
275
+ toast.success('Post updated!')
276
+ router.push('/posts')
277
+ }}
278
+ onError={(error) => {
279
+ // Custom error handling
280
+ toast.error(error.message)
281
+ }}
282
+ />
283
+ ```
284
+
285
+ ## Styling
286
+
287
+ Package includes Tailwind v4 styles:
288
+
289
+ ```typescript
290
+ // app/layout.tsx
291
+ import '@opensaas/stack-ui/styles'
292
+ ```
293
+
294
+ Custom theming via CSS variables (follows shadcn/ui conventions).
295
+
296
+ ## Type Safety
297
+
298
+ All components are fully typed:
299
+
300
+ - Context types inferred from Prisma client
301
+ - Field props typed based on field config
302
+ - Form data validated with react-hook-form + Zod
303
+
304
+ Avoid `any` types - all props are strongly typed for type safety.
305
+
306
+ ## Performance
307
+
308
+ - Server components by default (AdminUI, getAdminContext)
309
+ - Client components marked with `'use client'`
310
+ - Minimal client-side JS for interactive features only
311
+ - Data fetching on server reduces client bundle size
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 OpenSaas Stack Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1 +1 @@
1
- {"version":3,"file":"ItemFormClient.d.ts","sourceRoot":"","sources":["../../src/components/ItemFormClient.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAE7E,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACxE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,WAAgB,EAChB,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,gBAAqB,GACtB,EAAE,mBAAmB,2CAsMrB"}
1
+ {"version":3,"file":"ItemFormClient.d.ts","sourceRoot":"","sources":["../../src/components/ItemFormClient.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAE7E,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACxE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,WAAgB,EAChB,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,gBAAqB,GACtB,EAAE,mBAAmB,2CAwMrB"}
@@ -36,6 +36,7 @@ export function ItemFormClient({ listKey, urlKey, mode, fields, initialData = {}
36
36
  try {
37
37
  // Transform relationship fields to Prisma format
38
38
  // Filter out password fields with isSet objects (unchanged passwords)
39
+ // File/Image fields: pass File objects through (Next.js will serialize them)
39
40
  const transformedData = {};
40
41
  for (const [fieldName, value] of Object.entries(formData)) {
41
42
  const fieldConfig = fields[fieldName];
@@ -64,7 +65,8 @@ export function ItemFormClient({ listKey, urlKey, mode, fields, initialData = {}
64
65
  }
65
66
  }
66
67
  else {
67
- // Non-relationship field: pass through
68
+ // Non-relationship field: pass through (including File objects for file/image fields)
69
+ // File objects will be serialized by Next.js server action
68
70
  transformedData[fieldName] = value;
69
71
  }
70
72
  }
@@ -0,0 +1,21 @@
1
+ import type { FileMetadata } from '@opensaas/stack-core';
2
+ export interface FileFieldProps {
3
+ name: string;
4
+ value: File | FileMetadata | null;
5
+ onChange: (value: File | FileMetadata | null) => void;
6
+ label?: string;
7
+ error?: string;
8
+ disabled?: boolean;
9
+ required?: boolean;
10
+ mode?: 'read' | 'edit';
11
+ helpText?: string;
12
+ placeholder?: string;
13
+ }
14
+ /**
15
+ * File upload field with drag-and-drop support
16
+ *
17
+ * Stores File objects in form state. The actual upload happens server-side
18
+ * during form submission via field hooks.
19
+ */
20
+ export declare function FileField({ name, value, onChange, label, error, disabled, required, mode, helpText, placeholder, }: FileFieldProps): import("react/jsx-runtime").JSX.Element;
21
+ //# sourceMappingURL=FileField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FileField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/FileField.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AAMxD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,IAAI,GAAG,YAAY,GAAG,IAAI,CAAA;IACjC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,GAAG,YAAY,GAAG,IAAI,KAAK,IAAI,CAAA;IACrD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,QAAQ,EACR,WAA8C,GAC/C,EAAE,cAAc,2CA+KhB"}
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useState } from 'react';
4
+ import { Button } from '../../primitives/button.js';
5
+ import { Input } from '../../primitives/input.js';
6
+ import { Label } from '../../primitives/label.js';
7
+ import { Upload, X, File, Check } from 'lucide-react';
8
+ /**
9
+ * File upload field with drag-and-drop support
10
+ *
11
+ * Stores File objects in form state. The actual upload happens server-side
12
+ * during form submission via field hooks.
13
+ */
14
+ export function FileField({ name, value, onChange, label, error, disabled, required, mode = 'edit', helpText, placeholder = 'Choose a file or drag and drop', }) {
15
+ const [isDragOver, setIsDragOver] = useState(false);
16
+ const handleFileSelect = useCallback((file) => {
17
+ // Store File object in form state
18
+ // Upload will happen server-side during form submission
19
+ onChange(file);
20
+ }, [onChange]);
21
+ const handleDragOver = useCallback((e) => {
22
+ e.preventDefault();
23
+ setIsDragOver(true);
24
+ }, []);
25
+ const handleDragLeave = useCallback((e) => {
26
+ e.preventDefault();
27
+ setIsDragOver(false);
28
+ }, []);
29
+ const handleDrop = useCallback((e) => {
30
+ e.preventDefault();
31
+ setIsDragOver(false);
32
+ if (disabled || mode === 'read')
33
+ return;
34
+ const files = Array.from(e.dataTransfer.files);
35
+ if (files.length > 0) {
36
+ handleFileSelect(files[0]);
37
+ }
38
+ }, [disabled, mode, handleFileSelect]);
39
+ const handleInputChange = useCallback((e) => {
40
+ const files = Array.from(e.target.files || []);
41
+ if (files.length > 0) {
42
+ handleFileSelect(files[0]);
43
+ }
44
+ }, [handleFileSelect]);
45
+ const handleRemove = useCallback(() => {
46
+ onChange(null);
47
+ }, [onChange]);
48
+ // Determine if value is File or FileMetadata
49
+ // Use duck typing instead of instanceof to support SSR
50
+ const isFile = value &&
51
+ typeof value === 'object' &&
52
+ 'arrayBuffer' in value &&
53
+ typeof value.arrayBuffer === 'function';
54
+ const isFileMetadata = value && !isFile && typeof value === 'object' && 'url' in value;
55
+ // Read-only mode
56
+ if (mode === 'read') {
57
+ return (_jsxs("div", { className: "space-y-2", children: [label && (_jsxs(Label, { htmlFor: name, children: [label, required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] })), isFileMetadata ? (_jsxs("div", { className: "flex items-center gap-2 p-3 border rounded-md bg-muted", children: [_jsx(File, { className: "h-4 w-4" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("p", { className: "text-sm font-medium truncate", children: value.originalFilename }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatFileSize(value.size) })] }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => window.open(value.url, '_blank'), children: "Download" })] })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "No file uploaded" }))] }));
58
+ }
59
+ // Edit mode
60
+ return (_jsxs("div", { className: "space-y-2", children: [label && (_jsxs(Label, { htmlFor: name, children: [label, required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] })), isFile || isFileMetadata ? (
61
+ // File selected/uploaded - show file info
62
+ _jsxs("div", { className: "flex items-center gap-2 p-3 border rounded-md", children: [_jsx(Check, { className: "h-4 w-4 text-green-600" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("p", { className: "text-sm font-medium truncate", children: isFile ? value.name : value.originalFilename }), _jsxs("p", { className: "text-xs text-muted-foreground", children: [formatFileSize(isFile ? value.size : value.size), isFileMetadata && ` • ${value.mimeType}`, isFile && ' • Will upload on save'] })] }), isFileMetadata && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => window.open(value.url, '_blank'), children: "View" })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: handleRemove, disabled: disabled, children: _jsx(X, { className: "h-4 w-4" }) })] })) : (
63
+ // No file - show upload area
64
+ _jsx(_Fragment, { children: _jsxs("div", { className: `
65
+ relative border-2 border-dashed rounded-md p-6
66
+ transition-colors cursor-pointer
67
+ ${isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
68
+ ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary hover:bg-primary/5'}
69
+ `, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, children: [_jsx(Input, { id: name, type: "file", onChange: handleInputChange, disabled: disabled, className: "absolute inset-0 w-full h-full opacity-0 cursor-pointer" }), _jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [_jsx(Upload, { className: "h-8 w-8 text-muted-foreground" }), _jsx("p", { className: "text-sm font-medium", children: placeholder }), helpText && _jsx("p", { className: "text-xs text-muted-foreground", children: helpText })] })] }) })), error && _jsx("p", { className: "text-sm text-destructive", children: error })] }));
70
+ }
71
+ function formatFileSize(bytes) {
72
+ if (bytes === 0)
73
+ return '0 Bytes';
74
+ const k = 1024;
75
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
76
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
77
+ return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
78
+ }
@@ -0,0 +1,23 @@
1
+ import type { ImageMetadata } from '@opensaas/stack-core';
2
+ export interface ImageFieldProps {
3
+ name: string;
4
+ value: File | ImageMetadata | null;
5
+ onChange: (value: File | ImageMetadata | null) => void;
6
+ label?: string;
7
+ error?: string;
8
+ disabled?: boolean;
9
+ required?: boolean;
10
+ mode?: 'read' | 'edit';
11
+ helpText?: string;
12
+ placeholder?: string;
13
+ showPreview?: boolean;
14
+ previewSize?: number;
15
+ }
16
+ /**
17
+ * Image upload field with preview, drag-and-drop, and transformation support
18
+ *
19
+ * Stores File objects in form state with client-side preview. The actual upload
20
+ * happens server-side during form submission via field hooks.
21
+ */
22
+ export declare function ImageField({ name, value, onChange, label, error, disabled, required, mode, helpText, placeholder, showPreview, previewSize, }: ImageFieldProps): import("react/jsx-runtime").JSX.Element;
23
+ //# sourceMappingURL=ImageField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/ImageField.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAOzD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,IAAI,GAAG,aAAa,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,GAAG,aAAa,GAAG,IAAI,KAAK,IAAI,CAAA;IACtD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,QAAQ,EACR,WAAgD,EAChD,WAAkB,EAClB,WAAiB,GAClB,EAAE,eAAe,2CAmRjB"}
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useState } from 'react';
4
+ import { Button } from '../../primitives/button.js';
5
+ import { Input } from '../../primitives/input.js';
6
+ import { Label } from '../../primitives/label.js';
7
+ import { Upload, X, Eye, ImageIcon } from 'lucide-react';
8
+ import Image from 'next/image';
9
+ /**
10
+ * Image upload field with preview, drag-and-drop, and transformation support
11
+ *
12
+ * Stores File objects in form state with client-side preview. The actual upload
13
+ * happens server-side during form submission via field hooks.
14
+ */
15
+ export function ImageField({ name, value, onChange, label, error, disabled, required, mode = 'edit', helpText, placeholder = 'Choose an image or drag and drop', showPreview = true, previewSize = 200, }) {
16
+ const [isDragOver, setIsDragOver] = useState(false);
17
+ const [previewUrl, setPreviewUrl] = useState(null);
18
+ const handleFileSelect = useCallback((file) => {
19
+ // Validate file is an image
20
+ if (!file.type.startsWith('image/')) {
21
+ return;
22
+ }
23
+ // Generate client-side preview
24
+ if (showPreview) {
25
+ const reader = new FileReader();
26
+ reader.onload = (e) => {
27
+ setPreviewUrl(e.target?.result);
28
+ };
29
+ reader.readAsDataURL(file);
30
+ }
31
+ // Store File object in form state
32
+ // Upload will happen server-side during form submission
33
+ onChange(file);
34
+ }, [onChange, showPreview]);
35
+ const handleDragOver = useCallback((e) => {
36
+ e.preventDefault();
37
+ setIsDragOver(true);
38
+ }, []);
39
+ const handleDragLeave = useCallback((e) => {
40
+ e.preventDefault();
41
+ setIsDragOver(false);
42
+ }, []);
43
+ const handleDrop = useCallback((e) => {
44
+ e.preventDefault();
45
+ setIsDragOver(false);
46
+ if (disabled || mode === 'read')
47
+ return;
48
+ const files = Array.from(e.dataTransfer.files);
49
+ if (files.length > 0) {
50
+ handleFileSelect(files[0]);
51
+ }
52
+ }, [disabled, mode, handleFileSelect]);
53
+ const handleInputChange = useCallback((e) => {
54
+ const files = Array.from(e.target.files || []);
55
+ if (files.length > 0) {
56
+ handleFileSelect(files[0]);
57
+ }
58
+ }, [handleFileSelect]);
59
+ const handleRemove = useCallback(() => {
60
+ onChange(null);
61
+ setPreviewUrl(null);
62
+ }, [onChange]);
63
+ // Determine if value is File or ImageMetadata
64
+ // Use duck typing instead of instanceof to support SSR
65
+ const isFile = value &&
66
+ typeof value === 'object' &&
67
+ 'arrayBuffer' in value &&
68
+ typeof value.arrayBuffer === 'function';
69
+ const isImageMetadata = value && !isFile && typeof value === 'object' && 'url' in value;
70
+ // Read-only mode
71
+ if (mode === 'read') {
72
+ return (_jsxs("div", { className: "space-y-2", children: [label && (_jsxs(Label, { htmlFor: name, children: [label, required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] })), isImageMetadata ? (_jsxs("div", { className: "space-y-2", children: [_jsx("div", { className: "relative inline-block", children: _jsx(Image, { src: value.url, alt: value.originalFilename, width: previewSize, height: previewSize, className: "rounded-md object-cover border", style: {
73
+ maxWidth: `${previewSize}px`,
74
+ maxHeight: `${previewSize}px`,
75
+ } }) }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [value.width, " \u00D7 ", value.height, "px \u2022", ' ', formatFileSize(value.size)] }), value.transformations &&
76
+ Object.keys(value.transformations).length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium", children: "Transformations:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: Object.entries(value.transformations).map(([name, transform]) => {
77
+ const t = transform;
78
+ return (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => window.open(t.url, '_blank'), children: [name, " (", t.width, "\u00D7", t.height, ")"] }, name));
79
+ }) })] }))] })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "No image uploaded" }))] }));
80
+ }
81
+ // Edit mode
82
+ return (_jsxs("div", { className: "space-y-2", children: [label && (_jsxs(Label, { htmlFor: name, children: [label, required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] })), previewUrl || isImageMetadata ? (
83
+ // Image selected/uploaded or preview available - show preview
84
+ _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "relative inline-block group", children: [_jsx(Image, { src: previewUrl || value.url, alt: isImageMetadata ? value.originalFilename : 'Preview', width: previewSize, height: previewSize, className: "rounded-md object-cover border", style: {
85
+ maxWidth: `${previewSize}px`,
86
+ maxHeight: `${previewSize}px`,
87
+ } }), _jsxs("div", { className: "absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity", children: [isImageMetadata && (_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => window.open(value.url, '_blank'), children: _jsx(Eye, { className: "h-3 w-3" }) })), _jsx(Button, { type: "button", variant: "destructive", size: "sm", onClick: handleRemove, disabled: disabled, children: _jsx(X, { className: "h-3 w-3" }) })] })] }), isFile && (_jsxs("div", { className: "text-xs text-muted-foreground", children: [value.name, " \u2022 ", formatFileSize(value.size), " \u2022 Will upload on save"] })), isImageMetadata && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "text-xs text-muted-foreground", children: [value.originalFilename, " \u2022 ", value.width, " \u00D7", ' ', value.height, "px \u2022", ' ', formatFileSize(value.size)] }), value.transformations &&
88
+ Object.keys(value.transformations).length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium", children: "Transformations:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: Object.entries(value.transformations).map(([name, transform]) => {
89
+ const t = transform;
90
+ return (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => window.open(t.url, '_blank'), children: [name, " (", t.width, "\u00D7", t.height, ")"] }, name));
91
+ }) })] }))] }))] })) : (
92
+ // No image - show upload area
93
+ _jsx(_Fragment, { children: _jsxs("div", { className: `
94
+ relative border-2 border-dashed rounded-md p-6
95
+ transition-colors cursor-pointer
96
+ ${isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
97
+ ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary hover:bg-primary/5'}
98
+ `, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, children: [_jsx(Input, { id: name, type: "file", accept: "image/*", onChange: handleInputChange, disabled: disabled, className: "absolute inset-0 w-full h-full opacity-0 cursor-pointer" }), _jsxs("div", { className: "flex flex-col items-center gap-2 text-center", children: [showPreview ? (_jsx(ImageIcon, { className: "h-8 w-8 text-muted-foreground" })) : (_jsx(Upload, { className: "h-8 w-8 text-muted-foreground" })), _jsx("p", { className: "text-sm font-medium", children: placeholder }), helpText && _jsx("p", { className: "text-xs text-muted-foreground", children: helpText })] })] }) })), error && _jsx("p", { className: "text-sm text-destructive", children: error })] }));
99
+ }
100
+ function formatFileSize(bytes) {
101
+ if (bytes === 0)
102
+ return '0 Bytes';
103
+ const k = 1024;
104
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
105
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
106
+ return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
107
+ }
@@ -0,0 +1,15 @@
1
+ export interface JsonFieldProps {
2
+ name: string;
3
+ value: unknown;
4
+ onChange: (value: unknown) => void;
5
+ label: string;
6
+ placeholder?: string;
7
+ error?: string;
8
+ disabled?: boolean;
9
+ required?: boolean;
10
+ mode?: 'read' | 'edit';
11
+ rows?: number;
12
+ formatted?: boolean;
13
+ }
14
+ export declare function JsonField({ name, value, onChange, label, placeholder, error, disabled, required, mode, rows, formatted, }: JsonFieldProps): import("react/jsx-runtime").JSX.Element;
15
+ //# sourceMappingURL=JsonField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"JsonField.d.ts","sourceRoot":"","sources":["../../../src/components/fields/JsonField.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,OAAO,CAAA;IACd,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,wBAAgB,SAAS,CAAC,EACxB,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,KAAK,EACL,WAAkC,EAClC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,IAAa,EACb,IAAQ,EACR,SAAgB,GACjB,EAAE,cAAc,2CAgFhB"}