react-admin-crud-manager 1.0.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 +40 -0
- package/package.json +55 -0
- package/src/App.jsx +5 -0
- package/src/components/Button/Button.jsx +85 -0
- package/src/components/Chip/Chip.jsx +71 -0
- package/src/components/CrudPage.jsx +532 -0
- package/src/components/Details/Details.jsx +134 -0
- package/src/components/Filter/FilterDrawer.jsx +99 -0
- package/src/components/Form/Form.jsx +51 -0
- package/src/components/Form/components/Checkbox.jsx +119 -0
- package/src/components/Form/components/ImagePicker.jsx +128 -0
- package/src/components/Form/components/Input.jsx +71 -0
- package/src/components/Form/components/InputLabel.jsx +12 -0
- package/src/components/Form/components/PhoneInput.jsx +221 -0
- package/src/components/Form/components/RenderFields.jsx +181 -0
- package/src/components/Form/components/Select.jsx +191 -0
- package/src/components/Form/components/Switch.jsx +64 -0
- package/src/components/Form/components/TextArea.jsx +31 -0
- package/src/components/Form/components/TinyEditor.jsx +113 -0
- package/src/components/Loader/Spinner.jsx +21 -0
- package/src/components/Modal/Modal.jsx +152 -0
- package/src/components/Table/Table.jsx +554 -0
- package/src/components/Table/components/ImagePreview.jsx +58 -0
- package/src/components/Table/components/TableSkeleton.jsx +39 -0
- package/src/data/countries.js +252 -0
- package/src/data/teams.js +130 -0
- package/src/index.css +170 -0
- package/src/lib/utils.js +74 -0
- package/src/main.jsx +11 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Select from "./Select";
|
|
3
|
+
import Switch from "./Switch";
|
|
4
|
+
import PhoneInput from "./PhoneInput";
|
|
5
|
+
import { TextArea } from "./TextArea";
|
|
6
|
+
import ImagePicker from "./ImagePicker";
|
|
7
|
+
import { Input } from "./Input";
|
|
8
|
+
import TinyEditor from "./TinyEditor";
|
|
9
|
+
import Checkbox from "./Checkbox";
|
|
10
|
+
|
|
11
|
+
const RenderFields = ({ field, formData, handleChange }) => {
|
|
12
|
+
const {
|
|
13
|
+
key,
|
|
14
|
+
label,
|
|
15
|
+
type,
|
|
16
|
+
options,
|
|
17
|
+
placeholder,
|
|
18
|
+
rows,
|
|
19
|
+
inputClass,
|
|
20
|
+
search,
|
|
21
|
+
accept,
|
|
22
|
+
text,
|
|
23
|
+
required = false,
|
|
24
|
+
minLength,
|
|
25
|
+
dragDrop,
|
|
26
|
+
parentClass,
|
|
27
|
+
countriesList,
|
|
28
|
+
defaultCountry,
|
|
29
|
+
multiple,
|
|
30
|
+
dropdownMaxHeight,
|
|
31
|
+
editorKey,
|
|
32
|
+
fontFamily,
|
|
33
|
+
disabled,
|
|
34
|
+
} = field;
|
|
35
|
+
|
|
36
|
+
let value = formData?.[key];
|
|
37
|
+
if (value === undefined || value === null) {
|
|
38
|
+
value = "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const finalPlaceholder =
|
|
42
|
+
placeholder || (type === "select" ? `Select ${label}` : `Enter ${label}`);
|
|
43
|
+
|
|
44
|
+
const baseClass =
|
|
45
|
+
"w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-sm focus:outline-none focus:ring-1 focus:ring-blue-200 bg-white text-black dark:bg-gray-700 dark:text-white";
|
|
46
|
+
|
|
47
|
+
switch (type) {
|
|
48
|
+
case "select":
|
|
49
|
+
return (
|
|
50
|
+
<Select
|
|
51
|
+
options={options || []}
|
|
52
|
+
value={value}
|
|
53
|
+
onChange={(val) => handleChange(key, val)}
|
|
54
|
+
placeholder={finalPlaceholder}
|
|
55
|
+
className={inputClass || ""}
|
|
56
|
+
search={search}
|
|
57
|
+
required={required}
|
|
58
|
+
label={label}
|
|
59
|
+
name={key}
|
|
60
|
+
disabled={disabled}
|
|
61
|
+
parentClass={parentClass}
|
|
62
|
+
multiple={multiple}
|
|
63
|
+
dropdownMaxHeight={dropdownMaxHeight}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
case "checkbox":
|
|
68
|
+
return (
|
|
69
|
+
<Checkbox
|
|
70
|
+
name={key}
|
|
71
|
+
label={label}
|
|
72
|
+
options={options || []}
|
|
73
|
+
value={value}
|
|
74
|
+
onChange={(val) => handleChange(key, val)}
|
|
75
|
+
required={required}
|
|
76
|
+
parentClass={parentClass}
|
|
77
|
+
className={inputClass || ""}
|
|
78
|
+
multiSelect={multiple}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
case "switch":
|
|
84
|
+
return (
|
|
85
|
+
<Switch
|
|
86
|
+
value={value}
|
|
87
|
+
onChange={(val) => handleChange(key, val)}
|
|
88
|
+
text={text}
|
|
89
|
+
options={options || []}
|
|
90
|
+
label={label}
|
|
91
|
+
required={required}
|
|
92
|
+
name={key}
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
parentClass={parentClass}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
case "phone":
|
|
99
|
+
return (
|
|
100
|
+
<PhoneInput
|
|
101
|
+
value={value}
|
|
102
|
+
onChange={(val) => handleChange(key, val)}
|
|
103
|
+
countriesList={countriesList}
|
|
104
|
+
defaultCountry={defaultCountry}
|
|
105
|
+
required={required}
|
|
106
|
+
placeholder={finalPlaceholder}
|
|
107
|
+
search={search}
|
|
108
|
+
label={label}
|
|
109
|
+
name={key}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
parentClass={parentClass}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
case "textarea":
|
|
116
|
+
return (
|
|
117
|
+
<TextArea
|
|
118
|
+
value={value}
|
|
119
|
+
onChange={(e) => handleChange(key, e.target.value)}
|
|
120
|
+
placeholder={finalPlaceholder}
|
|
121
|
+
rows={rows || 3}
|
|
122
|
+
className={`${baseClass} ${inputClass || ""}`}
|
|
123
|
+
required={required}
|
|
124
|
+
name={key}
|
|
125
|
+
label={label}
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
parentClass={parentClass}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
case "image":
|
|
132
|
+
return (
|
|
133
|
+
<ImagePicker
|
|
134
|
+
value={value}
|
|
135
|
+
onChange={(imgObj) => handleChange(key, imgObj)}
|
|
136
|
+
required={required}
|
|
137
|
+
accept={accept || "image/*"}
|
|
138
|
+
id={`file-${key}`}
|
|
139
|
+
dragDrop={dragDrop}
|
|
140
|
+
label={label}
|
|
141
|
+
name={key}
|
|
142
|
+
parentClass={parentClass}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
case "tinyEditor":
|
|
147
|
+
return (
|
|
148
|
+
<TinyEditor
|
|
149
|
+
value={value}
|
|
150
|
+
onChange={(newValue) => handleChange(key, newValue)}
|
|
151
|
+
required={required}
|
|
152
|
+
key={`editor-${key}`}
|
|
153
|
+
placeholder={finalPlaceholder}
|
|
154
|
+
label={label}
|
|
155
|
+
parentClass={parentClass}
|
|
156
|
+
fontFamily={fontFamily}
|
|
157
|
+
editorKey={editorKey}
|
|
158
|
+
disabled={disabled}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
default:
|
|
163
|
+
return (
|
|
164
|
+
<Input
|
|
165
|
+
type={type || "text"}
|
|
166
|
+
value={value}
|
|
167
|
+
onChange={(e) => handleChange(key, e.target.value)}
|
|
168
|
+
placeholder={finalPlaceholder}
|
|
169
|
+
className={`${baseClass} ${inputClass || ""}`}
|
|
170
|
+
required={required}
|
|
171
|
+
name={key}
|
|
172
|
+
minLength={minLength}
|
|
173
|
+
label={label}
|
|
174
|
+
parentClass={parentClass}
|
|
175
|
+
disabled={disabled}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export default RenderFields;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { ChevronDown, Search, Check } from "lucide-react";
|
|
3
|
+
import InputLabel from "./InputLabel";
|
|
4
|
+
|
|
5
|
+
const Select = ({
|
|
6
|
+
options = [],
|
|
7
|
+
value,
|
|
8
|
+
onChange,
|
|
9
|
+
placeholder = "Select option",
|
|
10
|
+
className = "",
|
|
11
|
+
disabled = false,
|
|
12
|
+
search = false,
|
|
13
|
+
label = "",
|
|
14
|
+
required = false,
|
|
15
|
+
name = "",
|
|
16
|
+
parentClass = "",
|
|
17
|
+
multiple = false, // ✅ NEW
|
|
18
|
+
dropdownMaxHeight = "",
|
|
19
|
+
}) => {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
22
|
+
const [showAboveState, setShowAboveState] = useState(true);
|
|
23
|
+
const dropdownRef = useRef(null);
|
|
24
|
+
const searchInputRef = useRef(null);
|
|
25
|
+
|
|
26
|
+
const normalize = (val) =>
|
|
27
|
+
typeof val === "boolean" ? String(val) : String(val ?? "");
|
|
28
|
+
|
|
29
|
+
const normalizedValue = multiple
|
|
30
|
+
? (value || []).map(normalize)
|
|
31
|
+
: normalize(value);
|
|
32
|
+
|
|
33
|
+
const filteredOptions = options.filter((option) =>
|
|
34
|
+
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const isSelected = (optionValue) => {
|
|
38
|
+
const normalized = normalize(optionValue);
|
|
39
|
+
if (multiple) {
|
|
40
|
+
return normalizedValue.includes(normalized);
|
|
41
|
+
}
|
|
42
|
+
return normalized === normalizedValue;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const handleClickOutside = (event) => {
|
|
47
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
|
48
|
+
setOpen(false);
|
|
49
|
+
setSearchTerm("");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
53
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (open && dropdownRef.current) {
|
|
58
|
+
const rect = dropdownRef.current.getBoundingClientRect();
|
|
59
|
+
const viewportHeight = window.innerHeight;
|
|
60
|
+
const spaceBelow = viewportHeight - rect.bottom;
|
|
61
|
+
const dropdownHeight = 200;
|
|
62
|
+
setShowAboveState(spaceBelow < dropdownHeight);
|
|
63
|
+
}
|
|
64
|
+
}, [open]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (open && searchInputRef.current) {
|
|
68
|
+
searchInputRef.current.focus();
|
|
69
|
+
}
|
|
70
|
+
}, [open]);
|
|
71
|
+
|
|
72
|
+
const handleOptionClick = (optionValue) => {
|
|
73
|
+
let finalValue = optionValue;
|
|
74
|
+
if (optionValue === "true") finalValue = true;
|
|
75
|
+
else if (optionValue === "false") finalValue = false;
|
|
76
|
+
|
|
77
|
+
if (multiple) {
|
|
78
|
+
const exists = normalizedValue.includes(normalize(optionValue));
|
|
79
|
+
|
|
80
|
+
let newValues;
|
|
81
|
+
if (exists) {
|
|
82
|
+
newValues = value.filter(
|
|
83
|
+
(v) => normalize(v) !== normalize(optionValue),
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
newValues = [...(value || []), finalValue];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onChange(newValues);
|
|
90
|
+
} else {
|
|
91
|
+
onChange(finalValue);
|
|
92
|
+
setOpen(false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setSearchTerm("");
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const selectedLabels = multiple
|
|
99
|
+
? options
|
|
100
|
+
.filter((opt) => isSelected(opt.value))
|
|
101
|
+
.map((opt) => opt.label)
|
|
102
|
+
.join(", ")
|
|
103
|
+
: options.find((opt) => isSelected(opt.value))?.label;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div key={name} className={parentClass || "col-span-12"}>
|
|
107
|
+
<InputLabel label={label} required={required} />
|
|
108
|
+
|
|
109
|
+
<div className={`relative ${className}`} ref={dropdownRef}>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => !disabled && setOpen(!open)}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
className={`w-full h-10 px-3 border border-gray-300 dark:border-gray-600 rounded-md text-left text-sm flex items-center justify-between
|
|
115
|
+
${
|
|
116
|
+
!selectedLabels
|
|
117
|
+
? "text-gray-500 dark:text-gray-400"
|
|
118
|
+
: "dark:text-white"
|
|
119
|
+
}
|
|
120
|
+
${disabled ? "opacity-50 cursor-not-allowed" : "dark:bg-gray-700"}`}
|
|
121
|
+
>
|
|
122
|
+
<span className="truncate">{selectedLabels || placeholder}</span>
|
|
123
|
+
|
|
124
|
+
<ChevronDown
|
|
125
|
+
className={`w-4 h-4 transition-transform ${
|
|
126
|
+
open ? "rotate-180" : ""
|
|
127
|
+
}`}
|
|
128
|
+
/>
|
|
129
|
+
</button>
|
|
130
|
+
|
|
131
|
+
{open && (
|
|
132
|
+
<div
|
|
133
|
+
className={`absolute z-50 w-full border rounded-md bg-white dark:bg-gray-700 shadow-lg
|
|
134
|
+
${showAboveState ? "bottom-full mb-1" : "top-full mt-1"}`}
|
|
135
|
+
>
|
|
136
|
+
{search && (
|
|
137
|
+
<div className="p-2 border-b border-gray-200 dark:border-gray-600">
|
|
138
|
+
<div className="relative">
|
|
139
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
140
|
+
<input
|
|
141
|
+
ref={searchInputRef}
|
|
142
|
+
type="text"
|
|
143
|
+
value={searchTerm}
|
|
144
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
145
|
+
placeholder="Search..."
|
|
146
|
+
className="w-full pl-9 pr-3 py-2 text-sm border rounded-md bg-white dark:bg-gray-800 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none"
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<div
|
|
153
|
+
className={`max-h-40 overflow-y-auto`}
|
|
154
|
+
style={{
|
|
155
|
+
maxHeight: dropdownMaxHeight || "",
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{filteredOptions.length > 0 ? (
|
|
159
|
+
filteredOptions.map((option) => (
|
|
160
|
+
<button
|
|
161
|
+
key={String(option.value)}
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => handleOptionClick(String(option.value))}
|
|
164
|
+
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-600
|
|
165
|
+
${
|
|
166
|
+
isSelected(option.value)
|
|
167
|
+
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
|
|
168
|
+
: ""
|
|
169
|
+
}`}
|
|
170
|
+
>
|
|
171
|
+
<span>{option.label}</span>
|
|
172
|
+
|
|
173
|
+
{multiple && isSelected(option.value) && (
|
|
174
|
+
<Check className="w-4 h-4" />
|
|
175
|
+
)}
|
|
176
|
+
</button>
|
|
177
|
+
))
|
|
178
|
+
) : (
|
|
179
|
+
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
|
180
|
+
No options found
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export default Select;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import InputLabel from "./InputLabel";
|
|
3
|
+
|
|
4
|
+
const Switch = ({
|
|
5
|
+
value = true,
|
|
6
|
+
onChange,
|
|
7
|
+
text,
|
|
8
|
+
options = [],
|
|
9
|
+
label,
|
|
10
|
+
required,
|
|
11
|
+
name = "",
|
|
12
|
+
disabled = false,
|
|
13
|
+
parentClass = "",
|
|
14
|
+
}) => {
|
|
15
|
+
const radioOptions =
|
|
16
|
+
options.length > 0
|
|
17
|
+
? options
|
|
18
|
+
: [
|
|
19
|
+
{ label: "Active", value: true },
|
|
20
|
+
{ label: "Inactive", value: false },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<div key={name} className={parentClass || "col-span-12"}>
|
|
26
|
+
<InputLabel label={label} required={required} />
|
|
27
|
+
<div className="flex items-center justify-between h-10 gap-4 bg-gray-100 dark:bg-gray-700 px-3 rounded-md border border-gray-100 dark:border-gray-600">
|
|
28
|
+
{/* Left Text */}
|
|
29
|
+
{text && (
|
|
30
|
+
<p className="text-xs text-gray-600 dark:text-gray-400 flex-shrink overflow-hidden text-ellipsis whitespace-nowrap max-w-[200px]">
|
|
31
|
+
{text}
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{/* Radio Buttons */}
|
|
36
|
+
<div className="flex items-center gap-6">
|
|
37
|
+
{radioOptions.map((opt, idx) => (
|
|
38
|
+
<label
|
|
39
|
+
key={idx}
|
|
40
|
+
className="flex items-center gap-2 cursor-pointer select-none"
|
|
41
|
+
>
|
|
42
|
+
<input
|
|
43
|
+
type="radio"
|
|
44
|
+
name="switch-field"
|
|
45
|
+
required={required && idx === 0}
|
|
46
|
+
value={opt.value}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
checked={value === opt.value}
|
|
49
|
+
onChange={() => onChange(opt.value)}
|
|
50
|
+
className="w-4 h-4 border-gray-300 cursor-pointer"
|
|
51
|
+
/>
|
|
52
|
+
<span className="text-sm text-gray-700 dark:text-white">
|
|
53
|
+
{opt.label}
|
|
54
|
+
</span>
|
|
55
|
+
</label>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default Switch;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import InputLabel from "./InputLabel";
|
|
3
|
+
|
|
4
|
+
const TextArea = React.forwardRef(
|
|
5
|
+
({ className = "", label, required, ...props }, ref) => {
|
|
6
|
+
const combinedClassName = `
|
|
7
|
+
placeholder-gray-400 dark:placeholder-gray-400
|
|
8
|
+
${className}
|
|
9
|
+
`.trim();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<div key={props.name} className={props.parentClass || "col-span-12"}>
|
|
14
|
+
<InputLabel label={label} required={required} />
|
|
15
|
+
<div className="relative">
|
|
16
|
+
<textarea
|
|
17
|
+
className={combinedClassName}
|
|
18
|
+
ref={ref}
|
|
19
|
+
required={required}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
TextArea.displayName = "TextArea";
|
|
30
|
+
|
|
31
|
+
export { TextArea };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Editor } from "@tinymce/tinymce-react";
|
|
3
|
+
import InputLabel from "./InputLabel";
|
|
4
|
+
|
|
5
|
+
const TinyEditor = ({
|
|
6
|
+
key,
|
|
7
|
+
editorKey = "",
|
|
8
|
+
value = "",
|
|
9
|
+
onChange,
|
|
10
|
+
label = "",
|
|
11
|
+
required = false,
|
|
12
|
+
placeholder = "",
|
|
13
|
+
parentClass = "col-span-12",
|
|
14
|
+
height = 400,
|
|
15
|
+
inline = false,
|
|
16
|
+
disabled = false,
|
|
17
|
+
plugins,
|
|
18
|
+
toolbar,
|
|
19
|
+
menubar = false,
|
|
20
|
+
fontFamily = "Inter, sans-serif",
|
|
21
|
+
initConfig = {},
|
|
22
|
+
imageUploadHandler, // ✅ Promise function passed from parent
|
|
23
|
+
}) => {
|
|
24
|
+
const defaultPlugins = [
|
|
25
|
+
"advlist",
|
|
26
|
+
"autolink",
|
|
27
|
+
"lists",
|
|
28
|
+
"link",
|
|
29
|
+
"image",
|
|
30
|
+
"charmap",
|
|
31
|
+
"preview",
|
|
32
|
+
"anchor",
|
|
33
|
+
"searchreplace",
|
|
34
|
+
"visualblocks",
|
|
35
|
+
"code",
|
|
36
|
+
"fullscreen",
|
|
37
|
+
"insertdatetime",
|
|
38
|
+
"media",
|
|
39
|
+
"table",
|
|
40
|
+
"help",
|
|
41
|
+
"wordcount",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const defaultToolbar =
|
|
45
|
+
"undo redo | blocks | bold italic underline forecolor backcolor | " +
|
|
46
|
+
"alignleft aligncenter alignright alignjustify | " +
|
|
47
|
+
"bullist numlist outdent indent | link image media table | " +
|
|
48
|
+
"removeformat | code fullscreen preview";
|
|
49
|
+
|
|
50
|
+
// ✅ Handle Image Upload via Promise
|
|
51
|
+
const handleImageUpload = (blobInfo) => {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
if (!imageUploadHandler) {
|
|
54
|
+
// fallback to base64 if no handler provided
|
|
55
|
+
resolve(`data:${blobInfo.blob().type};base64,${blobInfo.base64()}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
imageUploadHandler(blobInfo)
|
|
60
|
+
.then((url) => {
|
|
61
|
+
if (!url) {
|
|
62
|
+
reject("Upload failed: No URL returned");
|
|
63
|
+
} else {
|
|
64
|
+
resolve(url);
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.catch((error) => {
|
|
68
|
+
reject(
|
|
69
|
+
typeof error === "string"
|
|
70
|
+
? error
|
|
71
|
+
: error?.message || "Image upload failed",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div key={key} className={parentClass}>
|
|
79
|
+
{label && <InputLabel label={label} required={required} />}
|
|
80
|
+
|
|
81
|
+
<Editor
|
|
82
|
+
apiKey={editorKey}
|
|
83
|
+
value={value}
|
|
84
|
+
disabled={disabled}
|
|
85
|
+
init={{
|
|
86
|
+
height,
|
|
87
|
+
inline,
|
|
88
|
+
menubar,
|
|
89
|
+
branding: false,
|
|
90
|
+
statusbar: true,
|
|
91
|
+
automatic_uploads: true,
|
|
92
|
+
images_upload_handler: handleImageUpload,
|
|
93
|
+
plugins: plugins ?? defaultPlugins,
|
|
94
|
+
toolbar: toolbar ?? defaultToolbar,
|
|
95
|
+
placeholder,
|
|
96
|
+
content_style: `
|
|
97
|
+
body {
|
|
98
|
+
font-family: ${fontFamily};
|
|
99
|
+
}
|
|
100
|
+
`,
|
|
101
|
+
...initConfig,
|
|
102
|
+
}}
|
|
103
|
+
onEditorChange={(content) => {
|
|
104
|
+
if (onChange) {
|
|
105
|
+
onChange(content);
|
|
106
|
+
}
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export default TinyEditor;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
const Spinner = ({ size = 'lg', className = '', color = 'border-blue-500' }) => {
|
|
4
|
+
const sizeClasses = {
|
|
5
|
+
sm: 'w-4 h-4 border-2',
|
|
6
|
+
md: 'w-6 h-6 border-4',
|
|
7
|
+
lg: 'w-8 h-8 border-4',
|
|
8
|
+
xl: 'w-12 h-12 border-8',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
className={`rounded-full border-4 border-blue-500 border-t-gray-200 animate-spin ${sizeClasses[size]} ${className}`}
|
|
14
|
+
style={{
|
|
15
|
+
borderTopColor: color,
|
|
16
|
+
}}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default Spinner;
|