From 3c1a35ae9c8261aef4a58c9b9372bf7980a3392b Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 10:20:49 +0100 Subject: [PATCH] feat: add ProjectFileManager component with file browser UI Co-Authored-By: Claude Opus 4.6 (1M context) --- src/admin/admin.css | 170 +++++ src/admin/components/ProjectFileManager.tsx | 676 ++++++++++++++++++++ 2 files changed, 846 insertions(+) create mode 100644 src/admin/components/ProjectFileManager.tsx diff --git a/src/admin/admin.css b/src/admin/admin.css index 9c0e0e1..e56456d 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -2858,3 +2858,173 @@ img { 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; +} + diff --git a/src/admin/components/ProjectFileManager.tsx b/src/admin/components/ProjectFileManager.tsx new file mode 100644 index 0000000..98560aa --- /dev/null +++ b/src/admin/components/ProjectFileManager.tsx @@ -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 ( + + + + ) + } + + const ext = (extension || '').toLowerCase() + const iconMap: Record = { + pdf: { color: '#e74c3c', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + doc: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + docx: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + xls: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + xlsx: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + ppt: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + pptx: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + jpg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, + jpeg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, + png: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, + gif: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' }, + zip: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }, + rar: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }, + '7z': { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }, + dwg: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + dxf: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + step: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + stp: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }, + } + + const cfg = iconMap[ext] || { color: 'var(--text-muted)', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } + + return ( + + + + + ) +} + +function SymlinkBadge({ target }: { target?: string }) { + return ( + + + + + + + ) +} + +function FileNameCell({ item, onFolderClick }: { item: FileItem; onFolderClick: (name: string) => void }) { + if (item.type === 'folder') { + return ( + + + {item.is_symlink && } + {item.item_count !== undefined && ( + {item.item_count} + )} + + ) + } + return ( + + {item.name} + {item.is_symlink && } + + ) +} + +export default function ProjectFileManager({ projectId, projectNumber, hasPermission, hasNasFolder }: ProjectFileManagerProps) { + const alert = useAlert() + const fileInputRef = useRef(null) + + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [currentPath, setCurrentPath] = useState('') + const [breadcrumb, setBreadcrumb] = useState(['']) + 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(null) + const [renameValue, setRenameValue] = useState('') + + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const [errorMessage, setErrorMessage] = useState(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) => { + 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 ( +
+
+

Soubory

+
+ {[0, 1, 2, 3].map(i => ( +
+
+
+
+ ))} +
+
+
+ ) + } + + if (errorMessage) { + return ( +
+
+

Soubory

+
+ + + + + + {errorMessage} +
+
+
+ ) + } + + return ( +
+
+

Soubory

+ + {/* Toolbar */} +
+
+ {breadcrumb.map((segment, i) => ( + + {i > 0 && /} + + + ))} +
+ + {fullPath && ( + {fullPath} + )} + + {canManage && ( +
+ + + +
+ )} +
+ + {/* New folder input */} + {newFolderMode && ( +
+ 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' }} + /> + + +
+ )} + + {/* Drop zone + table */} +
+ {dragOver && ( +
+ + + + + + Přetáhněte soubory sem +
+ )} + + {items.length === 0 && !loading ? ( +
+ + + + + {hasNasFolder ? 'Složka je prázdná' : 'Složka projektu zatím neexistuje'} + + {canManage && !hasNasFolder && ( + Nahrání souboru ji automaticky vytvoří + )} +
+ ) : ( +
+ + + + + + + + {canManage && } + + + + {items.map(item => ( + + + + + + {canManage && ( + + )} + + ))} + +
NázevVelikostZměněnoAkce
+ {getFileIcon(item.type, item.extension)} + + {renamingItem === item.name ? ( + 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)} + /> + ) : ( + + )} + + {item.type === 'file' ? item.size_formatted : '\u2014'} + {item.modified || '\u2014'} +
+ {item.type === 'file' && ( + + )} + + +
+
+
+ )} +
+
+ + 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} + /> +
+ ) +}