From 2586dc7e6aa9ce0891d1d9ba9dd1757f1ba65f95 Mon Sep 17 00:00:00 2001 From: Markov Date: Wed, 25 Feb 2026 09:19:50 +0100 Subject: [PATCH] Implement ProjectFiles component - Complete project files UI with file list, icons, search - Upload files via drag & drop or file picker with optional description - Preview images as thumbnails - Edit file descriptions inline - Download files by clicking filename - Delete files with confirmation - Show file size, mime type icons, uploader, upload date - Dark theme consistent with app design - File size formatting helper - Support for all common file types with appropriate icons --- src/components/ProjectFiles.tsx | 455 ++++++++++++++++++-------------- 1 file changed, 259 insertions(+), 196 deletions(-) diff --git a/src/components/ProjectFiles.tsx b/src/components/ProjectFiles.tsx index c16737c..6b95b58 100644 --- a/src/components/ProjectFiles.tsx +++ b/src/components/ProjectFiles.tsx @@ -1,268 +1,331 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useState, useEffect, useRef } from "react"; import type { Project, ProjectFile } from "@/lib/api"; import { getProjectFiles, uploadProjectFile, - getProjectFileUrl, updateProjectFile, deleteProjectFile, + getProjectFileUrl, } from "@/lib/api"; interface Props { project: Project; } -function formatSize(bytes: number): string { +function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } -function fileIcon(mime: string | null, filename: string): string { - if (mime?.startsWith("image/")) return "🖼️"; - if (mime?.startsWith("video/")) return "🎬"; - if (mime?.startsWith("audio/")) return "🎵"; - if (mime === "application/pdf") return "📕"; - const ext = filename.split(".").pop()?.toLowerCase(); - if (["md", "txt", "rst"].includes(ext || "")) return "📝"; - if (["json", "yaml", "yml", "toml", "xml"].includes(ext || "")) return "⚙️"; - if (["py", "ts", "js", "rs", "go", "java", "c", "cpp"].includes(ext || "")) return "💻"; - if (["zip", "tar", "gz", "rar", "7z"].includes(ext || "")) return "📦"; - return "📄"; +function getFileIcon(mimeType: string | null): string { + if (!mimeType) return "📄"; + if (mimeType.startsWith("image/")) return "📷"; + if (mimeType.startsWith("video/")) return "📹"; + if (mimeType.startsWith("audio/")) return "🎵"; + if (mimeType.includes("pdf")) return "📕"; + if (mimeType.includes("word") || mimeType.includes("document")) return "📝"; + if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) return "📊"; + if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) return "📋"; + if (mimeType.includes("zip") || mimeType.includes("archive")) return "📦"; + if (mimeType.includes("text")) return "📄"; + return "📁"; +} + +function isImage(mimeType: string | null): boolean { + return !!mimeType && mimeType.startsWith("image/"); } export default function ProjectFiles({ project }: Props) { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); - const [uploading, setUploading] = useState(false); - const [dragOver, setDragOver] = useState(false); - const [editingId, setEditingId] = useState(null); - const [editDesc, setEditDesc] = useState(""); - const [uploadDesc, setUploadDesc] = useState(""); - const [showUploadDesc, setShowUploadDesc] = useState(false); + const [uploadLoading, setUploadLoading] = useState(false); + const [editingDescription, setEditingDescription] = useState(null); + const [editDescription, setEditDescription] = useState(""); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + const uploadDescriptionRef = useRef(null); - const loadFiles = useCallback(async () => { + const loadFiles = async () => { try { - const data = await getProjectFiles(project.slug, search || undefined); - setFiles(data); - } catch (e) { - console.error("Failed to load files:", e); + const result = await getProjectFiles(project.slug, search || undefined); + setFiles(result); + } catch (err) { + console.error("Failed to load files:", err); } finally { setLoading(false); } - }, [project.slug, search]); + }; useEffect(() => { - setLoading(true); - const timer = setTimeout(loadFiles, search ? 300 : 0); - return () => clearTimeout(timer); - }, [loadFiles, search]); + loadFiles(); + }, [project.slug, search]); - const handleUpload = async (fileList: FileList) => { - setUploading(true); + const handleFileUpload = async (file: File, description?: string) => { + setUploadLoading(true); try { - for (const file of Array.from(fileList)) { - await uploadProjectFile(project.slug, file, uploadDesc || undefined); - } - setUploadDesc(""); - setShowUploadDesc(false); - await loadFiles(); - } catch (e) { - console.error("Upload failed:", e); + await uploadProjectFile(project.slug, file, description); + await loadFiles(); // Refresh list + alert("Файл загружен успешно!"); + } catch (err) { + console.error("Upload failed:", err); + alert(`Ошибка загрузки: ${err}`); } finally { - setUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ""; + setUploadLoading(false); } }; - const handleDelete = async (f: ProjectFile) => { - if (!confirm(`Удалить ${f.filename}?`)) return; + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const description = uploadDescriptionRef.current?.value.trim(); + handleFileUpload(file, description || undefined); + event.target.value = ""; + if (uploadDescriptionRef.current) { + uploadDescriptionRef.current.value = ""; + } + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(false); + const file = event.dataTransfer.files[0]; + if (file) { + const description = uploadDescriptionRef.current?.value.trim(); + handleFileUpload(file, description || undefined); + if (uploadDescriptionRef.current) { + uploadDescriptionRef.current.value = ""; + } + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(false); + }; + + const handleDownload = (file: ProjectFile) => { + const url = getProjectFileUrl(project.slug, file.id); + window.open(url, "_blank"); + }; + + const handleEditDescription = (file: ProjectFile) => { + setEditingDescription(file.id); + setEditDescription(file.description || ""); + }; + + const handleSaveDescription = async (file: ProjectFile) => { try { - await deleteProjectFile(project.slug, f.id); - setFiles((prev) => prev.filter((x) => x.id !== f.id)); - } catch (e) { - console.error("Delete failed:", e); + await updateProjectFile(project.slug, file.id, { + description: editDescription.trim() || undefined, + }); + await loadFiles(); + setEditingDescription(null); + } catch (err) { + console.error("Failed to update description:", err); + alert(`Ошибка обновления: ${err}`); } }; - const handleSaveDesc = async (f: ProjectFile) => { + const handleDeleteFile = async (file: ProjectFile) => { + if (!confirm(`Удалить файл "${file.filename}"?`)) return; + try { - const updated = await updateProjectFile(project.slug, f.id, { description: editDesc }); - setFiles((prev) => prev.map((x) => (x.id === f.id ? updated : x))); - setEditingId(null); - } catch (e) { - console.error("Update failed:", e); + await deleteProjectFile(project.slug, file.id); + await loadFiles(); + alert("Файл удален!"); + } catch (err) { + console.error("Failed to delete file:", err); + alert(`Ошибка удаления: ${err}`); } }; - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragOver(false); - if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files); - }; + if (loading) { + return ( +
+
Загрузка файлов...
+
+ ); + } return ( -
{ e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} - > - {/* Header */} -
-
+
+ {/* Header with search */} +
+
setSearch(e.target.value)} - placeholder="🔍 Поиск по имени..." - className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]" + className="w-full px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded text-[var(--fg)] placeholder-[var(--muted)] focus:outline-none focus:border-[var(--accent)]" /> - e.target.files && handleUpload(e.target.files)} - /> - -
- {showUploadDesc && ( - setUploadDesc(e.target.value)} - placeholder="Описание для загружаемых файлов (опционально)" - className="bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]" - /> - )} +
+ {files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} +
- {/* File list */} -
- {loading && ( -
Загрузка...
+ {/* Upload area */} +
+ {uploadLoading ? ( +
Загрузка...
+ ) : ( + <> +
📁
+
Перетащите файл сюда или
+
+ + +
+
+ Максимальный размер: 50 МБ +
+ )} + +
- {!loading && files.length === 0 && ( + {/* Files list */} +
+ {files.length === 0 ? (
-
📁
-
Нет файлов
-
Перетащите файлы сюда или нажмите «Загрузить»
+
📂
+
Файлов пока нет
+
Загрузите первый файл проекта
- )} + ) : ( +
+ {files.map((file) => ( +
+ {/* File icon & preview */} +
+ {isImage(file.mime_type) ? ( + {file.filename} + ) : ( + {getFileIcon(file.mime_type)} + )} +
- {!loading && files.length > 0 && ( -
- {files.map((f) => { - const url = getProjectFileUrl(project.slug, f.id); - const isImage = f.mime_type?.startsWith("image/"); - return ( -
- {/* Icon / thumbnail */} -
- {isImage ? ( - - ) : ( - {fileIcon(f.mime_type, f.filename)} - )} + {/* File info */} +
+
+ + + {formatFileSize(file.size)} +
- {/* Info */} -
- - {editingId === f.id ? ( -
- setEditDesc(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSaveDesc(f)} - className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-0.5 text-xs outline-none focus:border-[var(--accent)]" - autoFocus - /> - - -
- ) : ( - f.description && ( -
{f.description}
- ) - )} -
+ ) : ( +
+ {file.description || "Без описания"} +
+ )} - {/* Uploader */} -
- {f.uploader?.name} -
- - {/* Date */} -
- {new Date(f.created_at).toLocaleDateString("ru-RU")} -
- - {/* Actions */} -
- - +
+ Загрузил: {file.uploader?.name || "Unknown"} + + {new Date(file.created_at).toLocaleString("ru")}
- ); - })} -
- )} - {/* Drag overlay */} - {dragOver && ( -
-
📁 Отпустите для загрузки
+ {/* Actions */} +
+ + +
+
+ ))}
)}
- - {/* Footer stats */} - {files.length > 0 && ( -
- {files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} · {formatSize(files.reduce((s, f) => s + f.size, 0))} -
- )}
); -} +} \ No newline at end of file