diff --git a/src/components/ProjectFiles.tsx b/src/components/ProjectFiles.tsx index 2a4dfdb..c16737c 100644 --- a/src/components/ProjectFiles.tsx +++ b/src/components/ProjectFiles.tsx @@ -1,16 +1,268 @@ - -import type { Project } from "@/lib/api"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Project, ProjectFile } from "@/lib/api"; +import { + getProjectFiles, + uploadProjectFile, + getProjectFileUrl, + updateProjectFile, + deleteProjectFile, +} from "@/lib/api"; interface Props { project: Project; } -export default function ProjectFiles({ project: _project }: Props) { +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 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 "📄"; +} + +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 fileInputRef = useRef(null); + + const loadFiles = useCallback(async () => { + try { + const data = await getProjectFiles(project.slug, search || undefined); + setFiles(data); + } catch (e) { + console.error("Failed to load files:", e); + } finally { + setLoading(false); + } + }, [project.slug, search]); + + useEffect(() => { + setLoading(true); + const timer = setTimeout(loadFiles, search ? 300 : 0); + return () => clearTimeout(timer); + }, [loadFiles, search]); + + const handleUpload = async (fileList: FileList) => { + setUploading(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); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleDelete = async (f: ProjectFile) => { + if (!confirm(`Удалить ${f.filename}?`)) return; + try { + await deleteProjectFile(project.slug, f.id); + setFiles((prev) => prev.filter((x) => x.id !== f.id)); + } catch (e) { + console.error("Delete failed:", e); + } + }; + + const handleSaveDesc = async (f: ProjectFile) => { + 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); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files); + }; + return ( -
-
📁
-
Файлы проекта
-
Скоро здесь можно будет загружать и просматривать файлы
+
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {/* Header */} +
+
+ 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)]" + /> + 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)]" + /> + )} +
+ + {/* File list */} +
+ {loading && ( +
Загрузка...
+ )} + + {!loading && files.length === 0 && ( +
+
📁
+
Нет файлов
+
Перетащите файлы сюда или нажмите «Загрузить»
+
+ )} + + {!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) => 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}
+ ) + )} +
+ + {/* Uploader */} +
+ {f.uploader?.name} +
+ + {/* Date */} +
+ {new Date(f.created_at).toLocaleDateString("ru-RU")} +
+ + {/* Actions */} +
+ + +
+
+ ); + })} +
+ )} + + {/* Drag overlay */} + {dragOver && ( +
+
📁 Отпустите для загрузки
+
+ )} +
+ + {/* Footer stats */} + {files.length > 0 && ( +
+ {files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} · {formatSize(files.reduce((s, f) => s + f.size, 0))} +
+ )}
); } diff --git a/src/lib/api.ts b/src/lib/api.ts index 7c4db6a..a384f58 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -302,7 +302,57 @@ export async function revokeToken(slug: string): Promise { await request(`/api/v1/members/${slug}/revoke-token`, { method: "POST" }); } -// --- File Upload --- +// --- Project Files --- + +export interface ProjectFile { + id: string; + project_id: string; + filename: string; + description: string | null; + mime_type: string | null; + size: number; + uploaded_by: string; + uploader: { id: string; slug: string; name: string } | null; + created_at: string; + updated_at: string; +} + +export async function getProjectFiles(slug: string, search?: string): Promise { + const qs = search ? `?search=${encodeURIComponent(search)}` : ""; + return request(`/api/v1/projects/${slug}/files${qs}`); +} + +export async function uploadProjectFile(slug: string, file: File, description?: string): Promise { + const token = getToken(); + const formData = new FormData(); + formData.append("file", file); + if (description) formData.append("description", description); + + const res = await fetch(`${API_BASE}/api/v1/projects/${slug}/files`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || `HTTP ${res.status}`); + } + return res.json(); +} + +export function getProjectFileUrl(slug: string, fileId: string): string { + return `${API_BASE}/api/v1/projects/${slug}/files/${fileId}/download`; +} + +export async function updateProjectFile(slug: string, fileId: string, data: { description?: string }): Promise { + return request(`/api/v1/projects/${slug}/files/${fileId}`, { method: "PATCH", body: JSON.stringify(data) }); +} + +export async function deleteProjectFile(slug: string, fileId: string): Promise { + await request(`/api/v1/projects/${slug}/files/${fileId}`, { method: "DELETE" }); +} + +// --- Chat File Upload --- export interface UploadedFile { file_id: string;