import { useState, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import CircularProgress from "@mui/material/CircularProgress"; import { projectFilesOptions } from "../lib/queries/projects"; import { useAlert } from "../context/AlertContext"; import { Card, Button, TextField, ConfirmDialog, EmptyState, LoadingState, DataTable, type DataColumn, } from "../ui"; import apiFetch from "../utils/api"; const API_BASE = "/api/admin"; interface ProjectFileManagerProps { projectId: number; projectNumber: string | null; hasPermission: (perm: string) => boolean; hasNasFolder: boolean; } interface FileItem { name: string; type: "file" | "folder"; size?: number; size_formatted?: string; modified?: string; extension?: string; item_count?: number; is_symlink?: boolean; link_target?: string; } function getFileIcon(type: string, extension?: string) { if (type === "folder") { return ( ); } const ext = (extension || "").toLowerCase(); const iconMap: Record = { pdf: { color: "#e74c3c", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, doc: { color: "#3498db", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, docx: { color: "#3498db", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, xls: { color: "#27ae60", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, xlsx: { color: "#27ae60", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, ppt: { color: "#e67e22", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, pptx: { color: "#e67e22", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, jpg: { color: "#3498db", path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", }, jpeg: { color: "#3498db", path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", }, png: { color: "#3498db", path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", }, gif: { color: "#3498db", path: "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z", }, zip: { color: "#e67e22", path: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z", }, rar: { color: "#e67e22", path: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z", }, "7z": { color: "#e67e22", path: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z", }, dwg: { color: "#8e44ad", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, dxf: { color: "#8e44ad", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, step: { color: "#8e44ad", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, stp: { color: "#8e44ad", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }, }; const cfg = iconMap[ext] || { color: "var(--mui-palette-text-secondary)", path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", }; return ( ); } function SymlinkBadge({ target }: { target?: string }) { return ( ); } function FileNameCell({ item, onFolderClick, }: { item: FileItem; onFolderClick: (name: string) => void; }) { if (item.type === "folder") { return ( onFolderClick(item.name)} sx={{ background: "none", border: "none", p: 0, font: "inherit", color: "primary.main", cursor: "pointer", textAlign: "left", "&:hover": { textDecoration: "underline" }, }} > {item.name} {item.is_symlink && } {item.item_count !== undefined && ( {item.item_count} )} ); } return ( {item.name} {item.is_symlink && } ); } export default function ProjectFileManager({ projectId, projectNumber, hasPermission, hasNasFolder, }: ProjectFileManagerProps) { const alert = useAlert(); const queryClient = useQueryClient(); const fileInputRef = useRef(null); const isCancelling = useRef(false); const [currentPath, setCurrentPath] = useState(""); const [dragOver, setDragOver] = useState(false); const [uploading, setUploading] = useState(false); const [newFolderMode, setNewFolderMode] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [creatingFolder, setCreatingFolder] = useState(false); const [renamingItem, setRenamingItem] = useState(null); const [renameValue, setRenameValue] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const canManage = hasPermission("projects.files"); const { data: filesData, isPending: filesLoading, error: filesError, } = useQuery(projectFilesOptions(projectId, currentPath)); const items = filesData?.items ?? []; const breadcrumb = filesData?.breadcrumb ?? [""]; const fullPath = filesData?.full_path ?? ""; const errorMessage = filesError ? filesError.message || "Nepodařilo se načíst soubory" : null; const navigateTo = (path: string) => { setNewFolderMode(false); setRenamingItem(null); setCurrentPath(path); }; const handleBreadcrumbClick = (index: number) => { if (index === 0) { navigateTo(""); return; } const path = breadcrumb.slice(1, index + 1).join("/"); navigateTo(path); }; const handleFolderClick = (folderName: string) => { const path = currentPath ? `${currentPath}/${folderName}` : folderName; navigateTo(path); }; const handleUpload = async (files: FileList | null) => { if (!files || files.length === 0) return; setUploading(true); let successCount = 0; let errorMsg: string | null = null; for (const file of Array.from(files)) { const formData = new FormData(); formData.append("file", file); const params = new URLSearchParams({ action: "upload", project_id: String(projectId), }); if (currentPath) { params.set("path", currentPath); } try { const res = await apiFetch( `${API_BASE}/project-files/upload?${params}`, { method: "POST", body: formData, }, ); const data = await res.json(); if (data.success) { successCount++; } else { errorMsg = data.error || "Chyba při nahrávání"; } } catch { errorMsg = "Chyba připojení"; } } setUploading(false); if (successCount > 0) { const msg = successCount === 1 ? "Soubor byl nahrán" : `Nahráno ${successCount} souborů`; alert.success(msg); queryClient.invalidateQueries({ queryKey: ["projects", String(projectId), "files"], }); } if (errorMsg) { alert.error(errorMsg); } }; const handleFileInputChange = (e: React.ChangeEvent) => { handleUpload(e.target.files); e.target.value = ""; }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); if (!canManage) return; handleUpload(e.dataTransfer.files); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); if (canManage) { setDragOver(true); } }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); }; const handleCreateFolder = async () => { const name = newFolderName.trim(); if (!name) return; setCreatingFolder(true); try { const params = new URLSearchParams({ action: "create_folder", project_id: String(projectId), }); const res = await apiFetch(`${API_BASE}/project-files?${params}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: currentPath, folder_name: name }), }); const data = await res.json(); if (data.success) { alert.success("Složka byla vytvořena"); setNewFolderMode(false); setNewFolderName(""); queryClient.invalidateQueries({ queryKey: ["projects", String(projectId), "files"], }); } else { alert.error(data.error || "Nepodařilo se vytvořit složku"); } } catch { alert.error("Chyba připojení"); } finally { setCreatingFolder(false); } }; const handleDownload = async (item: FileItem) => { const filePath = currentPath ? `${currentPath}/${item.name}` : item.name; const params = new URLSearchParams({ action: "download", project_id: String(projectId), path: filePath, }); try { const res = await apiFetch(`${API_BASE}/project-files?${params}`); if (!res.ok) { const err = await res.json().catch(() => null); alert.error(err?.error || "Chyba při stahování"); return; } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = item.name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch { alert.error("Chyba připojení"); } }; const handleDelete = async () => { if (!deleteTarget) return; setDeleting(true); const filePath = currentPath ? `${currentPath}/${deleteTarget.name}` : deleteTarget.name; try { const params = new URLSearchParams({ project_id: String(projectId), path: filePath, }); const res = await apiFetch(`${API_BASE}/project-files?${params}`, { method: "DELETE", }); const data = await res.json(); if (data.success) { alert.success( deleteTarget.type === "folder" ? "Složka byla smazána" : "Soubor byl smazán", ); queryClient.invalidateQueries({ queryKey: ["projects", String(projectId), "files"], }); } else { alert.error(data.error || "Nepodařilo se smazat"); } } catch { alert.error("Chyba připojení"); } finally { setDeleting(false); setDeleteTarget(null); } }; const handleRename = async (item: FileItem) => { const newName = renameValue.trim(); if (!newName || newName === item.name) { setRenamingItem(null); return; } const fromPath = currentPath ? `${currentPath}/${item.name}` : item.name; const toPath = currentPath ? `${currentPath}/${newName}` : newName; try { const params = new URLSearchParams({ action: "move", project_id: String(projectId), }); const res = await apiFetch(`${API_BASE}/project-files?${params}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_path: fromPath, to_path: toPath }), }); const data = await res.json(); if (data.success) { alert.success("Přejmenováno"); queryClient.invalidateQueries({ queryKey: ["projects", String(projectId), "files"], }); } else { alert.error(data.error || "Nepodařilo se přejmenovat"); } } catch { alert.error("Chyba připojení"); } finally { setRenamingItem(null); } }; const startRename = (item: FileItem) => { setRenamingItem(item.name); setRenameValue(item.name); }; if (filesLoading && items.length === 0 && !errorMessage) { return ; } if (errorMessage) { return ( Soubory } title={errorMessage} /> ); } const columns: DataColumn[] = [ { key: "icon", header: "", width: 30, align: "left", render: (item) => ( {getFileIcon(item.type, item.extension)} ), }, { key: "name", header: "Název", render: (item) => renamingItem === item.name ? ( setRenameValue(e.target.value)} autoFocus sx={{ maxWidth: 300 }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleRename(item); } if (e.key === "Escape") { e.preventDefault(); isCancelling.current = true; setRenamingItem(null); setRenameValue(item.name); setTimeout(() => { isCancelling.current = false; }, 0); } }} onBlur={() => { if (isCancelling.current) { return; } handleRename(item); }} /> ) : ( ), }, { key: "size", header: "Velikost", width: 90, render: (item) => ( {item.type === "file" ? item.size_formatted : "—"} ), }, { key: "modified", header: "Změněno", width: 120, render: (item) => ( {item.modified || "—"} ), }, ...(canManage ? [ { key: "actions", header: "Akce", width: 100, align: "right" as const, render: (item: FileItem) => ( {item.type === "file" && ( handleDownload(item)} > )} startRename(item)} > setDeleteTarget(item)} > ), }, ] : []), ]; return ( Soubory {/* Toolbar */} {breadcrumb.map((segment, i) => ( {i > 0 && ( / )} handleBreadcrumbClick(i)} sx={{ background: "none", border: "none", p: 0, font: "inherit", cursor: "pointer", color: i === breadcrumb.length - 1 ? "text.primary" : "primary.main", fontWeight: i === breadcrumb.length - 1 ? 600 : 400, "&:hover": { textDecoration: "underline" }, }} > {i === 0 ? projectNumber : segment} ))} {fullPath && ( {fullPath} )} {canManage && ( )} {/* New folder input */} {newFolderMode && ( setNewFolderName(e.target.value)} placeholder="Název složky..." autoFocus sx={{ maxWidth: 320 }} onKeyDown={(e) => { if (e.key === "Enter") handleCreateFolder(); if (e.key === "Escape") { setNewFolderMode(false); setNewFolderName(""); } }} /> )} {/* Drop zone + table */} {dragOver && ( Přetáhněte soubory sem )} {items.length === 0 && !filesLoading ? ( } title={ hasNasFolder ? "Složka je prázdná" : "Složka projektu zatím neexistuje" } description={ canManage && !hasNasFolder ? "Nahrání souboru ji automaticky vytvoří" : undefined } /> ) : ( item.name} /> )} setDeleteTarget(null)} onConfirm={handleDelete} title={ deleteTarget?.type === "folder" ? "Smazat složku" : "Smazat soubor" } message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`} confirmText="Smazat" cancelText="Zrušit" confirmVariant="danger" loading={deleting} /> ); }