@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.
- package/.turbo/turbo-build.log +4 -2
- package/CHANGELOG.md +18 -0
- package/CLAUDE.md +311 -0
- package/LICENSE +21 -0
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +3 -1
- package/dist/components/fields/FileField.d.ts +21 -0
- package/dist/components/fields/FileField.d.ts.map +1 -0
- package/dist/components/fields/FileField.js +78 -0
- package/dist/components/fields/ImageField.d.ts +23 -0
- package/dist/components/fields/ImageField.d.ts.map +1 -0
- package/dist/components/fields/ImageField.js +107 -0
- package/dist/components/fields/JsonField.d.ts +15 -0
- package/dist/components/fields/JsonField.d.ts.map +1 -0
- package/dist/components/fields/JsonField.js +57 -0
- package/dist/components/fields/index.d.ts +6 -0
- package/dist/components/fields/index.d.ts.map +1 -1
- package/dist/components/fields/index.js +3 -0
- package/dist/components/fields/registry.d.ts.map +1 -1
- package/dist/components/fields/registry.js +6 -0
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/textarea.d.ts +6 -0
- package/dist/primitives/textarea.d.ts.map +1 -0
- package/dist/primitives/textarea.js +8 -0
- package/dist/styles/globals.css +89 -0
- package/package.json +5 -3
- package/src/components/ItemFormClient.tsx +3 -1
- package/src/components/fields/FileField.tsx +223 -0
- package/src/components/fields/ImageField.tsx +328 -0
- package/src/components/fields/JsonField.tsx +114 -0
- package/src/components/fields/index.ts +6 -0
- package/src/components/fields/registry.ts +6 -0
- package/src/primitives/index.ts +1 -0
- package/src/primitives/textarea.tsx +24 -0
- package/vitest.config.ts +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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
|
+
export function JsonField({ name, value, onChange, label, placeholder = 'Enter JSON data...', error, disabled, required, mode = 'edit', rows = 8, formatted = true, }) {
|
|
8
|
+
// Track the string being edited separately from the prop value
|
|
9
|
+
const [editingValue, setEditingValue] = useState(null);
|
|
10
|
+
const [parseError, setParseError] = useState();
|
|
11
|
+
// Compute the display value - either what's being edited or the prop value
|
|
12
|
+
const displayValue = useMemo(() => {
|
|
13
|
+
if (editingValue !== null) {
|
|
14
|
+
return editingValue;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return value !== undefined && value !== null
|
|
18
|
+
? JSON.stringify(value, null, formatted ? 2 : 0)
|
|
19
|
+
: '';
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
}, [value, formatted, editingValue]);
|
|
25
|
+
const handleChange = (text) => {
|
|
26
|
+
setEditingValue(text);
|
|
27
|
+
// Try to parse and update value
|
|
28
|
+
if (text.trim() === '') {
|
|
29
|
+
// Empty string - treat as null/undefined
|
|
30
|
+
onChange(undefined);
|
|
31
|
+
setParseError(undefined);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(text);
|
|
36
|
+
onChange(parsed);
|
|
37
|
+
setParseError(undefined);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
// Invalid JSON - set error but don't update value
|
|
41
|
+
if (e instanceof Error) {
|
|
42
|
+
setParseError(`Invalid JSON: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
setParseError('Invalid JSON');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const handleBlur = () => {
|
|
50
|
+
// Clear editing state on blur
|
|
51
|
+
setEditingValue(null);
|
|
52
|
+
};
|
|
53
|
+
if (mode === 'read') {
|
|
54
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-muted-foreground", children: label }), _jsx("pre", { className: "text-sm bg-muted rounded-md p-3 overflow-x-auto", children: displayValue || '-' })] }));
|
|
55
|
+
}
|
|
56
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: name, children: [label, required && _jsx("span", { className: "text-destructive ml-1", children: "*" })] }), _jsx(Textarea, { id: name, name: name, value: displayValue, onChange: (e) => handleChange(e.target.value), onBlur: handleBlur, placeholder: placeholder, disabled: disabled, required: required, rows: rows, className: cn('font-mono text-sm', (error || parseError) && 'border-destructive') }), parseError && _jsx("p", { className: "text-sm text-amber-600", children: parseError }), error && _jsx("p", { className: "text-sm text-destructive", children: error })] }));
|
|
57
|
+
}
|
|
@@ -5,9 +5,12 @@ export { SelectField } from './SelectField.js';
|
|
|
5
5
|
export { TimestampField } from './TimestampField.js';
|
|
6
6
|
export { PasswordField } from './PasswordField.js';
|
|
7
7
|
export { RelationshipField } from './RelationshipField.js';
|
|
8
|
+
export { JsonField } from './JsonField.js';
|
|
8
9
|
export { ComboboxField } from './ComboboxField.js';
|
|
9
10
|
export { RelationshipManager } from './RelationshipManager.js';
|
|
10
11
|
export { FieldRenderer } from './FieldRenderer.js';
|
|
12
|
+
export { FileField } from './FileField.js';
|
|
13
|
+
export { ImageField } from './ImageField.js';
|
|
11
14
|
export { fieldComponentRegistry, registerFieldComponent, getFieldComponent } from './registry.js';
|
|
12
15
|
export type { TextFieldProps } from './TextField.js';
|
|
13
16
|
export type { IntegerFieldProps } from './IntegerField.js';
|
|
@@ -16,8 +19,11 @@ export type { SelectFieldProps } from './SelectField.js';
|
|
|
16
19
|
export type { TimestampFieldProps } from './TimestampField.js';
|
|
17
20
|
export type { PasswordFieldProps } from './PasswordField.js';
|
|
18
21
|
export type { RelationshipFieldProps } from './RelationshipField.js';
|
|
22
|
+
export type { JsonFieldProps } from './JsonField.js';
|
|
19
23
|
export type { ComboboxFieldProps } from './ComboboxField.js';
|
|
20
24
|
export type { RelationshipManagerProps } from './RelationshipManager.js';
|
|
21
25
|
export type { FieldRendererProps } from './FieldRenderer.js';
|
|
26
|
+
export type { FileFieldProps } from './FileField.js';
|
|
27
|
+
export type { ImageFieldProps } from './ImageField.js';
|
|
22
28
|
export type { FieldComponent, FieldComponentProps } from './registry.js';
|
|
23
29
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/fields/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/fields/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAG5C,OAAO,EAAE,sBAAsB,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAGjG,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAC1D,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,YAAY,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AACxD,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAC9D,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AACpE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,YAAY,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAA;AACxE,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACtD,YAAY,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAA"}
|
|
@@ -6,8 +6,11 @@ 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
|
// Registry for custom field types
|
|
13
16
|
export { fieldComponentRegistry, registerFieldComponent, getFieldComponent } from './registry.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/components/fields/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/components/fields/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;AAY1C;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,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,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;CACvB,CAAA;AAED;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,aAAa,CAAC,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;AAEzF;;;;GAIG;AAEH,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,GAAG,CAAC,CAWrE,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EAEjB,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,GAC5B,IAAI,CAEN;AAED;;;GAGG;AAEH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,SAAS,CAEnF"}
|
|
@@ -5,6 +5,9 @@ import { SelectField } from './SelectField.js';
|
|
|
5
5
|
import { TimestampField } from './TimestampField.js';
|
|
6
6
|
import { PasswordField } from './PasswordField.js';
|
|
7
7
|
import { RelationshipField } from './RelationshipField.js';
|
|
8
|
+
import { JsonField } from './JsonField.js';
|
|
9
|
+
import { FileField } from './FileField.js';
|
|
10
|
+
import { ImageField } from './ImageField.js';
|
|
8
11
|
/**
|
|
9
12
|
* Registry mapping field types to their default UI components
|
|
10
13
|
* This can be extended for custom field types
|
|
@@ -19,6 +22,9 @@ export const fieldComponentRegistry = {
|
|
|
19
22
|
timestamp: TimestampField,
|
|
20
23
|
password: PasswordField,
|
|
21
24
|
relationship: RelationshipField,
|
|
25
|
+
json: JsonField,
|
|
26
|
+
file: FileField,
|
|
27
|
+
image: ImageField,
|
|
22
28
|
};
|
|
23
29
|
/**
|
|
24
30
|
* Register a custom field component for a field type
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Button, buttonVariants, type ButtonProps } from './button.js';
|
|
2
2
|
export { Input, type InputProps } from './input.js';
|
|
3
|
+
export { Textarea, type TextareaProps } from './textarea.js';
|
|
3
4
|
export { Label } from './label.js';
|
|
4
5
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card.js';
|
|
5
6
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption, } from './table.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/primitives/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAA;AACtE,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAA;AACnD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACjG,OAAO,EACL,KAAK,EACL,WAAW,EACX,SAAS,EACT,WAAW,EACX,SAAS,EACT,QAAQ,EACR,SAAS,EACT,YAAY,GACb,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,MAAM,EACN,YAAY,EACZ,aAAa,EACb,WAAW,EACX,aAAa,EACb,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,MAAM,EACN,WAAW,EACX,WAAW,EACX,aAAa,EACb,aAAa,EACb,WAAW,EACX,UAAU,EACV,eAAe,EACf,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACtE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAC5D,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC/E,OAAO,EACL,QAAQ,EACR,eAAe,EACf,eAAe,EACf,cAAc,EACd,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,iBAAiB,GAClB,MAAM,eAAe,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/primitives/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAA;AACtE,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAA;AACnD,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAC5D,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACjG,OAAO,EACL,KAAK,EACL,WAAW,EACX,SAAS,EACT,WAAW,EACX,SAAS,EACT,QAAQ,EACR,SAAS,EACT,YAAY,GACb,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,MAAM,EACN,YAAY,EACZ,aAAa,EACb,WAAW,EACX,aAAa,EACb,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,MAAM,EACN,WAAW,EACX,WAAW,EACX,aAAa,EACb,aAAa,EACb,WAAW,EACX,UAAU,EACV,eAAe,EACf,oBAAoB,EACpB,sBAAsB,GACvB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AACtE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAC5D,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AACnE,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC/E,OAAO,EACL,QAAQ,EACR,eAAe,EACf,eAAe,EACf,cAAc,EACd,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,iBAAiB,GAClB,MAAM,eAAe,CAAA"}
|
package/dist/primitives/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Primitive components from shadcn/ui
|
|
2
2
|
export { Button, buttonVariants } from './button.js';
|
|
3
3
|
export { Input } from './input.js';
|
|
4
|
+
export { Textarea } 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 { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption, } from './table.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
3
|
+
}
|
|
4
|
+
declare const Textarea: React.ForwardRefExoticComponent<TextareaProps & React.RefAttributes<HTMLTextAreaElement>>;
|
|
5
|
+
export { Textarea };
|
|
6
|
+
//# sourceMappingURL=textarea.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"textarea.d.ts","sourceRoot":"","sources":["../../src/primitives/textarea.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,MAAM,WAAW,aAAc,SAAQ,KAAK,CAAC,sBAAsB,CAAC,mBAAmB,CAAC;CAAG;AAE3F,QAAA,MAAM,QAAQ,2FAab,CAAA;AAGD,OAAO,EAAE,QAAQ,EAAE,CAAA"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { cn } from '../lib/utils.js';
|
|
4
|
+
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
|
5
|
+
return (_jsx("textarea", { className: cn('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', className), ref: ref, ...props }));
|
|
6
|
+
});
|
|
7
|
+
Textarea.displayName = 'Textarea';
|
|
8
|
+
export { Textarea };
|
package/dist/styles/globals.css
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
8
8
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
|
9
9
|
"Courier New", monospace;
|
|
10
|
+
--color-amber-600: oklch(66.6% 0.179 58.318);
|
|
11
|
+
--color-green-600: oklch(62.7% 0.194 149.214);
|
|
10
12
|
--color-gray-200: oklch(92.8% 0.006 264.531);
|
|
11
13
|
--color-black: #000;
|
|
12
14
|
--color-white: #fff;
|
|
@@ -274,6 +276,9 @@
|
|
|
274
276
|
.top-1\/2 {
|
|
275
277
|
top: calc(1/2 * 100%);
|
|
276
278
|
}
|
|
279
|
+
.top-2 {
|
|
280
|
+
top: calc(var(--spacing) * 2);
|
|
281
|
+
}
|
|
277
282
|
.top-4 {
|
|
278
283
|
top: calc(var(--spacing) * 4);
|
|
279
284
|
}
|
|
@@ -361,6 +366,9 @@
|
|
|
361
366
|
.table {
|
|
362
367
|
display: table;
|
|
363
368
|
}
|
|
369
|
+
.h-3 {
|
|
370
|
+
height: calc(var(--spacing) * 3);
|
|
371
|
+
}
|
|
364
372
|
.h-3\.5 {
|
|
365
373
|
height: calc(var(--spacing) * 3.5);
|
|
366
374
|
}
|
|
@@ -406,12 +414,18 @@
|
|
|
406
414
|
.max-h-\[300px\] {
|
|
407
415
|
max-height: 300px;
|
|
408
416
|
}
|
|
417
|
+
.min-h-\[80px\] {
|
|
418
|
+
min-height: 80px;
|
|
419
|
+
}
|
|
409
420
|
.min-h-screen {
|
|
410
421
|
min-height: 100vh;
|
|
411
422
|
}
|
|
412
423
|
.w-2\/3 {
|
|
413
424
|
width: calc(2/3 * 100%);
|
|
414
425
|
}
|
|
426
|
+
.w-3 {
|
|
427
|
+
width: calc(var(--spacing) * 3);
|
|
428
|
+
}
|
|
415
429
|
.w-3\.5 {
|
|
416
430
|
width: calc(var(--spacing) * 3.5);
|
|
417
431
|
}
|
|
@@ -499,6 +513,9 @@
|
|
|
499
513
|
--tw-translate-y: -50%;
|
|
500
514
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
|
501
515
|
}
|
|
516
|
+
.transform {
|
|
517
|
+
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
|
518
|
+
}
|
|
502
519
|
.animate-pulse {
|
|
503
520
|
animation: var(--animate-pulse);
|
|
504
521
|
}
|
|
@@ -508,6 +525,9 @@
|
|
|
508
525
|
.cursor-default {
|
|
509
526
|
cursor: default;
|
|
510
527
|
}
|
|
528
|
+
.cursor-not-allowed {
|
|
529
|
+
cursor: not-allowed;
|
|
530
|
+
}
|
|
511
531
|
.cursor-pointer {
|
|
512
532
|
cursor: pointer;
|
|
513
533
|
}
|
|
@@ -707,6 +727,18 @@
|
|
|
707
727
|
.border-input {
|
|
708
728
|
border-color: light-dark(var(--color-input-light), var(--color-input-dark));
|
|
709
729
|
}
|
|
730
|
+
.border-muted-foreground\/25 {
|
|
731
|
+
border-color: color-mix(in srgb, light-dark(
|
|
732
|
+
oklch(0.5 0.01 264),
|
|
733
|
+
oklch(0.6 0.01 264)
|
|
734
|
+
) 25%, transparent);
|
|
735
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
736
|
+
border-color: color-mix(in oklab, light-dark(
|
|
737
|
+
var(--color-muted-foreground-light),
|
|
738
|
+
var(--color-muted-foreground-dark)
|
|
739
|
+
) 25%, transparent);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
710
742
|
.border-primary {
|
|
711
743
|
border-color: light-dark(var(--color-primary-light), var(--color-primary-dark));
|
|
712
744
|
}
|
|
@@ -767,6 +799,12 @@
|
|
|
767
799
|
.bg-primary {
|
|
768
800
|
background-color: light-dark(var(--color-primary-light), var(--color-primary-dark));
|
|
769
801
|
}
|
|
802
|
+
.bg-primary\/5 {
|
|
803
|
+
background-color: color-mix(in srgb, light-dark(oklch(0.68 0.19 210), oklch(0.72 0.2 210)) 5%, transparent);
|
|
804
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
805
|
+
background-color: color-mix(in oklab, light-dark(var(--color-primary-light), var(--color-primary-dark)) 5%, transparent);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
770
808
|
.bg-primary\/10 {
|
|
771
809
|
background-color: color-mix(in srgb, light-dark(oklch(0.68 0.19 210), oklch(0.72 0.2 210)) 10%, transparent);
|
|
772
810
|
@supports (color: color-mix(in lab, red, red)) {
|
|
@@ -857,6 +895,9 @@
|
|
|
857
895
|
.bg-clip-text {
|
|
858
896
|
background-clip: text;
|
|
859
897
|
}
|
|
898
|
+
.object-cover {
|
|
899
|
+
object-fit: cover;
|
|
900
|
+
}
|
|
860
901
|
.p-0 {
|
|
861
902
|
padding: calc(var(--spacing) * 0);
|
|
862
903
|
}
|
|
@@ -947,6 +988,9 @@
|
|
|
947
988
|
.align-middle {
|
|
948
989
|
vertical-align: middle;
|
|
949
990
|
}
|
|
991
|
+
.font-mono {
|
|
992
|
+
font-family: var(--font-mono);
|
|
993
|
+
}
|
|
950
994
|
.text-2xl {
|
|
951
995
|
font-size: var(--text-2xl);
|
|
952
996
|
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
|
@@ -1016,6 +1060,9 @@
|
|
|
1016
1060
|
var(--color-accent-foreground-dark)
|
|
1017
1061
|
);
|
|
1018
1062
|
}
|
|
1063
|
+
.text-amber-600 {
|
|
1064
|
+
color: var(--color-amber-600);
|
|
1065
|
+
}
|
|
1019
1066
|
.text-card-foreground {
|
|
1020
1067
|
color: light-dark(
|
|
1021
1068
|
var(--color-card-foreground-light),
|
|
@@ -1037,6 +1084,9 @@
|
|
|
1037
1084
|
.text-foreground {
|
|
1038
1085
|
color: light-dark(var(--color-foreground-light), var(--color-foreground-dark));
|
|
1039
1086
|
}
|
|
1087
|
+
.text-green-600 {
|
|
1088
|
+
color: var(--color-green-600);
|
|
1089
|
+
}
|
|
1040
1090
|
.text-muted-foreground {
|
|
1041
1091
|
color: light-dark(
|
|
1042
1092
|
var(--color-muted-foreground-light),
|
|
@@ -1124,6 +1174,10 @@
|
|
|
1124
1174
|
outline-style: var(--tw-outline-style);
|
|
1125
1175
|
outline-width: 1px;
|
|
1126
1176
|
}
|
|
1177
|
+
.blur {
|
|
1178
|
+
--tw-blur: blur(8px);
|
|
1179
|
+
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
|
1180
|
+
}
|
|
1127
1181
|
.filter {
|
|
1128
1182
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
|
1129
1183
|
}
|
|
@@ -1288,6 +1342,16 @@
|
|
|
1288
1342
|
}
|
|
1289
1343
|
}
|
|
1290
1344
|
}
|
|
1345
|
+
.hover\:bg-primary\/5 {
|
|
1346
|
+
&:hover {
|
|
1347
|
+
@media (hover: hover) {
|
|
1348
|
+
background-color: color-mix(in srgb, light-dark(oklch(0.68 0.19 210), oklch(0.72 0.2 210)) 5%, transparent);
|
|
1349
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
1350
|
+
background-color: color-mix(in oklab, light-dark(var(--color-primary-light), var(--color-primary-dark)) 5%, transparent);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1291
1355
|
.hover\:bg-primary\/90 {
|
|
1292
1356
|
&:hover {
|
|
1293
1357
|
@media (hover: hover) {
|
|
@@ -1629,6 +1693,26 @@ body {
|
|
|
1629
1693
|
inherits: false;
|
|
1630
1694
|
initial-value: 0;
|
|
1631
1695
|
}
|
|
1696
|
+
@property --tw-rotate-x {
|
|
1697
|
+
syntax: "*";
|
|
1698
|
+
inherits: false;
|
|
1699
|
+
}
|
|
1700
|
+
@property --tw-rotate-y {
|
|
1701
|
+
syntax: "*";
|
|
1702
|
+
inherits: false;
|
|
1703
|
+
}
|
|
1704
|
+
@property --tw-rotate-z {
|
|
1705
|
+
syntax: "*";
|
|
1706
|
+
inherits: false;
|
|
1707
|
+
}
|
|
1708
|
+
@property --tw-skew-x {
|
|
1709
|
+
syntax: "*";
|
|
1710
|
+
inherits: false;
|
|
1711
|
+
}
|
|
1712
|
+
@property --tw-skew-y {
|
|
1713
|
+
syntax: "*";
|
|
1714
|
+
inherits: false;
|
|
1715
|
+
}
|
|
1632
1716
|
@property --tw-space-y-reverse {
|
|
1633
1717
|
syntax: "*";
|
|
1634
1718
|
inherits: false;
|
|
@@ -1846,6 +1930,11 @@ body {
|
|
|
1846
1930
|
--tw-translate-x: 0;
|
|
1847
1931
|
--tw-translate-y: 0;
|
|
1848
1932
|
--tw-translate-z: 0;
|
|
1933
|
+
--tw-rotate-x: initial;
|
|
1934
|
+
--tw-rotate-y: initial;
|
|
1935
|
+
--tw-rotate-z: initial;
|
|
1936
|
+
--tw-skew-x: initial;
|
|
1937
|
+
--tw-skew-y: initial;
|
|
1849
1938
|
--tw-space-y-reverse: 0;
|
|
1850
1939
|
--tw-space-x-reverse: 0;
|
|
1851
1940
|
--tw-divide-y-reverse: 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Composable React UI components for OpenSaas Stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"next": "^15.0.0 || ^16.0.0",
|
|
46
46
|
"react": "^19.0.0",
|
|
47
47
|
"react-dom": "^19.0.0",
|
|
48
|
-
"@opensaas/stack-core": "0.1.
|
|
48
|
+
"@opensaas/stack-core": "0.1.2"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@radix-ui/react-checkbox": "^1.3.3",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"class-variance-authority": "^0.7.1",
|
|
60
60
|
"clsx": "^2.1.1",
|
|
61
61
|
"date-fns": "^4.1.0",
|
|
62
|
+
"lucide-react": "^0.552.0",
|
|
62
63
|
"react-hook-form": "^7.54.2",
|
|
63
64
|
"tailwind-merge": "^3.3.1"
|
|
64
65
|
},
|
|
@@ -71,13 +72,14 @@
|
|
|
71
72
|
"@types/react": "^19.2.2",
|
|
72
73
|
"@types/react-dom": "^19.2.2",
|
|
73
74
|
"@vitejs/plugin-react": "^5.0.0",
|
|
75
|
+
"@vitest/coverage-v8": "^4.0.4",
|
|
74
76
|
"happy-dom": "^20.0.0",
|
|
75
77
|
"postcss": "^8.4.49",
|
|
76
78
|
"postcss-cli": "^11.0.0",
|
|
77
79
|
"tailwindcss": "^4.0.0",
|
|
78
80
|
"typescript": "^5.9.3",
|
|
79
81
|
"vitest": "^4.0.0",
|
|
80
|
-
"@opensaas/stack-core": "0.1.
|
|
82
|
+
"@opensaas/stack-core": "0.1.2"
|
|
81
83
|
},
|
|
82
84
|
"scripts": {
|
|
83
85
|
"build": "tsc && npm run build:css",
|
|
@@ -65,6 +65,7 @@ export function ItemFormClient({
|
|
|
65
65
|
try {
|
|
66
66
|
// Transform relationship fields to Prisma format
|
|
67
67
|
// Filter out password fields with isSet objects (unchanged passwords)
|
|
68
|
+
// File/Image fields: pass File objects through (Next.js will serialize them)
|
|
68
69
|
const transformedData: Record<string, unknown> = {}
|
|
69
70
|
for (const [fieldName, value] of Object.entries(formData)) {
|
|
70
71
|
const fieldConfig = fields[fieldName]
|
|
@@ -93,7 +94,8 @@ export function ItemFormClient({
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
} else {
|
|
96
|
-
// Non-relationship field: pass through
|
|
97
|
+
// Non-relationship field: pass through (including File objects for file/image fields)
|
|
98
|
+
// File objects will be serialized by Next.js server action
|
|
97
99
|
transformedData[fieldName] = value
|
|
98
100
|
}
|
|
99
101
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useState } from 'react'
|
|
4
|
+
import type { FileMetadata } 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, File, Check } from 'lucide-react'
|
|
9
|
+
|
|
10
|
+
export interface FileFieldProps {
|
|
11
|
+
name: string
|
|
12
|
+
value: File | FileMetadata | null
|
|
13
|
+
onChange: (value: File | FileMetadata | null) => void
|
|
14
|
+
label?: string
|
|
15
|
+
error?: string
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
required?: boolean
|
|
18
|
+
mode?: 'read' | 'edit'
|
|
19
|
+
helpText?: string
|
|
20
|
+
placeholder?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* File upload field with drag-and-drop support
|
|
25
|
+
*
|
|
26
|
+
* Stores File objects in form state. The actual upload happens server-side
|
|
27
|
+
* during form submission via field hooks.
|
|
28
|
+
*/
|
|
29
|
+
export function FileField({
|
|
30
|
+
name,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
label,
|
|
34
|
+
error,
|
|
35
|
+
disabled,
|
|
36
|
+
required,
|
|
37
|
+
mode = 'edit',
|
|
38
|
+
helpText,
|
|
39
|
+
placeholder = 'Choose a file or drag and drop',
|
|
40
|
+
}: FileFieldProps) {
|
|
41
|
+
const [isDragOver, setIsDragOver] = useState(false)
|
|
42
|
+
|
|
43
|
+
const handleFileSelect = useCallback(
|
|
44
|
+
(file: File) => {
|
|
45
|
+
// Store File object in form state
|
|
46
|
+
// Upload will happen server-side during form submission
|
|
47
|
+
onChange(file)
|
|
48
|
+
},
|
|
49
|
+
[onChange],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
53
|
+
e.preventDefault()
|
|
54
|
+
setIsDragOver(true)
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
setIsDragOver(false)
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
const handleDrop = useCallback(
|
|
63
|
+
(e: React.DragEvent) => {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
setIsDragOver(false)
|
|
66
|
+
|
|
67
|
+
if (disabled || mode === 'read') return
|
|
68
|
+
|
|
69
|
+
const files = Array.from(e.dataTransfer.files)
|
|
70
|
+
if (files.length > 0) {
|
|
71
|
+
handleFileSelect(files[0])
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[disabled, mode, handleFileSelect],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const handleInputChange = useCallback(
|
|
78
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
79
|
+
const files = Array.from(e.target.files || [])
|
|
80
|
+
if (files.length > 0) {
|
|
81
|
+
handleFileSelect(files[0])
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
[handleFileSelect],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const handleRemove = useCallback(() => {
|
|
88
|
+
onChange(null)
|
|
89
|
+
}, [onChange])
|
|
90
|
+
|
|
91
|
+
// Determine if value is File or FileMetadata
|
|
92
|
+
// Use duck typing instead of instanceof to support SSR
|
|
93
|
+
const isFile =
|
|
94
|
+
value &&
|
|
95
|
+
typeof value === 'object' &&
|
|
96
|
+
'arrayBuffer' in value &&
|
|
97
|
+
typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'
|
|
98
|
+
const isFileMetadata = value && !isFile && typeof value === 'object' && 'url' in value
|
|
99
|
+
|
|
100
|
+
// Read-only mode
|
|
101
|
+
if (mode === 'read') {
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-2">
|
|
104
|
+
{label && (
|
|
105
|
+
<Label htmlFor={name}>
|
|
106
|
+
{label}
|
|
107
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
108
|
+
</Label>
|
|
109
|
+
)}
|
|
110
|
+
{isFileMetadata ? (
|
|
111
|
+
<div className="flex items-center gap-2 p-3 border rounded-md bg-muted">
|
|
112
|
+
<File className="h-4 w-4" />
|
|
113
|
+
<div className="flex-1 min-w-0">
|
|
114
|
+
<p className="text-sm font-medium truncate">
|
|
115
|
+
{(value as FileMetadata).originalFilename}
|
|
116
|
+
</p>
|
|
117
|
+
<p className="text-xs text-muted-foreground">
|
|
118
|
+
{formatFileSize((value as FileMetadata).size)}
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
<Button
|
|
122
|
+
type="button"
|
|
123
|
+
variant="outline"
|
|
124
|
+
size="sm"
|
|
125
|
+
onClick={() => window.open((value as FileMetadata).url, '_blank')}
|
|
126
|
+
>
|
|
127
|
+
Download
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<p className="text-sm text-muted-foreground">No file uploaded</p>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Edit mode
|
|
138
|
+
return (
|
|
139
|
+
<div className="space-y-2">
|
|
140
|
+
{label && (
|
|
141
|
+
<Label htmlFor={name}>
|
|
142
|
+
{label}
|
|
143
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
144
|
+
</Label>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{isFile || isFileMetadata ? (
|
|
148
|
+
// File selected/uploaded - show file info
|
|
149
|
+
<div className="flex items-center gap-2 p-3 border rounded-md">
|
|
150
|
+
<Check className="h-4 w-4 text-green-600" />
|
|
151
|
+
<div className="flex-1 min-w-0">
|
|
152
|
+
<p className="text-sm font-medium truncate">
|
|
153
|
+
{isFile ? (value as File).name : (value as FileMetadata).originalFilename}
|
|
154
|
+
</p>
|
|
155
|
+
<p className="text-xs text-muted-foreground">
|
|
156
|
+
{formatFileSize(isFile ? (value as File).size : (value as FileMetadata).size)}
|
|
157
|
+
{isFileMetadata && ` • ${(value as FileMetadata).mimeType}`}
|
|
158
|
+
{isFile && ' • Will upload on save'}
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
{isFileMetadata && (
|
|
162
|
+
<Button
|
|
163
|
+
type="button"
|
|
164
|
+
variant="outline"
|
|
165
|
+
size="sm"
|
|
166
|
+
onClick={() => window.open((value as FileMetadata).url, '_blank')}
|
|
167
|
+
>
|
|
168
|
+
View
|
|
169
|
+
</Button>
|
|
170
|
+
)}
|
|
171
|
+
<Button
|
|
172
|
+
type="button"
|
|
173
|
+
variant="ghost"
|
|
174
|
+
size="sm"
|
|
175
|
+
onClick={handleRemove}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
>
|
|
178
|
+
<X className="h-4 w-4" />
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
) : (
|
|
182
|
+
// No file - show upload area
|
|
183
|
+
<>
|
|
184
|
+
<div
|
|
185
|
+
className={`
|
|
186
|
+
relative border-2 border-dashed rounded-md p-6
|
|
187
|
+
transition-colors cursor-pointer
|
|
188
|
+
${isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
|
|
189
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary hover:bg-primary/5'}
|
|
190
|
+
`}
|
|
191
|
+
onDragOver={handleDragOver}
|
|
192
|
+
onDragLeave={handleDragLeave}
|
|
193
|
+
onDrop={handleDrop}
|
|
194
|
+
>
|
|
195
|
+
<Input
|
|
196
|
+
id={name}
|
|
197
|
+
type="file"
|
|
198
|
+
onChange={handleInputChange}
|
|
199
|
+
disabled={disabled}
|
|
200
|
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
201
|
+
/>
|
|
202
|
+
|
|
203
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
204
|
+
<Upload className="h-8 w-8 text-muted-foreground" />
|
|
205
|
+
<p className="text-sm font-medium">{placeholder}</p>
|
|
206
|
+
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatFileSize(bytes: number): string {
|
|
218
|
+
if (bytes === 0) return '0 Bytes'
|
|
219
|
+
const k = 1024
|
|
220
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
221
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
222
|
+
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`
|
|
223
|
+
}
|