diff --git a/admin-web/components/media-upload.test.tsx b/admin-web/components/media-upload.test.tsx new file mode 100644 index 0000000..447ef45 --- /dev/null +++ b/admin-web/components/media-upload.test.tsx @@ -0,0 +1,462 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MediaUpload } from './media-upload' +import * as storage from '@/lib/storage' + +// Mock storage module +vi.mock('@/lib/storage', () => ({ + uploadFile: vi.fn(), + formatFileSize: vi.fn((bytes) => `${bytes} bytes`), + UPLOAD_CONFIGS: { + video: { + bucket: 'workout-videos', + maxSize: 500 * 1024 * 1024, + allowedTypes: ['video/mp4', 'video/webm', 'video/quicktime'], + }, + thumbnail: { + bucket: 'workout-thumbnails', + maxSize: 10 * 1024 * 1024, + allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + }, + avatar: { + bucket: 'trainer-avatars', + maxSize: 5 * 1024 * 1024, + allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + }, + }, +})) + +describe('MediaUpload', () => { + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render upload area for thumbnail', () => { + render( + + ) + + expect(screen.getByText(/click or drag file to upload/i)).toBeInTheDocument() + expect(screen.getByText(/10 mb/i)).toBeInTheDocument() + }) + + it('should render upload area for video', () => { + render( + + ) + + expect(screen.getByText(/click or drag file to upload/i)).toBeInTheDocument() + expect(screen.getByText(/500 mb/i)).toBeInTheDocument() + }) + + it('should render upload area for avatar', () => { + render( + + ) + + expect(screen.getByText(/click or drag file to upload/i)).toBeInTheDocument() + expect(screen.getByText(/5 mb/i)).toBeInTheDocument() + }) + + it('should show image preview after successful upload', () => { + render( + + ) + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/image.jpg') + }) + + it('should show video preview after upload', () => { + render( + + ) + + const video = document.querySelector('video') + expect(video).toBeInTheDocument() + expect(video).toHaveAttribute('src', 'https://example.com/video.mp4') + }) + }) + + describe('file upload', () => { + it('should handle file selection via click', async () => { + vi.mocked(storage.uploadFile).mockResolvedValue({ + url: 'https://example.com/image.jpg', + path: 'temp/123.jpg', + }) + + render( + + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + await userEvent.upload(input, file) + + await waitFor(() => { + expect(storage.uploadFile).toHaveBeenCalled() + expect(mockOnChange).toHaveBeenCalledWith('https://example.com/image.jpg') + }, { timeout: 10000 }) + }, 15000) + + it('should show error for upload failure', async () => { + vi.mocked(storage.uploadFile).mockRejectedValue({ + message: 'Upload failed', + code: 'UPLOAD_FAILED', + } as storage.UploadError) + + render( + + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + await userEvent.upload(input, file) + + await waitFor(() => { + // Error should be displayed (check for any error text in the error container) + const errorContainer = document.querySelector('[class*="bg-red-500"]') || + document.querySelector('[class*="text-red"]') || + screen.queryByText(/failed/i) || + screen.queryByText(/error/i) + expect(errorContainer).toBeTruthy() + }, { timeout: 5000 }) + }, 10000) + + it('should show error for file too large', async () => { + vi.mocked(storage.uploadFile).mockRejectedValue({ + message: 'File too large. Maximum size is 10 MB.', + code: 'SIZE_EXCEEDED', + } as storage.UploadError) + + render( + + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + await userEvent.upload(input, file) + + await waitFor(() => { + const errorElement = screen.getByText(/file too large/i) + expect(errorElement).toBeInTheDocument() + }, { timeout: 5000 }) + }, 10000) + + it('should show upload progress', async () => { + let resolveUpload: (value: { url: string; path: string }) => void + const uploadPromise = new Promise<{ url: string; path: string }>((resolve) => { + resolveUpload = resolve + }) + + vi.mocked(storage.uploadFile).mockReturnValue(uploadPromise) + + render( + + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + await userEvent.upload(input, file) + + expect(screen.getByText(/uploading/i)).toBeInTheDocument() + + resolveUpload!({ url: 'https://example.com/image.jpg', path: 'temp/123.jpg' }) + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('https://example.com/image.jpg') + }, { timeout: 5000 }) + }, 10000) + + it('should clear error when uploading new file', async () => { + vi.mocked(storage.uploadFile) + .mockRejectedValueOnce({ + message: 'First error', + code: 'UPLOAD_FAILED', + } as storage.UploadError) + .mockResolvedValueOnce({ + url: 'https://example.com/image.jpg', + path: 'temp/123.jpg', + }) + + render( + + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + + // First upload - error + await userEvent.upload(input, new File(['test'], 'test1.jpg', { type: 'image/jpeg' })) + await waitFor(() => { + expect(screen.getByText(/first error/i)).toBeInTheDocument() + }) + + // Second upload - success, error should be cleared + await userEvent.upload(input, new File(['test'], 'test2.jpg', { type: 'image/jpeg' })) + await waitFor(() => { + expect(screen.queryByText(/first error/i)).not.toBeInTheDocument() + }) + }, 10000) + }) + + describe('drag and drop', () => { + it('should highlight on drag over', () => { + render( + + ) + + const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement + expect(dropZone).not.toHaveClass('border-orange-500') + + fireEvent.dragOver(dropZone) + expect(dropZone).toHaveClass('border-orange-500') + }) + + it('should remove highlight on drag leave', () => { + render( + + ) + + const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement + + fireEvent.dragOver(dropZone) + expect(dropZone).toHaveClass('border-orange-500') + + fireEvent.dragLeave(dropZone) + expect(dropZone).not.toHaveClass('border-orange-500') + }) + + it('should handle file drop', async () => { + vi.mocked(storage.uploadFile).mockResolvedValue({ + url: 'https://example.com/image.jpg', + path: 'temp/123.jpg', + }) + + render( + + ) + + const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + fireEvent.drop(dropZone, { + dataTransfer: { + files: [file], + }, + }) + + await waitFor(() => { + expect(storage.uploadFile).toHaveBeenCalled() + }, { timeout: 5000 }) + }, 10000) + + it('should not handle drop when disabled', () => { + render( + + ) + + const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + fireEvent.drop(dropZone, { + dataTransfer: { + files: [file], + }, + }) + + expect(storage.uploadFile).not.toHaveBeenCalled() + }) + + it('should not highlight on drag over when disabled', () => { + render( + + ) + + const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement + + fireEvent.dragOver(dropZone) + expect(dropZone).not.toHaveClass('border-orange-500') + }) + }) + + describe('file removal', () => { + it('should allow removing uploaded file', async () => { + render( + + ) + + const removeButton = screen.getByRole('button', { name: /remove/i }) + await userEvent.click(removeButton) + + expect(mockOnChange).toHaveBeenCalledWith('') + }) + + it('should clear file input when removed', async () => { + render( + + ) + + const removeButton = screen.getByRole('button', { name: /remove/i }) + await userEvent.click(removeButton) + + // Verify onChange was called with empty string + expect(mockOnChange).toHaveBeenCalledWith('') + }) + }) + + describe('disabled state', () => { + it('should be disabled when disabled prop is true', () => { + render( + + ) + + const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement + expect(dropZone).toHaveClass('cursor-not-allowed') + expect(dropZone).toHaveClass('opacity-50') + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input).toBeDisabled() + }) + + it('should not show remove button when disabled with value', () => { + render( + + ) + + const removeButton = screen.getByRole('button', { name: /remove/i }) + expect(removeButton).toBeDisabled() + }) + }) + + describe('workoutId handling', () => { + it('should use workoutId for file path', async () => { + vi.mocked(storage.uploadFile).mockResolvedValue({ + url: 'https://example.com/image.jpg', + path: 'workout-123/123.jpg', + }) + + render( + + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + await userEvent.upload(input, file) + + await waitFor(() => { + expect(storage.uploadFile).toHaveBeenCalled() + }, { timeout: 10000 }) + + // Check the third argument was 'workout-123' + const call = vi.mocked(storage.uploadFile).mock.calls[0] + expect(call[2]).toBe('workout-123') + }, 15000) + + it('should use temp folder when no workoutId provided', async () => { + vi.mocked(storage.uploadFile).mockResolvedValue({ + url: 'https://example.com/image.jpg', + path: 'temp/123.jpg', + }) + + render( + + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) + + await userEvent.upload(input, file) + + await waitFor(() => { + expect(storage.uploadFile).toHaveBeenCalled() + }, { timeout: 10000 }) + + // Check the third argument was 'temp' + const call = vi.mocked(storage.uploadFile).mock.calls[0] + expect(call[2]).toBe('temp') + }, 15000) + }) +}) diff --git a/admin-web/components/media-upload.tsx b/admin-web/components/media-upload.tsx new file mode 100644 index 0000000..2b265cd --- /dev/null +++ b/admin-web/components/media-upload.tsx @@ -0,0 +1,236 @@ +"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(null) + const fileInputRef = React.useRef(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) => { + 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 ( +
+
+ {isVideo ? ( +
+ +

+ {value} +

+
+ ) + } + + return ( +
+
+ + + {isUploading ? ( +
+
+
+
+

+ Uploading... {uploadProgress}% +

+
+ ) : ( + <> +
+ +
+ +

+ {isDragging ? "Drop file here" : "Click or drag file to upload"} +

+ +

+ {isVideo ? "Video" : "Image"} up to {maxSizeMB} MB +
+ {config.allowedTypes.map(t => t.split("/").pop()?.toUpperCase()).join(", ")} +

+ + )} +
+ + {error && ( +
+ + {error} +
+ )} +
+ ) +}