From 68d056d367f82a0c7e8cb467c0b1d74718cdf82c Mon Sep 17 00:00:00 2001 From: Zephrynis Date: Sun, 13 Jul 2025 17:33:30 +0100 Subject: [PATCH] Initial Commit --- components/Components.yml | 4 + components/Editor.tsx | 432 ++++++++++++++++++++++++++++++++++++++ components/tsconfig.json | 26 +++ conf.yml | 16 ++ icon.png | Bin 0 -> 4954 bytes style.css | 18 ++ view.blade.php | 4 + 7 files changed, 500 insertions(+) create mode 100644 components/Components.yml create mode 100644 components/Editor.tsx create mode 100644 components/tsconfig.json create mode 100644 conf.yml create mode 100644 icon.png create mode 100644 style.css create mode 100644 view.blade.php diff --git a/components/Components.yml b/components/Components.yml new file mode 100644 index 0000000..9d993b3 --- /dev/null +++ b/components/Components.yml @@ -0,0 +1,4 @@ +Server: + Files: + Edit: + BeforeEdit: "Editor" \ No newline at end of file diff --git a/components/Editor.tsx b/components/Editor.tsx new file mode 100644 index 0000000..982e766 --- /dev/null +++ b/components/Editor.tsx @@ -0,0 +1,432 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useHistory, useLocation, useParams } from 'react-router'; +import { dirname } from 'path'; + +import { encodePathSegments, hashToPath } from '@/helpers'; +import { httpErrorToHuman } from '@/api/http'; +import getFileContents from '@/api/server/files/getFileContents'; +import saveFileContents from '@/api/server/files/saveFileContents'; +import FileNameModal from '@/components/server/files/FileNameModal'; +import { ServerContext } from '@/state/server'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +import Can from '@/components/elements/Can'; +import Select from '@/components/elements/Select'; +import Button from '@/components/elements/Button'; +import modes from '@/modes'; +declare global { + interface Window { + monaco: any; + require: any; + } +} + +// Add CSS to document head for responsive button styling +const addButtonStyles = () => { + const styleId = 'responsive-button-styles'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .responsive-button { + flex: 1 1 0%; + } + @media (min-width: 640px) { + .responsive-button { + flex: none; + } + } + `; + document.head.appendChild(style); + } +}; + +const Editor = () => { + // Router hooks + const { hash } = useLocation(); + const history = useHistory(); + const { action } = useParams<{ action: 'new' | string }>(); + + // Component state + const [content, setContent] = useState(''); + const [modalVisible, setModalVisible] = useState(false); + const [loading, setLoading] = useState(action === 'edit'); + const [monacoLoaded, setMonacoLoaded] = useState(false); + const [lang, setLang] = useState('text/plain'); + + // Refs + const containerRef = useRef(null); + const editorRef = useRef(null); + + // Context and hooks + const id = ServerContext.useStoreState((state) => state.server.data!.id); + const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + const setDirectory = ServerContext.useStoreActions((actions) => actions.files.setDirectory); + const { addError, clearFlashes } = useFlash(); + + // Auto-detect language based on file extension + const detectLanguageFromPath = (filePath: string): string => { + const extension = filePath.split('.').pop()?.toLowerCase(); + + const extensionToMime: { [key: string]: string } = { + // JavaScript/TypeScript + 'js': 'text/javascript', + 'jsx': 'text/javascript', + 'ts': 'application/typescript', + 'tsx': 'application/typescript', + + // Web technologies + 'html': 'text/html', + 'htm': 'text/html', + 'css': 'text/css', + 'scss': 'text/x-scss', + 'sass': 'text/x-sass', + 'xml': 'application/xml', + + // Data formats + 'json': 'application/json', + 'yaml': 'text/x-yaml', + 'yml': 'text/x-yaml', + 'toml': 'text/x-toml', + + // Programming languages + 'py': 'text/x-python', + 'php': 'text/x-php', + 'java': 'text/x-java', + 'c': 'text/x-csrc', + 'cpp': 'text/x-c++src', + 'cxx': 'text/x-c++src', + 'cc': 'text/x-c++src', + 'cs': 'text/x-csharp', + 'go': 'text/x-go', + 'rs': 'text/x-rustsrc', + 'rb': 'text/x-ruby', + 'lua': 'text/x-lua', + + // Shell/Config + 'sh': 'text/x-sh', + 'bash': 'text/x-sh', + 'zsh': 'text/x-sh', + 'dockerfile': 'text/x-dockerfile', + 'env': 'text/x-properties', + 'properties': 'text/x-properties', + 'conf': 'text/x-nginx-conf', + 'nginx': 'text/x-nginx-conf', + + // Database + 'sql': 'text/x-sql', + + // Documentation + 'md': 'text/x-markdown', + 'markdown': 'text/x-markdown', + 'txt': 'text/plain', + + // Other + 'diff': 'text/x-diff', + 'patch': 'text/x-diff', + 'vue': 'script/x-vue' + }; + + return extensionToMime[extension || ''] || 'text/plain'; + }; + + // Map MIME types to Monaco language identifiers + const getMonacoLanguage = (mimeType: string): string => { + const languageMap: { [key: string]: string } = { + 'text/plain': 'plaintext', + 'application/json': 'json', + 'text/javascript': 'javascript', + 'application/javascript': 'javascript', + 'application/typescript': 'typescript', + 'text/typescript': 'typescript', + 'text/html': 'html', + 'text/css': 'css', + 'text/xml': 'xml', + 'application/xml': 'xml', + 'text/yaml': 'yaml', + 'application/x-yaml': 'yaml', + 'text/x-yaml': 'yaml', + 'application/yaml': 'yaml', + 'text/x-python': 'python', + 'application/x-python': 'python', + 'text/python': 'python', + 'application/x-php': 'php', + 'text/x-php': 'php', + 'text/php': 'php', + 'text/x-java': 'java', + 'application/java': 'java', + 'text/x-csharp': 'csharp', + 'text/csharp': 'csharp', + 'text/x-sql': 'sql', + 'application/sql': 'sql', + 'text/x-sh': 'shell', + 'application/x-sh': 'shell', + 'text/x-shellscript': 'shell', + 'application/x-shellscript': 'shell', + 'text/x-dockerfile': 'dockerfile', + 'application/x-dockerfile': 'dockerfile', + 'text/markdown': 'markdown', + 'text/x-markdown': 'markdown', + 'text/x-gfm': 'markdown', + 'application/x-httpd-php': 'php', + 'text/x-c': 'c', + 'text/x-csrc': 'c', + 'text/x-c++': 'cpp', + 'text/x-c++src': 'cpp', + 'text/x-cpp': 'cpp', + 'text/x-go': 'go', + 'text/x-ruby': 'ruby', + 'text/x-rustsrc': 'rust', + 'text/x-lua': 'lua', + 'text/x-sass': 'scss', + 'text/x-scss': 'scss', + 'text/x-toml': 'ini', + 'text/x-nginx-conf': 'nginx', + 'text/x-properties': 'ini', + 'text/x-diff': 'diff', + 'text/x-cassandra': 'sql', + 'text/x-mariadb': 'mysql', + 'text/x-mssql': 'sql', + 'text/x-mysql': 'mysql', + 'text/x-pgsql': 'pgsql', + 'text/x-sqlite': 'sql', + 'message/http': 'http', + 'script/x-vue': 'html' + }; + return languageMap[mimeType] || 'plaintext'; + }; + + // Auto-detect and set language when editing existing files + useEffect(() => { + if (action === 'edit' && hash) { + const path = hashToPath(hash); + const detectedMime = detectLanguageFromPath(path); + setLang(detectedMime); + } + }, [action, hash]); + + const save = (name?: string) => { + if (!editorRef.current) { + return; + } + + setLoading(true); + clearFlashes('files:view'); + + const editorContent = editorRef.current.getValue(); + const filePath = name || hashToPath(hash); + + saveFileContents(uuid, filePath, editorContent) + .then(() => { + // Update the content state to match what was saved + setContent(editorContent); + + if (name) { + // For new files, navigate to edit mode with the new file + history.push(`/server/${id}/files/edit#/${encodePathSegments(name)}`); + // Update the directory context + setDirectory(dirname(name)); + } + }) + .catch((error) => { + console.error('Error saving file:', error); + addError({ message: httpErrorToHuman(error), key: 'files:view' }); + }) + .then(() => setLoading(false)); + }; + // Load file contents for existing files + useEffect(() => { + if (action === 'new') return; + + setLoading(true); + const path = hashToPath(hash); + setDirectory(dirname(path)); + + getFileContents(uuid, path) + .then(setContent) + .catch((error) => { + console.error(error); + addError({ message: httpErrorToHuman(error), key: 'files:view' }); + }) + .then(() => setLoading(false)); + }, [action, uuid, hash]); + // Load Monaco Editor from CDN + useEffect(() => { + const loadMonaco = () => { + // Check if Monaco is already loaded + if (window.monaco) { + setMonacoLoaded(true); + return; + } + + // Load the Monaco Editor CSS + const cssLink = document.createElement('link'); + cssLink.rel = 'stylesheet'; + cssLink.href = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/editor/editor.main.css'; + document.head.appendChild(cssLink); + + // Load the Monaco Editor JavaScript + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js'; + script.onload = () => { + window.require.config({ + paths: { + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' + } + }); + window.require(['vs/editor/editor.main'], () => { + setMonacoLoaded(true); + }); + }; + document.head.appendChild(script); + }; + + loadMonaco(); + + // Cleanup function + return () => { + if (editorRef.current) { + editorRef.current.dispose(); + } + }; + }, []); + // Initialize editor when both Monaco is loaded and we have content (or for new files) + useEffect(() => { + if (!monacoLoaded || !containerRef.current) { + return; + } + + // For new files, initialize immediately + // For edit files, wait until content is loaded (loading is false) + // But don't recreate editor when loading changes during save operations + if ((action === 'new' && !editorRef.current) || (action === 'edit' && !loading && !editorRef.current)) { + // Destroy existing editor if it exists (should not happen with new logic) + if (editorRef.current) { + editorRef.current.dispose(); + } + + // For new files, use empty string if content is empty + const initialContent = action === 'new' && !content ? '' : content; + + // Always start with plaintext for new files, language will be set separately + const editorLanguage = 'plaintext'; + + // Create the editor + editorRef.current = window.monaco.editor.create(containerRef.current, { + value: initialContent, + language: editorLanguage, + theme: 'vs-dark', + automaticLayout: true, + minimap: { + enabled: true + }, + fontSize: 14, + wordWrap: 'off', + scrollBeyondLastLine: false + }); + + // Apply the current language after editor is fully initialized + setTimeout(() => { + if (editorRef.current && lang !== 'text/plain') { + const monacoLanguage = getMonacoLanguage(lang); + const model = editorRef.current.getModel(); + if (model && window.monaco) { + window.monaco.editor.setModelLanguage(model, monacoLanguage); + } + } + }, 100); + } + }, [monacoLoaded, content, loading, action, lang]); + // Update editor language when lang changes + useEffect(() => { + if (editorRef.current && window.monaco) { + const model = editorRef.current.getModel(); + if (model) { + const monacoLanguage = getMonacoLanguage(lang); + + // Check if the language is supported by Monaco + const supportedLanguages = window.monaco.languages.getLanguages(); + const isSupported = supportedLanguages.some((l: any) => l.id === monacoLanguage); + + if (isSupported) { + window.monaco.editor.setModelLanguage(model, monacoLanguage); + } else { + window.monaco.editor.setModelLanguage(model, 'plaintext'); + } + } + } + }, [lang]); + + // Add keyboard shortcut for Ctrl+S + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.ctrlKey && event.key === 's') { + event.preventDefault(); + if (action === 'edit') { + save(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [action, save]); + + // Update editor content when content state changes (for existing editor) + useEffect(() => { + if (editorRef.current && window.monaco && content !== editorRef.current.getValue()) { + editorRef.current.setValue(content); + } + }, [content]); + + + return ( + <> + setModalVisible(false)} + onFileNamed={(name) => { + setModalVisible(false); + // Auto-detect language based on the new file name + const detectedMime = detectLanguageFromPath(name); + setLang(detectedMime); + save(name); + }} + /> + +
+ +
+
+ +
+
+ +
+ + {action === 'edit' ? ( + + + + ) : ( + + + + )} +
+ + ); +}; +export default Editor; \ No newline at end of file diff --git a/components/tsconfig.json b/components/tsconfig.json new file mode 100644 index 0000000..b33fa80 --- /dev/null +++ b/components/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "jsx": "react", + "baseUrl": ".", + "paths": { + "@/*": ["../.dist/types/*"], + "@definitions/*": ["../.dist/types/api/definitions/*"], + "@feature/*": ["../.dist/types/components/server/features/*"], + "@blueprint/*": ["../.dist/types/blueprint/*"] + }, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "noEmit": true, + "typeRoots": [ + "../.dist/types" + ] + }, + "include": [ + "./**/*" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/conf.yml b/conf.yml new file mode 100644 index 0000000..df11685 --- /dev/null +++ b/conf.yml @@ -0,0 +1,16 @@ +info: + name: "PteroMonaco" + identifier: "pteromonaco" + description: "Replaces the regular pterodactyl file editor with Monaco" + version: "1.0" + target: "beta-2024-12" + author: "Zephrynis" + icon: "icon.png" + website: "https://zephrynis.me/" + +admin: + view: "view.blade.php" + +dashboard: + css: "style.css" + components: "components" \ No newline at end of file diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..069205e328629022a827d608ad545359da40ac50 GIT binary patch literal 4954 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 \ No newline at end of file