- Auth: pessimistic locking on login tokens and refresh token rotation, backup code attempt counter, rate limiting verification - Schema: unique constraints on business numbers, FK relations, unsigned/signed alignment, attendance duplicate prevention - Invoices/PDFs: DOMPurify sanitization, bounded queries in stats and alerts, VAT rounding, Puppeteer error handling - Orders/Offers: transactional parent+child creation, Zod NaN refinement, status enums, uniqueness checks - Projects/Files: path traversal protection, streamed uploads, permission guards, query param validation - Attendance/HR: duplicate checks, ownership validation, GPS restrictions, trip distance validation - Frontend: modal lock reference counting, XSS escaping in print HTML, ref mutation fixes, accessibility attributes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
893 lines
28 KiB
TypeScript
893 lines
28 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import ConfirmModal from "./ConfirmModal";
|
|
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 (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="#e6a817"
|
|
strokeWidth="1.5"
|
|
>
|
|
<path
|
|
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
|
fill="rgba(230, 168, 23, 0.15)"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const ext = (extension || "").toLowerCase();
|
|
const iconMap: Record<string, { color: string; path: string }> = {
|
|
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(--text-muted)",
|
|
path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z",
|
|
};
|
|
|
|
return (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke={cfg.color}
|
|
strokeWidth="1.5"
|
|
>
|
|
<path d={cfg.path} />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SymlinkBadge({ target }: { target?: string }) {
|
|
return (
|
|
<span className="fm-symlink-badge" title={target || "Odkaz"}>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
</svg>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function FileNameCell({
|
|
item,
|
|
onFolderClick,
|
|
}: {
|
|
item: FileItem;
|
|
onFolderClick: (name: string) => void;
|
|
}) {
|
|
if (item.type === "folder") {
|
|
return (
|
|
<span className="fm-name-cell">
|
|
<button
|
|
type="button"
|
|
className="fm-folder-link"
|
|
onClick={() => onFolderClick(item.name)}
|
|
>
|
|
{item.name}
|
|
</button>
|
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
|
{item.item_count !== undefined && (
|
|
<span className="fm-item-count">{item.item_count}</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="fm-name-cell">
|
|
<span className="fm-file-name">{item.name}</span>
|
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function ProjectFileManager({
|
|
projectId,
|
|
projectNumber,
|
|
hasPermission,
|
|
hasNasFolder,
|
|
}: ProjectFileManagerProps) {
|
|
const alert = useAlert();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const isCancelling = useRef(false);
|
|
|
|
const [items, setItems] = useState<FileItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [currentPath, setCurrentPath] = useState("");
|
|
const [breadcrumb, setBreadcrumb] = useState<string[]>([""]);
|
|
const [fullPath, setFullPath] = 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<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
|
|
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
const canManage = hasPermission("projects.files");
|
|
|
|
const fetchFiles = useCallback(
|
|
async (path = "", options: { ignore?: boolean } = {}) => {
|
|
setLoading(true);
|
|
setErrorMessage(null);
|
|
try {
|
|
const params = new URLSearchParams({ project_id: String(projectId) });
|
|
if (path) {
|
|
params.set("path", path);
|
|
}
|
|
const res = await apiFetch(`${API_BASE}/project-files?${params}`);
|
|
if (options.ignore) return;
|
|
if (res.status === 401) return;
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setItems(data.data.items || []);
|
|
setBreadcrumb(data.data.breadcrumb || [""]);
|
|
setCurrentPath(data.data.path || "");
|
|
setFullPath(data.data.full_path || "");
|
|
} else if (res.status === 404) {
|
|
setItems([]);
|
|
setBreadcrumb([""]);
|
|
} else {
|
|
setErrorMessage(data.error || "Nepodařilo se načíst soubory");
|
|
}
|
|
} catch {
|
|
if (!options.ignore) {
|
|
setErrorMessage("Chyba připojení");
|
|
}
|
|
} finally {
|
|
if (!options.ignore) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
},
|
|
[projectId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const opts = { ignore: false };
|
|
fetchFiles("", opts);
|
|
return () => {
|
|
opts.ignore = true;
|
|
};
|
|
}, [fetchFiles]);
|
|
|
|
const navigateTo = (path: string) => {
|
|
setNewFolderMode(false);
|
|
setRenamingItem(null);
|
|
fetchFiles(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);
|
|
fetchFiles(currentPath);
|
|
}
|
|
if (errorMsg) {
|
|
alert.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
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("");
|
|
fetchFiles(currentPath);
|
|
} 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",
|
|
);
|
|
fetchFiles(currentPath);
|
|
} 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");
|
|
fetchFiles(currentPath);
|
|
} 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 (loading && items.length === 0 && !errorMessage) {
|
|
return (
|
|
<div className="admin-card">
|
|
<div className="admin-card-body">
|
|
<h3 className="admin-card-title">Soubory</h3>
|
|
<div className="admin-skeleton" style={{ padding: 0, gap: "0.5rem" }}>
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<div key={i} className="admin-skeleton-row">
|
|
<div
|
|
className="admin-skeleton-line"
|
|
style={{
|
|
width: "18px",
|
|
height: "18px",
|
|
borderRadius: "4px",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<div
|
|
className="admin-skeleton-line"
|
|
style={{ width: `${60 + i * 10}%` }}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (errorMessage) {
|
|
return (
|
|
<div className="admin-card">
|
|
<div className="admin-card-body">
|
|
<h3 className="admin-card-title">Soubory</h3>
|
|
<div className="fm-empty">
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="var(--text-tertiary)"
|
|
strokeWidth="1.5"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" y1="8" x2="12" y2="12" />
|
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="admin-card">
|
|
<div className="admin-card-body">
|
|
<h3 className="admin-card-title">Soubory</h3>
|
|
|
|
{/* Toolbar */}
|
|
<div className="fm-toolbar">
|
|
<div className="fm-breadcrumb">
|
|
{breadcrumb.map((segment, i) => (
|
|
<span key={i} className="fm-breadcrumb-segment">
|
|
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
|
|
<button
|
|
type="button"
|
|
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? "active" : ""}`}
|
|
onClick={() => handleBreadcrumbClick(i)}
|
|
>
|
|
{i === 0 ? projectNumber : segment}
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{fullPath && (
|
|
<span className="fm-full-path" title={fullPath}>
|
|
{fullPath}
|
|
</span>
|
|
)}
|
|
|
|
{canManage && (
|
|
<div className="fm-toolbar-actions">
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
onClick={() => {
|
|
setNewFolderMode(!newFolderMode);
|
|
setNewFolderName("");
|
|
}}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
<line x1="12" y1="11" x2="12" y2="17" />
|
|
<line x1="9" y1="14" x2="15" y2="14" />
|
|
</svg>
|
|
Složka
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-primary admin-btn-sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
{uploading ? (
|
|
<>
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
Nahrávání...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="17 8 12 3 7 8" />
|
|
<line x1="12" y1="3" x2="12" y2="15" />
|
|
</svg>
|
|
Nahrát
|
|
</>
|
|
)}
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
style={{ display: "none" }}
|
|
onChange={handleFileInputChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* New folder input */}
|
|
{newFolderMode && (
|
|
<div className="fm-new-folder">
|
|
<input
|
|
type="text"
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
className="admin-form-input"
|
|
placeholder="Název složky..."
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleCreateFolder();
|
|
if (e.key === "Escape") {
|
|
setNewFolderMode(false);
|
|
setNewFolderName("");
|
|
}
|
|
}}
|
|
style={{ fontSize: "12px", padding: "6px 10px" }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-primary admin-btn-sm"
|
|
onClick={handleCreateFolder}
|
|
disabled={creatingFolder || !newFolderName.trim()}
|
|
>
|
|
{creatingFolder ? (
|
|
<div className="admin-spinner admin-spinner-sm" />
|
|
) : (
|
|
"Vytvořit"
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
onClick={() => {
|
|
setNewFolderMode(false);
|
|
setNewFolderName("");
|
|
}}
|
|
>
|
|
Zrušit
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Drop zone + table */}
|
|
<div
|
|
className={`fm-content ${dragOver ? "fm-drag-over" : ""}`}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
>
|
|
{dragOver && (
|
|
<div className="fm-dropzone-overlay">
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
>
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="17 8 12 3 7 8" />
|
|
<line x1="12" y1="3" x2="12" y2="15" />
|
|
</svg>
|
|
<span>Přetáhněte soubory sem</span>
|
|
</div>
|
|
)}
|
|
|
|
{items.length === 0 && !loading ? (
|
|
<div className="fm-empty">
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="var(--text-tertiary)"
|
|
strokeWidth="1.5"
|
|
>
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
<span>
|
|
{hasNasFolder
|
|
? "Složka je prázdná"
|
|
: "Složka projektu zatím neexistuje"}
|
|
</span>
|
|
{canManage && !hasNasFolder && (
|
|
<span style={{ fontSize: "11px" }}>
|
|
Nahrání souboru ji automaticky vytvoří
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: "30px" }}></th>
|
|
<th>Název</th>
|
|
<th style={{ width: "90px" }}>Velikost</th>
|
|
<th style={{ width: "120px" }}>Změněno</th>
|
|
{canManage && (
|
|
<th style={{ width: "100px", textAlign: "right" }}>
|
|
Akce
|
|
</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item) => (
|
|
<tr key={item.name}>
|
|
<td style={{ textAlign: "center" }}>
|
|
{getFileIcon(item.type, item.extension)}
|
|
</td>
|
|
<td>
|
|
{renamingItem === item.name ? (
|
|
<input
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
className="admin-form-input"
|
|
style={{
|
|
fontSize: "11px",
|
|
padding: "3px 8px",
|
|
maxWidth: "300px",
|
|
}}
|
|
autoFocus
|
|
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);
|
|
}}
|
|
/>
|
|
) : (
|
|
<FileNameCell
|
|
item={item}
|
|
onFolderClick={handleFolderClick}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="fm-meta">
|
|
{item.type === "file" ? item.size_formatted : "\u2014"}
|
|
</td>
|
|
<td className="fm-meta">{item.modified || "\u2014"}</td>
|
|
{canManage && (
|
|
<td style={{ textAlign: "right" }}>
|
|
<div className="fm-actions">
|
|
{item.type === "file" && (
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
title="Stáhnout"
|
|
onClick={() => handleDownload(item)}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="7 10 12 15 17 10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon"
|
|
title="Přejmenovat"
|
|
onClick={() => startRename(item)}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="admin-btn-icon danger"
|
|
title="Smazat"
|
|
onClick={() => setDeleteTarget(item)}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
<path d="M10 11v6M14 11v6" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmModal
|
|
isOpen={deleteTarget !== null}
|
|
onClose={() => 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"
|
|
type="danger"
|
|
loading={deleting}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|