web-client-vite/src/components/ProjectFiles.tsx
Markov 2586dc7e6a 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
2026-02-25 09:19:50 +01:00

331 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}