- 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
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
||
import type { Project, ProjectFile } from "@/lib/api";
|
||
import {
|
||
getProjectFiles,
|
||
uploadProjectFile,
|
||
updateProjectFile,
|
||
deleteProjectFile,
|
||
getProjectFileUrl,
|
||
} from "@/lib/api";
|
||
|
||
interface Props {
|
||
project: Project;
|
||
}
|
||
|
||
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 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<ProjectFile[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState("");
|
||
const [uploadLoading, setUploadLoading] = useState(false);
|
||
const [editingDescription, setEditingDescription] = useState<string | null>(null);
|
||
const [editDescription, setEditDescription] = useState("");
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const uploadDescriptionRef = useRef<HTMLInputElement>(null);
|
||
|
||
const loadFiles = async () => {
|
||
try {
|
||
const result = await getProjectFiles(project.slug, search || undefined);
|
||
setFiles(result);
|
||
} catch (err) {
|
||
console.error("Failed to load files:", err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadFiles();
|
||
}, [project.slug, search]);
|
||
|
||
const handleFileUpload = async (file: File, description?: string) => {
|
||
setUploadLoading(true);
|
||
try {
|
||
await uploadProjectFile(project.slug, file, description);
|
||
await loadFiles(); // Refresh list
|
||
alert("Файл загружен успешно!");
|
||
} catch (err) {
|
||
console.error("Upload failed:", err);
|
||
alert(`Ошибка загрузки: ${err}`);
|
||
} finally {
|
||
setUploadLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
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 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 handleDeleteFile = async (file: ProjectFile) => {
|
||
if (!confirm(`Удалить файл "${file.filename}"?`)) return;
|
||
|
||
try {
|
||
await deleteProjectFile(project.slug, file.id);
|
||
await loadFiles();
|
||
alert("Файл удален!");
|
||
} catch (err) {
|
||
console.error("Failed to delete file:", err);
|
||
alert(`Ошибка удаления: ${err}`);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="text-[var(--muted)]">Загрузка файлов...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full p-4 space-y-4">
|
||
{/* Header with search */}
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex-1">
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск файлов..."
|
||
value={search}
|
||
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)]"
|
||
/>
|
||
</div>
|
||
<div className="text-sm text-[var(--muted)]">
|
||
{files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Upload area */}
|
||
<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>
|
||
|
||
{/* Files list */}
|
||
<div className="flex-1 overflow-auto">
|
||
{files.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-full text-[var(--muted)]">
|
||
<div className="text-4xl mb-4">📂</div>
|
||
<div className="text-sm">Файлов пока нет</div>
|
||
<div className="text-xs mt-1">Загрузите первый файл проекта</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 */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center space-x-2 mb-1">
|
||
<button
|
||
onClick={() => handleDownload(file)}
|
||
className="text-[var(--fg)] hover:text-[var(--accent)] font-medium truncate text-left"
|
||
>
|
||
{file.filename}
|
||
</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>
|
||
|
||
{/* 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>
|
||
);
|
||
} |