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:
@@ -2858,3 +2858,173 @@ img {
|
|||||||
background-color: var(--accent-color) !important;
|
background-color: var(--accent-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── File Manager ── */
|
||||||
|
|
||||||
|
.fm-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-full-path {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
user-select: all;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-breadcrumb-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-breadcrumb-sep {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0 4px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-breadcrumb-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-breadcrumb-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-breadcrumb-btn.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-new-folder {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-new-folder .admin-form-input {
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-content {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-content.fm-drag-over {
|
||||||
|
border: 2px dashed var(--accent-color);
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-dropzone-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
z-index: 5;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-folder-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-folder-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-item-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-name-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-symlink-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
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