File size: 14,533 Bytes
0e5f52f
 
 
 
 
 
 
 
ab73985
0e5f52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22c0f3f
 
 
 
 
 
 
 
0e5f52f
 
 
 
 
 
 
 
70a9b1c
 
 
 
 
 
 
 
0e5f52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab73985
0e5f52f
 
 
 
22c0f3f
0e5f52f
70a9b1c
0e5f52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22c0f3f
 
9426437
22c0f3f
 
ab73985
0e5f52f
 
 
22c0f3f
ab73985
0e5f52f
 
 
22c0f3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e5f52f
 
 
 
9426437
22c0f3f
9426437
22c0f3f
 
 
0e5f52f
22c0f3f
9426437
22c0f3f
 
 
9426437
22c0f3f
 
0e5f52f
22c0f3f
 
 
 
4f11e95
 
 
 
3488efb
22c0f3f
 
 
4f11e95
22c0f3f
9426437
22c0f3f
 
9426437
22c0f3f
 
9426437
22c0f3f
 
 
0e5f52f
 
22c0f3f
0e5f52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22c0f3f
 
 
0e5f52f
 
 
 
22c0f3f
4f11e95
 
 
 
3488efb
4f11e95
 
 
3488efb
4f11e95
22c0f3f
3488efb
4f11e95
22c0f3f
4f11e95
 
22c0f3f
9426437
22c0f3f
 
 
 
 
 
9426437
22c0f3f
 
 
 
9426437
 
22c0f3f
 
 
9426437
22c0f3f
 
0e5f52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab73985
0e5f52f
 
 
 
 
 
 
 
ab73985
0e5f52f
 
 
 
 
ab73985
 
 
 
 
 
0e5f52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70a9b1c
0e5f52f
 
 
 
 
 
 
 
 
70a9b1c
 
 
22c0f3f
70a9b1c
 
 
 
22c0f3f
 
 
 
 
b212444
22c0f3f
 
 
 
70a9b1c
 
 
 
0e5f52f
 
 
 
70a9b1c
0e5f52f
 
70a9b1c
ab73985
0e5f52f
 
 
22c0f3f
b212444
22c0f3f
 
 
0e5f52f
 
 
 
 
70a9b1c
b212444
22c0f3f
 
 
 
70a9b1c
 
 
 
0e5f52f
 
 
 
 
ab73985
0e5f52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
/**
 * @see https://github.com/sadmann7/file-uploader
 */

import * as React from 'react'
import { FileText, Upload, X } from 'lucide-react'
import Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'

import { cn } from '@/lib/utils'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import Button from '@/components/ui/Button'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { supportedFileTypes } from '@/lib/constants'

interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Value of the uploader.
   * @type File[]
   * @default undefined
   * @example value={files}
   */
  value?: File[]

  /**
   * Function to be called when the value changes.
   * @type (files: File[]) => void
   * @default undefined
   * @example onValueChange={(files) => setFiles(files)}
   */
  onValueChange?: (files: File[]) => void

  /**
   * Function to be called when files are uploaded.
   * @type (files: File[]) => Promise<void>
   * @default undefined
   * @example onUpload={(files) => uploadFiles(files)}
   */
  onUpload?: (files: File[]) => Promise<void>

  /**
   * Function to be called when files are rejected.
   * @type (rejections: FileRejection[]) => void
   * @default undefined
   * @example onReject={(rejections) => handleRejectedFiles(rejections)}
   */
  onReject?: (rejections: FileRejection[]) => void

  /**
   * Progress of the uploaded files.
   * @type Record<string, number> | undefined
   * @default undefined
   * @example progresses={{ "file1.png": 50 }}
   */
  progresses?: Record<string, number>

  /**
   * Error messages for failed uploads.
   * @type Record<string, string> | undefined
   * @default undefined
   * @example fileErrors={{ "file1.png": "Upload failed" }}
   */
  fileErrors?: Record<string, string>

  /**
   * Accepted file types for the uploader.
   * @type { [key: string]: string[]}
   * @default
   * ```ts
   * { "text/*": [] }
   * ```
   * @example accept={["text/plain", "application/pdf"]}
   */
  accept?: DropzoneProps['accept']

  /**
   * Maximum file size for the uploader.
   * @type number | undefined
   * @default 1024 * 1024 * 200 // 200MB
   * @example maxSize={1024 * 1024 * 2} // 2MB
   */
  maxSize?: DropzoneProps['maxSize']

  /**
   * Maximum number of files for the uploader.
   * @type number | undefined
   * @default 1
   * @example maxFileCount={4}
   */
  maxFileCount?: DropzoneProps['maxFiles']

  /**
   * Whether the uploader should accept multiple files.
   * @type boolean
   * @default false
   * @example multiple
   */
  multiple?: boolean

  /**
   * Whether the uploader is disabled.
   * @type boolean
   * @default false
   * @example disabled
   */
  disabled?: boolean

  description?: string
}

