fix: rewrite ProjectFiles — remove alerts, add debounce, multi-upload, hover actions, proper icons

This commit is contained in:
Markov 2026-02-25 10:37:47 +01:00
parent 2586dc7e6a
commit f676329206

View File

@ -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 type { Project, ProjectFile } from "@/lib/api";
import { import {
getProjectFiles, getProjectFiles,
uploadProjectFile, uploadProjectFile,
getProjectFileUrl,
updateProjectFile, updateProjectFile,
deleteProjectFile, deleteProjectFile,
getProjectFileUrl,
} from "@/lib/api"; } from "@/lib/api";
interface Props { interface Props {
project: Project; project: Project;
} }
function formatFileSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
} }
function getFileIcon(mimeType: string | null): string { function fileIcon(mime: string | null, filename: string): string {
if (!mimeType) return "📄"; if (mime?.startsWith("image/")) return "🖼️";
if (mimeType.startsWith("image/")) return "📷"; if (mime?.startsWith("video/")) return "🎬";
if (mimeType.startsWith("video/")) return "📹"; if (mime?.startsWith("audio/")) return "🎵";
if (mimeType.startsWith("audio/")) return "🎵"; if (mime === "application/pdf") return "📕";
if (mimeType.includes("pdf")) return "📕"; const ext = filename.split(".").pop()?.toLowerCase();
if (mimeType.includes("word") || mimeType.includes("document")) return "📝"; if (["md", "txt", "rst"].includes(ext || "")) return "📝";
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) return "📊"; if (["json", "yaml", "yml", "toml", "xml"].includes(ext || "")) return "⚙️";
if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) return "📋"; if (["py", "ts", "js", "rs", "go", "java", "c", "cpp", "h"].includes(ext || "")) return "💻";
if (mimeType.includes("zip") || mimeType.includes("archive")) return "📦"; if (["zip", "tar", "gz", "rar", "7z"].includes(ext || "")) return "📦";
if (mimeType.includes("text")) return "📄"; if (["doc", "docx", "odt"].includes(ext || "")) return "📝";
return "📁"; if (["xls", "xlsx", "csv"].includes(ext || "")) return "📊";
} return "📄";
function isImage(mimeType: string | null): boolean {
return !!mimeType && mimeType.startsWith("image/");
} }
export default function ProjectFiles({ project }: Props) { export default function ProjectFiles({ project }: Props) {
const [files, setFiles] = useState<ProjectFile[]>([]); const [files, setFiles] = useState<ProjectFile[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [uploadLoading, setUploadLoading] = useState(false); const [uploading, setUploading] = useState(false);
const [editingDescription, setEditingDescription] = useState<string | null>(null); const [dragOver, setDragOver] = useState(false);
const [editDescription, setEditDescription] = useState(""); const [editingId, setEditingId] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false); const [editDesc, setEditDesc] = useState("");
const [uploadDesc, setUploadDesc] = useState("");
const [showUploadDesc, setShowUploadDesc] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const uploadDescriptionRef = useRef<HTMLInputElement>(null);
const loadFiles = async () => { const loadFiles = useCallback(async () => {
try { try {
const result = await getProjectFiles(project.slug, search || undefined); const data = await getProjectFiles(project.slug, search || undefined);
setFiles(result); setFiles(data);
} catch (err) { } catch (e) {
console.error("Failed to load files:", err); console.error("Failed to load files:", e);
} finally { } finally {
setLoading(false); setLoading(false);
} }
};
useEffect(() => {
loadFiles();
}, [project.slug, search]); }, [project.slug, search]);
const handleFileUpload = async (file: File, description?: string) => { // Debounced search
setUploadLoading(true); useEffect(() => {
setLoading(true);
const timer = setTimeout(loadFiles, search ? 300 : 0);
return () => clearTimeout(timer);
}, [loadFiles, search]);
const handleUpload = async (fileList: FileList) => {
setUploading(true);
try { try {
await uploadProjectFile(project.slug, file, description); for (const file of Array.from(fileList)) {
await loadFiles(); // Refresh list await uploadProjectFile(project.slug, file, uploadDesc || undefined);
alert("Файл загружен успешно!"); }
} catch (err) { setUploadDesc("");
console.error("Upload failed:", err); setShowUploadDesc(false);
alert(`Ошибка загрузки: ${err}`); await loadFiles();
} catch (e) {
console.error("Upload failed:", e);
} finally { } finally {
setUploadLoading(false); setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
} }
}; };
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleDelete = async (f: ProjectFile) => {
const file = event.target.files?.[0]; if (!confirm(`Удалить ${f.filename}?`)) return;
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 { try {
await updateProjectFile(project.slug, file.id, { await deleteProjectFile(project.slug, f.id);
description: editDescription.trim() || undefined, setFiles((prev) => prev.filter((x) => x.id !== f.id));
}); } catch (e) {
await loadFiles(); console.error("Delete failed:", e);
setEditingDescription(null);
} catch (err) {
console.error("Failed to update description:", err);
alert(`Ошибка обновления: ${err}`);
} }
}; };
const handleDeleteFile = async (file: ProjectFile) => { const handleSaveDesc = async (f: ProjectFile) => {
if (!confirm(`Удалить файл "${file.filename}"?`)) return;
try { try {
await deleteProjectFile(project.slug, file.id); const updated = await updateProjectFile(project.slug, f.id, { description: editDesc });
await loadFiles(); setFiles((prev) => prev.map((x) => (x.id === f.id ? updated : x)));
alert("Файл удален!"); setEditingId(null);
} catch (err) { } catch (e) {
console.error("Failed to delete file:", err); console.error("Update failed:", e);
alert(`Ошибка удаления: ${err}`);
} }
}; };
if (loading) { const handleDrop = (e: React.DragEvent) => {
return ( e.preventDefault();
<div className="flex items-center justify-center h-full"> setDragOver(false);
<div className="text-[var(--muted)]">Загрузка файлов...</div> if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files);
</div> };
);
}
return ( return (
<div className="flex flex-col h-full p-4 space-y-4"> <div
{/* Header with search */} className={`flex flex-col h-full ${dragOver ? "ring-2 ring-[var(--accent)] ring-inset" : ""}`}
<div className="flex items-center space-x-4"> onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
<div className="flex-1"> onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{/* Header */}
<div className="px-6 py-4 border-b border-[var(--border)] flex flex-col gap-3">
<div className="flex items-center gap-3">
<input <input
type="text"
placeholder="Поиск файлов..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => 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)]"
/> />
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => e.target.files && handleUpload(e.target.files)}
/>
<button
onClick={() => setShowUploadDesc(!showUploadDesc)}
className={`text-xs transition-colors ${showUploadDesc ? "text-[var(--accent)]" : "text-[var(--muted)] hover:text-[var(--fg)]"}`}
title="Добавить описание при загрузке"
>
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 shrink-0"
>
{uploading ? "⏳ Загрузка..." : "📎 Загрузить"}
</button>
</div> </div>
<div className="text-sm text-[var(--muted)]"> {showUploadDesc && (
{files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} <input
</div> value={uploadDesc}
</div> onChange={(e) => setUploadDesc(e.target.value)}
placeholder="Описание для загружаемых файлов (опционально)"
{/* Upload area */} className="bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
<div />
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDragOver
? "border-[var(--accent)] bg-[var(--accent)]/10"
: "border-[var(--border)] hover:border-[var(--muted)]"
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{uploadLoading ? (
<div className="text-[var(--muted)]">Загрузка...</div>
) : (
<>
<div className="text-3xl mb-2">📁</div>
<div className="text-[var(--fg)] mb-2">Перетащите файл сюда или</div>
<div className="flex items-center justify-center space-x-4">
<input
ref={uploadDescriptionRef}
type="text"
placeholder="Описание (необязательно)"
className="px-3 py-1 bg-[var(--surface)] border border-[var(--border)] rounded text-sm text-[var(--fg)] placeholder-[var(--muted)] focus:outline-none focus:border-[var(--accent)]"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded hover:bg-[var(--accent)]/80 transition-colors"
>
Выбрать файл
</button>
</div>
<div className="text-xs text-[var(--muted)] mt-2">
Максимальный размер: 50 МБ
</div>
</>
)} )}
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
/>
</div> </div>
{/* Files list */} {/* File list */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-y-auto px-6 py-4">
{files.length === 0 ? ( {loading && (
<div className="text-center text-sm text-[var(--muted)] py-8">Загрузка...</div>
)}
{!loading && files.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-[var(--muted)]"> <div className="flex flex-col items-center justify-center h-full text-[var(--muted)]">
<div className="text-4xl mb-4">📂</div> <div className="text-4xl mb-4">📁</div>
<div className="text-sm">Файлов пока нет</div> <div className="text-sm">Нет файлов</div>
<div className="text-xs mt-1">Загрузите первый файл проекта</div> <div className="text-xs mt-1">Перетащите файлы сюда или нажмите «Загрузить»</div>
</div> </div>
) : ( )}
<div className="space-y-2">
{files.map((file) => (
<div
key={file.id}
className="flex items-center p-3 bg-[var(--surface)] border border-[var(--border)] rounded hover:bg-[var(--surface)]/80 transition-colors"
>
{/* File icon & preview */}
<div className="w-12 h-12 flex items-center justify-center bg-[var(--bg)] rounded mr-3">
{isImage(file.mime_type) ? (
<img
src={getProjectFileUrl(project.slug, file.id)}
alt={file.filename}
className="w-full h-full object-cover rounded"
loading="lazy"
/>
) : (
<span className="text-2xl">{getFileIcon(file.mime_type)}</span>
)}
</div>
{/* File info */} {!loading && files.length > 0 && (
<div className="flex-1 min-w-0"> <div className="space-y-1">
<div className="flex items-center space-x-2 mb-1"> {files.map((f) => {
const url = getProjectFileUrl(project.slug, f.id);
const isImage = f.mime_type?.startsWith("image/");
return (
<div
key={f.id}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 group"
>
{/* Icon / thumbnail */}
<div className="shrink-0 w-8 text-center">
{isImage ? (
<img src={url} alt="" className="w-8 h-8 rounded object-cover" loading="lazy" />
) : (
<span className="text-lg">{fileIcon(f.mime_type, f.filename)}</span>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-[var(--fg)] hover:text-[var(--accent)] truncate"
>
{f.filename}
</a>
<span className="text-xs text-[var(--muted)]">{formatSize(f.size)}</span>
</div>
{editingId === f.id ? (
<div className="flex items-center gap-1 mt-1">
<input
value={editDesc}
onChange={(e) => 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
/>
<button onClick={() => handleSaveDesc(f)} className="text-xs text-green-400 hover:text-green-300"></button>
<button onClick={() => setEditingId(null)} className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"></button>
</div>
) : (
f.description && (
<div className="text-xs text-[var(--muted)] truncate mt-0.5">{f.description}</div>
)
)}
</div>
{/* Uploader */}
<div className="text-xs text-[var(--muted)] hidden md:block shrink-0">
{f.uploader?.name}
</div>
{/* Date */}
<div className="text-xs text-[var(--muted)] hidden md:block shrink-0">
{new Date(f.created_at).toLocaleDateString("ru-RU")}
</div>
{/* Actions — visible on hover */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button <button
onClick={() => handleDownload(file)} onClick={() => { setEditingId(f.id); setEditDesc(f.description || ""); }}
className="text-[var(--fg)] hover:text-[var(--accent)] font-medium truncate text-left" className="text-xs text-[var(--muted)] hover:text-[var(--fg)] p-1"
title="Описание"
> >
{file.filename}
</button>
<button
onClick={() => handleDelete(f)}
className="text-xs text-[var(--muted)] hover:text-red-400 p-1"
title="Удалить"
>
🗑
</button> </button>
<span className="text-xs text-[var(--muted)] shrink-0">
{formatFileSize(file.size)}
</span>
</div>
{/* Description */}
{editingDescription === file.id ? (
<div className="flex items-center space-x-2 mt-1">
<input
value={editDescription}
onChange={(e) => 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);
}
}}
/>
<button
onClick={() => handleSaveDescription(file)}
className="text-green-400 hover:text-green-300 text-sm"
>
</button>
<button
onClick={() => setEditingDescription(null)}
className="text-[var(--muted)] hover:text-[var(--fg)] text-sm"
>
</button>
</div>
) : (
<div className="text-sm text-[var(--muted)]">
{file.description || "Без описания"}
</div>
)}
<div className="flex items-center space-x-2 mt-1 text-xs text-[var(--muted)]">
<span>Загрузил: {file.uploader?.name || "Unknown"}</span>
<span></span>
<span>{new Date(file.created_at).toLocaleString("ru")}</span>
</div> </div>
</div> </div>
);
{/* Actions */} })}
<div className="flex items-center space-x-1 ml-2">
<button
onClick={() => handleEditDescription(file)}
className="p-1 text-[var(--muted)] hover:text-[var(--fg)] transition-colors"
title="Редактировать описание"
>
</button>
<button
onClick={() => handleDeleteFile(file)}
className="p-1 text-[var(--muted)] hover:text-red-400 transition-colors"
title="Удалить файл"
>
🗑
</button>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
{/* Footer stats */}
{!loading && files.length > 0 && (
<div className="px-6 py-2 border-t border-[var(--border)] text-xs text-[var(--muted)]">
{files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} · {formatSize(files.reduce((s, f) => s + f.size, 0))}
</div>
)}
</div> </div>
); );
} }