Skip to content

Commit e48976e

Browse files
authored
Merge branch 'main' into docs/small-docs-fix
2 parents 00b066e + 76a601a commit e48976e

20 files changed

+1019
-478
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import * as React from 'react'
2+
import { useUploadThing } from '~/utils/uploadthing.client'
3+
import { useToast } from './ToastProvider'
4+
import { Upload, X, Loader2 } from 'lucide-react'
5+
import { twMerge } from 'tailwind-merge'
6+
7+
export interface ImageUploadProps {
8+
value?: string
9+
onChange: (url: string | undefined) => void
10+
label: string
11+
hint?: string
12+
required?: boolean
13+
aspectRatio?: 'video' | 'square'
14+
size?: 'default' | 'small'
15+
className?: string
16+
}
17+
18+
export function ImageUploadClient({
19+
value,
20+
onChange,
21+
label,
22+
hint,
23+
required,
24+
aspectRatio = 'video',
25+
size = 'default',
26+
className,
27+
}: ImageUploadProps) {
28+
const { notify } = useToast()
29+
const [isUploading, setIsUploading] = React.useState(false)
30+
const [dragOver, setDragOver] = React.useState(false)
31+
const fileInputRef = React.useRef<HTMLInputElement>(null)
32+
33+
const { startUpload } = useUploadThing('showcaseUploader', {
34+
onClientUploadComplete: (res) => {
35+
setIsUploading(false)
36+
if (res?.[0]?.ufsUrl) {
37+
onChange(res[0].ufsUrl)
38+
}
39+
},
40+
onUploadError: (error) => {
41+
setIsUploading(false)
42+
notify(
43+
<div>
44+
<div className="font-medium">Upload failed</div>
45+
<div className="text-gray-500 dark:text-gray-400 text-xs">
46+
{error.message}
47+
</div>
48+
</div>,
49+
)
50+
},
51+
})
52+
53+
const handleFileSelect = async (file: File) => {
54+
if (!file.type.startsWith('image/')) {
55+
notify(
56+
<div>
57+
<div className="font-medium">Invalid file type</div>
58+
<div className="text-gray-500 dark:text-gray-400 text-xs">
59+
Please select an image file
60+
</div>
61+
</div>,
62+
)
63+
return
64+
}
65+
66+
setIsUploading(true)
67+
await startUpload([file])
68+
}
69+
70+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
71+
const file = e.target.files?.[0]
72+
if (file) {
73+
handleFileSelect(file)
74+
}
75+
e.target.value = ''
76+
}
77+
78+
const handleDrop = (e: React.DragEvent) => {
79+
e.preventDefault()
80+
setDragOver(false)
81+
const file = e.dataTransfer.files?.[0]
82+
if (file) {
83+
handleFileSelect(file)
84+
}
85+
}
86+
87+
const handleDragOver = (e: React.DragEvent) => {
88+
e.preventDefault()
89+
setDragOver(true)
90+
}
91+
92+
const handleDragLeave = () => {
93+
setDragOver(false)
94+
}
95+
96+
const handleRemove = () => {
97+
onChange(undefined)
98+
}
99+
100+
const isSmall = size === 'small'
101+
const aspectClass = aspectRatio === 'video' ? 'aspect-video' : 'aspect-square'
102+
const sizeClass = isSmall
103+
? 'w-24'
104+
: aspectRatio === 'video'
105+
? 'max-w-md'
106+
: 'w-40'
107+
108+
return (
109+
<div className={className}>
110+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
111+
{label} {required && '*'}
112+
</label>
113+
114+
{value ? (
115+
<div className={twMerge('relative', sizeClass)}>
116+
<div
117+
className={twMerge(
118+
'relative overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-900',
119+
aspectClass,
120+
)}
121+
>
122+
<img
123+
src={value}
124+
alt="Uploaded"
125+
className="w-full h-full object-cover"
126+
/>
127+
</div>
128+
<button
129+
type="button"
130+
onClick={handleRemove}
131+
className={twMerge(
132+
'absolute bg-red-600 hover:bg-red-700 text-white rounded-full transition-colors',
133+
isSmall ? 'top-1 right-1 p-1' : 'top-2 right-2 p-1.5',
134+
)}
135+
title="Remove image"
136+
>
137+
<X className={isSmall ? 'w-3 h-3' : 'w-4 h-4'} />
138+
</button>
139+
</div>
140+
) : (
141+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
142+
<div
143+
onClick={() => fileInputRef.current?.click()}
144+
onDrop={handleDrop}
145+
onDragOver={handleDragOver}
146+
onDragLeave={handleDragLeave}
147+
className={twMerge(
148+
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed cursor-pointer transition-colors',
149+
aspectClass,
150+
sizeClass,
151+
dragOver
152+
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
153+
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-50 dark:bg-gray-800/50',
154+
isUploading && 'pointer-events-none opacity-60',
155+
)}
156+
>
157+
{isUploading ? (
158+
<div className="p-4 text-center">
159+
<Loader2
160+
className={twMerge(
161+
'text-blue-500 animate-spin mx-auto',
162+
isSmall ? 'w-5 h-5' : 'w-8 h-8',
163+
)}
164+
/>
165+
{!isSmall && (
166+
<span className="mt-2 block text-sm text-gray-600 dark:text-gray-400">
167+
Uploading...
168+
</span>
169+
)}
170+
</div>
171+
) : (
172+
<div className="p-4 text-center">
173+
<Upload
174+
className={twMerge(
175+
'text-gray-400 mx-auto',
176+
isSmall ? 'w-5 h-5' : 'w-8 h-8',
177+
)}
178+
/>
179+
{!isSmall && (
180+
<>
181+
<span className="mt-2 block text-sm text-gray-600 dark:text-gray-400">
182+
Click or drag to upload
183+
</span>
184+
<span className="mt-1 block text-xs text-gray-500">
185+
PNG, JPG up to 4MB
186+
</span>
187+
</>
188+
)}
189+
</div>
190+
)}
191+
</div>
192+
)}
193+
194+
<input
195+
ref={fileInputRef}
196+
type="file"
197+
accept="image/*"
198+
onChange={handleInputChange}
199+
className="hidden"
200+
/>
201+
202+
{hint && <p className="mt-1 text-xs text-gray-500">{hint}</p>}
203+
</div>
204+
)
205+
}

0 commit comments

Comments
 (0)