diff --git a/src/api/index.js b/src/api/index.js index 82b9e0b..3d2712e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1944,6 +1944,85 @@ export const modelconversation = { }, }; +export const vfs = { + /** + * GET /api/vfs + * List files in the virtual filesystem for the selected company. + * → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] } + */ + list() { + return http('/api/vfs'); + }, + + /** + * GET /api/vfs/*path + * Fetch raw content of a file (e.g. text for the editor). + * @param {string} path + */ + async getText(path) { + const clean = String(path || '').replace(/^\/+/, ''); + const res = await fetch(`/api/vfs/${clean}?raw=1`, { + credentials: 'include', + }); + if (!res.ok) { + throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`); + } + return res.text(); + }, + + /** + * PUT /api/vfs/*path + * Create or update a file. + * @param {string} path + * @param {string} content + * @param {string} [mime] + */ + save(path, content, mime) { + const clean = String(path || '').replace(/^\/+/, ''); + return http(`/api/vfs/${clean}`, { + method: 'PUT', + body: { content, mime }, + }); + }, + + /** + * DELETE /api/vfs/*path + * Delete a file. + * @param {string} path + */ + delete(path) { + const clean = String(path || '').replace(/^\/+/, ''); + return http(`/api/vfs/${clean}`, { + method: 'DELETE', + }); + }, + + /** + * POST /api/vfs/upload + * Upload multiple files or a single file to VFS. + * @param {File[]|FileList|File} files + * @param {string} [targetDir] + */ + async upload(files, targetDir = '') { + const formData = new FormData(); + if (targetDir) formData.append('targetDir', targetDir); + const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files]; + for (const f of list) { + formData.append('files', f); + } + const res = await fetch('/api/vfs/upload', { + method: 'POST', + credentials: 'include', + body: formData, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`); + } + return res.json(); + }, +}; + export default { auth, article, @@ -1968,8 +2047,10 @@ export default { supplier, thumb, unit, + vfs, warehouse, stock, globalHistory, }; + diff --git a/src/automats/index.js b/src/automats/index.js index 3db7c92..6f2b3cb 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -156,6 +156,9 @@ export { selectHasPendingAsk, } from './chatAutomat'; +export { default as vfsAutomat } from './vfsAutomat'; + + /** * ============================================================================ * Overview of Automats in Dashfront diff --git a/src/automats/pwaAutomat.js b/src/automats/pwaAutomat.js index f0fa6a2..13494bd 100644 --- a/src/automats/pwaAutomat.js +++ b/src/automats/pwaAutomat.js @@ -97,7 +97,7 @@ export function initPwa() { onRegistered(registration) { if (!registration) return; - // Check for new updates periodically (every 30 minutes) + // Check for new updates periodically (every 1 minutes) setInterval(() => { registration.update().catch(() => { }); }, 1 * 60 * 1000); diff --git a/src/automats/vfsAutomat.js b/src/automats/vfsAutomat.js new file mode 100644 index 0000000..05899e4 --- /dev/null +++ b/src/automats/vfsAutomat.js @@ -0,0 +1,376 @@ +/** + * @file vfsAutomat.js + * @description State container for the VFS (Virtual Filesystem) explorer and editor, + * integrated side-by-side with the AI assistant chat drawer. + * + * Upstream Triggers: + * - `authAutomat`: Subscribes to auth state changes — loads file listing on + * `'authed'` with a `selectedCompanyId`, and calls `actions.reset()` on `'anon'`. + * - `sseAutomat`: Dispatches `'vfs'` SSE mutation events to silently refresh file listing. + * - UI / ChatDrawer: Actions (`toggleVfs`, `selectFile`, `saveFile`, `uploadFiles`, `deleteFile`, `createFile`). + */ + +import { Automat } from 'react-automat'; +import { vfs as vfsApi } from '../api'; +import authAutomat from './authAutomat'; +import sseAutomat from './sseAutomat'; + +/** + * @typedef {Object} VfsFile + * @property {string} name Relative path in VFS (e.g. `artikelpreise.csv`, `attachments/test.pdf`) + * @property {number} size File size in bytes + * @property {string} mime Detected MIME type + * @property {string} updated_at ISO timestamp + * @property {string} created_at ISO timestamp + * @property {boolean} isText Whether the file can be edited in the text editor + * @property {boolean} isImage Whether the file can be rendered as an image + * @property {boolean} isPdf Whether the file is a PDF + */ + +/** + * @typedef {Object} VfsState + * @property {boolean} isOpen Whether VFS editor panel is open side-by-side with chat + * @property {VfsFile[]} files List of files in the current company's VFS + * @property {boolean} loadingFiles + * @property {string|null} loadError + * @property {string|null} selectedFileName Currently selected file path + * @property {VfsFile|null} selectedFile Metadata of the selected file + * @property {string} fileContent Current text content in editor + * @property {string} originalContent Content as saved on server (for dirty tracking) + * @property {boolean} isDirty True if user made uncommitted changes + * @property {boolean} loadingContent True while fetching text file content + * @property {boolean} isSaving + * @property {string|null} saveError + * @property {boolean} isUploading + * @property {string|null} uploadError + * @property {string} searchQuery Current filter query in file explorer + * @property {number} vfsWidth Width of the VFS panel in pixels + * @property {boolean} newFileDialogOpen + */ + +/** @type {VfsState} */ +const initialState = { + isOpen: false, + files: [], + loadingFiles: false, + loadError: null, + selectedFileName: null, + selectedFile: null, + fileContent: '', + originalContent: '', + isDirty: false, + loadingContent: false, + isSaving: false, + saveError: null, + isUploading: false, + uploadError: null, + searchQuery: '', + vfsWidth: 480, + newFileDialogOpen: false, +}; + +let vfsAutomat; + +const actions = { + /** + * Toggle or set the open state of the VFS panel in the chat drawer. + * @param {boolean} [nextOpen] + */ + toggleVfs(nextOpen) { + const willOpen = typeof nextOpen === 'boolean' ? nextOpen : !vfsAutomat.state.isOpen; + vfsAutomat.setState({ isOpen: willOpen }); + if (willOpen && vfsAutomat.state.files.length === 0 && !vfsAutomat.state.loadingFiles) { + void actions.loadFiles(); + } + }, + + openVfs() { + actions.toggleVfs(true); + }, + + closeVfs() { + actions.toggleVfs(false); + }, + + /** + * Set width of the VFS editor panel. + * @param {number} width + */ + setVfsWidth(width) { + const clamped = Math.max(340, Math.min(Math.round(Number(width) || 540), 1200)); + vfsAutomat.setState({ vfsWidth: clamped }); + }, + + setSearchQuery(searchQuery) { + vfsAutomat.setState({ searchQuery }); + }, + + setNewFileDialogOpen(newFileDialogOpen) { + vfsAutomat.setState({ newFileDialogOpen }); + }, + + /** + * Load or refresh the file listing for the current company. + * @param {boolean} [silent=false] + */ + async loadFiles(silent = false) { + if (!silent) { + vfsAutomat.setState({ loadingFiles: true, loadError: null }); + } + try { + const res = await vfsApi.list(); + const files = Array.isArray(res?.files) ? res.files : []; + const currentSelected = vfsAutomat.state.selectedFileName; + const updatedSelectedFile = currentSelected + ? files.find((f) => f.name === currentSelected) || null + : null; + + vfsAutomat.setState({ + files, + selectedFile: updatedSelectedFile, + loadingFiles: false, + loadError: null, + }); + return files; + } catch (err) { + console.error('[vfsAutomat] loadFiles failed:', err); + vfsAutomat.setState({ + loadingFiles: false, + loadError: err?.message || 'Fehler beim Laden des VFS-Dateisystems', + }); + return []; + } + }, + + /** + * Select a file for viewing or editing. + * @param {string|null} fileName + */ + async selectFile(fileName) { + if (!fileName) { + vfsAutomat.setState({ + selectedFileName: null, + selectedFile: null, + fileContent: '', + originalContent: '', + isDirty: false, + loadingContent: false, + saveError: null, + }); + return; + } + + const file = vfsAutomat.state.files.find((f) => f.name === fileName) || { + name: fileName, + isText: true, + }; + + vfsAutomat.setState({ + selectedFileName: fileName, + selectedFile: file, + saveError: null, + isDirty: false, + }); + + if (file.isText) { + vfsAutomat.setState({ loadingContent: true }); + try { + const text = await vfsApi.getText(fileName); + // Only apply if still selected + if (vfsAutomat.state.selectedFileName === fileName) { + vfsAutomat.setState({ + fileContent: text, + originalContent: text, + isDirty: false, + loadingContent: false, + }); + } + } catch (err) { + console.error(`[vfsAutomat] Failed to load text for "${fileName}":`, err); + if (vfsAutomat.state.selectedFileName === fileName) { + vfsAutomat.setState({ + loadingContent: false, + saveError: `Fehler beim Laden: ${err.message}`, + }); + } + } + } else { + vfsAutomat.setState({ + fileContent: '', + originalContent: '', + isDirty: false, + loadingContent: false, + }); + } + }, + + /** + * Update the in-memory draft content of the currently open text file. + * @param {string} content + */ + updateContent(content) { + const isDirty = content !== vfsAutomat.state.originalContent; + vfsAutomat.setState({ fileContent: content, isDirty }); + }, + + /** + * Save changes to the currently selected file. + */ + async saveCurrentFile() { + const { selectedFileName, selectedFile, fileContent, isSaving } = vfsAutomat.state; + if (!selectedFileName || isSaving) return; + + vfsAutomat.setState({ isSaving: true, saveError: null }); + try { + await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime); + vfsAutomat.setState({ + originalContent: fileContent, + isDirty: false, + isSaving: false, + saveError: null, + }); + void actions.loadFiles(true); + } catch (err) { + console.error('[vfsAutomat] saveCurrentFile failed:', err); + vfsAutomat.setState({ + isSaving: false, + saveError: err?.message || 'Speichern fehlgeschlagen', + }); + throw err; + } + }, + + /** + * Create a new text file and immediately open it in the editor. + * @param {string} name + * @param {string} [initialContent=''] + */ + async createFile(name, initialContent = '') { + const clean = String(name || '').trim().replace(/^[\/\\]+/, ''); + if (!clean) throw new Error('Dateiname darf nicht leer sein'); + + vfsAutomat.setState({ isSaving: true, saveError: null, newFileDialogOpen: false }); + try { + await vfsApi.save(clean, initialContent); + await actions.loadFiles(true); + await actions.selectFile(clean); + vfsAutomat.setState({ isSaving: false }); + } catch (err) { + console.error('[vfsAutomat] createFile failed:', err); + vfsAutomat.setState({ + isSaving: false, + saveError: err?.message || 'Erstellen der Datei fehlgeschlagen', + }); + throw err; + } + }, + + /** + * Delete a file from VFS. + * @param {string} fileName + */ + async deleteFile(fileName) { + if (!fileName) return; + try { + await vfsApi.delete(fileName); + if (vfsAutomat.state.selectedFileName === fileName) { + actions.selectFile(null); + } + await actions.loadFiles(true); + } catch (err) { + console.error('[vfsAutomat] deleteFile failed:', err); + throw err; + } + }, + + /** + * Upload one or more files to VFS. + * @param {File[]|FileList|File} files + * @param {string} [targetDir=''] + */ + async uploadFiles(files, targetDir = '') { + vfsAutomat.setState({ isUploading: true, uploadError: null }); + try { + const res = await vfsApi.upload(files, targetDir); + await actions.loadFiles(true); + vfsAutomat.setState({ isUploading: false, uploadError: null }); + + // If exactly one file was uploaded, select it + if (res?.uploaded?.length === 1) { + void actions.selectFile(res.uploaded[0].name); + } + return res; + } catch (err) { + console.error('[vfsAutomat] uploadFiles failed:', err); + vfsAutomat.setState({ + isUploading: false, + uploadError: err?.message || 'Upload fehlgeschlagen', + }); + throw err; + } + }, + + /** + * Download a VFS file directly via browser. + * @param {string} fileName + */ + downloadFile(fileName) { + if (!fileName) return; + const clean = String(fileName).replace(/^\/+/, ''); + const url = `/api/vfs/${clean}?download=1`; + const a = document.createElement('a'); + a.href = url; + a.download = clean.split('/').pop() || 'download'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }, + + /** + * Reset state (on logout or company switch). + */ + reset() { + vfsAutomat.setState({ + files: [], + loadingFiles: false, + loadError: null, + selectedFileName: null, + selectedFile: null, + fileContent: '', + originalContent: '', + isDirty: false, + loadingContent: false, + isSaving: false, + saveError: null, + isUploading: false, + uploadError: null, + searchQuery: '', + }); + }, +}; + +vfsAutomat = new Automat(initialState, actions); + +// Subscriptions for lifecycle coordination: +authAutomat.subscribe((authState) => { + if (authState.status === 'authed' && authState.user?.selectedCompanyId) { + if (vfsAutomat.state.isOpen) { + void vfsAutomat.actions.loadFiles(true); + } + } else if (authState.status === 'anon') { + vfsAutomat.actions.reset(); + } +}); + +// SSE auto-refresh on 'vfs' events +sseAutomat.actions.on('vfs', () => { + void vfsAutomat.actions.loadFiles(true); +}); + +// Register on window for devtools inspector +if (typeof window !== 'undefined') { + window.__AUTOMATS__ = window.__AUTOMATS__ || {}; + window.__AUTOMATS__.vfsAutomat = vfsAutomat; +} + +export default vfsAutomat; diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx index 5e06c4a..d25e770 100644 --- a/src/components/chat/ChatDrawer.jsx +++ b/src/components/chat/ChatDrawer.jsx @@ -26,10 +26,13 @@ import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded'; import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded'; import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded'; import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded'; +import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded'; import { setting as settingApi } from '../../api'; import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat'; import userSettingsAutomat from '../../automats/userSettingsAutomat'; +import vfsAutomat from '../../automats/vfsAutomat'; +import { VfsEditorPanel } from '../vfs'; import ChatFlowList from './ChatFlowList'; import AskUserPrompt from './AskUserPrompt'; import { ACCEPT_STRING, readFileAttachment } from './chatAttachments'; @@ -43,12 +46,16 @@ const DEFAULT_DRAWER_WIDTH = 460; const MIN_DRAWER_WIDTH = 320; export default class ChatDrawer extends PureComponent { + constructor(props) { super(props); const s = chatAutomat.state; const settings = userSettingsAutomat.state; + const vfs = vfsAutomat.state; this.state = { isOpen: s.isOpen, + isVfsOpen: vfs.isOpen, + vfsWidth: vfs.vfsWidth || 480, conversations: s.conversations, selectedConversationId: s.selectedConversationId, draftConversation: s.draftConversation, @@ -123,12 +130,18 @@ export default class ChatDrawer extends PureComponent { } return null; }); + + this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({ + isVfsOpen: state.isOpen, + vfsWidth: state.vfsWidth, + })); } componentWillUnmount() { if (this.focusTimeout) clearTimeout(this.focusTimeout); this.unsub?.(); this.unsubSettings?.(); + this.unsubVfs?.(); this.cleanupResizeListeners(); } @@ -136,7 +149,14 @@ export default class ChatDrawer extends PureComponent { chatAutomat.actions.closeChat(); }; - handleResizeStart = (e) => { + handleInsertToChat = (text) => { + this.setState((prev) => ({ + inputText: prev.inputText ? `${prev.inputText}\n${text}` : text, + })); + this.focusInput(50); + }; + + handleVfsResizerStart = (e) => { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); e.stopPropagation(); @@ -144,25 +164,36 @@ export default class ChatDrawer extends PureComponent { const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX); if (startX === undefined) return; - const startWidth = this.state.drawerWidth; - this.setState({ isResizing: true }); + const startVfsWidth = this.state.vfsWidth; + const startChatWidth = this.state.drawerWidth; + const totalPairWidth = startVfsWidth + startChatWidth; + this.setState({ isResizing: true }); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; - let currentWidth = startWidth; + let currentChatWidth = startChatWidth; + let currentVfsWidth = startVfsWidth; const onMove = (moveEvt) => { moveEvt.preventDefault(); const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX); if (clientX === undefined) return; - const deltaX = startX - clientX; - const maxWidth = Math.min(window.innerWidth - 40, 1400); - const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX))); + const deltaX = clientX - startX; + const minVfs = 260; + const minChat = MIN_DRAWER_WIDTH; // 320 - currentWidth = newWidth; - this.setState({ drawerWidth: newWidth }); + // Drag right (deltaX > 0): VFS increases, Chat decreases + // Drag left (deltaX < 0): VFS decreases, Chat increases + const clampedVfs = Math.max(minVfs, Math.min(totalPairWidth - minChat, Math.round(startVfsWidth + deltaX))); + const clampedChat = totalPairWidth - clampedVfs; + + currentVfsWidth = clampedVfs; + currentChatWidth = clampedChat; + + vfsAutomat.actions.setVfsWidth(clampedVfs); + this.setState({ drawerWidth: clampedChat }); }; const onEnd = () => { @@ -177,7 +208,75 @@ export default class ChatDrawer extends PureComponent { window.removeEventListener('touchend', onEnd); this.setState({ isResizing: false }); - userSettingsAutomat.actions.setChatDrawerWidth(currentWidth); + vfsAutomat.actions.setVfsWidth(currentVfsWidth); + userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth); + }; + + window.addEventListener('pointermove', onMove, { passive: false }); + window.addEventListener('pointerup', onEnd); + window.addEventListener('pointercancel', onEnd); + window.addEventListener('mousemove', onMove, { passive: false }); + window.addEventListener('mouseup', onEnd); + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('touchend', onEnd); + }; + + handleResizeStart = (e) => { + if (e.button !== undefined && e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX); + if (startX === undefined) return; + + const startWidth = this.state.drawerWidth; + const startVfsWidth = this.state.vfsWidth; + const isVfs = this.state.isVfsOpen; + + this.setState({ isResizing: true }); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + let currentChatWidth = startWidth; + let currentVfsWidth = startVfsWidth; + + const onMove = (moveEvt) => { + moveEvt.preventDefault(); + const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX); + if (clientX === undefined) return; + + const deltaX = startX - clientX; + + if (isVfs) { + const maxVfs = Math.min(window.innerWidth - startWidth - 40, 1200); + const newVfsWidth = Math.max(260, Math.min(maxVfs, Math.round(startVfsWidth + deltaX))); + currentVfsWidth = newVfsWidth; + vfsAutomat.actions.setVfsWidth(newVfsWidth); + } else { + const maxWidth = Math.min(window.innerWidth - 40, 1400); + const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX))); + currentChatWidth = newWidth; + this.setState({ drawerWidth: newWidth }); + } + }; + + const onEnd = () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onEnd); + window.removeEventListener('pointercancel', onEnd); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onEnd); + window.removeEventListener('touchmove', onMove); + window.removeEventListener('touchend', onEnd); + + this.setState({ isResizing: false }); + if (isVfs) { + vfsAutomat.actions.setVfsWidth(currentVfsWidth); + } else { + userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth); + } }; window.addEventListener('pointermove', onMove, { passive: false }); @@ -199,6 +298,7 @@ export default class ChatDrawer extends PureComponent { document.body.style.userSelect = ''; }; + handleSelectConversation = (e) => { const id = Number(e.target.value); if (id) { @@ -427,6 +527,7 @@ export default class ChatDrawer extends PureComponent { modelslugflag, modelreasoning, modelslug, + isVfsOpen, } = this.state; return ( @@ -695,11 +796,36 @@ export default class ChatDrawer extends PureComponent { )} - - - + + + vfsAutomat.actions.toggleVfs()} + aria-label="VFS Dateisystem" + sx={{ + p: 0.5, + color: isVfsOpen ? 'primary.main' : 'text.secondary', + bgcolor: isVfsOpen ? 'action.selected' : 'transparent', + borderRadius: 1.5, + border: '1px solid', + borderColor: isVfsOpen ? 'primary.main' : 'transparent', + '&:hover': { + color: 'primary.main', + bgcolor: 'action.hover', + }, + }} + > + + + + + + + + + {/* Conversation Selector Bar */} {conversations.length > 0 && ( @@ -1095,7 +1221,8 @@ export default class ChatDrawer extends PureComponent { } render() { - const { isOpen, drawerWidth, isResizing } = this.state; + const { isOpen, drawerWidth, isResizing, isVfsOpen, vfsWidth } = this.state; + const totalWidth = isVfsOpen ? drawerWidth + vfsWidth : drawerWidth; return ( <> @@ -1106,10 +1233,10 @@ export default class ChatDrawer extends PureComponent { sx={(theme) => ({ zIndex: theme.zIndex.drawer + 2, '& .MuiDrawer-paper': { - width: { xs: '100vw', sm: `${drawerWidth}px` }, + width: { xs: '100vw', sm: `${totalWidth}px` }, maxWidth: '100vw', display: 'flex', - flexDirection: 'column', + flexDirection: 'row', boxShadow: SHADOW_DRAWER, position: 'fixed', overflow: 'visible', @@ -1122,10 +1249,10 @@ export default class ChatDrawer extends PureComponent { }, paper: { sx: { - width: { xs: '100vw', sm: `${drawerWidth}px` }, + width: { xs: '100vw', sm: `${totalWidth}px` }, maxWidth: '100vw', display: 'flex', - flexDirection: 'column', + flexDirection: 'row', boxShadow: SHADOW_DRAWER, position: 'fixed', overflow: 'visible', @@ -1177,11 +1304,79 @@ export default class ChatDrawer extends PureComponent { /> - {this.renderHeader()} - {this.renderBody()} - {this.renderFooter()} + {/* Left: VFS Filesystem Editor Panel (parallel to chat) */} + {isVfsOpen && ( + + + + )} + + {/* Vertical Split Resizer between VFS and Chat */} + {isVfsOpen && ( + + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)', + }} + /> + + )} + + {/* Right: Chat Panel */} + + {this.renderHeader()} + {this.renderBody()} + {this.renderFooter()} + ); } + } diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx index 3f21ba0..74b7312 100644 --- a/src/components/tiles/ArticleTile.jsx +++ b/src/components/tiles/ArticleTile.jsx @@ -18,7 +18,6 @@ import { thumb } from '../../api'; import { GREEN, GREEN_BRIGHT, - ICE, INK_STRONG, SCRIM_LABEL, SCRIM_TILE, @@ -409,69 +408,92 @@ export class ArticleTile extends Tile { textShadow: TEXT_GLOW_DARK, }} > - {article?.brand?.name ? ( - - ) : null} - {label ? ( - - {label} - - ) : null} - {sku ? ( - - {sku} - - ) : null} - {grossPrice != null && ( - - {formatPrice(grossPrice)} - - )} + {/* Row 1: Brand */} + + {article?.brand?.name ? ( + + ) : null} + + + {/* Rows 2 & 3: Name */} + + {label || article?.name?.trim() || '\u00A0'} + + + {/* Row 4: SKU */} + + {sku || '\u00A0'} + + + {/* Row 5: Price */} + + {grossPrice != null ? formatPrice(grossPrice) : '\u00A0'} + ); diff --git a/src/components/vfs/VfsEditorPanel.jsx b/src/components/vfs/VfsEditorPanel.jsx new file mode 100644 index 0000000..0bb9551 --- /dev/null +++ b/src/components/vfs/VfsEditorPanel.jsx @@ -0,0 +1,194 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; + +import vfsAutomat from '../../automats/vfsAutomat'; +import VfsFileExplorer from './VfsFileExplorer'; +import VfsTextEditor from './VfsTextEditor'; +import VfsFilePreview from './VfsFilePreview'; +import VfsNewFileDialog from './VfsNewFileDialog'; + +export default class VfsEditorPanel extends PureComponent { + constructor(props) { + super(props); + const s = vfsAutomat.state; + this.state = { + files: s.files, + loadingFiles: s.loadingFiles, + loadError: s.loadError, + selectedFileName: s.selectedFileName, + selectedFile: s.selectedFile, + fileContent: s.fileContent, + originalContent: s.originalContent, + isDirty: s.isDirty, + loadingContent: s.loadingContent, + isSaving: s.isSaving, + saveError: s.saveError, + isUploading: s.isUploading, + searchQuery: s.searchQuery, + newFileDialogOpen: s.newFileDialogOpen, + }; + } + + componentDidMount() { + this.unsub = vfsAutomat.subscribe(this, (state) => ({ + files: state.files, + loadingFiles: state.loadingFiles, + loadError: state.loadError, + selectedFileName: state.selectedFileName, + selectedFile: state.selectedFile, + fileContent: state.fileContent, + originalContent: state.originalContent, + isDirty: state.isDirty, + loadingContent: state.loadingContent, + isSaving: state.isSaving, + saveError: state.saveError, + isUploading: state.isUploading, + searchQuery: state.searchQuery, + newFileDialogOpen: state.newFileDialogOpen, + })); + + if (this.state.files.length === 0 && !this.state.loadingFiles) { + void vfsAutomat.actions.loadFiles(); + } + } + + componentWillUnmount() { + this.unsub?.(); + } + + handleMentionInChat = (file) => { + if (!file) return; + if (this.props.onInsertToChat) { + const isImg = file.isImage; + const tag = isImg + ? `![${file.name}](${file.name})` + : `[${file.name}](${file.name})`; + this.props.onInsertToChat(tag); + } + }; + + render() { + const { + files, + loadingFiles, + selectedFileName, + selectedFile, + fileContent, + originalContent, + isDirty, + loadingContent, + isSaving, + saveError, + searchQuery, + newFileDialogOpen, + } = this.state; + + return ( + + {selectedFileName && selectedFile?.isText ? ( + /* Single Column Mode: Text Editor */ + vfsAutomat.actions.updateContent(c)} + onSave={() => vfsAutomat.actions.saveCurrentFile()} + onClose={() => vfsAutomat.actions.selectFile(null)} + onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)} + onMentionInChat={() => this.handleMentionInChat(selectedFile)} + /> + ) : selectedFileName && !selectedFile?.isText ? ( + /* Single Column Mode: Preview (Image, PDF, Binary) */ + vfsAutomat.actions.selectFile(null)} + onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)} + onMentionInChat={() => this.handleMentionInChat(selectedFile)} + /> + ) : ( + /* Single Column Mode: File Explorer */ + <> + + + + + VFS Dateisystem + + + + + vfsAutomat.actions.closeVfs()} + aria-label="VFS schließen" + > + + + + + + + vfsAutomat.actions.selectFile(name)} + onRefresh={() => vfsAutomat.actions.loadFiles()} + onOpenNewDialog={() => vfsAutomat.actions.setNewFileDialogOpen(true)} + onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)} + onDownload={(name) => vfsAutomat.actions.downloadFile(name)} + onDelete={(name) => vfsAutomat.actions.deleteFile(name)} + onUpload={(filesToUpload) => vfsAutomat.actions.uploadFiles(filesToUpload)} + onMentionInChat={this.handleMentionInChat} + /> + + + )} + + {/* New File Dialog */} + vfsAutomat.actions.setNewFileDialogOpen(false)} + onCreate={(name) => vfsAutomat.actions.createFile(name)} + isCreating={isSaving} + /> + + ); + } +} diff --git a/src/components/vfs/VfsFileExplorer.jsx b/src/components/vfs/VfsFileExplorer.jsx new file mode 100644 index 0000000..f582dc2 --- /dev/null +++ b/src/components/vfs/VfsFileExplorer.jsx @@ -0,0 +1,735 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import InputBase from '@mui/material/InputBase'; +import Tooltip from '@mui/material/Tooltip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Chip from '@mui/material/Chip'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import SearchRoundedIcon from '@mui/icons-material/SearchRounded'; +import ClearRoundedIcon from '@mui/icons-material/ClearRounded'; +import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; +import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded'; +import CloudUploadRoundedIcon from '@mui/icons-material/CloudUploadRounded'; +import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; +import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; +import EditRoundedIcon from '@mui/icons-material/EditRounded'; +import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded'; +import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'; +import FolderRoundedIcon from '@mui/icons-material/FolderRounded'; +import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded'; +import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; +import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; +import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; +import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; + +import VfsFileIcon, { getCategoryBadge } from './VfsFileIcon'; +import { formatVfsSize, formatVfsDate } from './vfsUtils'; + +/** + * Returns all ancestor directory paths for a file or folder path. + * e.g. "docs/api/v1/spec.json" -> ["docs", "docs/api", "docs/api/v1"] + */ +function getParentFolderPaths(filePath) { + if (!filePath) return []; + const clean = String(filePath).replace(/^\/+/, '').replace(/\/+$/, ''); + const parts = clean.split('/').filter(Boolean); + const paths = []; + for (let i = 0; i < parts.length - 1; i++) { + paths.push(parts.slice(0, i + 1).join('/')); + } + return paths; +} + +/** + * Builds a hierarchical tree node structure from a flat list of VfsFiles. + * In every node, folders and files are separated and can be rendered recursively. + */ +function buildFileTree(files, searchQuery = '') { + const q = searchQuery ? searchQuery.trim().toLowerCase() : ''; + const filtered = (files || []).filter((f) => { + if (!q) return true; + return f.name.toLowerCase().includes(q) || (f.mime && f.mime.toLowerCase().includes(q)); + }); + + const root = { + name: '', + path: '', + isDirectory: true, + folders: new Map(), + files: [], + }; + + for (const file of filtered) { + const cleanPath = String(file.name || '').replace(/^\/+/, '').replace(/\/+$/, ''); + if (!cleanPath) continue; + + const parts = cleanPath.split('/').filter(Boolean); + let current = root; + + for (let i = 0; i < parts.length - 1; i++) { + const folderName = parts[i]; + const folderPath = parts.slice(0, i + 1).join('/'); + + if (!current.folders.has(folderName)) { + current.folders.set(folderName, { + name: folderName, + path: folderPath, + isDirectory: true, + folders: new Map(), + files: [], + }); + } + current = current.folders.get(folderName); + } + + const fileName = parts[parts.length - 1]; + current.files.push({ + name: fileName, + path: cleanPath, + isDirectory: false, + fileData: file, + }); + } + + // Calculate recursive file counts + function countFiles(node) { + let count = node.files.length; + for (const folder of node.folders.values()) { + count += countFiles(folder); + } + node.totalFileCount = count; + return count; + } + countFiles(root); + + return { root, totalCount: filtered.length }; +} + +export default class VfsFileExplorer extends PureComponent { + constructor(props) { + super(props); + const initialExpanded = new Set(); + if (props.selectedFileName) { + for (const p of getParentFolderPaths(props.selectedFileName)) { + initialExpanded.add(p); + } + } + this.state = { + menuAnchor: null, + menuFile: null, + deleteConfirmFile: null, + isDraggingOver: false, + expandedFolders: initialExpanded, + }; + this.fileInputRef = React.createRef(); + } + + componentDidUpdate(prevProps) { + if (this.props.selectedFileName && this.props.selectedFileName !== prevProps.selectedFileName) { + const parents = getParentFolderPaths(this.props.selectedFileName); + if (parents.length > 0) { + this.setState((prev) => { + const next = new Set(prev.expandedFolders); + let changed = false; + for (const p of parents) { + if (!next.has(p)) { + next.add(p); + changed = true; + } + } + return changed ? { expandedFolders: next } : null; + }); + } + } + } + + toggleFolder = (folderPath) => { + this.setState((prev) => { + const next = new Set(prev.expandedFolders); + if (next.has(folderPath)) { + next.delete(folderPath); + } else { + next.add(folderPath); + } + return { expandedFolders: next }; + }); + }; + + + handleOpenMenu = (e, file) => { + e.stopPropagation(); + e.preventDefault(); + this.setState({ menuAnchor: e.currentTarget, menuFile: file }); + }; + + handleCloseMenu = () => { + this.setState({ menuAnchor: null, menuFile: null }); + }; + + handleTriggerUpload = () => { + this.fileInputRef.current?.click(); + }; + + handleFileInputChange = (e) => { + const files = e.target?.files; + if (files && files.length > 0) { + this.props.onUpload(Array.from(files)); + } + if (e.target) e.target.value = ''; + }; + + handleDragEnter = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer?.types?.includes('Files')) { + this.setState({ isDraggingOver: true }); + } + }; + + handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!this.state.isDraggingOver && e.dataTransfer?.types?.includes('Files')) { + this.setState({ isDraggingOver: true }); + } + }; + + handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.currentTarget.contains(e.relatedTarget)) return; + this.setState({ isDraggingOver: false }); + }; + + handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + this.setState({ isDraggingOver: false }); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + this.props.onUpload(Array.from(files)); + } + }; + + handleCopyPath = (file) => { + if (!file) return; + navigator.clipboard?.writeText?.(file.name); + this.handleCloseMenu(); + }; + + handleMentionInChat = (file) => { + if (!file) return; + if (this.props.onMentionInChat) { + this.props.onMentionInChat(file); + } + this.handleCloseMenu(); + }; + + renderFolderNode = (folder, depth) => { + const { searchQuery } = this.props; + const isSearching = Boolean(searchQuery && searchQuery.trim()); + const isExpanded = isSearching || this.state.expandedFolders.has(folder.path); + + const subfolders = Array.from(folder.folders.values()).sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); + const files = [...folder.files].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); + + return ( + + {/* Folder Header Row */} + this.toggleFolder(folder.path)} + sx={{ + display: 'flex', + alignItems: 'center', + py: 0.5, + px: 1, + pl: `${depth * 14 + 6}px`, + borderRadius: 1.5, + cursor: 'pointer', + userSelect: 'none', + transition: 'background-color 0.1s', + '&:hover': { + bgcolor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', + }, + }} + > + {/* Chevron */} + + {isExpanded ? ( + + ) : ( + + )} + + + {/* Folder Icon */} + {isExpanded ? ( + + ) : ( + + )} + + {/* Folder Name */} + + {folder.name} + + + {/* Total Files Count in Folder */} + + {folder.totalFileCount} + + + + {/* Folder Children (Directories FIRST, then files) */} + {isExpanded && ( + + {/* 1. Subdirectories FIRST */} + {subfolders.map((sub) => this.renderFolderNode(sub, depth + 1))} + + {/* 2. Files NEXT */} + {files.map((file) => this.renderFileNode(file, depth + 1))} + + )} + + ); + }; + + renderFileNode = (file, depth) => { + const { selectedFileName, onSelectFile } = this.props; + const isSelected = file.path === selectedFileName; + const badge = getCategoryBadge(file.path, file.fileData.mime); + + return ( + onSelectFile(file.path)} + sx={(theme) => ({ + display: 'flex', + alignItems: 'center', + py: 0.5, + px: 1, + pl: `${depth * 14 + 26}px`, + borderRadius: 1.5, + cursor: 'pointer', + userSelect: 'none', + bgcolor: isSelected + ? theme.palette.mode === 'dark' + ? 'primary.dark' + : 'action.selected' + : 'transparent', + border: '1px solid', + borderColor: isSelected ? 'primary.main' : 'transparent', + transition: 'background-color 0.1s, border-color 0.1s', + '&:hover': { + bgcolor: isSelected + ? theme.palette.mode === 'dark' + ? 'primary.dark' + : 'action.selected' + : 'action.hover', + }, + })} + > + + + + + + {file.name} + + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', + color: badge.color, + }} + /> + + + + + {formatVfsSize(file.fileData.size)} + + {file.fileData.updated_at && ( + + {formatVfsDate(file.fileData.updated_at)} + + )} + + + + {/* Quick action buttons on hover / select */} + e.stopPropagation()} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.2, + opacity: isSelected ? 1 : 0.7, + '&:hover': { opacity: 1 }, + }} + > + + this.props.onDownload(file.path)} + sx={{ p: 0.5 }} + > + + + + + this.handleOpenMenu(e, file.fileData)} + sx={{ p: 0.5 }} + > + + + + + ); + }; + + renderTree() { + const { files, searchQuery } = this.props; + const { root, totalCount } = buildFileTree(files, searchQuery); + + if (totalCount === 0) { + return ( + + + + {searchQuery ? `Keine Dateien für "${searchQuery}" gefunden` : 'Keine Dateien im VFS vorhanden'} + + {!searchQuery && ( + + Klicke oben auf Upload oder erstelle eine neue Datei. + + )} + + ); + } + + const rootFolders = Array.from(root.folders.values()).sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); + const rootFiles = [...root.files].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); + + return ( + + {/* Top-level directories FIRST */} + {rootFolders.map((folder) => this.renderFolderNode(folder, 0))} + + {/* Top-level files NEXT */} + {rootFiles.map((file) => this.renderFileNode(file, 0))} + + ); + } + + render() { + const { + loading, + onRefresh, + onOpenNewDialog, + searchQuery, + onSearchChange, + files, + } = this.props; + + const { menuAnchor, menuFile, isDraggingOver } = this.state; + const isText = menuFile?.isText; + + return ( + + {/* Hidden Upload Input */} + + + {/* Drag & Drop Overlay */} + {isDraggingOver && ( + + + + Dateien im VFS ablegen + + + Die Dateien werden sofort auf den Server hochgeladen + + + )} + + {/* Explorer Header / Actions */} + + {/* Top Bar with Action Buttons */} + + + {loading && } + + + + + + + + + + + + + + + + + + + + + + {/* Search Filter Bar */} + ({ + display: 'flex', + alignItems: 'center', + px: 1, + py: 0.25, + borderRadius: 2, + bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', + border: '1px solid transparent', + '&:focus-within': { + borderColor: 'primary.main', + bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.09)' : '#fff', + }, + })} + > + + onSearchChange(e.target.value)} + sx={{ fontSize: '0.82rem' }} + /> + {searchQuery && ( + onSearchChange('')} sx={{ p: 0.2 }}> + + + )} + + + + {/* Scrollable File List */} + + {this.renderTree()} + + + {/* Context Menu */} + + {isText && ( + { + if (menuFile) this.props.onSelectFile(menuFile.name); + this.handleCloseMenu(); + }} + > + + + + + + )} + + {!isText && ( + { + if (menuFile) this.props.onSelectFile(menuFile.name); + this.handleCloseMenu(); + }} + > + + + + + + )} + + { + if (menuFile) this.props.onDownload(menuFile.name); + this.handleCloseMenu(); + }} + > + + + + + + + this.handleCopyPath(menuFile)}> + + + + + + + {this.props.onMentionInChat && ( + this.handleMentionInChat(menuFile)}> + + + + + + )} + + { + if (menuFile && window.confirm(`Möchtest du "${menuFile.name}" wirklich aus dem VFS löschen?`)) { + this.props.onDelete(menuFile.name); + } + this.handleCloseMenu(); + }} + sx={{ color: 'error.main' }} + > + + + + + + + + ); + } +} diff --git a/src/components/vfs/VfsFileIcon.jsx b/src/components/vfs/VfsFileIcon.jsx new file mode 100644 index 0000000..5abdb78 --- /dev/null +++ b/src/components/vfs/VfsFileIcon.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded'; +import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded'; +import ImageRoundedIcon from '@mui/icons-material/ImageRounded'; +import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; +import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; +import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded'; +import ArticleRoundedIcon from '@mui/icons-material/ArticleRounded'; +import { getVfsCategory } from './vfsUtils'; + +const CATEGORY_CONFIG = { + skill: { + icon: AutoAwesomeRoundedIcon, + color: '#9c27b0', // Purple + label: 'Skill', + }, + code: { + icon: CodeRoundedIcon, + color: '#e65100', // Deep Orange + label: 'Code', + }, + table: { + icon: TableChartRoundedIcon, + color: '#2e7d32', // Green + label: 'Tabelle', + }, + pdf: { + icon: PictureAsPdfRoundedIcon, + color: '#d32f2f', // Red + label: 'PDF', + }, + image: { + icon: ImageRoundedIcon, + color: '#0288d1', // Light Blue / Cyan + label: 'Bild', + }, + markdown: { + icon: ArticleRoundedIcon, + color: '#1976d2', // Blue + label: 'Markdown', + }, + text: { + icon: DescriptionRoundedIcon, + color: '#546e7a', // Blue Grey + label: 'Text', + }, + binary: { + icon: InsertDriveFileRoundedIcon, + color: '#757575', // Grey + label: 'Datei', + }, +}; + +export default function VfsFileIcon({ fileName, mime, sx = {}, fontSize = 'small' }) { + const category = getVfsCategory(fileName, mime); + const cfg = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.binary; + const IconComponent = cfg.icon; + + return ( + + + + ); +} + +export function getCategoryBadge(fileName, mime) { + const category = getVfsCategory(fileName, mime); + return CATEGORY_CONFIG[category] || CATEGORY_CONFIG.binary; +} diff --git a/src/components/vfs/VfsFilePreview.jsx b/src/components/vfs/VfsFilePreview.jsx new file mode 100644 index 0000000..e1f509b --- /dev/null +++ b/src/components/vfs/VfsFilePreview.jsx @@ -0,0 +1,232 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Chip from '@mui/material/Chip'; +import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded'; +import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; + +import VfsFileIcon, { getCategoryBadge } from './VfsFileIcon'; +import { formatVfsSize, formatVfsDate } from './vfsUtils'; + +export default class VfsFilePreview extends PureComponent { + renderImagePreview(url) { + return ( + + + + ); + } + + renderPdfPreview(url, fileName) { + return ( + + + + PDF-Dokument + + + Klicke unten, um das Dokument in einem neuen Tab zu öffnen oder herunterzuladen. + + + + + + + ); + } + + renderBinaryPreview(fileName, file) { + return ( + + + + Binärdatei + + + Dieser Dateityp kann nicht direkt im Browser bearbeitet werden. + + + + ); + } + + render() { + const { fileName, file, onClose, onDownload, onMentionInChat } = this.props; + const badge = getCategoryBadge(fileName, file?.mime); + const vfsUrl = `/api/vfs/${encodeURIComponent(fileName).replace(/%2F/g, '/')}?raw=1`; + + return ( + + {/* Preview Header */} + + + + + + + + + + {fileName} + + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', + color: badge.color, + }} + /> + + + + {onMentionInChat && ( + + + + + + )} + + + + + + + + + + + + + + + + {/* Preview Content */} + {file?.isImage && this.renderImagePreview(vfsUrl)} + {file?.isPdf && this.renderPdfPreview(vfsUrl, fileName)} + {!file?.isImage && !file?.isPdf && this.renderBinaryPreview(fileName, file)} + + {/* Footer info bar */} + + {formatVfsSize(file?.size)} + {file?.mime || 'application/octet-stream'} + {file?.updated_at && {formatVfsDate(file.updated_at)}} + + + ); + } +} diff --git a/src/components/vfs/VfsNewFileDialog.jsx b/src/components/vfs/VfsNewFileDialog.jsx new file mode 100644 index 0000000..bc51a46 --- /dev/null +++ b/src/components/vfs/VfsNewFileDialog.jsx @@ -0,0 +1,151 @@ +import React, { PureComponent } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Typography from '@mui/material/Typography'; +import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded'; + +const COMMON_EXTENSIONS = [ + { ext: '.txt', label: 'Text (.txt)' }, + { ext: '.md', label: 'Markdown (.md)' }, + { ext: '.csv', label: 'CSV (.csv)' }, + { ext: '.json', label: 'JSON (.json)' }, + { ext: '.js', label: 'JavaScript (.js)' }, + { ext: '.html', label: 'HTML (.html)' }, + { ext: '.SKILL', label: 'Agent Skill (.SKILL)' }, +]; + +export default class VfsNewFileDialog extends PureComponent { + constructor(props) { + super(props); + this.state = { + filename: '', + error: '', + }; + this.inputRef = React.createRef(); + } + + componentDidUpdate(prevProps) { + if (!prevProps.open && this.props.open) { + this.setState({ filename: '', error: '' }, () => { + setTimeout(() => this.inputRef.current?.focus(), 80); + }); + } + } + + handleExtensionClick = (ext) => { + let name = this.state.filename.trim(); + if (!name) { + name = `neue_datei${ext}`; + } else { + // If it already has an extension, replace it + const lastDot = name.lastIndexOf('.'); + if (lastDot > 0) { + name = name.slice(0, lastDot) + ext; + } else { + name = name + ext; + } + } + this.setState({ filename: name, error: '' }); + }; + + handleSubmit = (e) => { + e?.preventDefault(); + const clean = this.state.filename.trim().replace(/^[\/\\]+/, ''); + if (!clean) { + this.setState({ error: 'Bitte gib einen Dateinamen ein.' }); + return; + } + if (clean.includes('..') || clean.includes('\\')) { + this.setState({ error: 'Ungültige Zeichen im Dateinamen.' }); + return; + } + this.props.onCreate(clean); + }; + + render() { + const { open, onClose, isCreating } = this.props; + const { filename, error } = this.state; + + return ( + + + + + Neue VFS-Datei erstellen + + + +
+ + + Dateien werden im virtuellen Dateisystem des aktuellen Mandanten gespeichert. + Verwende ggf. Unterordner wie scripts/test.js. + + + this.setState({ filename: e.target.value, error: '' })} + error={Boolean(error)} + helperText={error} + autoFocus + spellCheck={false} + autoCapitalize="off" + /> + + + + Häufige Dateitypen: + + + {COMMON_EXTENSIONS.map((item) => ( + this.handleExtensionClick(item.ext)} + sx={{ fontSize: '0.75rem', fontFamily: 'monospace', borderRadius: 1.5 }} + /> + ))} + + + + + + + + +
+
+ ); + } +} diff --git a/src/components/vfs/VfsTextEditor.jsx b/src/components/vfs/VfsTextEditor.jsx new file mode 100644 index 0000000..e397c8c --- /dev/null +++ b/src/components/vfs/VfsTextEditor.jsx @@ -0,0 +1,386 @@ +import React, { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Chip from '@mui/material/Chip'; +import SaveRoundedIcon from '@mui/icons-material/SaveRounded'; +import UndoRoundedIcon from '@mui/icons-material/UndoRounded'; +import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; +import WrapTextRoundedIcon from '@mui/icons-material/WrapTextRounded'; +import Alert from '@mui/material/Alert'; + +import VfsFileIcon from './VfsFileIcon'; +import { formatVfsSize } from './vfsUtils'; + +export default class VfsTextEditor extends PureComponent { + constructor(props) { + super(props); + this.textareaRef = React.createRef(); + this.gutterRef = React.createRef(); + this.state = { + wrapLines: true, + cursorLine: 1, + cursorCol: 1, + }; + } + + componentDidMount() { + this.focusEditor(); + } + + componentDidUpdate(prevProps) { + if (prevProps.fileName !== this.props.fileName) { + this.focusEditor(); + } + } + + focusEditor = () => { + setTimeout(() => { + this.textareaRef.current?.focus(); + }, 50); + }; + + handleScroll = () => { + if (this.textareaRef.current && this.gutterRef.current) { + this.gutterRef.current.scrollTop = this.textareaRef.current.scrollTop; + } + }; + + toggleWrap = () => { + this.setState((prev) => ({ wrapLines: !prev.wrapLines })); + }; + + handleCursorMove = () => { + const ta = this.textareaRef.current; + if (!ta) return; + const pos = ta.selectionStart || 0; + const textBefore = (this.props.content || '').substring(0, pos); + const lines = textBefore.split('\n'); + const line = lines.length; + const col = lines[lines.length - 1].length + 1; + if (this.state.cursorLine !== line || this.state.cursorCol !== col) { + this.setState({ cursorLine: line, cursorCol: col }); + } + }; + + handleKeyDown = (e) => { + // Ctrl+S or Cmd+S -> Save + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { + e.preventDefault(); + if (this.props.isDirty && !this.props.isSaving) { + this.props.onSave(); + } + return; + } + + // Tab key -> Insert 2 spaces + if (e.key === 'Tab') { + e.preventDefault(); + const ta = this.textareaRef.current; + if (!ta) return; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const val = this.props.content; + const next = val.substring(0, start) + ' ' + val.substring(end); + this.props.onChange(next); + setTimeout(() => { + ta.selectionStart = ta.selectionEnd = start + 2; + }, 0); + } + }; + + handleRevert = () => { + if (window.confirm('Möchtest du alle ungespeicherten Änderungen verwerfen?')) { + this.props.onChange(this.props.originalContent); + } + }; + + renderLineNumbers(lineCount) { + const lines = []; + for (let i = 1; i <= lineCount; i++) { + lines.push( +
+ {i} +
+ ); + } + return lines; + } + + render() { + const { + fileName, + file, + content, + isDirty, + isSaving, + saveError, + loadingContent, + onSave, + onClose, + onDownload, + onMentionInChat, + } = this.props; + + const lineCount = (content || '').split('\n').length; + const charCount = (content || '').length; + + return ( + + theme.palette.mode === 'dark' ? '#14171a' : '#f8f9fa', + overflow: 'hidden', + }} + > + {/* Editor Top Bar */} + + {/* File Name & Status */} + + + + + + + + + {fileName} + + + {isDirty ? ( + + ) : ( + + )} + + + {/* Action Buttons */} + + {onMentionInChat && ( + + + + + + )} + + + + + + + + {isDirty && ( + + + + + + )} + + + + + + + + + + + + + + + + + + {/* Error notification if save failed */} + {saveError && ( + + {saveError} + + )} + + {/* Editor Area */} + + {loadingContent ? ( + + + Lade Dateiinhalt… + + ) : ( + <> + {/* Line Numbers Gutter (shown when wrap is off to maintain line alignment) */} + {!this.state.wrapLines && ( + ({ + width: 44, + flexShrink: 0, + bgcolor: theme.palette.mode === 'dark' ? '#0f1215' : '#f0f2f5', + color: 'text.disabled', + fontFamily: 'Consolas, Monaco, "Courier New", monospace', + fontSize: '13px', + textAlign: 'right', + pr: 1.2, + pt: 1.5, + pb: 3, + userSelect: 'none', + overflow: 'hidden', + borderRight: 1, + borderColor: 'divider', + })} + > + {this.renderLineNumbers(lineCount)} + + )} + + {/* Textarea Code Input */} + { + this.props.onChange(e.target.value); + this.handleCursorMove(); + }} + onClick={this.handleCursorMove} + onKeyUp={this.handleCursorMove} + onSelect={this.handleCursorMove} + onScroll={this.handleScroll} + onKeyDown={this.handleKeyDown} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + wrap={this.state.wrapLines ? 'soft' : 'off'} + sx={(theme) => ({ + flexGrow: 1, + height: '100%', + p: 1.5, + border: 'none', + outline: 'none', + resize: 'none', + bgcolor: 'transparent', + color: 'text.primary', + fontFamily: 'Consolas, Monaco, "Courier New", monospace', + fontSize: '13px', + lineHeight: '21px', + tabSize: 2, + whiteSpace: this.state.wrapLines ? 'pre-wrap' : 'pre', + wordBreak: this.state.wrapLines ? 'break-word' : 'normal', + overflowWrap: this.state.wrapLines ? 'anywhere' : 'normal', + overflowX: this.state.wrapLines ? 'hidden' : 'auto', + overflowY: 'auto', + '&:focus': { + outline: 'none', + }, + })} + /> + + )} + + + {/* Status Bar */} + + + + Zeile {this.state.cursorLine}, Spalte {this.state.cursorCol} + + {lineCount} Zeilen + {charCount} Zeichen + {formatVfsSize(charCount)} + + + + UTF-8 + {file?.mime || 'text/plain'} + + + + ); + } +} diff --git a/src/components/vfs/index.js b/src/components/vfs/index.js new file mode 100644 index 0000000..270fc40 --- /dev/null +++ b/src/components/vfs/index.js @@ -0,0 +1,6 @@ +export { default as VfsEditorPanel } from './VfsEditorPanel'; +export { default as VfsFileExplorer } from './VfsFileExplorer'; +export { default as VfsTextEditor } from './VfsTextEditor'; +export { default as VfsFilePreview } from './VfsFilePreview'; +export { default as VfsFileIcon } from './VfsFileIcon'; +export * from './vfsUtils'; diff --git a/src/components/vfs/vfsUtils.js b/src/components/vfs/vfsUtils.js new file mode 100644 index 0000000..70f0788 --- /dev/null +++ b/src/components/vfs/vfsUtils.js @@ -0,0 +1,97 @@ +/** + * @file vfsUtils.js + * @description Helper functions for VFS file types, formatting, and path manipulation. + */ + +/** + * Format bytes to human readable string. + * @param {number} bytes + * @returns {string} + */ +export function formatVfsSize(bytes) { + if (bytes == null || Number.isNaN(bytes) || bytes < 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Format date string into local compact date/time. + * @param {string} isoString + * @returns {string} + */ +export function formatVfsDate(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString([], { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return ''; + } +} + +/** + * Detect category of file. + * @param {string} filePath + * @param {string} [mime] + * @returns {'code'|'table'|'markdown'|'text'|'pdf'|'image'|'skill'|'binary'} + */ +export function getVfsCategory(filePath, mime = '') { + const p = String(filePath || '').toLowerCase(); + const m = String(mime || '').toLowerCase(); + + if (p.endsWith('.skill')) return 'skill'; + if (p.endsWith('.pdf') || m === 'application/pdf') return 'pdf'; + if ( + m.startsWith('image/') || + /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/.test(p) + ) { + return 'image'; + } + if ( + p.endsWith('.csv') || + p.endsWith('.tsv') || + p.endsWith('.xlsx') || + p.endsWith('.xls') || + m.includes('csv') || + m.includes('spreadsheet') + ) { + return 'table'; + } + if (p.endsWith('.md')) return 'markdown'; + if ( + /\.(js|mjs|cjs|ts|tsx|jsx|json|html|css|scss|xml|sql|py|sh|yaml|yml|env)$/.test(p) || + m === 'application/json' || + m === 'application/javascript' + ) { + return 'code'; + } + if (m.startsWith('text/') || /\.(txt|log|ini|conf)$/.test(p)) { + return 'text'; + } + return 'binary'; +} + +/** + * Split a VFS file path into directory and basename. + * @param {string} path + * @returns {{ dir: string, base: string }} + */ +export function splitVfsPath(path) { + const clean = String(path || '').replace(/^[\/\\]+/, ''); + const lastSlash = clean.lastIndexOf('/'); + if (lastSlash === -1) { + return { dir: '', base: clean }; + } + return { + dir: clean.slice(0, lastSlash), + base: clean.slice(lastSlash + 1), + }; +}