@skalfa/skalfa-app 1.0.0 → 1.0.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 (87) hide show
  1. package/.env.example +43 -43
  2. package/.github/workflows/publish.yml +39 -0
  3. package/CONTRIBUTING.md +45 -0
  4. package/LICENSE +21 -0
  5. package/README.md +91 -28
  6. package/app/auth/edit/page.tsx +65 -65
  7. package/app/auth/login/page.tsx +63 -63
  8. package/app/auth/me/page.tsx +58 -58
  9. package/app/auth/register/page.tsx +69 -69
  10. package/app/auth/verify/page.tsx +53 -53
  11. package/app/dashboard/user/page.tsx +76 -76
  12. package/app/layout.tsx +37 -37
  13. package/app/manifest.ts +25 -0
  14. package/app/page.tsx +13 -13
  15. package/barrels.json +5 -5
  16. package/blueprints/starter.blueprint.json +102 -102
  17. package/bun.lock +916 -0
  18. package/components/base.components/chip/Chip.component.tsx +39 -39
  19. package/components/base.components/document/DocumentViewer.component.tsx +163 -163
  20. package/components/base.components/document/ExportExcel.component.tsx +340 -340
  21. package/components/base.components/document/ImportExcel.component.tsx +315 -315
  22. package/components/base.components/document/PrintTable.component.tsx +204 -204
  23. package/components/base.components/document/RenderPDF.component.tsx +415 -415
  24. package/components/base.components/input/Checkbox.component.tsx +109 -109
  25. package/components/base.components/input/Input.component.tsx +332 -332
  26. package/components/base.components/input/InputCheckbox.component.tsx +174 -174
  27. package/components/base.components/input/InputCurrency.component.tsx +163 -163
  28. package/components/base.components/input/InputDate.component.tsx +352 -352
  29. package/components/base.components/input/InputDatetime.component.tsx +260 -260
  30. package/components/base.components/input/InputDocument.component.tsx +351 -351
  31. package/components/base.components/input/InputImage.component.tsx +533 -533
  32. package/components/base.components/input/InputMap.component.tsx +317 -317
  33. package/components/base.components/input/InputNumber.component.tsx +192 -192
  34. package/components/base.components/input/InputOtp.component.tsx +169 -169
  35. package/components/base.components/input/InputPassword.component.tsx +236 -236
  36. package/components/base.components/input/InputRadio.component.tsx +175 -175
  37. package/components/base.components/input/InputTime.component.tsx +275 -275
  38. package/components/base.components/input/InputValues.component.tsx +68 -68
  39. package/components/base.components/input/Radio.component.tsx +102 -102
  40. package/components/base.components/input/Select.component.tsx +541 -541
  41. package/components/base.components/modal/BottomSheet.component.tsx +245 -245
  42. package/components/base.components/supervision/FormSupervision.component.tsx +433 -433
  43. package/components/base.components/supervision/TableSupervision.component.tsx +697 -697
  44. package/components/base.components/table/ControlBar.component.tsx +497 -497
  45. package/components/base.components/table/FilterComponent.tsx +518 -518
  46. package/components/base.components/table/Table.component.tsx +469 -469
  47. package/components/base.components/typography/TypographyArticle.component.tsx +26 -26
  48. package/components/base.components/typography/TypographyColumn.component.tsx +20 -20
  49. package/components/base.components/typography/TypographyContent.component.tsx +20 -20
  50. package/components/base.components/typography/TypographyTips.component.tsx +20 -20
  51. package/components/base.components/wrap/Draggable.component.tsx +303 -303
  52. package/components/base.components/wrap/IDBProvider.tsx +12 -12
  53. package/components/base.components/wrap/Image.component.tsx +9 -9
  54. package/components/base.components/wrap/ShortcutProvider.tsx +57 -57
  55. package/components/base.components/wrap/Swipe.component.tsx +93 -93
  56. package/components/index.ts +2 -2
  57. package/contexts/AppProvider.tsx +11 -11
  58. package/contexts/Auth.context.tsx +64 -64
  59. package/contexts/Toggle.context.tsx +44 -44
  60. package/next.config.ts +15 -1
  61. package/package.json +14 -13
  62. package/public/204.svg +19 -19
  63. package/public/500.svg +39 -39
  64. package/public/icon-192.png +0 -0
  65. package/public/icon-512.png +0 -0
  66. package/public/images/logo-fill.png +0 -0
  67. package/public/images/logo-full-fill.png +0 -0
  68. package/public/images/logo-full.png +0 -0
  69. package/public/images/logo.png +0 -0
  70. package/schema/idb/app.schema.ts +8 -8
  71. package/src-tauri/Cargo.toml +14 -0
  72. package/src-tauri/build.rs +3 -0
  73. package/src-tauri/capabilities/default.json +11 -0
  74. package/src-tauri/icons/128x128.png +0 -0
  75. package/src-tauri/icons/128x128@2x.png +0 -0
  76. package/src-tauri/icons/32x32.png +0 -0
  77. package/src-tauri/icons/icon.icns +0 -0
  78. package/src-tauri/icons/icon.ico +0 -0
  79. package/src-tauri/src/main.rs +7 -0
  80. package/src-tauri/tauri.conf.json +36 -0
  81. package/styles/globals.css +231 -231
  82. package/styles/tailwind.safelist +68 -68
  83. package/utils/commands/barrels.ts +27 -27
  84. package/utils/commands/light.ts +21 -21
  85. package/utils/commands/logger.ts +42 -42
  86. package/utils/commands/stubs/table-blueprint.stub +12 -12
  87. package/utils/commands/use-pdf.ts +29 -29
@@ -1,352 +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
- </>)
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
352
  }