Files
tabatago/admin-web/components/media-upload.tsx
Millian Lamiaux 6adf709dce feat: add media upload component with comprehensive tests
- 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
2026-03-14 20:42:51 +01:00

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>
)
}