commit 68d056d367f82a0c7e8cb467c0b1d74718cdf82c Author: Zephrynis Date: Sun Jul 13 17:33:30 2025 +0100 Initial Commit 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 0000000..069205e Binary files /dev/null and b/icon.png differ diff --git a/style.css b/style.css new file mode 100644 index 0000000..e5f7744 --- /dev/null +++ b/style.css @@ -0,0 +1,18 @@ +.igexuH, .eDlcZT { + display: none !important; +} + +#monaco-container { + height: calc(-20rem + 100vh); + min-height: 16rem; +} + +.cDkCmT { + flex: 1 1 0%; +} + +@media (min-width: 640px) { + .cDkCmT { + flex: none; + } +} \ No newline at end of file diff --git a/view.blade.php b/view.blade.php new file mode 100644 index 0000000..08e4b64 --- /dev/null +++ b/view.blade.php @@ -0,0 +1,4 @@ + \ No newline at end of file