feat: add ProjectFileManager component with file browser UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
676
src/admin/components/ProjectFileManager.tsx
Normal file
676
src/admin/components/ProjectFileManager.tsx
Normal file
@@ -0,0 +1,676 @@
|
||||
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 [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') handleRename(item)
|
||||
if (e.key === 'Escape') setRenamingItem(null)
|
||||
}}
|
||||
onBlur={() => 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user