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:
BOHA
2026-03-23 10:20:49 +01:00
parent b87081dd2c
commit 3c1a35ae9c
2 changed files with 846 additions and 0 deletions

View 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>
)
}