@opensaas/stack-ui 0.1.0 → 0.1.2

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 +18 -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
@@ -0,0 +1,328 @@
1
+ 'use client'
2
+
3
+ import React, { useCallback, useState } from 'react'
4
+ import type { ImageMetadata } from '@opensaas/stack-core'
5
+ import { Button } from '../../primitives/button.js'
6
+ import { Input } from '../../primitives/input.js'
7
+ import { Label } from '../../primitives/label.js'
8
+ import { Upload, X, Eye, ImageIcon } from 'lucide-react'
9
+ import Image from 'next/image'
10
+
11
+ export interface ImageFieldProps {
12
+ name: string
13
+ value: File | ImageMetadata | null
14
+ onChange: (value: File | ImageMetadata | null) => void
15
+ label?: string
16
+ error?: string
17
+ disabled?: boolean
18
+ required?: boolean
19
+ mode?: 'read' | 'edit'
20
+ helpText?: string
21
+ placeholder?: string
22
+ showPreview?: boolean
23
+ previewSize?: number
24
+ }
25
+
26
+ /**
27
+ * Image upload field with preview, drag-and-drop, and transformation support
28
+ *
29
+ * Stores File objects in form state with client-side preview. The actual upload
30
+ * happens server-side during form submission via field hooks.
31
+ */
32
+ export function ImageField({
33
+ name,
34
+ value,
35
+ onChange,
36
+ label,
37
+ error,
38
+ disabled,
39
+ required,
40
+ mode = 'edit',
41
+ helpText,
42
+ placeholder = 'Choose an image or drag and drop',
43
+ showPreview = true,
44
+ previewSize = 200,
45
+ }: ImageFieldProps) {
46
+ const [isDragOver, setIsDragOver] = useState(false)
47
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null)
48
+
49
+ const handleFileSelect = useCallback(
50
+ (file: File) => {
51
+ // Validate file is an image
52
+ if (!file.type.startsWith('image/')) {
53
+ return
54
+ }
55
+
56
+ // Generate client-side preview
57
+ if (showPreview) {
58
+ const reader = new FileReader()
59
+ reader.onload = (e) => {
60
+ setPreviewUrl(e.target?.result as string)
61
+ }
62
+ reader.readAsDataURL(file)
63
+ }
64
+
65
+ // Store File object in form state
66
+ // Upload will happen server-side during form submission
67
+ onChange(file)
68
+ },
69
+ [onChange, showPreview],
70
+ )
71
+
72
+ const handleDragOver = useCallback((e: React.DragEvent) => {
73
+ e.preventDefault()
74
+ setIsDragOver(true)
75
+ }, [])
76
+
77
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
78
+ e.preventDefault()
79
+ setIsDragOver(false)
80
+ }, [])
81
+
82
+ const handleDrop = useCallback(
83
+ (e: React.DragEvent) => {
84
+ e.preventDefault()
85
+ setIsDragOver(false)
86
+
87
+ if (disabled || mode === 'read') return
88
+
89
+ const files = Array.from(e.dataTransfer.files)
90
+ if (files.length > 0) {
91
+ handleFileSelect(files[0])
92
+ }
93
+ },
94
+ [disabled, mode, handleFileSelect],
95
+ )
96
+
97
+ const handleInputChange = useCallback(
98
+ (e: React.ChangeEvent<HTMLInputElement>) => {
99
+ const files = Array.from(e.target.files || [])
100
+ if (files.length > 0) {
101
+ handleFileSelect(files[0])
102
+ }
103
+ },
104
+ [handleFileSelect],
105
+ )
106
+
107
+ const handleRemove = useCallback(() => {
108
+ onChange(null)
109
+ setPreviewUrl(null)
110
+ }, [onChange])
111
+
112
+ // Determine if value is File or ImageMetadata
113
+ // Use duck typing instead of instanceof to support SSR
114
+ const isFile =
115
+ value &&
116
+ typeof value === 'object' &&
117
+ 'arrayBuffer' in value &&
118
+ typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'
119
+ const isImageMetadata = value && !isFile && typeof value === 'object' && 'url' in value
120
+
121
+ // Read-only mode
122
+ if (mode === 'read') {
123
+ return (
124
+ <div className="space-y-2">
125
+ {label && (
126
+ <Label htmlFor={name}>
127
+ {label}
128
+ {required && <span className="text-destructive ml-1">*</span>}
129
+ </Label>
130
+ )}
131
+ {isImageMetadata ? (
132
+ <div className="space-y-2">
133
+ <div className="relative inline-block">
134
+ <Image
135
+ src={(value as ImageMetadata).url}
136
+ alt={(value as ImageMetadata).originalFilename}
137
+ width={previewSize}
138
+ height={previewSize}
139
+ className="rounded-md object-cover border"
140
+ style={{
141
+ maxWidth: `${previewSize}px`,
142
+ maxHeight: `${previewSize}px`,
143
+ }}
144
+ />
145
+ </div>
146
+ <div className="text-xs text-muted-foreground">
147
+ {(value as ImageMetadata).width} × {(value as ImageMetadata).height}px •{' '}
148
+ {formatFileSize((value as ImageMetadata).size)}
149
+ </div>
150
+ {(value as ImageMetadata).transformations &&
151
+ Object.keys((value as ImageMetadata).transformations!).length > 0 && (
152
+ <div className="space-y-1">
153
+ <p className="text-xs font-medium">Transformations:</p>
154
+ <div className="flex flex-wrap gap-2">
155
+ {Object.entries((value as ImageMetadata).transformations!).map(
156
+ ([name, transform]) => {
157
+ const t = transform as {
158
+ url: string
159
+ width: number
160
+ height: number
161
+ size: number
162
+ }
163
+ return (
164
+ <Button
165
+ key={name}
166
+ type="button"
167
+ variant="outline"
168
+ size="sm"
169
+ onClick={() => window.open(t.url, '_blank')}
170
+ >
171
+ {name} ({t.width}×{t.height})
172
+ </Button>
173
+ )
174
+ },
175
+ )}
176
+ </div>
177
+ </div>
178
+ )}
179
+ </div>
180
+ ) : (
181
+ <p className="text-sm text-muted-foreground">No image uploaded</p>
182
+ )}
183
+ </div>
184
+ )
185
+ }
186
+
187
+ // Edit mode
188
+ return (
189
+ <div className="space-y-2">
190
+ {label && (
191
+ <Label htmlFor={name}>
192
+ {label}
193
+ {required && <span className="text-destructive ml-1">*</span>}
194
+ </Label>
195
+ )}
196
+
197
+ {previewUrl || isImageMetadata ? (
198
+ // Image selected/uploaded or preview available - show preview
199
+ <div className="space-y-2">
200
+ <div className="relative inline-block group">
201
+ <Image
202
+ src={previewUrl || (value as ImageMetadata).url}
203
+ alt={isImageMetadata ? (value as ImageMetadata).originalFilename : 'Preview'}
204
+ width={previewSize}
205
+ height={previewSize}
206
+ className="rounded-md object-cover border"
207
+ style={{
208
+ maxWidth: `${previewSize}px`,
209
+ maxHeight: `${previewSize}px`,
210
+ }}
211
+ />
212
+ <div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
213
+ {isImageMetadata && (
214
+ <Button
215
+ type="button"
216
+ variant="secondary"
217
+ size="sm"
218
+ onClick={() => window.open((value as ImageMetadata).url, '_blank')}
219
+ >
220
+ <Eye className="h-3 w-3" />
221
+ </Button>
222
+ )}
223
+ <Button
224
+ type="button"
225
+ variant="destructive"
226
+ size="sm"
227
+ onClick={handleRemove}
228
+ disabled={disabled}
229
+ >
230
+ <X className="h-3 w-3" />
231
+ </Button>
232
+ </div>
233
+ </div>
234
+
235
+ {isFile && (
236
+ <div className="text-xs text-muted-foreground">
237
+ {(value as File).name} • {formatFileSize((value as File).size)} • Will upload on save
238
+ </div>
239
+ )}
240
+
241
+ {isImageMetadata && (
242
+ <>
243
+ <div className="text-xs text-muted-foreground">
244
+ {(value as ImageMetadata).originalFilename} • {(value as ImageMetadata).width} ×{' '}
245
+ {(value as ImageMetadata).height}px •{' '}
246
+ {formatFileSize((value as ImageMetadata).size)}
247
+ </div>
248
+
249
+ {(value as ImageMetadata).transformations &&
250
+ Object.keys((value as ImageMetadata).transformations!).length > 0 && (
251
+ <div className="space-y-1">
252
+ <p className="text-xs font-medium">Transformations:</p>
253
+ <div className="flex flex-wrap gap-2">
254
+ {Object.entries((value as ImageMetadata).transformations!).map(
255
+ ([name, transform]) => {
256
+ const t = transform as {
257
+ url: string
258
+ width: number
259
+ height: number
260
+ size: number
261
+ }
262
+ return (
263
+ <Button
264
+ key={name}
265
+ type="button"
266
+ variant="outline"
267
+ size="sm"
268
+ onClick={() => window.open(t.url, '_blank')}
269
+ >
270
+ {name} ({t.width}×{t.height})
271
+ </Button>
272
+ )
273
+ },
274
+ )}
275
+ </div>
276
+ </div>
277
+ )}
278
+ </>
279
+ )}
280
+ </div>
281
+ ) : (
282
+ // No image - show upload area
283
+ <>
284
+ <div
285
+ className={`
286
+ relative border-2 border-dashed rounded-md p-6
287
+ transition-colors cursor-pointer
288
+ ${isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
289
+ ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary hover:bg-primary/5'}
290
+ `}
291
+ onDragOver={handleDragOver}
292
+ onDragLeave={handleDragLeave}
293
+ onDrop={handleDrop}
294
+ >
295
+ <Input
296
+ id={name}
297
+ type="file"
298
+ accept="image/*"
299
+ onChange={handleInputChange}
300
+ disabled={disabled}
301
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
302
+ />
303
+
304
+ <div className="flex flex-col items-center gap-2 text-center">
305
+ {showPreview ? (
306
+ <ImageIcon className="h-8 w-8 text-muted-foreground" />
307
+ ) : (
308
+ <Upload className="h-8 w-8 text-muted-foreground" />
309
+ )}
310
+ <p className="text-sm font-medium">{placeholder}</p>
311
+ {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
312
+ </div>
313
+ </div>
314
+ </>
315
+ )}
316
+
317
+ {error && <p className="text-sm text-destructive">{error}</p>}
318
+ </div>
319
+ )
320
+ }
321
+
322
+ function formatFileSize(bytes: number): string {
323
+ if (bytes === 0) return '0 Bytes'
324
+ const k = 1024
325
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
326
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
327
+ return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`
328
+ }
@@ -0,0 +1,114 @@
1
+ 'use client'
2
+
3
+ import { useState, useMemo } from 'react'
4
+ import { Textarea } from '../../primitives/textarea.js'
5
+ import { Label } from '../../primitives/label.js'
6
+ import { cn } from '../../lib/utils.js'
7
+
8
+ export interface JsonFieldProps {
9
+ name: string
10
+ value: unknown
11
+ onChange: (value: unknown) => void
12
+ label: string
13
+ placeholder?: string
14
+ error?: string
15
+ disabled?: boolean
16
+ required?: boolean
17
+ mode?: 'read' | 'edit'
18
+ rows?: number
19
+ formatted?: boolean
20
+ }
21
+
22
+ export function JsonField({
23
+ name,
24
+ value,
25
+ onChange,
26
+ label,
27
+ placeholder = 'Enter JSON data...',
28
+ error,
29
+ disabled,
30
+ required,
31
+ mode = 'edit',
32
+ rows = 8,
33
+ formatted = true,
34
+ }: JsonFieldProps) {
35
+ // Track the string being edited separately from the prop value
36
+ const [editingValue, setEditingValue] = useState<string | null>(null)
37
+ const [parseError, setParseError] = useState<string | undefined>()
38
+
39
+ // Compute the display value - either what's being edited or the prop value
40
+ const displayValue = useMemo(() => {
41
+ if (editingValue !== null) {
42
+ return editingValue
43
+ }
44
+ try {
45
+ return value !== undefined && value !== null
46
+ ? JSON.stringify(value, null, formatted ? 2 : 0)
47
+ : ''
48
+ } catch {
49
+ return ''
50
+ }
51
+ }, [value, formatted, editingValue])
52
+
53
+ const handleChange = (text: string) => {
54
+ setEditingValue(text)
55
+
56
+ // Try to parse and update value
57
+ if (text.trim() === '') {
58
+ // Empty string - treat as null/undefined
59
+ onChange(undefined)
60
+ setParseError(undefined)
61
+ return
62
+ }
63
+
64
+ try {
65
+ const parsed = JSON.parse(text)
66
+ onChange(parsed)
67
+ setParseError(undefined)
68
+ } catch (e) {
69
+ // Invalid JSON - set error but don't update value
70
+ if (e instanceof Error) {
71
+ setParseError(`Invalid JSON: ${e.message}`)
72
+ } else {
73
+ setParseError('Invalid JSON')
74
+ }
75
+ }
76
+ }
77
+
78
+ const handleBlur = () => {
79
+ // Clear editing state on blur
80
+ setEditingValue(null)
81
+ }
82
+
83
+ if (mode === 'read') {
84
+ return (
85
+ <div className="space-y-1">
86
+ <Label className="text-muted-foreground">{label}</Label>
87
+ <pre className="text-sm bg-muted rounded-md p-3 overflow-x-auto">{displayValue || '-'}</pre>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ return (
93
+ <div className="space-y-2">
94
+ <Label htmlFor={name}>
95
+ {label}
96
+ {required && <span className="text-destructive ml-1">*</span>}
97
+ </Label>
98
+ <Textarea
99
+ id={name}
100
+ name={name}
101
+ value={displayValue}
102
+ onChange={(e) => handleChange(e.target.value)}
103
+ onBlur={handleBlur}
104
+ placeholder={placeholder}
105
+ disabled={disabled}
106
+ required={required}
107
+ rows={rows}
108
+ className={cn('font-mono text-sm', (error || parseError) && 'border-destructive')}
109
+ />
110
+ {parseError && <p className="text-sm text-amber-600">{parseError}</p>}
111
+ {error && <p className="text-sm text-destructive">{error}</p>}
112
+ </div>
113
+ )
114
+ }
@@ -6,9 +6,12 @@ export { SelectField } from './SelectField.js'
6
6
  export { TimestampField } from './TimestampField.js'
7
7
  export { PasswordField } from './PasswordField.js'
8
8
  export { RelationshipField } from './RelationshipField.js'
9
+ export { JsonField } from './JsonField.js'
9
10
  export { ComboboxField } from './ComboboxField.js'
10
11
  export { RelationshipManager } from './RelationshipManager.js'
11
12
  export { FieldRenderer } from './FieldRenderer.js'
13
+ export { FileField } from './FileField.js'
14
+ export { ImageField } from './ImageField.js'
12
15
 
13
16
  // Registry for custom field types
14
17
  export { fieldComponentRegistry, registerFieldComponent, getFieldComponent } from './registry.js'
@@ -21,7 +24,10 @@ export type { SelectFieldProps } from './SelectField.js'
21
24
  export type { TimestampFieldProps } from './TimestampField.js'
22
25
  export type { PasswordFieldProps } from './PasswordField.js'
23
26
  export type { RelationshipFieldProps } from './RelationshipField.js'
27
+ export type { JsonFieldProps } from './JsonField.js'
24
28
  export type { ComboboxFieldProps } from './ComboboxField.js'
25
29
  export type { RelationshipManagerProps } from './RelationshipManager.js'
26
30
  export type { FieldRendererProps } from './FieldRenderer.js'
31
+ export type { FileFieldProps } from './FileField.js'
32
+ export type { ImageFieldProps } from './ImageField.js'
27
33
  export type { FieldComponent, FieldComponentProps } from './registry.js'
@@ -6,6 +6,9 @@ import { SelectField } from './SelectField.js'
6
6
  import { TimestampField } from './TimestampField.js'
7
7
  import { PasswordField } from './PasswordField.js'
8
8
  import { RelationshipField } from './RelationshipField.js'
9
+ import { JsonField } from './JsonField.js'
10
+ import { FileField } from './FileField.js'
11
+ import { ImageField } from './ImageField.js'
9
12
 
10
13
  /**
11
14
  * Base props that all field components must accept
@@ -45,6 +48,9 @@ export const fieldComponentRegistry: Record<string, ComponentType<any>> = {
45
48
  timestamp: TimestampField,
46
49
  password: PasswordField,
47
50
  relationship: RelationshipField,
51
+ json: JsonField,
52
+ file: FileField,
53
+ image: ImageField,
48
54
  }
49
55
 
50
56
  /**
@@ -1,6 +1,7 @@
1
1
  // Primitive components from shadcn/ui
2
2
  export { Button, buttonVariants, type ButtonProps } from './button.js'
3
3
  export { Input, type InputProps } from './input.js'
4
+ export { Textarea, type TextareaProps } from './textarea.js'
4
5
  export { Label } from './label.js'
5
6
  export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card.js'
6
7
  export {
@@ -0,0 +1,24 @@
1
+ import * as React from 'react'
2
+
3
+ import { cn } from '../lib/utils.js'
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
6
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
7
+
8
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
9
+ ({ className, ...props }, ref) => {
10
+ return (
11
+ <textarea
12
+ className={cn(
13
+ 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
14
+ className,
15
+ )}
16
+ ref={ref}
17
+ {...props}
18
+ />
19
+ )
20
+ },
21
+ )
22
+ Textarea.displayName = 'Textarea'
23
+
24
+ export { Textarea }
package/vitest.config.ts CHANGED
@@ -10,7 +10,7 @@ export default defineConfig({
10
10
  setupFiles: ['./tests/setup.ts'],
11
11
  coverage: {
12
12
  provider: 'v8',
13
- reporter: ['text', 'json', 'html'],
13
+ reporter: ['text', 'json', 'html', 'json-summary'],
14
14
  exclude: ['node_modules/', 'tests/', 'dist/', '**/*.d.ts', '**/*.config.*', '**/index.ts'],
15
15
  },
16
16
  },