function formatBytes(
  bytes: number,
  opts: {
    decimals?: number
    sizeType?: 'accurate' | 'normal'
  } = {}
) {
  const { decimals = 0, sizeType = 'normal' } = opts

  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
  const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']
  if (bytes === 0) return '0 Byte'
  const i = Math.floor(Math.log(bytes) / Math.log(1024))
  return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
    sizeType === 'accurate' ? (accurateSizes[i] ?? 'Bytes') : (sizes[i] ?? 'Bytes')
  }`
}

function FileUploader(props: FileUploaderProps) {
  const { t } = useTranslation()
  const {
    value: valueProp,
    onValueChange,
    onUpload,
    onReject,
    progresses,
    fileErrors,
    accept = supportedFileTypes,
    maxSize = 1024 * 1024 * 200,
    maxFileCount = 1,
    multiple = false,
    disabled = false,
    description,
    className,
    ...dropzoneProps
  } = props

  const [files, setFiles] = useControllableState({
    prop: valueProp,
    onChange: onValueChange
  })

  const onDrop = React.useCallback(
    (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
      // Calculate total file count including both accepted and rejected files
      const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length

      // Check file count limits
      if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) {
        toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit'))
        return
      }

      if (totalFileCount > maxFileCount) {
        toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount }))
        return
      }

      // Handle rejected files first - this will set error states
      if (rejectedFiles.length > 0) {
        if (onReject) {
          // Use the onReject callback if provided
          onReject(rejectedFiles)
        } else {
          // Fall back to toast notifications if no callback is provided
          rejectedFiles.forEach(({ file }) => {
            toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))
          })
        }
      }

      // Process accepted files
      const newAcceptedFiles = acceptedFiles.map((file) =>
        Object.assign(file, {
          preview: URL.createObjectURL(file)
        })
      )

      // Process rejected files for UI display
      const newRejectedFiles = rejectedFiles.map(({ file }) =>
        Object.assign(file, {
          preview: URL.createObjectURL(file),
          rejected: true
        })
      )

      // Combine all files for display
      const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles]
      const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles

      // Update the files state with all files
      setFiles(updatedFiles)

      // Only upload accepted files - make sure we're not uploading rejected files
      if (onUpload && acceptedFiles.length > 0) {
        // Filter out any files that might have been rejected by our custom validator
        const validFiles = acceptedFiles.filter(file => {
          // Skip files without a name
          if (!file.name) {
            return false;
          }

          // Check if file type is accepted
          const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
          const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
            return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));
          });

          // Check file size
          const isSizeValid = file.size <= maxSize;

          return isAccepted && isSizeValid;
        });

        if (validFiles.length > 0) {
          onUpload(validFiles);
        }
      }
    },
    [files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize]
  )

  function onRemove(index: number) {
    if (!files) return
    const newFiles = files.filter((_, i) => i !== index)
    setFiles(newFiles)
    onValueChange?.(newFiles)
  }

  // Revoke preview url when component unmounts
  React.useEffect(() => {
    return () => {
      if (!files) return
      files.forEach((file) => {
        if (isFileWithPreview(file)) {
          URL.revokeObjectURL(file.preview)
        }
      })
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount

  return (
    <div className="relative flex flex-col gap-6 overflow-hidden">
      <Dropzone
        onDrop={onDrop}
        // remove acceptuse customizd validator
        noClick={false}
        noKeyboard={false}
        maxSize={maxSize}
        maxFiles={maxFileCount}
        multiple={maxFileCount > 1 || multiple}
        disabled={isDisabled}
        validator={(file) => {
          // Ensure file name exists
          if (!file.name) {
            return {
              code: 'invalid-file-name',
              message: t('documentPanel.uploadDocuments.fileUploader.invalidFileName',
                { fallback: 'Invalid file name' })
            };
          }

          // Safely extract file extension
          const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;

          // Ensure accept object exists and has correct format
          const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
            // Ensure extensions is an array before calling includes
            return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));
          });

          if (!isAccepted) {
            return {
              code: 'file-invalid-type',
              message: t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
            };
          }

          // Check file size
          if (file.size > maxSize) {
            return {
              code: 'file-too-large',
              message: t('documentPanel.uploadDocuments.fileUploader.fileTooLarge', {
                maxSize: formatBytes(maxSize)
              })
            };
          }

          return null;
        }}
      >
        {({ getRootProps, getInputProps, isDragActive }) => (
          <div
            {...getRootProps()}
            className={cn(
              'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition',
              'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
              isDragActive && 'border-muted-foreground/50',
              isDisabled && 'pointer-events-none opacity-60',
              className
            )}
            {...dropzoneProps}
          >
            <input {...getInputProps()} />
            {isDragActive ? (
              <div className="flex flex-col items-center justify-center gap-4 sm:px-5">
                <div className="rounded-full border border-dashed p-3">
                  <Upload className="text-muted-foreground size-7" aria-hidden="true" />
                </div>
                <p className="text-muted-foreground font-medium">{t('documentPanel.uploadDocuments.fileUploader.dropHere')}</p>
              </div>
            ) : (
              <div className="flex flex-col items-center justify-center gap-4 sm:px-5">
                <div className="rounded-full border border-dashed p-3">
                  <Upload className="text-muted-foreground size-7" aria-hidden="true" />
                </div>
                <div className="flex flex-col gap-px">
                  <p className="text-muted-foreground font-medium">
                    {t('documentPanel.uploadDocuments.fileUploader.dragAndDrop')}
                  </p>
                  {description ? (
                    <p className="text-muted-foreground/70 text-sm">{description}</p>
                  ) : (
                    <p className="text-muted-foreground/70 text-sm">
                      {t('documentPanel.uploadDocuments.fileUploader.uploadDescription', {
                        count: maxFileCount,
                        isMultiple: maxFileCount === Infinity,
                        maxSize: formatBytes(maxSize)
                      })}
                      {t('documentPanel.uploadDocuments.fileTypes')}
                    </p>
                  )}
                </div>
              </div>
            )}
          </div>
        )}
      </Dropzone>
      {files?.length ? (
        <ScrollArea className="h-fit w-full px-3">
          <div className="flex max-h-48 flex-col gap-4">
            {files?.map((file, index) => (
              <FileCard
                key={index}
                file={file}
                onRemove={() => onRemove(index)}
                progress={progresses?.[file.name]}
                error={fileErrors?.[file.name]}
              />
            ))}
          </div>
        </ScrollArea>
      ) : null}
    </div>
  )
}

interface ProgressProps {
  value: number
  error?: boolean
  showIcon?: boolean  // New property to control icon display
}

function Progress({ value, error }: ProgressProps) {
  return (
    <div className="relative h-2 w-full">
      <div className="h-full w-full overflow-hidden rounded-full bg-secondary">
        <div
          className={cn(
            'h-full transition-all',
            error ? 'bg-red-400' : 'bg-primary'
          )}
          style={{ width: `${value}%` }}
        />
      </div>
    </div>
  )
}

interface FileCardProps {
  file: File
  onRemove: () => void
  progress?: number
  error?: string
}

function FileCard({ file, progress, error, onRemove }: FileCardProps) {
  const { t } = useTranslation()
  return (
    <div className="relative flex items-center gap-2.5">
      <div className="flex flex-1 gap-2.5">
        {error ? (
          <FileText className="text-red-400 size-10" aria-hidden="true" />
        ) : (
          isFileWithPreview(file) ? <FilePreview file={file} /> : null
        )}
        <div className="flex w-full flex-col gap-2">
          <div className="flex flex-col gap-px">
            <p className="text-foreground/80 line-clamp-1 text-sm font-medium">{file.name}</p>
            <p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p>
          </div>
          {error ? (
            <div className="text-red-400 text-sm">
              <div className="relative mb-2">
                <Progress value={100} error={true} />
              </div>
              <p>{error}</p>
            </div>
          ) : (
            progress ? <Progress value={progress} /> : null
          )}
        </div>
      </div>
      <div className="flex items-center gap-2">
        <Button type="button" variant="outline" size="icon" className="size-7" onClick={onRemove}>
          <X className="size-4" aria-hidden="true" />
          <span className="sr-only">{t('documentPanel.uploadDocuments.fileUploader.removeFile')}</span>
        </Button>
      </div>
    </div>
  )
}

function isFileWithPreview(file: File): file is File & { preview: string } {
  return 'preview' in file && typeof file.preview === 'string'
}

interface FilePreviewProps {
  file: File & { preview: string }
}

function FilePreview({ file }: FilePreviewProps) {
  if (file.type.startsWith('image/')) {
    return <div className="aspect-square shrink-0 rounded-md object-cover" />
  }

  return <FileText className="text-muted-foreground size-10" aria-hidden="true" />
}

export default FileUploader