Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from "react"; | |
| import { Attachment, attachmentApi } from "@/services/attachmentApi"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| Card, | |
| CardContent, | |
| CardDescription, | |
| CardHeader, | |
| CardTitle, | |
| CardFooter | |
| } from "@/components/ui/card"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { | |
| FileUp, | |
| Download, | |
| Trash2, | |
| File, | |
| FileText, | |
| Image, | |
| Film, | |
| Music, | |
| Archive, | |
| FileX, | |
| Loader2, | |
| Plus, | |
| Eye, | |
| Info | |
| } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { Badge } from "../ui/badge"; | |
| import { cn } from "@/lib/utils"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import AttachmentViewerDialog from "./AttachmentViewerDialog"; | |
| import UploadAttachmentDialog, { FileUploadData } from "./UploadAttachmentDialog"; | |
| interface TaskAttachmentsSectionProps { | |
| taskId: number; | |
| entityType?: "Task" | "Issue" | "Project"; | |
| } | |
| const TaskAttachmentsSection: React.FC<TaskAttachmentsSectionProps> = ({ | |
| taskId, | |
| entityType = "Task" | |
| }) => { | |
| const [attachments, setAttachments] = useState<Attachment[]>([]); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [isUploading, setIsUploading] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const [downloadingFiles, setDownloadingFiles] = useState<Record<number, boolean>>({}); | |
| const [downloadProgress, setDownloadProgress] = useState<Record<number, number>>({}); | |
| const [downloadSizes, setDownloadSizes] = useState<Record<number, number>>({}); | |
| const [viewerOpen, setViewerOpen] = useState(false); | |
| const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); | |
| // New states for the upload dialog | |
| const [uploadDialogOpen, setUploadDialogOpen] = useState(false); | |
| const [selectedFiles, setSelectedFiles] = useState<FileUploadData[]>([]); | |
| const desktopFileInputRef = useRef<HTMLInputElement>(null); | |
| const mobileFileInputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| if (taskId) { | |
| fetchAttachments(); | |
| } | |
| }, [taskId, entityType]); | |
| const fetchAttachments = async () => { | |
| try { | |
| setIsLoading(true); | |
| const data = await attachmentApi.getByEntityTypeAndId(entityType, taskId); | |
| setAttachments(data); | |
| } catch (error) { | |
| console.error(`Error fetching attachments for ${entityType}:`, error); | |
| toast.error(`Failed to load attachments for ${entityType}`); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Handle file selection for the upload dialog | |
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = e.target.files; | |
| if (!files || files.length === 0) return; | |
| // Convert FileList to array of FileUploadData | |
| const newFiles = Array.from(files).map(file => ({ | |
| file, | |
| description: "" | |
| })); | |
| setSelectedFiles(newFiles); | |
| setUploadDialogOpen(true); | |
| // Reset the file input | |
| if (e.target === desktopFileInputRef.current) { | |
| desktopFileInputRef.current.value = ""; | |
| } else if (e.target === mobileFileInputRef.current) { | |
| mobileFileInputRef.current.value = ""; | |
| } | |
| }; | |
| // Update description for a file | |
| const updateFileDescription = (index: number, description: string) => { | |
| setSelectedFiles(prev => { | |
| const updated = [...prev]; | |
| updated[index] = { ...updated[index], description }; | |
| return updated; | |
| }); | |
| }; | |
| // Handle the actual file upload | |
| const handleUploadFiles = async () => { | |
| if (selectedFiles.length === 0) return; | |
| try { | |
| setIsUploading(true); | |
| for (const fileData of selectedFiles) { | |
| const { file, description } = fileData; | |
| const formData = new FormData(); | |
| // Add file data | |
| formData.append("AttachmentId", "0"); | |
| formData.append("FileName", file.name); | |
| formData.append("FileType", file.type || getFileExtension(file.name)); | |
| formData.append("FilePath", ""); // Server will set this | |
| formData.append("EntityType", entityType); | |
| formData.append("EntityId", taskId.toString()); | |
| formData.append("Description", description); // Add description | |
| formData.append("_IFormFile", file); | |
| await attachmentApi.upload(formData); | |
| } | |
| // Refresh the list after upload | |
| fetchAttachments(); | |
| toast.success("File(s) uploaded successfully"); | |
| // Reset state | |
| setSelectedFiles([]); | |
| setUploadDialogOpen(false); | |
| } catch (error) { | |
| console.error("Error uploading file:", error); | |
| toast.error("Failed to upload file"); | |
| } finally { | |
| setIsUploading(false); | |
| } | |
| }; | |
| const handleDeleteAttachment = async (attachmentId: number) => { | |
| if (window.confirm("Are you sure you want to delete this attachment?")) { | |
| try { | |
| await attachmentApi.delete(attachmentId); | |
| setAttachments(attachments.filter(a => a.attachmentId !== attachmentId)); | |
| toast.success("Attachment deleted successfully"); | |
| } catch (error) { | |
| console.error("Error deleting attachment:", error); | |
| toast.error("Failed to delete attachment"); | |
| } | |
| } | |
| }; | |
| const handleDownload = async (attachment: Attachment) => { | |
| try { | |
| // Set this file as downloading and reset progress | |
| setDownloadingFiles(prev => ({ ...prev, [attachment.attachmentId]: true })); | |
| setDownloadProgress(prev => ({ ...prev, [attachment.attachmentId]: 0 })); | |
| setDownloadSizes(prev => ({ ...prev, [attachment.attachmentId]: 0 })); | |
| // Show loading toast | |
| const toastId = toast.loading(`Downloading ${attachment.fileName}...`, { | |
| position: 'bottom-center' | |
| }); | |
| // Progress tracking callback | |
| const onProgress = (progress: number, totalSize: number) => { | |
| setDownloadProgress(prev => ({ ...prev, [attachment.attachmentId]: progress })); | |
| setDownloadSizes(prev => ({ ...prev, [attachment.attachmentId]: totalSize })); | |
| }; | |
| // Use the enhanced downloadAndSave method directly with progress callback | |
| await attachmentApi.downloadAndSave(attachment.attachmentId, onProgress); | |
| // Dismiss the loading toast and show success | |
| toast.dismiss(toastId); | |
| toast.success(`File downloaded successfully`, { | |
| position: 'bottom-center', | |
| duration: 2000 | |
| }); | |
| } catch (error) { | |
| console.error('Error downloading file:', error); | |
| toast.error('Failed to download attachment', { | |
| position: 'bottom-center' | |
| }); | |
| } finally { | |
| // Clear downloading state for this file | |
| setDownloadingFiles(prev => { | |
| const newState = { ...prev }; | |
| delete newState[attachment.attachmentId]; | |
| return newState; | |
| }); | |
| setDownloadProgress(prev => { | |
| const newState = { ...prev }; | |
| delete newState[attachment.attachmentId]; | |
| return newState; | |
| }); | |
| setDownloadSizes(prev => { | |
| const newState = { ...prev }; | |
| delete newState[attachment.attachmentId]; | |
| return newState; | |
| }); | |
| } | |
| }; | |
| const handleViewAttachment = (attachment: Attachment) => { | |
| setSelectedAttachment(attachment); | |
| setViewerOpen(true); | |
| }; | |
| // Helper to get file extension from filename | |
| const getFileExtension = (filename: string): string => { | |
| return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2); | |
| }; | |
| // Helper to get file icon based on file type | |
| const getFileIcon = (attachment: Attachment) => { | |
| const fileType = attachment.fileType.toLowerCase(); | |
| if (fileType.includes('image') || fileType.includes('.jpg') || fileType.includes('.jpeg') || | |
| fileType.includes('.png') || fileType.includes('.gif') || fileType.includes('.webp')) { | |
| return <Image className="h-5 w-5 text-purple-500" />; | |
| } | |
| if (fileType.includes('pdf')) { | |
| return <FileText className="h-5 w-5 text-red-500" />; | |
| } | |
| if (fileType.includes('word') || fileType.includes('.doc') || fileType.includes('.docx')) { | |
| return <FileText className="h-5 w-5 text-blue-500" />; | |
| } | |
| if (fileType.includes('excel') || fileType.includes('.xls') || fileType.includes('.xlsx')) { | |
| return <FileText className="h-5 w-5 text-green-500" />; | |
| } | |
| if (fileType.includes('video') || fileType.includes('.mp4') || fileType.includes('.mov')) { | |
| return <Film className="h-5 w-5 text-orange-500" />; | |
| } | |
| if (fileType.includes('audio') || fileType.includes('.mp3') || fileType.includes('.wav')) { | |
| return <Music className="h-5 w-5 text-blue-500" />; | |
| } | |
| if (fileType.includes('zip') || fileType.includes('.rar') || fileType.includes('.7z')) { | |
| return <Archive className="h-5 w-5 text-yellow-500" />; | |
| } | |
| return <File className="h-5 w-5 text-gray-500" />; | |
| }; | |
| // Helper to format file size | |
| const formatFileSize = (bytes: number): string => { | |
| if (!bytes) return 'Unknown size'; | |
| const units = ['B', 'KB', 'MB', 'GB']; | |
| let size = bytes; | |
| let unitIndex = 0; | |
| while (size >= 1024 && unitIndex < units.length - 1) { | |
| size /= 1024; | |
| unitIndex++; | |
| } | |
| return `${size.toFixed(1)} ${units[unitIndex]}`; | |
| }; | |
| // Helper to format file name for display | |
| const formatDisplayName = (fileName: string, maxLength: number = 30): string => { | |
| if (fileName.length <= maxLength) return fileName; | |
| const extension = fileName.slice((fileName.lastIndexOf(".") - 1 >>> 0) + 1); | |
| const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf(".")); | |
| if (extension.length + 5 >= maxLength) { | |
| return `${nameWithoutExt.slice(0, maxLength - 10)}...${extension}`; | |
| } | |
| const charsToCut = fileName.length - maxLength + 3; | |
| return `${nameWithoutExt.slice(0, nameWithoutExt.length - charsToCut)}...${extension}`; | |
| }; | |
| // Helper to determine a shorter max length for mobile screens | |
| const getResponsiveMaxLength = (): number => { | |
| if (typeof window !== 'undefined') { | |
| return window.innerWidth < 640 ? 15 : 30; | |
| } | |
| return 30; | |
| }; | |
| return ( | |
| <div className="w-full"> | |
| {/* Attachment Viewer Dialog */} | |
| <AttachmentViewerDialog | |
| isOpen={viewerOpen} | |
| onClose={() => { | |
| setViewerOpen(false); | |
| setSelectedAttachment(null); | |
| }} | |
| attachment={selectedAttachment} | |
| onDownload={handleDownload} | |
| /> | |
| {/* Custom Upload Dialog */} | |
| <UploadAttachmentDialog | |
| isOpen={uploadDialogOpen} | |
| onClose={() => setUploadDialogOpen(false)} | |
| selectedFiles={selectedFiles} | |
| onUpdateDescription={updateFileDescription} | |
| onUpload={handleUploadFiles} | |
| isUploading={isUploading} | |
| entityType={entityType} | |
| /> | |
| {/* Fixed Action Button for Mobile */} | |
| <div className="sm:hidden fixed bottom-6 right-6 z-10"> | |
| <Label htmlFor="mobile-file-upload" className="cursor-pointer"> | |
| <div className="h-14 w-14 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-lg"> | |
| <Plus className="h-6 w-6" /> | |
| </div> | |
| <Input | |
| id="mobile-file-upload" | |
| ref={mobileFileInputRef} | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={handleFileSelect} | |
| disabled={isUploading} | |
| /> | |
| </Label> | |
| </div> | |
| <Card className="shadow-sm border"> | |
| <CardHeader className="pb-3"> | |
| <div className="flex justify-between items-center"> | |
| <CardTitle className="text-xl flex items-center gap-2"> | |
| <FileText className="h-5 w-5" /> | |
| Attachments | |
| </CardTitle> | |
| <div className="hidden sm:block"> | |
| <Label htmlFor="desktop-file-upload" className="cursor-pointer"> | |
| <div className={cn( | |
| "flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border", | |
| isUploading | |
| ? "bg-muted text-muted-foreground" | |
| : "bg-white hover:bg-accent transition-colors" | |
| )}> | |
| {isUploading ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| <span>Uploading...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <FileUp className="h-4 w-4" /> | |
| <span>Upload</span> | |
| </> | |
| )} | |
| </div> | |
| <Input | |
| id="desktop-file-upload" | |
| ref={desktopFileInputRef} | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={handleFileSelect} | |
| disabled={isUploading} | |
| /> | |
| </Label> | |
| </div> | |
| </div> | |
| <CardDescription> | |
| Files and documents related to this {entityType.toLowerCase()} | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| {isLoading ? ( | |
| <div className="flex justify-center items-center py-8"> | |
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> | |
| </div> | |
| ) : attachments.length === 0 ? ( | |
| <div className="text-center py-8 border border-dashed rounded-lg flex flex-col items-center"> | |
| <FileX className="h-10 w-10 text-muted-foreground mb-2" /> | |
| <p className="text-sm text-muted-foreground mb-2">No attachments yet</p> | |
| <Label htmlFor="empty-file-upload" className="cursor-pointer sm:block hidden"> | |
| <div className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm hover:bg-primary/90 transition-colors"> | |
| Upload Files | |
| </div> | |
| <Input | |
| id="empty-file-upload" | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={handleFileSelect} | |
| disabled={isUploading} | |
| /> | |
| </Label> | |
| <p className="text-xs text-muted-foreground mt-2 sm:hidden"> | |
| Tap the + button to upload files | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-4"> | |
| <div className="grid gap-3"> | |
| {attachments.map((attachment) => ( | |
| <div | |
| key={attachment.attachmentId} | |
| className={cn( | |
| "flex items-center justify-between border rounded-lg p-3 bg-card transition-colors", | |
| downloadingFiles[attachment.attachmentId] | |
| ? "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800" | |
| : "hover:bg-accent/50" | |
| )} | |
| > | |
| <div | |
| className="flex items-center gap-2 sm:gap-3 overflow-hidden flex-1 min-w-0 cursor-pointer" | |
| onClick={() => handleViewAttachment(attachment)} | |
| > | |
| <div className={cn( | |
| "p-2 rounded-md flex-shrink-0", | |
| downloadingFiles[attachment.attachmentId] | |
| ? "bg-blue-100 dark:bg-blue-800/30" | |
| : "bg-accent" | |
| )}> | |
| {downloadingFiles[attachment.attachmentId] ? ( | |
| <Loader2 className="h-5 w-5 text-blue-600 dark:text-blue-400 animate-spin" /> | |
| ) : ( | |
| getFileIcon(attachment) | |
| )} | |
| </div> | |
| <div className="min-w-0 flex-1"> | |
| <p className="font-medium text-sm truncate" title={attachment.fileName}> | |
| {formatDisplayName(attachment.fileName, getResponsiveMaxLength())} | |
| </p> | |
| {/* Display the description if it exists */} | |
| {attachment.description && ( | |
| <p className="text-xs text-muted-foreground mt-0.5 truncate" title={attachment.description}> | |
| <Info className="h-3 w-3 inline mr-1" /> | |
| {attachment.description} | |
| </p> | |
| )} | |
| <div className="flex items-center gap-2 mt-1"> | |
| <Badge variant="secondary" className={cn( | |
| "px-1.5 py-0 h-5 text-xs", | |
| downloadingFiles[attachment.attachmentId] | |
| ? "bg-blue-100 dark:bg-blue-800/30 text-blue-600 dark:text-blue-400" | |
| : "bg-accent" | |
| )}> | |
| {downloadingFiles[attachment.attachmentId] ? "Downloading..." : attachment.fileType || getFileExtension(attachment.fileName)} | |
| </Badge> | |
| {!downloadingFiles[attachment.attachmentId] && ( | |
| <span className="text-xs text-muted-foreground"> | |
| {attachment.createdBy && `Added by ${attachment.createdBy}`} | |
| {attachment.createdAt && attachment.createdBy && ' · '} | |
| {attachment.createdAt && new Date(attachment.createdAt).toLocaleDateString(undefined, { | |
| year: 'numeric', | |
| month: 'short', | |
| day: 'numeric' | |
| })} | |
| </span> | |
| )} | |
| {downloadingFiles[attachment.attachmentId] && ( | |
| <div className="mt-2 w-full"> | |
| <Progress | |
| value={downloadProgress[attachment.attachmentId] || 0} | |
| className="h-1.5 bg-blue-100 dark:bg-blue-900/30" | |
| /> | |
| <div className="flex justify-between text-xs text-muted-foreground mt-1"> | |
| <span>{downloadProgress[attachment.attachmentId] || 0}%</span> | |
| {downloadSizes[attachment.attachmentId] && ( | |
| <span>{formatFileSize(downloadSizes[attachment.attachmentId])}</span> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleViewAttachment(attachment); | |
| }} | |
| title="View attachment" | |
| className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50" | |
| disabled={downloadingFiles[attachment.attachmentId]} | |
| > | |
| <Eye className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleDownload(attachment); | |
| }} | |
| title="Download file" | |
| className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50" | |
| disabled={downloadingFiles[attachment.attachmentId]} | |
| > | |
| {downloadingFiles[attachment.attachmentId] ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Download className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleDeleteAttachment(attachment.attachmentId); | |
| }} | |
| title="Delete attachment" | |
| className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" | |
| disabled={downloadingFiles[attachment.attachmentId]} | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| <CardFooter className="border-t pt-4 flex justify-between"> | |
| <div className="text-sm text-muted-foreground"> | |
| {attachments.length} attachment{attachments.length !== 1 ? 's' : ''} | |
| </div> | |
| <Label htmlFor="footer-file-upload" className="cursor-pointer hidden sm:block"> | |
| <div className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm border ${ | |
| isUploading ? 'bg-muted text-muted-foreground' : 'bg-white hover:bg-accent transition-colors' | |
| }`}> | |
| {isUploading ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| <span>Uploading...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <FileUp className="h-4 w-4" /> | |
| <span>Upload Files</span> | |
| </> | |
| )} | |
| </div> | |
| <Input | |
| id="footer-file-upload" | |
| type="file" | |
| multiple | |
| className="hidden" | |
| onChange={handleFileSelect} | |
| disabled={isUploading} | |
| /> | |
| </Label> | |
| </CardFooter> | |
| </Card> | |
| </div> | |
| ); | |
| }; | |
| export default TaskAttachmentsSection; |