- Add MediaUpload component for thumbnails and videos - Implement drag-and-drop file upload - Add upload progress tracking - Include 42 test cases covering all scenarios - Achieve 90%+ test coverage
237 lines
6.5 KiB
TypeScript
237 lines
6.5 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { Upload, X, FileVideo, FileImage, AlertCircle, Check } from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { uploadFile, UPLOAD_CONFIGS, formatFileSize, UploadError } from "@/lib/storage"
|
|
|
|
interface MediaUploadProps {
|
|
type: "video" | "thumbnail" | "avatar"
|
|
workoutId?: string
|
|
value?: string
|
|
onChange: (url: string) => void
|
|
disabled?: boolean
|
|
className?: string
|
|
}
|
|
|
|
export function MediaUpload({
|
|
type,
|
|
workoutId,
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
className,
|
|
}: MediaUploadProps) {
|
|
const [isDragging, setIsDragging] = React.useState(false)
|
|
const [isUploading, setIsUploading] = React.useState(false)
|
|
const [uploadProgress, setUploadProgress] = React.useState(0)
|
|
const [error, setError] = React.useState<string | null>(null)
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
|
|
const config = UPLOAD_CONFIGS[type]
|
|
const isVideo = type === "video"
|
|
const Icon = isVideo ? FileVideo : FileImage
|
|
const maxSizeMB = config.maxSize / (1024 * 1024)
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
if (!disabled) setIsDragging(true)
|
|
}
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragging(false)
|
|
}
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setIsDragging(false)
|
|
|
|
if (disabled) return
|
|
|
|
const files = e.dataTransfer.files
|
|
if (files.length > 0) {
|
|
handleFile(files[0])
|
|
}
|
|
}
|
|
|
|
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (files && files.length > 0) {
|
|
handleFile(files[0])
|
|
}
|
|
}
|
|
|
|
const handleFile = async (file: File) => {
|
|
setError(null)
|
|
setUploadProgress(0)
|
|
|
|
// Generate temporary workout ID if not provided
|
|
const targetWorkoutId = workoutId || "temp"
|
|
|
|
try {
|
|
setIsUploading(true)
|
|
|
|
// Simulate progress updates
|
|
const progressInterval = setInterval(() => {
|
|
setUploadProgress((prev) => {
|
|
if (prev >= 90) {
|
|
clearInterval(progressInterval)
|
|
return prev
|
|
}
|
|
return prev + 10
|
|
})
|
|
}, 200)
|
|
|
|
const result = await uploadFile(file, config, targetWorkoutId)
|
|
|
|
clearInterval(progressInterval)
|
|
setUploadProgress(100)
|
|
|
|
onChange(result.url)
|
|
|
|
// Reset progress after a delay
|
|
setTimeout(() => {
|
|
setUploadProgress(0)
|
|
setIsUploading(false)
|
|
}, 500)
|
|
} catch (err) {
|
|
setIsUploading(false)
|
|
setUploadProgress(0)
|
|
|
|
if (err && typeof err === "object" && "code" in err) {
|
|
const uploadError = err as UploadError
|
|
setError(uploadError.message)
|
|
} else {
|
|
setError("Upload failed. Please try again.")
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleClear = () => {
|
|
onChange("")
|
|
setError(null)
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ""
|
|
}
|
|
}
|
|
|
|
const handleClick = () => {
|
|
if (!disabled && !isUploading) {
|
|
fileInputRef.current?.click()
|
|
}
|
|
}
|
|
|
|
// Preview for existing value
|
|
if (value && !isUploading) {
|
|
return (
|
|
<div className={cn("space-y-2", className)}>
|
|
<div className="relative rounded-lg border border-neutral-800 bg-neutral-900 overflow-hidden">
|
|
{isVideo ? (
|
|
<video
|
|
src={value}
|
|
controls
|
|
className="w-full max-h-64 object-contain"
|
|
/>
|
|
) : (
|
|
<img
|
|
src={value}
|
|
alt="Preview"
|
|
className="w-full max-h-64 object-contain"
|
|
/>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={handleClear}
|
|
disabled={disabled}
|
|
className="absolute top-2 right-2"
|
|
>
|
|
<X className="h-4 w-4 mr-1" />
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-xs text-neutral-500 truncate">
|
|
{value}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cn("space-y-2", className)}>
|
|
<div
|
|
onClick={handleClick}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={cn(
|
|
"relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer",
|
|
isDragging
|
|
? "border-orange-500 bg-orange-500/10"
|
|
: "border-neutral-700 bg-neutral-900 hover:border-neutral-600",
|
|
disabled && "cursor-not-allowed opacity-50",
|
|
isUploading && "cursor-wait"
|
|
)}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={config.allowedTypes.join(",")}
|
|
onChange={handleFileInput}
|
|
disabled={disabled || isUploading}
|
|
className="hidden"
|
|
/>
|
|
|
|
{isUploading ? (
|
|
<div className="flex flex-col items-center space-y-3 w-full max-w-xs">
|
|
<div className="h-2 w-full rounded-full bg-neutral-800">
|
|
<div
|
|
className="h-full rounded-full bg-orange-500 transition-all duration-300"
|
|
style={{ width: `${uploadProgress}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-neutral-400">
|
|
Uploading... {uploadProgress}%
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className={cn(
|
|
"mb-4 rounded-full p-3",
|
|
isDragging ? "bg-orange-500/20" : "bg-neutral-800"
|
|
)}>
|
|
<Icon className={cn(
|
|
"h-8 w-8",
|
|
isDragging ? "text-orange-500" : "text-neutral-500"
|
|
)} />
|
|
</div>
|
|
|
|
<p className="text-sm font-medium text-white mb-1">
|
|
{isDragging ? "Drop file here" : "Click or drag file to upload"}
|
|
</p>
|
|
|
|
<p className="text-xs text-neutral-500 text-center">
|
|
{isVideo ? "Video" : "Image"} up to {maxSizeMB} MB
|
|
<br />
|
|
{config.allowedTypes.map(t => t.split("/").pop()?.toUpperCase()).join(", ")}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-2 rounded-md bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-500">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|