feat: project files tab — upload, search, preview, descriptions, drag & drop
This commit is contained in:
parent
66beca5308
commit
1671352ef9
@ -1,16 +1,268 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Project } from "@/lib/api";
|
import type { Project, ProjectFile } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
getProjectFiles,
|
||||||
|
uploadProjectFile,
|
||||||
|
getProjectFileUrl,
|
||||||
|
updateProjectFile,
|
||||||
|
deleteProjectFile,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
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<ProjectFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
const [uploadDesc, setUploadDesc] = useState("");
|
||||||
|
const [showUploadDesc, setShowUploadDesc] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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 (
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col h-full ${dragOver ? "ring-2 ring-[var(--accent)] ring-inset" : ""}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
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
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUploadDesc(!showUploadDesc)}
|
||||||
|
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] transition-colors"
|
||||||
|
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>
|
||||||
|
{showUploadDesc && (
|
||||||
|
<input
|
||||||
|
value={uploadDesc}
|
||||||
|
onChange={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && files.length > 0 && (
|
||||||
|
<div className="space-y-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" />
|
||||||
|
) : (
|
||||||
|
<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) => 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
|
||||||
|
/>
|
||||||
|
<button onClick={() => handleSaveDesc(f)} className="text-xs text-[var(--accent)]">✓</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-xs text-[var(--muted)]">✕</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 */}
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(f.id); setEditDesc(f.description || ""); }}
|
||||||
|
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] p-1"
|
||||||
|
title="Описание"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(f)}
|
||||||
|
className="text-xs text-[var(--muted)] hover:text-red-400 p-1"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag overlay */}
|
||||||
|
{dragOver && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 pointer-events-none">
|
||||||
|
<div className="text-white text-lg font-medium">📁 Отпустите для загрузки</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer stats */}
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -302,7 +302,57 @@ export async function revokeToken(slug: string): Promise<void> {
|
|||||||
await request(`/api/v1/members/${slug}/revoke-token`, { method: "POST" });
|
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<ProjectFile[]> {
|
||||||
|
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<ProjectFile> {
|
||||||
|
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<ProjectFile> {
|
||||||
|
return request(`/api/v1/projects/${slug}/files/${fileId}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProjectFile(slug: string, fileId: string): Promise<void> {
|
||||||
|
await request(`/api/v1/projects/${slug}/files/${fileId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chat File Upload ---
|
||||||
|
|
||||||
export interface UploadedFile {
|
export interface UploadedFile {
|
||||||
file_id: string;
|
file_id: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user