@skalfa/skalfa-app 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.
Files changed (112) hide show
  1. package/.env.example +44 -0
  2. package/README.md +28 -0
  3. package/app/auth/edit/page.tsx +65 -0
  4. package/app/auth/login/page.tsx +63 -0
  5. package/app/auth/me/page.tsx +58 -0
  6. package/app/auth/register/page.tsx +69 -0
  7. package/app/auth/verify/page.tsx +53 -0
  8. package/app/dashboard/layout.tsx +47 -0
  9. package/app/dashboard/page.tsx +9 -0
  10. package/app/dashboard/user/page.tsx +77 -0
  11. package/app/index.ts +14 -0
  12. package/app/layout.tsx +38 -0
  13. package/app/page.tsx +13 -0
  14. package/barrels.json +6 -0
  15. package/blueprints/starter.blueprint.json +103 -0
  16. package/components/base.components/accordion/Accordion.component.tsx +82 -0
  17. package/components/base.components/breadcrumb/Breadcrumb.component.tsx +80 -0
  18. package/components/base.components/button/Button.component.tsx +91 -0
  19. package/components/base.components/button/IconButton.component.tsx +88 -0
  20. package/components/base.components/button/button.decorate.ts +82 -0
  21. package/components/base.components/card/AlertCard.component.tsx +69 -0
  22. package/components/base.components/card/Card.component.tsx +25 -0
  23. package/components/base.components/card/DashboardCard.component.tsx +44 -0
  24. package/components/base.components/card/GalleryCard.component.tsx +50 -0
  25. package/components/base.components/card/ProductCard.component.tsx +65 -0
  26. package/components/base.components/card/ProfileCard.component.tsx +71 -0
  27. package/components/base.components/carousel/Carousel.component.tsx +113 -0
  28. package/components/base.components/chip/Chip.component.tsx +39 -0
  29. package/components/base.components/document/DocumentViewer.component.tsx +164 -0
  30. package/components/base.components/document/ExportExcel.component.tsx +340 -0
  31. package/components/base.components/document/ImportExcel.component.tsx +315 -0
  32. package/components/base.components/document/PrintTable.component.tsx +204 -0
  33. package/components/base.components/document/RenderPDF.component.tsx +416 -0
  34. package/components/base.components/index.ts +85 -0
  35. package/components/base.components/input/Checkbox.component.tsx +109 -0
  36. package/components/base.components/input/Input.component.tsx +332 -0
  37. package/components/base.components/input/InputCheckbox.component.tsx +174 -0
  38. package/components/base.components/input/InputCurrency.component.tsx +163 -0
  39. package/components/base.components/input/InputDate.component.tsx +352 -0
  40. package/components/base.components/input/InputDatetime.component.tsx +260 -0
  41. package/components/base.components/input/InputDocument.component.tsx +352 -0
  42. package/components/base.components/input/InputImage.component.tsx +533 -0
  43. package/components/base.components/input/InputMap.component.tsx +318 -0
  44. package/components/base.components/input/InputNumber.component.tsx +192 -0
  45. package/components/base.components/input/InputOtp.component.tsx +169 -0
  46. package/components/base.components/input/InputPassword.component.tsx +236 -0
  47. package/components/base.components/input/InputRadio.component.tsx +175 -0
  48. package/components/base.components/input/InputTime.component.tsx +276 -0
  49. package/components/base.components/input/InputValues.component.tsx +68 -0
  50. package/components/base.components/input/Radio.component.tsx +102 -0
  51. package/components/base.components/input/Select.component.tsx +541 -0
  52. package/components/base.components/modal/BottomSheet.component.tsx +246 -0
  53. package/components/base.components/modal/FloatingPage.component.tsx +104 -0
  54. package/components/base.components/modal/Modal.component.tsx +96 -0
  55. package/components/base.components/modal/ModalConfirm.component.tsx +218 -0
  56. package/components/base.components/modal/Toast.component.tsx +126 -0
  57. package/components/base.components/nav/Bottombar.component.tsx +116 -0
  58. package/components/base.components/nav/Footer.component.tsx +144 -0
  59. package/components/base.components/nav/Headbar.component.tsx +104 -0
  60. package/components/base.components/nav/Navbar.component.tsx +100 -0
  61. package/components/base.components/nav/Sidebar.component.tsx +301 -0
  62. package/components/base.components/nav/Tabbar.component.tsx +60 -0
  63. package/components/base.components/nav/Wizard.component.tsx +73 -0
  64. package/components/base.components/supervision/FormSupervision.component.tsx +434 -0
  65. package/components/base.components/supervision/TableSupervision.component.tsx +697 -0
  66. package/components/base.components/table/ControlBar.component.tsx +497 -0
  67. package/components/base.components/table/FilterComponent.tsx +518 -0
  68. package/components/base.components/table/Pagination.component.tsx +159 -0
  69. package/components/base.components/table/Table.component.tsx +469 -0
  70. package/components/base.components/typography/TypographyArticle.component.tsx +26 -0
  71. package/components/base.components/typography/TypographyColumn.component.tsx +20 -0
  72. package/components/base.components/typography/TypographyContent.component.tsx +20 -0
  73. package/components/base.components/typography/TypographyTips.component.tsx +20 -0
  74. package/components/base.components/wrap/Draggable.component.tsx +303 -0
  75. package/components/base.components/wrap/IDBProvider.tsx +12 -0
  76. package/components/base.components/wrap/Image.component.tsx +10 -0
  77. package/components/base.components/wrap/OutsideClick.component.tsx +48 -0
  78. package/components/base.components/wrap/ScrollContainer.component.tsx +104 -0
  79. package/components/base.components/wrap/ShortcutProvider.tsx +57 -0
  80. package/components/base.components/wrap/Swipe.component.tsx +93 -0
  81. package/components/construct.components/example.tsx +1 -0
  82. package/components/construct.components/index.ts +5 -0
  83. package/components/index.ts +3 -0
  84. package/components/structure.components/example.tsx +1 -0
  85. package/components/structure.components/index.ts +5 -0
  86. package/contexts/AppProvider.tsx +12 -0
  87. package/contexts/Auth.context.tsx +64 -0
  88. package/contexts/Toggle.context.tsx +44 -0
  89. package/contexts/index.ts +7 -0
  90. package/eslint.config.mjs +34 -0
  91. package/langs/index.ts +1 -0
  92. package/langs/validation.langs.ts +17 -0
  93. package/next.config.ts +17 -0
  94. package/package.json +43 -0
  95. package/postcss.config.mjs +12 -0
  96. package/public/204.svg +19 -0
  97. package/public/500.svg +39 -0
  98. package/public/images/avatar.jpg +0 -0
  99. package/public/images/example.png +0 -0
  100. package/schema/idb/app.schema.ts +9 -0
  101. package/schema/index.ts +5 -0
  102. package/styles/globals.css +231 -0
  103. package/styles/tailwind.safelist +69 -0
  104. package/tailwind.config.ts +10 -0
  105. package/tsconfig.json +35 -0
  106. package/utils/commands/barrels.ts +28 -0
  107. package/utils/commands/blueprint.ts +421 -0
  108. package/utils/commands/light.ts +21 -0
  109. package/utils/commands/logger.ts +42 -0
  110. package/utils/commands/stubs/table-blueprint.stub +13 -0
  111. package/utils/commands/use-pdf.ts +29 -0
  112. package/utils/index.ts +3 -0
