|
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')); |
|
|