From f676329206547b13bbf8f5212012c17a250f6e28 Mon Sep 17 00:00:00 2001 From: Markov Date: Wed, 25 Feb 2026 10:37:47 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20rewrite=20ProjectFiles=20=E2=80=94=20rem?= =?UTF-8?q?ove=20alerts,=20add=20debounce,=20multi-upload,=20hover=20actio?= =?UTF-8?q?ns,=20proper=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProjectFiles.tsx | 465 ++++++++++++++------------------ 1 file changed, 201 insertions(+), 264 deletions(-) diff --git a/src/components/ProjectFiles.tsx b/src/components/ProjectFiles.tsx index 6b95b58..e9d8ce2 100644 --- a/src/components/ProjectFiles.tsx +++ b/src/components/ProjectFiles.tsx @@ -1,331 +1,268 @@ -import { useState, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { Project, ProjectFile } from "@/lib/api"; import { getProjectFiles, uploadProjectFile, + getProjectFileUrl, updateProjectFile, deleteProjectFile, - getProjectFileUrl, } from "@/lib/api"; interface Props { project: Project; } -function formatFileSize(bytes: number): string { +function formatSize(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 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/"); +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", "h"].includes(ext || "")) return "💻"; + if (["zip", "tar", "gz", "rar", "7z"].includes(ext || "")) return "📦"; + if (["doc", "docx", "odt"].includes(ext || "")) return "📝"; + if (["xls", "xlsx", "csv"].includes(ext || "")) return "📊"; + return "📄"; } export default function ProjectFiles({ project }: Props) { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); - const [uploadLoading, setUploadLoading] = useState(false); - const [editingDescription, setEditingDescription] = useState(null); - const [editDescription, setEditDescription] = useState(""); - const [isDragOver, setIsDragOver] = useState(false); - + 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 fileInputRef = useRef(null); - const uploadDescriptionRef = useRef(null); - const loadFiles = async () => { + const loadFiles = useCallback(async () => { try { - const result = await getProjectFiles(project.slug, search || undefined); - setFiles(result); - } catch (err) { - console.error("Failed to load files:", err); + const data = await getProjectFiles(project.slug, search || undefined); + setFiles(data); + } catch (e) { + console.error("Failed to load files:", e); } finally { setLoading(false); } - }; - - useEffect(() => { - loadFiles(); }, [project.slug, search]); - const handleFileUpload = async (file: File, description?: string) => { - setUploadLoading(true); + // Debounced search + useEffect(() => { + setLoading(true); + const timer = setTimeout(loadFiles, search ? 300 : 0); + return () => clearTimeout(timer); + }, [loadFiles, search]); + + const handleUpload = async (fileList: FileList) => { + setUploading(true); try { - await uploadProjectFile(project.slug, file, description); - await loadFiles(); // Refresh list - alert("Файл загружен успешно!"); - } catch (err) { - console.error("Upload failed:", err); - alert(`Ошибка загрузки: ${err}`); + 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); } finally { - setUploadLoading(false); + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; } }; - 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) => { + const handleDelete = async (f: ProjectFile) => { + if (!confirm(`Удалить ${f.filename}?`)) return; try { - 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}`); + await deleteProjectFile(project.slug, f.id); + setFiles((prev) => prev.filter((x) => x.id !== f.id)); + } catch (e) { + console.error("Delete failed:", e); } }; - const handleDeleteFile = async (file: ProjectFile) => { - if (!confirm(`Удалить файл "${file.filename}"?`)) return; - + const handleSaveDesc = async (f: ProjectFile) => { try { - await deleteProjectFile(project.slug, file.id); - await loadFiles(); - alert("Файл удален!"); - } catch (err) { - console.error("Failed to delete file:", err); - alert(`Ошибка удаления: ${err}`); + 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); } }; - if (loading) { - return ( -
-
Загрузка файлов...
-
- ); - } + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files); + }; return ( -
- {/* Header with search */} -
-
+
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {/* Header */} +
+
setSearch(e.target.value)} - 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)]" + 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)]" /> + e.target.files && handleUpload(e.target.files)} + /> + +
-
- {files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} -
-
- - {/* Upload area */} -
- {uploadLoading ? ( -
Загрузка...
- ) : ( - <> -
📁
-
Перетащите файл сюда или
-
- - -
-
- Максимальный размер: 50 МБ -
- + {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 list */} -
- {files.length === 0 ? ( + {/* File list */} +
+ {loading && ( +
Загрузка...
+ )} + + {!loading && files.length === 0 && (
-
📂
-
Файлов пока нет
-
Загрузите первый файл проекта
+
📁
+
Нет файлов
+
Перетащите файлы сюда или нажмите «Загрузить»
- ) : ( -
- {files.map((file) => ( -
- {/* File icon & preview */} -
- {isImage(file.mime_type) ? ( - {file.filename} - ) : ( - {getFileIcon(file.mime_type)} - )} -
+ )} - {/* File info */} -
-
+ {!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)} + )} +
+ + {/* Info */} +
+
+ + {f.filename} + + {formatSize(f.size)} +
+ {editingId === f.id ? ( +
+ setEditDesc(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveDesc(f); + if (e.key === "Escape") setEditingId(null); + }} + placeholder="Описание файла..." + 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}
+ ) + )} +
+ + {/* Uploader */} +
+ {f.uploader?.name} +
+ + {/* Date */} +
+ {new Date(f.created_at).toLocaleDateString("ru-RU")} +
+ + {/* Actions — visible on hover */} +
+ - - {formatFileSize(file.size)} - -
- - {/* Description */} - {editingDescription === file.id ? ( -
- setEditDescription(e.target.value)} - placeholder="Описание файла..." - className="flex-1 px-2 py-1 text-sm bg-[var(--bg)] border border-[var(--border)] rounded text-[var(--fg)] placeholder-[var(--muted)] focus:outline-none focus:border-[var(--accent)]" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSaveDescription(file); - } else if (e.key === "Escape") { - setEditingDescription(null); - } - }} - /> - - -
- ) : ( -
- {file.description || "Без описания"} -
- )} - -
- Загрузил: {file.uploader?.name || "Unknown"} - - {new Date(file.created_at).toLocaleString("ru")}
- - {/* Actions */} -
- - -
-
- ))} + ); + })}
)}
+ + {/* Footer stats */} + {!loading && 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 +}