Skip to content

Commit

Permalink
feat: add Dropzone component
Browse files Browse the repository at this point in the history
  • Loading branch information
matejfalat committed May 3, 2024
1 parent f6699ae commit e4eee1f
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 0 deletions.
19 changes: 19 additions & 0 deletions src/components/ui/Card/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {HTMLAttributes, forwardRef} from 'react'

import {cn} from '@/lib/utils/client/tailwind'

const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({className, ...props}, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-slate-200 bg-white text-slate-950 shadow-sm dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50',
className,
)}
{...props}
/>
),
)
Card.displayName = 'Card'

export default Card
12 changes: 12 additions & 0 deletions src/components/ui/Card/CardContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {HTMLAttributes, forwardRef} from 'react'

import {cn} from '@/lib/utils/client/tailwind'

const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({className, ...props}, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
)
CardContent.displayName = 'CardContent'

export default CardContent
17 changes: 17 additions & 0 deletions src/components/ui/Card/CardDescription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {HTMLAttributes, forwardRef} from 'react'

import {cn} from '@/lib/utils/client/tailwind'

const CardDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({className, ...props}, ref) => (
<p
ref={ref}
className={cn('text-sm text-slate-500 dark:text-slate-400', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'

export default CardDescription
16 changes: 16 additions & 0 deletions src/components/ui/Card/CardFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {HTMLAttributes, forwardRef} from 'react'

import {cn} from '@/lib/utils/client/tailwind'

const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({className, ...props}, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
),
)
CardFooter.displayName = 'CardFooter'

export default CardFooter
16 changes: 16 additions & 0 deletions src/components/ui/Card/CardHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {HTMLAttributes, forwardRef} from 'react'

import {cn} from '@/lib/utils/client/tailwind'

const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({className, ...props}, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
),
)
CardHeader.displayName = 'CardHeader'

export default CardHeader
21 changes: 21 additions & 0 deletions src/components/ui/Card/CardTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {HTMLAttributes, forwardRef} from 'react'

import {cn} from '@/lib/utils/client/tailwind'

const CardTitle = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLHeadingElement>
>(({className, children, ...props}, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className,
)}
{...props}>
{children}
</h3>
))
CardTitle.displayName = 'CardTitle'

export default CardTitle
6 changes: 6 additions & 0 deletions src/components/ui/Card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {default as Card} from './Card'
export {default as CardContent} from './CardContent'
export {default as CardDescription} from './CardDescription'
export {default as CardFooter} from './CardFooter'
export {default as CardHeader} from './CardHeader'
export {default as CardTitle} from './CardTitle'
142 changes: 142 additions & 0 deletions src/components/ui/Dropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use client'

import React, {
DragEvent,
forwardRef,
InputHTMLAttributes,
useRef,
useState,
} from 'react'
import {FileCheck2Icon, X} from 'lucide-react'

import {Button} from './Button'

import {Card, CardContent} from '@/components/ui/Card'
import {Input} from '@/components/ui/Input'
import {cn} from '@/lib/utils/client/tailwind'

type DropzoneProps = {
wrapperClassName?: string
className?: string
message: string
onChange: (acceptedFiles: File[] | null) => void
value: File[] | null | undefined
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'>

const Dropzone = forwardRef<HTMLDivElement, DropzoneProps>(
({className, wrapperClassName, message, onChange, value, ...props}, ref) => {
const [isDraggedOver, setIsDraggedOver] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)

const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggedOver(true)
}

const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggedOver(false)
}

const setFiles = (files: File[]) => {
// It's not possible to construct FileList directly
const dataTransfer = new DataTransfer()
files.forEach((file) => dataTransfer.items.add(file))

if (inputRef.current) {
inputRef.current.files = dataTransfer.files
onChange(files)
}
}

const addFiles = (filelist: FileList) => {
const files = [...(value ?? []), ...filelist]

const uniqueFiles = files.filter((file, currentIndex) => {
const firstOccurrenceIndex = files.findIndex(
({name, size}) => name === file.name && size === file.size,
)

return firstOccurrenceIndex === currentIndex
})

setFiles(uniqueFiles)
}

const removeFile = (indexToRemove: number) => {
if (!value) {
return
}

const files = value.filter((_, index) => index !== indexToRemove)
setFiles(files)
}

const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggedOver(false)

addFiles(event.dataTransfer.files)
}

const triggerFileInputClick = () => {
if (inputRef.current) {
inputRef.current.click()
}
}

return (
<div className={cn('flex flex-col', wrapperClassName)}>
<Card
ref={ref}
className={cn(
`border-2 border-dashed hover:cursor-pointer hover:border-slate-500 hover:bg-slate-100`,
isDraggedOver && 'border-slate-500 bg-slate-100',
className,
)}>
<CardContent
className="flex flex-col items-center justify-center space-y-2 px-2 py-12 text-sm"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={triggerFileInputClick}>
<div className="pointer-events-none flex items-center justify-center">
<span className="font-medium">{message}</span>
<Input
{...props}
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={({target: {files}}) => files && addFiles(files)}
/>
</div>
</CardContent>
</Card>
{value?.map((file, index) => (
<div
key={`${file.name}-${file.size}`}
className="relative flex items-center justify-between p-2">
<div className="flex items-center gap-3">
<FileCheck2Icon className="h-4 w-4" />
<p className="text-sm font-medium">{file.name}</p>
</div>
<Button
className="rounded-full"
size="icon"
variant="ghost"
onClick={() => removeFile(index)}>
<X />
</Button>
</div>
))}
</div>
)
},
)
Dropzone.displayName = 'Dropzone'

export default Dropzone

0 comments on commit e4eee1f

Please sign in to comment.