| import { render } from 'solid-js/web'; | |
| import { createSignal, onMount, For, Show, onCleanup, createEffect } from 'solid-js'; | |
| import hljs from 'highlight.js/lib/core'; | |
| import javascript_hljs from 'highlight.js/lib/languages/javascript'; | |
| import typescript_hljs from 'highlight.js/lib/languages/typescript'; | |
| import json_hljs from 'highlight.js/lib/languages/json'; | |
| import xml_hljs from 'highlight.js/lib/languages/xml'; | |
| import css_hljs from 'highlight.js/lib/languages/css'; | |
| import bash_hljs from 'highlight.js/lib/languages/bash'; | |
| import markdown_hljs from 'highlight.js/lib/languages/markdown'; | |
| import sql_hljs from 'highlight.js/lib/languages/sql'; | |
| import { EditorView, keymap, lineNumbers } from '@codemirror/view'; | |
| import { EditorState, Compartment } from '@codemirror/state'; | |
| import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; | |
| import { syntaxHighlighting, defaultHighlightStyle, foldGutter, foldKeymap } from '@codemirror/language'; | |
| import { lintGutter } from "@codemirror/lint"; | |
| import { oneDark } from '@codemirror/theme-one-dark'; | |
| import { javascript } from '@codemirror/lang-javascript'; | |
| import { json } from '@codemirror/lang-json'; | |
| import { html } from '@codemirror/lang-html'; | |
| import { css } from '@codemirror/lang-css'; | |
| import { markdown } from '@codemirror/lang-markdown'; | |
| hljs.registerLanguage('javascript', javascript_hljs); | |
| hljs.registerLanguage('typescript', typescript_hljs); | |
| hljs.registerLanguage('json', json_hljs); | |
| hljs.registerLanguage('html', xml_hljs); | |
| hljs.registerLanguage('css', css_hljs); | |
| hljs.registerLanguage('bash', bash_hljs); | |
| hljs.registerLanguage('markdown', markdown_hljs); | |
| hljs.registerLanguage('sql', sql_hljs); | |
| function App() { | |
| const [loading, setLoading] = createSignal(true); | |
| const [status, setStatus] = createSignal(''); | |
| const [notifications, setNotifications] = createSignal([]); | |
| const [userData, setUserData] = createSignal(null); | |
| const [files, setFiles] = createSignal([]); | |
| const [selectedFile, setSelectedFile] = createSignal(null); | |
| const [fileContent, setFileContent] = createSignal(''); | |
| const [newFileName, setNewFileName] = createSignal(''); | |
| const [newFolderName, setNewFolderName] = createSignal(''); | |
| const [openFolders, setOpenFolders] = createSignal({}); | |
| const [contextMenu, setContextMenu] = createSignal({ | |
| visible: false, | |
| x: 0, | |
| y: 0, | |
| file: null, | |
| }); | |
| const [isMobileView, setIsMobileView] = createSignal(false); | |
| const [isEditingFile, setIsEditingFile] = createSignal(true); | |
| const [initialLoadComplete, setInitialLoadComplete] = createSignal(false); | |
| const [renameContainerInfo, setRenameContainerInfo] = createSignal({ | |
| visible: false, | |
| file: null, | |
| newName: '', | |
| }); | |
| const [createItemModalInfo, setCreateItemModalInfo] = createSignal({ | |
| visible: false, | |
| parentPath: null, | |
| isDir: false, | |
| itemName: '', | |
| }); | |
| let renameInputRef; | |
| let codeRef; | |
| let createItemInputRef; | |
| let editorRef; | |
| let editorViewInstance = null; | |
| const languageCompartment = new Compartment(); | |
| const themeCompartment = new Compartment(); | |
| const editableCompartment = new Compartment(); | |
| const linkManager = '/private/server/exocore/web/file'; | |
| const getToken = () => localStorage.getItem('exocore-token') || ''; | |
| const getCookies = () => localStorage.getItem('exocore-cookies') || ''; | |
| const addNotification = (message, type = 'info', duration = 4000) => { | |
| const id = Date.now(); | |
| setNotifications(prev => [...prev, { id, message, type }]); | |
| setTimeout(() => { | |
| setNotifications(prev => prev.filter(n => n.id !== id)); | |
| }, duration); | |
| }; | |
| function sortFileSystemItems(items) { | |
| if (!Array.isArray(items)) return []; | |
| const specialOrder = ['.git', 'package.json', 'package-lock.json']; | |
| const nodeModulesName = 'node_modules'; | |
| const regularItems = items.filter(item => !specialOrder.includes(item.name) && item.name !== nodeModulesName); | |
| const specialItemsOnList = items.filter(item => specialOrder.includes(item.name)); | |
| const nodeModulesItem = items.find(item => item.name === nodeModulesName); | |
| regularItems.sort((a, b) => { | |
| if (a.isDir && !b.isDir) return -1; | |
| if (!a.isDir && b.isDir) return 1; | |
| return a.name.localeCompare(b.name); | |
| }); | |
| specialItemsOnList.sort((a, b) => specialOrder.indexOf(a.name) - specialOrder.indexOf(b.name)); | |
| const sortedItems = [...regularItems, ...specialItemsOnList]; | |
| if (nodeModulesItem) { | |
| sortedItems.push(nodeModulesItem); | |
| } | |
| return sortedItems; | |
| } | |
| async function fetchUserInfo() { | |
| setLoading(true); | |
| const token = getToken(); | |
| const cookies = getCookies(); | |
| if (!token || !cookies) { | |
| setLoading(false); | |
| setInitialLoadComplete(true); | |
| window.location.href = '/private/server/exocore/web/public/login'; | |
| return; | |
| } | |
| try { | |
| const res = await fetch('/private/server/exocore/web/userinfo', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ token, cookies }), | |
| }); | |
| if (!res.ok) { | |
| let errorMsg = `Server error: ${res.status}`; | |
| try { | |
| const errorData = await res.json(); | |
| errorMsg = errorData.message || errorMsg; | |
| } catch (parseError) {} | |
| throw new Error(errorMsg); | |
| } | |
| const data = await res.json(); | |
| if (data.data?.user && data.data.user.verified === 'success') { | |
| setUserData(data.data.user); | |
| await fetchFiles(''); | |
| } else { | |
| setUserData(null); | |
| const redirectMsg = data.message || 'User verification failed. Redirecting to login...'; | |
| setStatus(redirectMsg); | |
| localStorage.removeItem('exocore-token'); | |
| localStorage.removeItem('exocore-cookies'); | |
| setTimeout(() => { | |
| window.location.href = '/private/server/exocore/web/public/login'; | |
| }, 2500); | |
| } | |
| } catch (err) { | |
| setUserData(null); | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| const redirectMsg = 'Failed to fetch user info: ' + errorMessage + '. Redirecting to login...'; | |
| setStatus(redirectMsg); | |
| localStorage.removeItem('exocore-token'); | |
| localStorage.removeItem('exocore-cookies'); | |
| setTimeout(() => { | |
| window.location.href = '/private/server/exocore/web/public/login'; | |
| }, 2500); | |
| } finally { | |
| setLoading(false); | |
| setInitialLoadComplete(true); | |
| } | |
| } | |
| async function fetchFiles(currentPath = '') { | |
| setLoading(true); | |
| let endpoint = ''; | |
| let bodyPayload = {}; | |
| if (currentPath) { | |
| endpoint = `${linkManager}/open-folder`; | |
| bodyPayload = { | |
| folder: currentPath, | |
| }; | |
| } else { | |
| endpoint = `${linkManager}/list`; | |
| bodyPayload = {}; | |
| } | |
| try { | |
| const res = await fetch(endpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(bodyPayload), | |
| }); | |
| const responseText = await res.text(); | |
| if (!res.ok) { | |
| let errorMsg = responseText; | |
| try { | |
| const errData = JSON.parse(responseText); | |
| errorMsg = errData.message || errData.error || responseText; | |
| } catch (e) {} | |
| throw new Error(errorMsg || `HTTP error! status: ${res.status}`); | |
| } | |
| const data = JSON.parse(responseText); | |
| if (currentPath) { | |
| if (data && Array.isArray(data.items)) { | |
| setOpenFolders((prev) => ({ | |
| ...prev, | |
| [currentPath]: sortFileSystemItems(data.items), | |
| })); | |
| } else { | |
| setOpenFolders((prev) => ({ ...prev, [currentPath]: [] })); | |
| addNotification(`Error: Could not load content for folder ${currentPath}.`, 'error'); | |
| } | |
| } else { | |
| if (Array.isArray(data)) { | |
| setFiles(sortFileSystemItems(data)); | |
| } else { | |
| setFiles([]); | |
| addNotification(`Error: Could not load root directory.`, 'error'); | |
| } | |
| } | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification(`Failed to list ${currentPath || 'root'}: ${errorMessage}`, 'error'); | |
| if (currentPath) { | |
| setOpenFolders((prev) => ({ ...prev, [currentPath]: undefined })); | |
| } | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function openFile(file) { | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`${linkManager}/open`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| file, | |
| }), | |
| }); | |
| const text = await res.text(); | |
| if (!res.ok) throw new Error(text || `HTTP error! status: ${res.status}`); | |
| setSelectedFile(file); | |
| setFileContent(text); | |
| setIsEditingFile(true); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification('Failed to open file: ' + errorMessage, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| function closeFileEditor() { | |
| setSelectedFile(null); | |
| setFileContent(''); | |
| } | |
| async function saveFile() { | |
| if (!selectedFile()) return; | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`${linkManager}/save`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| file: selectedFile(), | |
| content: fileContent(), | |
| }), | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) throw new Error(message || `HTTP error! status: ${res.status}`); | |
| addNotification(message, 'success'); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification('Failed to save file: ' + errorMessage, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function refreshFileSystem(affectedItemPath = '') { | |
| let parentPath = ''; | |
| if (affectedItemPath) { | |
| const lastSlashIndex = affectedItemPath.lastIndexOf('/'); | |
| if (lastSlashIndex !== -1) { | |
| parentPath = affectedItemPath.substring(0, lastSlashIndex); | |
| } | |
| } | |
| await fetchFiles(); | |
| if (parentPath && openFolders()[parentPath]) { | |
| await fetchFiles(parentPath); | |
| } | |
| } | |
| function fileOrFolderNameIsDirectory(path) { | |
| if (openFolders()[path] !== undefined) return true; | |
| const checkList = (list, currentBuildPath = '') => { | |
| for (const item of list) { | |
| const itemFullPath = currentBuildPath ? `${currentBuildPath}/${item.name}` : item.name; | |
| if (itemFullPath === path) return item.isDir; | |
| if (item.isDir && openFolders()[itemFullPath]) { | |
| const foundInSub = checkList(openFolders()[itemFullPath], itemFullPath); | |
| if (foundInSub !== undefined) return foundInSub; | |
| } | |
| } | |
| return undefined; | |
| }; | |
| return checkList(files()) || false; | |
| } | |
| async function createFile() { | |
| const name = newFileName().trim(); | |
| if (!name) { | |
| addNotification('Please enter a file name.', 'error'); | |
| return; | |
| } | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`${linkManager}/create`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ file: name }), | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) throw new Error(message || `HTTP error! status: ${res.status}`); | |
| setNewFileName(''); | |
| await refreshFileSystem(name); | |
| addNotification(`File "${name}" created successfully.`, 'success'); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification('Failed to create file: ' + errorMessage, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function createFolder() { | |
| const name = newFolderName().trim(); | |
| if (!name) { | |
| addNotification('Please enter a folder name.', 'error'); | |
| return; | |
| } | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`${linkManager}/create-folder`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ folder: name }), | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) throw new Error(message || `HTTP error! status: ${res.status}`); | |
| setNewFolderName(''); | |
| await refreshFileSystem(name); | |
| addNotification(`Folder "${name}" created successfully.`, 'success'); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification('Failed to create folder: ' + errorMessage, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function uploadFile(e) { | |
| const fileToUpload = e.target.files[0]; | |
| if (!fileToUpload) return; | |
| const targetPathForUpload = fileToUpload.name; | |
| const form = new FormData(); | |
| form.append('file', fileToUpload); | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`${linkManager}/upload`, { | |
| method: 'POST', | |
| body: form, | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) throw new Error(message || `HTTP error! status: ${res.status}`); | |
| await refreshFileSystem(targetPathForUpload); | |
| addNotification(`File "${fileToUpload.name}" uploaded successfully.`, 'success'); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification('Failed to upload file: ' + errorMessage, 'error'); | |
| } finally { | |
| setLoading(false); | |
| e.target.value = null; | |
| } | |
| } | |
| function download(file) { | |
| const form = document.createElement('form'); | |
| form.method = 'POST'; | |
| form.action = `${linkManager}/download`; | |
| form.style.display = 'none'; | |
| const input = document.createElement('input'); | |
| input.name = 'file'; | |
| input.value = file; | |
| input.type = 'hidden'; | |
| form.appendChild(input); | |
| document.body.appendChild(form); | |
| form.submit(); | |
| document.body.removeChild(form); | |
| addNotification(`Downloading "${file}"...`, 'info'); | |
| } | |
| function toggleFolder(folderPath) { | |
| if (openFolders()[folderPath]) { | |
| setOpenFolders((prev) => { | |
| const updated = { ...prev }; | |
| delete updated[folderPath]; | |
| Object.keys(updated).forEach(key => { | |
| if (key.startsWith(folderPath + '/')) { | |
| delete updated[key]; | |
| } | |
| }); | |
| return updated; | |
| }); | |
| } else { | |
| fetchFiles(folderPath); | |
| } | |
| } | |
| function handleFileClick(file, fullPath) { | |
| if (file.isDir) { | |
| toggleFolder(fullPath); | |
| } else { | |
| openFile(fullPath); | |
| } | |
| } | |
| function handleContextMenu(e, file, fullPath) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setContextMenu({ | |
| visible: true, | |
| x: e.clientX, | |
| y: e.clientY, | |
| file: { ...file, path: fullPath }, | |
| }); | |
| } | |
| function handleOpenFolderFromContextMenu() { | |
| const folderToOpen = contextMenu().file; | |
| if (folderToOpen && folderToOpen.isDir) { | |
| if (!openFolders()[folderToOpen.path]) { | |
| fetchFiles(folderToOpen.path); | |
| } | |
| } | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| } | |
| function handleDownloadSelected() { | |
| if (contextMenu().file && !contextMenu().file.isDir) { | |
| download(contextMenu().file.path); | |
| } | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| } | |
| async function handleUnzipSelected() { | |
| const fileToUnzip = contextMenu().file; | |
| if (!fileToUnzip || fileToUnzip.isDir || !fileToUnzip.name.toLowerCase().endsWith('.zip')) { | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| return; | |
| } | |
| const zipFilePath = fileToUnzip.path; | |
| setLoading(true); | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| try { | |
| const res = await fetch(`${linkManager}/unzip`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| zipFilePath: zipFilePath, | |
| overwrite: true, | |
| destinationPath: '', | |
| }), | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) { | |
| let errorMsg = message; | |
| try { | |
| const errData = JSON.parse(message); | |
| errorMsg = errData.message || errData.error || message; | |
| } catch (e) { } | |
| throw new Error(errorMsg || `HTTP error! status: ${res.status}`); | |
| } | |
| addNotification(message, 'success'); | |
| await refreshFileSystem(zipFilePath); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification(`Failed to unzip "${fileToUnzip.name}": ${errorMessage}`, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| function handleRenameClick() { | |
| if (contextMenu().file) { | |
| setRenameContainerInfo({ | |
| visible: true, | |
| file: contextMenu().file, | |
| newName: contextMenu().file.name, | |
| }); | |
| } | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| } | |
| async function handleDeleteSelected() { | |
| const fileToDelete = contextMenu().file; | |
| if (!fileToDelete) return; | |
| const confirmation = window.confirm(`Are you sure you want to delete "${fileToDelete.name}"? This action cannot be undone.`); | |
| if (!confirmation) { | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| return; | |
| } | |
| setLoading(true); | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| try { | |
| const res = await fetch(`${linkManager}/delete`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ path: fileToDelete.path }), | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) { | |
| let errorMsg = message; | |
| try { | |
| const errData = JSON.parse(message); | |
| errorMsg = errData.message || errData.error || message; | |
| } catch (e) { } | |
| throw new Error(errorMsg || `HTTP error! status: ${res.status}`); | |
| } | |
| addNotification(message || `Successfully deleted "${fileToDelete.name}".`, 'success'); | |
| if (selectedFile() && selectedFile().startsWith(fileToDelete.path)) { | |
| closeFileEditor(); | |
| } | |
| if (fileToDelete.isDir) { | |
| setOpenFolders((prev) => { | |
| const updated = { ...prev }; | |
| delete updated[fileToDelete.path]; | |
| Object.keys(updated).forEach(key => { | |
| if (key.startsWith(fileToDelete.path + '/')) { | |
| delete updated[key]; | |
| } | |
| }); | |
| return updated; | |
| }); | |
| } | |
| await refreshFileSystem(fileToDelete.path); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification(`Failed to delete "${fileToDelete.name}": ${errorMessage}`, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| function cancelRename() { | |
| setRenameContainerInfo({ visible: false, file: null, newName: '' }); | |
| } | |
| async function performRename() { | |
| const fileToRename = renameContainerInfo().file; | |
| const newName = renameContainerInfo().newName.trim(); | |
| if (!fileToRename || !newName) { | |
| addNotification('Invalid rename operation. New name cannot be empty.', 'error'); | |
| cancelRename(); | |
| return; | |
| } | |
| setLoading(true); | |
| try { | |
| const oldPath = fileToRename.path; | |
| const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/')); | |
| const newPath = parentPath ? `${parentPath}/${newName}` : newName; | |
| if (oldPath === newPath) { | |
| addNotification('No change detected. Renaming cancelled.', 'info'); | |
| cancelRename(); | |
| setLoading(false); | |
| return; | |
| } | |
| const res = await fetch(`${linkManager}/rename`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ oldPath, newPath }), | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) { | |
| let errorMsg = message; | |
| try { | |
| const errData = JSON.parse(message); | |
| errorMsg = errData.message || errData.error || message; | |
| } catch (e) { } | |
| throw new Error(errorMsg || `HTTP error! status: ${res.status}`); | |
| } | |
| addNotification(`Renamed "${fileToRename.name}" to "${newName}" successfully.`, 'success'); | |
| if (fileToRename.isDir && openFolders()[oldPath]) { | |
| const contents = openFolders()[oldPath]; | |
| setOpenFolders((prev) => { | |
| const updated = { ...prev }; | |
| delete updated[oldPath]; | |
| updated[newPath] = contents; | |
| Object.keys(updated).forEach(key => { | |
| if (key.startsWith(oldPath + '/')) { | |
| const subPath = key.substring(oldPath.length); | |
| const oldSubOpenFolderContent = updated[key]; | |
| delete updated[key]; | |
| updated[newPath + subPath] = oldSubOpenFolderContent; | |
| } | |
| }); | |
| return updated; | |
| }); | |
| } | |
| if (selectedFile() === oldPath) { | |
| setSelectedFile(newPath); | |
| } | |
| await refreshFileSystem(newPath); | |
| cancelRename(); | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification('Failed to rename: ' + errorMessage, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| function handleShowCreateItemModal(isDirContext) { | |
| const contextFile = contextMenu().file; | |
| let parentPathTarget = ''; | |
| if (contextFile) { | |
| if (contextFile.isDir) { | |
| parentPathTarget = contextFile.path; | |
| } else { | |
| const lastSlash = contextFile.path.lastIndexOf('/'); | |
| parentPathTarget = lastSlash === -1 ? '' : contextFile.path.substring(0, lastSlash); | |
| } | |
| } | |
| setCreateItemModalInfo({ | |
| visible: true, | |
| parentPath: parentPathTarget, | |
| isDir: isDirContext, | |
| itemName: '', | |
| }); | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| } | |
| function cancelCreateItem() { | |
| setCreateItemModalInfo({ visible: false, parentPath: null, isDir: false, itemName: '' }); | |
| } | |
| async function performCreateItem() { | |
| const { parentPath, itemName, isDir } = createItemModalInfo(); | |
| const newItemNameTrimmed = itemName.trim(); | |
| if (!newItemNameTrimmed) { | |
| addNotification(`Please enter a ${isDir ? 'folder' : 'file'} name.`, 'error'); | |
| return; | |
| } | |
| const fullPath = parentPath ? `${parentPath}/${newItemNameTrimmed}` : newItemNameTrimmed; | |
| setLoading(true); | |
| try { | |
| const endpoint = isDir ? `${linkManager}/create-folder` : `${linkManager}/create`; | |
| const payload = isDir ? { folder: fullPath } : { file: fullPath }; | |
| const res = await fetch(endpoint, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const message = await res.text(); | |
| if (!res.ok) { | |
| let errorMsg = message; | |
| try { | |
| const errData = JSON.parse(message); | |
| errorMsg = errData.message || errData.error || message; | |
| } catch (e) { } | |
| throw new Error(errorMsg || `HTTP error! status: ${res.status}`); | |
| } | |
| addNotification(`${isDir ? 'Folder' : 'File'} "${newItemNameTrimmed}" created successfully in "${parentPath || 'root'}".`, 'success'); | |
| cancelCreateItem(); | |
| await refreshFileSystem(fullPath); | |
| if (parentPath) { | |
| await fetchFiles(parentPath); | |
| } | |
| } catch (err) { | |
| const errorMessage = err instanceof Error ? err.message : String(err); | |
| addNotification(`Failed to create ${isDir ? 'folder' : 'file'}: ${errorMessage}`, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| function getFileIconPath(fileItem, isFolderOpen) { | |
| const baseIconPath = './icons/'; | |
| const nameLower = fileItem.name.toLowerCase(); | |
| if (fileItem.isDir) { | |
| if (nameLower === '.git') { | |
| return `${baseIconPath}git.svg`; | |
| } | |
| return isFolderOpen ? `${baseIconPath}folder-open.svg` : `${baseIconPath}folder.svg`; | |
| } | |
| if (nameLower === 'exocore.run') { return `${baseIconPath}exocore.run.svg`; } | |
| if (nameLower === '.gitignore' || nameLower === '.gitattributes' || nameLower === '.gitmodules') { | |
| return `${baseIconPath}git.svg`; | |
| } | |
| const parts = nameLower.split('.'); | |
| let extension = ''; | |
| if (parts.length > 1) { | |
| const potentialExtension = parts.pop(); | |
| if (parts[0] !== '' || parts.length > 0) { | |
| if (potentialExtension !== undefined) { | |
| extension = potentialExtension; | |
| } | |
| } else if (potentialExtension !== undefined) { | |
| extension = potentialExtension; | |
| } | |
| } | |
| switch (extension) { | |
| case 'js': return `${baseIconPath}js.svg`; | |
| case 'jsx': return `${baseIconPath}jsx.svg`; | |
| case 'ts': return `${baseIconPath}ts.svg`; | |
| case 'tsx': return `${baseIconPath}tsx.svg`; | |
| case 'json': return `${baseIconPath}json.svg`; | |
| case 'xml': return `${baseIconPath}xml.svg`; | |
| case 'html': return `${baseIconPath}html.svg`; | |
| case 'css': return `${baseIconPath}css.svg`; | |
| case 'md': return `${baseIconPath}md.svg`; | |
| case 'sh': return `${baseIconPath}sh.svg`; | |
| case 'sql': return `${baseIconPath}sql.svg`; | |
| case 'zip': return `${baseIconPath}zip.svg`; | |
| case 'gif': return `${baseIconPath}gifImage.svg`; | |
| case 'jpg': | |
| case 'jpeg': | |
| case 'png': | |
| return `${baseIconPath}image.svg`; | |
| case 'mp4': | |
| case 'mov': | |
| case 'avi': | |
| case 'mkv': | |
| case 'webm': | |
| case 'flv': | |
| case 'wmv': | |
| return `${baseIconPath}video.svg`; | |
| case 'git': | |
| return `${baseIconPath}git.svg`; | |
| default: | |
| return `${baseIconPath}undefined.svg`; | |
| } | |
| } | |
| function renderFiles(list, parentPath = '') { | |
| return ( | |
| <ul style={{ 'margin-left': '1rem', 'padding-left': '0', 'list-style-type': 'none' }}> | |
| <For each={list}> | |
| {(file) => { | |
| const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name; | |
| const isDirOpen = file.isDir && openFolders()[fullPath]; | |
| const iconPath = getFileIconPath(file, isDirOpen); | |
| let listItemRef; | |
| const baseItemStyle = { | |
| 'user-select': 'none', | |
| padding: '0.2rem 0.1rem', | |
| 'border-radius': theme.borderRadius, | |
| transition: 'background-color 0.2s', | |
| 'margin-bottom': '2px', | |
| }; | |
| return ( | |
| <li | |
| ref={listItemRef} | |
| style={baseItemStyle} | |
| onMouseEnter={() => { if (listItemRef) listItemRef.style.backgroundColor = theme.itemHoverBg; }} | |
| onMouseLeave={() => { if (listItemRef) listItemRef.style.backgroundColor = 'transparent'; }} | |
| title={fullPath} | |
| > | |
| <div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between' }}> | |
| <div | |
| onClick={() => handleFileClick(file, fullPath)} | |
| style={{ flexGrow: 1, display: 'flex', 'align-items': 'center', cursor: 'pointer', padding: '0.3rem 0.2rem' }} | |
| > | |
| <img src={iconPath} alt={file.isDir ? 'Folder' : 'File'} style={{ width: '18px', height: '18px', 'margin-right': '0.75rem', 'flex-shrink': 0 }} /> | |
| <span style={{ color: theme.text, 'font-size': '1.05rem' }}>{file.name}</span> | |
| </div> | |
| <span | |
| onClick={(e) => handleContextMenu(e, file, fullPath)} | |
| style={{ cursor: 'pointer', 'font-weight': 'bold', padding: '0 0.5rem', color: theme.textMuted, 'font-size': '1.2rem' }} | |
| onMouseEnter={(e) => e.target.style.color = theme.primary} | |
| onMouseLeave={(e) => e.target.style.color = theme.textMuted} | |
| > ⋮ </span> | |
| </div> | |
| <Show when={file.isDir && openFolders()[fullPath] && Array.isArray(openFolders()[fullPath]) && openFolders()[fullPath].length > 0}> | |
| {renderFiles(openFolders()[fullPath], fullPath)} | |
| </Show> | |
| <Show when={file.isDir && openFolders()[fullPath] && Array.isArray(openFolders()[fullPath]) && openFolders()[fullPath].length === 0}> | |
| <div style={{ 'margin-left': '2rem', padding: '0.3rem 0', color: theme.textMuted, 'font-style': 'italic', 'font-size': '0.9rem' }}>(empty folder)</div> | |
| </Show> | |
| </li> | |
| ); | |
| }} | |
| </For> | |
| </ul> | |
| ); | |
| } | |
| function handleDownloadAll() { | |
| const form = document.createElement('form'); | |
| form.method = 'POST'; | |
| form.action = `${linkManager}/download-zip`; | |
| form.style.display = 'none'; | |
| document.body.appendChild(form); | |
| form.submit(); | |
| document.body.removeChild(form); | |
| addNotification('Downloading all files (root) as ZIP...', 'info'); | |
| } | |
| const getCodeMirrorLanguageSupport = (filename) => { | |
| const extension = filename?.split('.').pop()?.toLowerCase(); | |
| if (!extension) return javascript(); | |
| switch (extension) { | |
| case 'js': case 'jsx': return javascript(); | |
| case 'ts': case 'tsx': return javascript({typescript: true, jsx: true}); | |
| case 'json': return json(); | |
| case 'html': case 'htm': case 'xml': case 'svg': return html(); | |
| case 'css': return css(); | |
| case 'md': case 'markdown': return markdown(); | |
| default: return javascript(); | |
| } | |
| }; | |
| onMount(() => { | |
| const patrickHandFontLink = document.createElement('link'); | |
| patrickHandFontLink.href = 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap'; | |
| patrickHandFontLink.rel = 'stylesheet'; | |
| document.head.appendChild(patrickHandFontLink); | |
| const firaCodeFontLink = document.createElement('link'); | |
| firaCodeFontLink.href = 'https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap'; | |
| firaCodeFontLink.rel = 'stylesheet'; | |
| document.head.appendChild(firaCodeFontLink); | |
| const hljsThemeLink = document.createElement('link'); | |
| hljsThemeLink.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css'; | |
| hljsThemeLink.rel = 'stylesheet'; | |
| document.head.appendChild(hljsThemeLink); | |
| const appElement = document.getElementById('app'); | |
| if (appElement) { | |
| appElement.style.fontFamily = theme.fontFamily; | |
| appElement.style.minHeight = '100vh'; | |
| } | |
| fetchUserInfo(); | |
| const handleClickOutside = (e) => { | |
| const contextMenuElement = document.getElementById('context-menu'); | |
| const createItemModalOverlayElement = document.querySelector('.create-item-modal-overlay'); | |
| const renameModalOverlayElement = document.querySelector('.rename-modal-overlay'); | |
| if (contextMenu().visible && contextMenuElement && !contextMenuElement.contains(e.target)) { | |
| setContextMenu({ visible: false, x: 0, y: 0, file: null }); | |
| } | |
| if (createItemModalInfo().visible && createItemModalOverlayElement && e.target === createItemModalOverlayElement) { | |
| cancelCreateItem(); | |
| } | |
| if (renameContainerInfo().visible && renameModalOverlayElement && e.target === renameModalOverlayElement) { | |
| cancelRename(); | |
| } | |
| }; | |
| document.addEventListener('click', handleClickOutside); | |
| const checkMobile = () => setIsMobileView(window.innerWidth < 768); | |
| checkMobile(); | |
| window.addEventListener('resize', checkMobile); | |
| onCleanup(() => { | |
| window.removeEventListener('resize', checkMobile); | |
| document.removeEventListener('click', handleClickOutside); | |
| if (editorViewInstance) { | |
| editorViewInstance.destroy(); | |
| editorViewInstance = null; | |
| } | |
| }); | |
| }); | |
| createEffect(() => { | |
| const currentFile = selectedFile(); | |
| const editing = isEditingFile(); | |
| const content = fileContent(); | |
| if (editorRef && editing && currentFile) { | |
| const cmLanguageSupport = getCodeMirrorLanguageSupport(currentFile); | |
| if (editorViewInstance) { | |
| if (editorViewInstance.state.doc.toString() !== content) { | |
| editorViewInstance.dispatch({ | |
| changes: { from: 0, to: editorViewInstance.state.doc.length, insert: content || '' } | |
| }); | |
| } | |
| editorViewInstance.dispatch({ | |
| effects: [ | |
| languageCompartment.reconfigure(cmLanguageSupport), | |
| editableCompartment.reconfigure(EditorView.editable.of(true)), | |
| ] | |
| }); | |
| } else { | |
| const state = EditorState.create({ | |
| doc: content || '', | |
| extensions: [ | |
| EditorView.lineWrapping, | |
| history(), | |
| keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap]), | |
| lineNumbers(), | |
| foldGutter(), | |
| lintGutter(), | |
| editableCompartment.of(EditorView.editable.of(true)), | |
| languageCompartment.of(cmLanguageSupport), | |
| themeCompartment.of(oneDark), | |
| syntaxHighlighting(defaultHighlightStyle, { fallback: true }), | |
| EditorView.theme({ | |
| '&': { fontSize: '12px' }, | |
| '.cm-content': { fontFamily: theme.monospaceFontFamily }, | |
| '.cm-gutters': { fontSize: '13px', backgroundColor: '#282c34' }, | |
| '.cm-lineNumbers .cm-gutterElement': { padding: '0 3px 0 5px', minWidth: '20px', textAlign: 'right' } | |
| }), | |
| EditorView.updateListener.of(update => { | |
| if (update.docChanged) { | |
| if (update.transactions.some(tr => tr.isUserEvent('input') || tr.isUserEvent('delete'))) { | |
| setFileContent(update.state.doc.toString()); | |
| } | |
| } | |
| }) | |
| ] | |
| }); | |
| editorViewInstance = new EditorView({ state, parent: editorRef }); | |
| } | |
| } else if (editorViewInstance) { | |
| editorViewInstance.destroy(); | |
| editorViewInstance = null; | |
| } | |
| }); | |
| let hasFocusedRenameInput = false; | |
| createEffect(() => { | |
| if (renameContainerInfo().visible && renameInputRef && !hasFocusedRenameInput) { | |
| setTimeout(() => { | |
| if (renameInputRef) { | |
| renameInputRef.focus(); | |
| renameInputRef.select(); | |
| } | |
| hasFocusedRenameInput = true; | |
| }, 50); | |
| } else if (!renameContainerInfo().visible) { | |
| hasFocusedRenameInput = false; | |
| } | |
| }); | |
| let hasFocusedCreateItemInput = false; | |
| createEffect(() => { | |
| if (createItemModalInfo().visible && createItemInputRef && !hasFocusedCreateItemInput) { | |
| setTimeout(() => { | |
| if (createItemInputRef) { | |
| createItemInputRef.focus(); | |
| } | |
| hasFocusedCreateItemInput = true; | |
| }, 50); | |
| } else if (!createItemModalInfo().visible) { | |
| hasFocusedCreateItemInput = false; | |
| } | |
| }); | |
| const theme = { | |
| bg: '#0F172A', | |
| panelBg: '#1E293B', | |
| border: '#334155', | |
| text: '#E2E8F0', | |
| textMuted: '#94A3B8', | |
| primary: '#38BDF8', | |
| primaryHover: '#0EA5E9', | |
| primaryText: '#0F172A', | |
| secondary: '#FACC15', | |
| secondaryHover: '#EAB308', | |
| secondaryText: '#1E293B', | |
| destructive: '#F43F5E', | |
| destructiveHover: '#E11D48', | |
| destructiveText: '#E2E8F0', | |
| inputBg: '#0A0F1A', | |
| inputBorder: '#334155', | |
| inputFocusBorder: '#38BDF8', | |
| fontFamily: "'Patrick Hand', cursive", | |
| monospaceFontFamily: "'Fira Code', 'Source Code Pro', monospace", | |
| borderRadius: '6px', | |
| itemHoverBg: 'rgba(51, 65, 85, 0.7)', | |
| shadow: '0 6px 16px rgba(0, 0, 0, 0.4)', | |
| itemSelectedBg: '#38BDF8', | |
| notificationSuccess: '#10B981', | |
| notificationError: '#F43F5E', | |
| notificationInfo: '#38BDF8', | |
| }; | |
| const NotificationContainer = () => { | |
| const baseStyle = { | |
| padding: '1rem 1.5rem', | |
| 'margin-bottom': '0.75rem', | |
| 'border-radius': theme.borderRadius, | |
| color: 'white', | |
| 'font-size': '1.05rem', | |
| 'box-shadow': '0 4px 10px rgba(0,0,0,0.3)', | |
| 'font-family': theme.fontFamily, | |
| 'letter-spacing': '0.5px', | |
| transition: 'transform 0.3s ease-out, opacity 0.3s ease-out', | |
| transform: 'translateX(0)', | |
| opacity: 1, | |
| }; | |
| const typeStyles = { | |
| success: { 'background-color': theme.notificationSuccess }, | |
| error: { 'background-color': theme.notificationError }, | |
| info: { 'background-color': theme.notificationInfo, color: theme.primaryText }, | |
| }; | |
| return ( | |
| <div style={{ position: 'fixed', top: '20px', right: '20px', 'z-index': '2000', width: '350px', 'max-width': '90%' }}> | |
| <For each={notifications()}> | |
| {notification => ( | |
| <div style={{ ...baseStyle, ...typeStyles[notification.type] }}> | |
| {notification.message} | |
| </div> | |
| )} | |
| </For> | |
| </div> | |
| ); | |
| }; | |
| const baseButtonStyle = { | |
| padding: '0.6rem 1.2rem', | |
| 'font-size': '1.1rem', | |
| border: 'none', | |
| 'border-radius': theme.borderRadius, | |
| cursor: 'pointer', | |
| 'font-family': theme.fontFamily, | |
| 'letter-spacing': '0.5px', | |
| transition: 'background-color 0.2s, transform 0.1s', | |
| 'margin-right': '0.5rem', | |
| 'line-height': '1.4', | |
| }; | |
| const createButtonStyler = (baseColor, hoverColor, textColor) => { | |
| let btnRef; | |
| return { | |
| ref: el => btnRef = el, | |
| style: { ...baseButtonStyle, background: baseColor, color: textColor }, | |
| onMouseEnter: () => btnRef && (btnRef.style.backgroundColor = hoverColor), | |
| onMouseLeave: () => btnRef && (btnRef.style.backgroundColor = baseColor), | |
| }; | |
| }; | |
| const primaryButtonProps = (text, onClick) => { | |
| const { ref, style, onMouseEnter, onMouseLeave } = createButtonStyler(theme.primary, theme.primaryHover, theme.primaryText); | |
| return <button ref={ref} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick}>{text}</button>; | |
| }; | |
| const secondaryButtonProps = (text, onClick) => { | |
| const { ref, style, onMouseEnter, onMouseLeave } = createButtonStyler(theme.secondary, theme.secondaryHover, theme.secondaryText); | |
| return <button ref={ref} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick}>{text}</button>; | |
| }; | |
| const destructiveButtonProps = (text, onClick) => { | |
| const { ref, style, onMouseEnter, onMouseLeave } = createButtonStyler(theme.destructive, theme.destructiveHover, theme.destructiveText); | |
| return <button ref={ref} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick}>{text}</button>; | |
| }; | |
| const defaultButtonProps = (text, onClick, additionalStyles = {}) => { | |
| const { ref, style, onMouseEnter, onMouseLeave } = createButtonStyler(theme.panelBg, theme.border, theme.textMuted); | |
| return <button ref={ref} style={{...style, border: `1px solid ${theme.border}`, ...additionalStyles}} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick}>{text}</button>; | |
| }; | |
| const iconButtonStyler = (baseColor, hoverColor, textColor) => { | |
| let btnRef; | |
| const iconBaseStyle = { ...baseButtonStyle, padding: '0.5rem 0.7rem', 'font-size': '1.5rem', 'line-height': '1', 'margin-right': '0' }; | |
| return { | |
| ref: el => btnRef = el, | |
| style: { ...iconBaseStyle, background: baseColor, color: textColor, border: `1px solid ${theme.border}`}, | |
| onMouseEnter: () => btnRef && (btnRef.style.backgroundColor = hoverColor), | |
| onMouseLeave: () => btnRef && (btnRef.style.backgroundColor = baseColor), | |
| }; | |
| }; | |
| const inputStyle = { | |
| padding: '0.7rem 0.9rem', | |
| 'font-size': '1.05rem', | |
| border: `1px solid ${theme.inputBorder}`, | |
| 'border-radius': theme.borderRadius, | |
| flex: '1', | |
| 'margin-right': '0.5rem', | |
| 'font-family': theme.fontFamily, | |
| 'background-color': theme.inputBg, | |
| color: theme.text, | |
| outline: 'none', | |
| transition: 'border-color 0.2s, box-shadow 0.2s', | |
| 'letter-spacing': '0.5px', | |
| }; | |
| const modalInputStyle = { | |
| ...inputStyle, | |
| width: 'calc(100% - 22px)', | |
| 'margin-bottom': '20px', | |
| 'margin-right': '0', | |
| 'font-size': '1.1rem', | |
| }; | |
| const codeViewerBaseStyle = { | |
| width: '100%', | |
| fontFamily: theme.monospaceFontFamily, | |
| border: `1px solid ${theme.border}`, | |
| padding: '15px', | |
| boxSizing: 'border-box', | |
| flex: '1', | |
| marginBottom: '15px', | |
| minHeight: isMobileView() ? '250px' : '350px', | |
| resize: 'vertical', | |
| overflow: 'auto', | |
| backgroundColor: '#282c34', | |
| color: theme.text, | |
| borderRadius: theme.borderRadius, | |
| fontSize: '12px', | |
| }; | |
| const codeMirrorEditorStyle = { | |
| ...codeViewerBaseStyle, | |
| padding: '0px', | |
| }; | |
| return ( | |
| <div style={{ 'font-size': '1.1rem', background: theme.bg, color: theme.text, 'min-height': '100vh', padding: '25px', 'box-sizing': 'border-box' }}> | |
| <NotificationContainer /> | |
| <h2 style={{ color: theme.primary, 'font-size': '2.8rem', 'margin-bottom': '25px', 'text-align': 'center', 'letter-spacing': '1px' }}> | |
| 📁 ExoCore Explorer 📂 | |
| </h2> | |
| <Show when={status()}> | |
| <div class="status-box" style={{ 'background-color': theme.panelBg, border: `1px solid ${theme.border}`, color: theme.text, padding: '12px 18px', 'margin-bottom': '1.5rem', 'border-radius': theme.borderRadius, display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'box-shadow': '0 2px 5px rgba(0,0,0,0.2)', 'font-size': '1.05rem' }}> | |
| {status()} | |
| {defaultButtonProps('Clear', () => setStatus(''), { padding: '0.2rem 0.5rem', 'margin-left': '10px', 'font-size': '0.9rem' })} | |
| </div> | |
| </Show> | |
| <Show when={loading() && !(isMobileView() && selectedFile())}> | |
| <div style={{ 'margin-top': '1.5rem', color: theme.textMuted, 'font-size': '1.2rem', 'text-align': 'center' }}>Loading... ⏳ Please wait...</div> | |
| </Show> | |
| <div class="main-content-flex" style={{ display: 'flex', 'margin-top': '1.5rem', gap: '25px', 'flex-wrap': (isMobileView() && selectedFile()) ? 'wrap' : 'nowrap' }}> | |
| <div class="file-list-panel" style={{ ...(isMobileView() && selectedFile() ? {display: 'none'} : {flex: '1'}), border: `1px solid ${theme.border}`, padding: '20px', background: theme.panelBg, 'min-width': '320px', color: theme.text, 'border-radius': theme.borderRadius, 'box-shadow': theme.shadow }}> | |
| <h4 style={{ color: theme.primary, 'font-size': '1.5rem', 'margin-top': '0', 'margin-bottom': '1rem', 'border-bottom': `1px solid ${theme.border}`, 'padding-bottom': '0.5rem' }}>Controls</h4> | |
| <div style={{ 'margin-bottom': '0.8rem', display: 'flex', 'align-items': 'center', gap: '0.5rem' }}> | |
| <input style={inputStyle} placeholder="New file name..." value={newFileName()} onInput={e => setNewFileName(e.target.value)} onKeyPress={e => e.key === 'Enter' && createFile()} onFocus={e => e.target.style.borderColor = theme.inputFocusBorder} onBlur={e => e.target.style.borderColor = theme.inputBorder} /> | |
| {(() => { | |
| const { ref, style, onMouseEnter, onMouseLeave } = iconButtonStyler(theme.panelBg, theme.border, theme.primary); | |
| return <button ref={ref} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={createFile} title="Create File">📄</button>; | |
| })()} | |
| </div> | |
| <div style={{ 'margin-bottom': '0.8rem', display: 'flex', 'align-items': 'center', gap: '0.5rem' }}> | |
| <input style={inputStyle} placeholder="New folder name..." value={newFolderName()} onInput={e => setNewFolderName(e.target.value)} onKeyPress={e => e.key === 'Enter' && createFolder()} onFocus={e => e.target.style.borderColor = theme.inputFocusBorder} onBlur={e => e.target.style.borderColor = theme.inputBorder} /> | |
| {(() => { | |
| const { ref, style, onMouseEnter, onMouseLeave } = iconButtonStyler(theme.panelBg, theme.border, theme.primary); | |
| return <button ref={ref} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={createFolder} title="Create Folder">📁</button>; | |
| })()} | |
| </div> | |
| <div style={{ 'margin-bottom': '1.5rem' }}> | |
| {(() => { | |
| const { ref, style, onMouseEnter, onMouseLeave } = createButtonStyler(theme.primary, theme.primaryHover, theme.primaryText); | |
| return <label for="fileUpload" ref={ref} style={{...style, display: 'inline-block', width: '100%', 'box-sizing': 'border-box', 'text-align': 'center', 'margin-right': 0}} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>📤 Upload File</label>; | |
| })()} | |
| <input id="fileUpload" type="file" onChange={uploadFile} style={{display: 'none'}} /> | |
| </div> | |
| <h4 style={{ color: theme.primary, 'font-size': '1.5rem', 'margin-bottom': '1rem', 'border-bottom': `1px solid ${theme.border}`, 'padding-bottom': '0.5rem' }}>File System</h4> | |
| <div style={{ 'max-height': '450px', 'overflow-y': 'auto', border: `1px solid ${theme.border}`, padding: '10px', 'border-radius': theme.borderRadius, background: theme.inputBg }}> | |
| {renderFiles(files())} | |
| </div> | |
| <div style={{'margin-top': '1.5rem'}}> | |
| {secondaryButtonProps('Download All (Root) as ZIP 📦', handleDownloadAll)} | |
| </div> | |
| </div> | |
| <div class="file-editor-panel" style={ isMobileView() && selectedFile() ? { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', background: 'rgba(15, 23, 42, 0.9)', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'z-index': '1001', padding: '15px', 'box-sizing': 'border-box' } : { flex: '3.5', 'min-width': '0', display: selectedFile() ? 'flex' : 'block', 'flex-direction': 'column' } }> | |
| <Show when={selectedFile()} fallback={ !isMobileView() || (isMobileView() && !selectedFile()) ? <div style={{ border: `2px dashed ${theme.border}`, padding: '30px', 'text-align': 'center', background: theme.panelBg, color: theme.textMuted, height: '100%', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'border-radius': theme.borderRadius, 'font-size': '1.3rem', 'box-shadow': theme.shadow }}>Select a file to embark on an editing adventure! 🚀</div> : null }> | |
| <div style={ isMobileView() ? { background: theme.panelBg, padding: '20px', 'border-radius': theme.borderRadius, 'box-shadow': theme.shadow, width: '100%', height: 'auto', 'max-height': 'calc(100vh - 40px)', display: 'flex', 'flex-direction': 'column', 'overflow-y': 'auto', color: theme.text, border: `1px solid ${theme.border}` } : { border: `1px solid ${theme.border}`, padding: '20px', background: theme.panelBg, height: '100%', display: 'flex', 'flex-direction': 'column', color: theme.text, 'border-radius': theme.borderRadius, 'box-shadow': theme.shadow } }> | |
| <h3 style={{ 'margin-top': '0', color: theme.primary, 'font-size': '1.6rem', 'margin-bottom': '1rem', 'word-break': 'break-all' }}> | |
| Now Editing: {selectedFile()} | |
| </h3> | |
| <div ref={el => editorRef = el} style={codeMirrorEditorStyle}></div> | |
| <div style={{ 'text-align': 'right', 'margin-top': '10px', display: 'flex', 'justify-content': 'flex-end', gap: '0.5rem' }}> | |
| {primaryButtonProps('💾 Save', saveFile)} | |
| {destructiveButtonProps('❌ Close', closeFileEditor)} | |
| </div> | |
| </div> | |
| </Show> | |
| </div> | |
| </div> | |
| <Show when={contextMenu().visible}> | |
| <div id="context-menu" style={{ position: 'fixed', top: `${contextMenu().y}px`, left: `${contextMenu().x}px`, background: theme.panelBg, border: `1px solid ${theme.border}`, padding: '0.4rem', 'z-index': '1000', 'box-shadow': '0 5px 15px rgba(0,0,0,0.5)', 'min-width': '170px', 'text-align': 'left', color: theme.text, 'border-radius': theme.borderRadius, transform: contextMenu().x > (window.innerWidth - 200) ? 'translateX(-100%)' : 'none' }}> | |
| {[ | |
| { label: '✏️ Rename', action: handleRenameClick, show: () => true }, | |
| { label: '📂 Open', action: handleOpenFolderFromContextMenu, show: () => contextMenu().file?.isDir }, | |
| { label: '➕📄 Add New File', action: () => handleShowCreateItemModal(false), show: () => contextMenu().file?.isDir || !contextMenu().file }, | |
| { label: '➕📁 Add New Folder', action: () => handleShowCreateItemModal(true), show: () => contextMenu().file?.isDir || !contextMenu().file }, | |
| { label: '⬇️ Download', action: handleDownloadSelected, show: () => contextMenu().file && !contextMenu().file.isDir }, | |
| { | |
| label: '🌀 Unzip Here', | |
| action: handleUnzipSelected, | |
| show: () => { | |
| const file = contextMenu().file; | |
| return file && !file.isDir && file.name.toLowerCase().endsWith('.zip'); | |
| } | |
| }, | |
| { label: '🗑️ Delete', action: handleDeleteSelected, show: () => contextMenu().file, color: theme.destructive }, | |
| ].map(item => ( | |
| <Show when={item.show()}> | |
| <div | |
| style={{ cursor: 'pointer', padding: '0.5rem 0.7rem', 'white-space': 'nowrap', color: item.color || theme.text, 'border-radius': '4px', transition: 'background-color 0.15s', 'font-size': '0.9rem' }} | |
| onClick={item.action} | |
| onMouseEnter={e => e.target.style.backgroundColor = theme.itemHoverBg} | |
| onMouseLeave={e => e.target.style.backgroundColor = 'transparent'} | |
| > | |
| {item.label} | |
| </div> | |
| </Show> | |
| ))} | |
| <Show when={contextMenu().file}> | |
| <div style={{ 'font-size': '0.8em', color: theme.textMuted, 'margin-top': '6px', 'border-top': `1px solid ${theme.border}`, 'padding-top': '6px' }}> | |
| {contextMenu().file.name} ({contextMenu().file.isDir ? 'Folder' : 'File'}) | |
| </div> | |
| </Show> | |
| </div> | |
| </Show> | |
| <Show when={renameContainerInfo().visible}> | |
| <div class="rename-modal-overlay" style={{ position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', background: 'rgba(15, 23, 42, 0.85)', display: 'flex', 'justify-content': 'center', 'align-items': 'center', 'z-index': '1002' }}> | |
| <div class="rename-modal-content" style={{ background: theme.panelBg, padding: '25px', 'border-radius': theme.borderRadius, 'box-shadow': theme.shadow, 'text-align': 'center', 'min-width': '320px', 'max-width': '90%', 'box-sizing': 'border-box', position: 'relative', color: theme.text, border: `1px solid ${theme.border}` }} onClick={e => e.stopPropagation()}> | |
| <h3 style={{ color: theme.primary, 'margin-top': 0, 'margin-bottom': '20px', 'font-size': '1.5rem' }}>Rename "{renameContainerInfo().file?.name}"</h3> | |
| <input ref={el => renameInputRef = el} style={modalInputStyle} value={renameContainerInfo().newName} onInput={e => setRenameContainerInfo(prev => ({ ...prev, newName: e.target.value }))} onKeyPress={e => e.key === 'Enter' && performRename()} onFocus={e => e.target.style.borderColor = theme.inputFocusBorder} onBlur={e => e.target.style.borderColor = theme.inputBorder} /> | |
| <div style={{ display: 'flex', 'justify-content': 'center', gap: '10px', 'margin-top': '10px' }}> | |
| {primaryButtonProps('✔️ Confirm', performRename)} | |
| {destructiveButtonProps('✖️ Cancel', cancelRename)} | |
| </div> | |
| </div> | |
| </div> | |
| </Show> | |
| <Show when={createItemModalInfo().visible}> | |
| <div class="create-item-modal-overlay" style={{ position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', background: 'rgba(15, 23, 42, 0.85)', display: 'flex', 'justify-content': 'center', 'align-items': 'center', 'z-index': '1002' }}> | |
| <div class="create-item-modal-content" style={{ background: theme.panelBg, padding: '25px', 'border-radius': theme.borderRadius, 'box-shadow': theme.shadow, 'text-align': 'center', 'min-width': '360px', 'max-width': '90%', 'box-sizing': 'border-box', position: 'relative', color: theme.text, border: `1px solid ${theme.border}` }} onClick={e => e.stopPropagation()}> | |
| <h3 style={{ color: theme.primary, 'margin-top': 0, 'margin-bottom': '20px', 'font-size': '1.4rem' }}> Create New {createItemModalInfo().isDir ? 'Folder' : 'File'} in "{createItemModalInfo().parentPath || 'root'}" </h3> | |
| <input ref={el => createItemInputRef = el} style={modalInputStyle} placeholder={createItemModalInfo().isDir ? 'New folder name...' : 'New file name (e.g., script.js)'} value={createItemModalInfo().itemName} onInput={e => setCreateItemModalInfo(prev => ({ ...prev, itemName: e.target.value }))} onKeyPress={e => e.key === 'Enter' && performCreateItem()} onFocus={e => e.target.style.borderColor = theme.inputFocusBorder} onBlur={e => e.target.style.borderColor = theme.inputBorder} /> | |
| <div style={{ display: 'flex', 'justify-content': 'center', gap: '10px', 'margin-top': '10px' }}> | |
| {primaryButtonProps('✔️ Create', performCreateItem)} | |
| {destructiveButtonProps('✖️ Cancel', cancelCreateItem)} | |
| </div> | |
| </div> | |
| </div> | |
| </Show> | |
| </div> | |
| ); | |
| } | |
| render(() => <App />, document.getElementById('app')); | |