@@ -0,0 +1,260 @@
1
+ "use client"
2
+
3
+ import { InputHTMLAttributes, ReactNode, useEffect, useState } from "react";
4
+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5
+ import { cn, pcn, useInputHandler, useInputRandomId, useResponsive, useValidation, validation, ValidationRules } from "@utils";
6
+ import { OutsideClickComponent, InputDatePickerComponent, InputTimePickerComponent, BottomSheetComponent, ButtonComponent, TabbarComponent } from "@components";
7
+
8
+
9
+
10
+ type CT = "label" | "tip" | "error" | "input" | "icon";
11
+
12
+ export interface InputDateTimeProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> {
13
+ label ?: string;
14
+ tip ?: string | ReactNode;
15
+ leftIcon ?: any;
16
+ rightIcon ?: any;
17
+
18
+ value ?: string;
19
+ invalid ?: string;
20
+ validations ?: ValidationRules;
21
+
22
+ onChange ?: (value: string) => any;
23
+ register ?: (name: string, validations?: ValidationRules) => void;
24
+ unregister ?: (name: string) => void;
25
+
26
+ /** Use custom class with: "label::", "tip::", "error::", "icon::", "suggest::", "suggest-item::". */
27
+ className ?: string;
28
+ }
29
+
30
+
31
+
32
+ export function InputDatetimeComponent({
33
+ label,
34
+ tip,
35
+ leftIcon,
36
+ rightIcon,
37
+
38
+ value,
39
+ invalid,
40
+ validations,
41
+
42
+ register,
43
+ unregister,
44
+ onChange,
45
+
46
+ className = "",
47
+ ...props
48
+ }: InputDateTimeProps) {
49
+ const { isSm } = useResponsive();
50
+
51
+ const [pickerType, setPickerType] = useState<"date" | "time">("date");
52
+ const [dateValue, setDateValue] = useState("");
53
+ const [timeValue, setTimeValue] = useState("");
54
+
55
+
56
+ // =========================>
57
+ // ## Initial
58
+ // =========================>
59
+ const inputHandler = useInputHandler(props.name, value, validations, register, unregister, false)
60
+ const randomId = useInputRandomId()
61
+
62
+
63
+ // =========================>
64
+ // ## Invalid handler
65
+ // =========================>
66
+ const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
67
+
68
+
69
+ // =========================>
70
+ // ## change value handler
71
+ // =========================>
72
+ useEffect(() => {
73
+ inputHandler.setValue(value || "");
74
+ value && inputHandler.setValue(false);
75
+
76
+ if (value) {
77
+ const [d, t] = value.split(" ");
78
+ setDateValue(d || "");
79
+ setTimeValue(t || "");
80
+ }
81
+ }, [value]);
82
+
83
+
84
+ const handleChange = (date: string, time: string) => {
85
+ const newVal = `${date} ${time}`;
86
+ inputHandler.setValue(newVal.trim());
87
+ onChange?.(newVal.trim());
88
+ };
89
+
90
+
91
+ return (
92
+ <>
93
+ <div className="relative flex flex-col gap-y-0.5">
94
+ <label
95
+ htmlFor={randomId}
96
+ className={cn(
97
+ "input-label",
98
+ pcn<CT>(className, "label"),
99
+ props.disabled && "opacity-50",
100
+ inputHandler.focus && "text-primary",
101
+ !!invalidMessage && "text-danger"
102
+ )}
103
+ >
104
+ {label}
105
+ {validations && validation.hasRules(validations, "required") && <span className="text-danger ml-1">*</span>}
106
+ </label>
107
+
108
+ {tip && (
109
+ <small
110
+ className={cn(
111
+ "input-tip",
112
+ pcn<CT>(className, "tip"),
113
+ props.disabled && "opacity-60"
114
+ )}
115
+ >{tip}</small>
116
+ )}
117
+
118
+ <OutsideClickComponent onOutsideClick={!isSm ? () => inputHandler.setFocus(false) : undefined}>
119
+ <div className="relative">
120
+ <input
121
+ {...props}
122
+ id={randomId}
123
+ readOnly
124
+ className={cn(
125
+ "input",
126
+ leftIcon && "pl-12",
127
+ rightIcon && "pr-12",
128
+ pcn<CT>(className, "input"),
129
+ !!invalidMessage && "input-error"
130
+ )}
131
+ value={inputHandler.value}
132
+ onFocus={(e) => {
133
+ props.onFocus?.(e);
134
+ inputHandler.setFocus(true);
135
+ }}
136
+ autoComplete="off"
137
+ inputMode={isSm ? "none" : undefined}
138
+ />
139
+
140
+ {leftIcon && (
141
+ <FontAwesomeIcon
142
+ className={cn(
143
+ "left-4 input-icon",
144
+ pcn<CT>(className, "icon"),
145
+ props.disabled && "opacity-60",
146
+ inputHandler.focus && "text-primary"
147
+ )}
148
+ icon={leftIcon}
149
+ />
150
+ )}
151
+
152
+ {rightIcon && (
153
+ <FontAwesomeIcon
154
+ className={cn(
155
+ "right-4 input-icon",
156
+ pcn<CT>(className, "icon"),
157
+ props.disabled && "opacity-60",
158
+ inputHandler.focus && "text-primary"
159
+ )}
160
+ icon={rightIcon}
161
+ />
162
+ )}
163
+
164
+ {!isSm && inputHandler.focus && (
165
+ <>
166
+ <div className="absolute z-50 top-full right-0 mt-1 w-max bg-background border rounded-[6px] p-2 shadow min-w-[350]">
167
+ <TabbarComponent
168
+ items={[
169
+ {
170
+ label: "Tanggal",
171
+ value: 'date'
172
+ },
173
+ {
174
+ label: "Jam",
175
+ value: 'time'
176
+ },
177
+ ]}
178
+ active={pickerType}
179
+ onChange={(e) => setPickerType(e as "time" | "date")}
180
+ className="mb-4"
181
+ />
182
+ {pickerType === "date" ? (
183
+ <InputDatePickerComponent
184
+ onChange={(e) => {
185
+ setDateValue(e);
186
+ handleChange(e, timeValue);
187
+ }}
188
+ />
189
+ ) : (
190
+ <InputTimePickerComponent
191
+ onChange={(e) => {
192
+ setTimeValue(e);
193
+ handleChange(dateValue, e);
194
+ }}
195
+ />
196
+ )}
197
+ </div>
198
+ </>
199
+ )}
200
+ </div>
201
+ </OutsideClickComponent>
202
+
203
+ {invalidMessage && (
204
+ <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalidMessage}</small>
205
+ )}
206
+ </div>
207
+
208
+ {isSm && (
209
+ <BottomSheetComponent
210
+ show={inputHandler.focus}
211
+ onClose={() => inputHandler.setFocus(false)}
212
+ size={430}
213
+ footer={
214
+ <div className="p-4">
215
+ <ButtonComponent
216
+ label="Selesai"
217
+ variant="outline"
218
+ onClick={() => inputHandler.setFocus(false)}
219
+ block
220
+ />
221
+ </div>
222
+ }
223
+ >
224
+ <div className="p-4">
225
+ <TabbarComponent
226
+ items={[
227
+ {
228
+ label: "Tanggal",
229
+ value: 'date'
230
+ },
231
+ {
232
+ label: "Jam",
233
+ value: 'time'
234
+ },
235
+ ]}
236
+ active={pickerType}
237
+ onChange={(e) => setPickerType(e as "time" | "date")}
238
+ className="mb-4"
239
+ />
240
+ {pickerType === "date" ? (
241
+ <InputDatePickerComponent
242
+ onChange={(e) => {
243
+ setDateValue(e);
244
+ handleChange(e, timeValue);
245
+ }}
246
+ />
247
+ ) : (
248
+ <InputTimePickerComponent
249
+ onChange={(e) => {
250
+ setTimeValue(e);
251
+ handleChange(dateValue, e);
252
+ }}
253
+ />
254
+ )}
255
+ </div>
256
+ </BottomSheetComponent>
257
+ )}
258
+ </>
259
+ );
260
+ }
@@ -0,0 +1,352 @@
1
+ "use client"
2
+
3
+ import { useState, useRef, InputHTMLAttributes, ReactNode } from "react";
4
+ import { faPlus, faTimes } from "@fortawesome/free-solid-svg-icons";
5
+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6
+ import { cn, pcn, registry, useInputHandler, useInputRandomId, useResponsive, useValidation, validation, ValidationRules } from "@utils";
7
+ import { IconButtonComponent, ButtonComponent, FloatingPageComponent, BottomSheetComponent } from "@components";
8
+
9
+ const DocumentViewerComponent = (props: any) => {
10
+ const Comp = registry.get("DocumentViewerComponent");
11
+ return Comp ? <Comp {...props} /> : null;
12
+ };
13
+
14
+ const DocumentViewerIcon = (ext: string) => {
15
+ const getIcon = registry.get("DocumentViewerIcon");
16
+ return getIcon ? getIcon(ext) : null;
17
+ };
18
+
19
+
20
+
21
+ type DocFile = {
22
+ id: string;
23
+ file: File;
24
+ url: string;
25
+ type: string;
26
+ };
27
+
28
+ type CT = "label" | "tip" | "error" | "base" | "icon";
29
+
30
+ export interface InputDocumentProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> {
31
+ label ?: string;
32
+ tip ?: string | ReactNode;
33
+ leftIcon ?: any;
34
+ rightIcon ?: any;
35
+
36
+ value ?: any;
37
+ invalid ?: string;
38
+
39
+ validations ?: ValidationRules;
40
+
41
+ onChange ?: (value: any) => any;
42
+ register ?: (name: string, validations ?: ValidationRules) => void;
43
+ unregister ?: (name: string) => void;
44
+
45
+ /** Use custom class with: "label::", "tip::", "error::", "icon::", "suggest::", "suggest-item::". */
46
+ className ?: string;
47
+ }
48
+
49
+
50
+
51
+ export function InputDocumentComponent({
52
+ label,
53
+ tip,
54
+ leftIcon,
55
+ rightIcon,
56
+ className = "",
57
+
58
+ value,
59
+ invalid,
60
+
61
+ validations,
62
+
63
+ register,
64
+ unregister,
65
+ onChange,
66
+
67
+ ...props
68
+ } : InputDocumentProps) {
69
+ const { isSm } = useResponsive();
70
+
71
+ // =========================>
72
+ // ## Initial
73
+ // =========================>
74
+ const inputHandler = useInputHandler(props.name, value, validations, register, unregister, props.type == "file")
75
+ const randomId = useInputRandomId()
76
+
77
+ // =========================>
78
+ // ## Invalid handler
79
+ // =========================>
80
+ const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
81
+
82
+
83
+ return (
84
+ <>
85
+ <div className="relative flex flex-col gap-y-0.5">
86
+ <label
87
+ htmlFor={randomId}
88
+ className={cn(
89
+ "input-label",
90
+ pcn<CT>(className, "label"),
91
+ props.disabled && "opacity-50",
92
+ props.disabled && pcn<CT>(className, "label", "disabled"),
93
+ inputHandler.focus && "text-primary",
94
+ inputHandler.focus && pcn<CT>(className, "label", "focus"),
95
+ !!invalidMessage && "text-danger",
96
+ !!invalidMessage && pcn<CT>(className, "label", "focus"),
97
+ )}
98
+ >
99
+ {label}
100
+ {validations && validation.hasRules(validations, "required") && <span className="text-danger ml-1">*</span>}
101
+ </label>
102
+
103
+ {tip && (
104
+ <small
105
+ className={cn(
106
+ "input-tip",
107
+ pcn<CT>(className, "tip"),
108
+ props.disabled && "opacity-60",
109
+ props.disabled && pcn<CT>(className, "tip", "disabled"),
110
+ )}
111
+ >{tip}</small>
112
+ )}
113
+
114
+ <div className="relative">
115
+ <input
116
+ {...props}
117
+ id={randomId}
118
+ placeholder={!inputHandler.value ? props.placeholder : ""}
119
+ className={cn(
120
+ "input cursor-pointer",
121
+ props.type == "file" && "input-file",
122
+ leftIcon && "pl-12",
123
+ rightIcon && "pr-12",
124
+ pcn<CT>(className, "base"),
125
+ !!invalidMessage && "input-error",
126
+ !!invalidMessage && pcn<CT>(className, "base", "error"),
127
+ )}
128
+ onFocus={(e) => {
129
+ props.onFocus?.(e);
130
+ inputHandler.setFocus(true);
131
+ }}
132
+ inputMode="none"
133
+ />
134
+
135
+
136
+ {(inputHandler.value) && (
137
+ <div
138
+ className={`${(leftIcon ? "5.2rem" : "ml-2")} absolute top-1/2 -translate-y-1/2 overflow-x-hidden py-1.5 input-scroll w-max flex gap-2 overflow-hidden cursor-pointer`}
139
+ style={{ maxWidth: `calc(100% - ${leftIcon ? "5.2rem" : "2rem"})` }}
140
+ onClick={() => {
141
+ inputHandler.setFocus(true);
142
+ }}
143
+ >
144
+ {(Array.isArray(inputHandler.value) ? inputHandler.value : []).map((f) => {
145
+ return (
146
+ <span
147
+ key={f.id}
148
+ className="flex items-center gap-1 input-values-item py-1 max-w-[150px] border-dashed"
149
+ >
150
+ <FontAwesomeIcon icon={DocumentViewerIcon(f.file.name.split(".").pop()?.toLowerCase())} className="text-light-foreground" />
151
+ <span className="line-clamp-1">{f.file.name}</span>
152
+ </span>
153
+ )
154
+ })}
155
+ </div>
156
+ )}
157
+
158
+ {leftIcon && (
159
+ <FontAwesomeIcon
160
+ className={cn(
161
+ "left-4 input-icon",
162
+ pcn<CT>(className, "icon"),
163
+ props.disabled && "opacity-60",
164
+ props.disabled && pcn<CT>(className, "icon", "disabled"),
165
+ inputHandler.focus && "text-primary",
166
+ inputHandler.focus && pcn<CT>(className, "icon", "focus"),
167
+ )}
168
+ icon={leftIcon}
169
+ />
170
+ )}
171
+
172
+ {rightIcon && (
173
+ <FontAwesomeIcon
174
+ className={cn(
175
+ "right-4 input-icon",
176
+ pcn<CT>(className, "icon"),
177
+ props.disabled && "opacity-60",
178
+ props.disabled && pcn<CT>(className, "icon", "disabled"),
179
+ inputHandler.focus && "text-primary",
180
+ inputHandler.focus && pcn<CT>(className, "icon", "focus"),
181
+ )}
182
+ icon={rightIcon}
183
+ />
184
+ )}
185
+ </div>
186
+
187
+ {invalidMessage && <small className={cn("input-error-message", pcn<CT>(className, "error"))}>{invalidMessage}</small>}
188
+ </div>
189
+
190
+ {!isSm ? (
191
+ <FloatingPageComponent
192
+ show={inputHandler.focus}
193
+ onClose={() => inputHandler.setFocus(false)}
194
+ title={label}
195
+ footer={
196
+ <ButtonComponent
197
+ label="Selesai"
198
+ variant="outline"
199
+ onClick={() => inputHandler.setFocus(false)}
200
+ block
201
+ />
202
+ }
203
+ >
204
+ <InputDocumentPicker
205
+ value={inputHandler.value}
206
+ onChange={(e) => {
207
+ inputHandler.setValue(e);
208
+ if (inputHandler.idle) inputHandler.setIdle(false);
209
+ onChange?.(e)
210
+ }}
211
+ />
212
+
213
+ </FloatingPageComponent>
214
+ ) : (
215
+ <BottomSheetComponent
216
+ show={inputHandler.focus}
217
+ onClose={() => inputHandler.setFocus(false)}
218
+ size={'98vh'}
219
+ footer={
220
+ <ButtonComponent
221
+ label="Selesai"
222
+ variant="outline"
223
+ onClick={() => inputHandler.setFocus(false)}
224
+ block
225
+ />
226
+ }
227
+ >
228
+ <InputDocumentPicker
229
+ value={inputHandler.value}
230
+ onChange={(e) => {
231
+ inputHandler.setValue(e);
232
+ if (inputHandler.idle) inputHandler.setIdle(false);
233
+ onChange?.(e)
234
+ }}
235
+ />
236
+
237
+ </BottomSheetComponent>
238
+ )}
239
+ </>
240
+ );
241
+ }
242
+
243
+
244
+ export interface InputDocumentPickerProps {
245
+ value ?: any[];
246
+ onChange ?: (value: any[]) => void;
247
+ }
248
+
249
+ export const InputDocumentPicker: React.FC<InputDocumentPickerProps> = ({ value, onChange }) => {
250
+ const { isSm } = useResponsive();
251
+ const fileInputRef = useRef<HTMLInputElement>(null);
252
+
253
+ const [previewActive, setPreviewActive] = useState<string | null>(null);
254
+
255
+ const files: DocFile[] = Array.isArray(value) ? value : [];
256
+
257
+ function updateFiles(next: DocFile[]) {
258
+ onChange?.(next);
259
+ }
260
+
261
+ function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
262
+ const picked = e.target.files;
263
+ if (!picked) return;
264
+
265
+ const next = [...files];
266
+
267
+ Array.from(picked).forEach((f) => {
268
+ const id = Math.random().toString(36).substring(7);
269
+ next.push({
270
+ id,
271
+ file: f,
272
+ url: URL.createObjectURL(f),
273
+ type: f.type,
274
+ });
275
+
276
+ if (!previewActive) setPreviewActive(id);
277
+ });
278
+
279
+ updateFiles(next);
280
+ e.target.value = "";
281
+ }
282
+
283
+ function removeFile(id: string) {
284
+ const next = files.filter((x) => x.id !== id);
285
+
286
+ updateFiles(next);
287
+
288
+ if (previewActive === id) {
289
+ setPreviewActive(next[0]?.id || null);
290
+ }
291
+ }
292
+
293
+ return (<>
294
+ <div className="p-4 flex flex-col gap-4">
295
+ <div className="border rounded border-dashed p-3 bg-background h-[350px] md:h-[600px] flex justify-center items-center">
296
+ {files.find((x) => x.id === previewActive)?.file ? (
297
+ <DocumentViewerComponent
298
+ file={files.find((x) => x.id === previewActive)?.file}
299
+ />
300
+ ) : (
301
+ <div className="w-full h-full flex flex-col gap-6 justify-center items-center cursor-pointer text-light-foreground" onClick={() => fileInputRef.current?.click()}>
302
+ <FontAwesomeIcon icon={faPlus} className="text-3xl" />
303
+ <p className="text-lg">Tambah Dokumen</p>
304
+ </div>
305
+ )}
306
+ </div>
307
+
308
+ <input
309
+ ref={fileInputRef}
310
+ type="file"
311
+ multiple
312
+ className="hidden"
313
+ onChange={handleFilePick}
314
+ />
315
+
316
+ <div className="max-h-[calc(100vh-650px)] md:max-h-[calc(100vh-750px)] overflow-y-auto">
317
+ <div className="grid grid-cols-5 gap-1">
318
+ {files.map((f) => {
319
+ return (
320
+ <div
321
+ key={f.id}
322
+ className={`relative w-full aspect-square flex justify-center bg-background items-center cursor-pointer ${
323
+ previewActive === f.id ? "brightness-100" : "brightness-70 hover:brightness-90"
324
+ }`}
325
+ onClick={() => setPreviewActive(f.id)}
326
+ >
327
+ <DocumentViewerComponent
328
+ file={f.file}
329
+ mode="thumb"
330
+ />
331
+
332
+ <IconButtonComponent
333
+ icon={faTimes}
334
+ onClick={() => removeFile(f.id)}
335
+ variant="light"
336
+ paint="danger"
337
+ className="absolute top-2 right-2"
338
+ size={isSm ? "xs" : "sm"}
339
+ />
340
+ </div>
341
+ );
342
+ })}
343
+
344
+ <div className="w-full aspect-square flex flex-col gap-2 justify-center items-center border border-dashed cursor-pointer text-light-foreground" onClick={() => fileInputRef.current?.click()}>
345
+ <FontAwesomeIcon icon={faPlus} />
346
+ <p className="text-[10px] text-center">Tambah Dokumen</p>
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ </>)
352
+ }