From e38d255ff112d744bf9f3c42c735ccb0419b0dea Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sat, 31 Jan 2026 15:13:13 -0500 Subject: [PATCH 01/13] Add CSS styles for editor modal - Uses unRAID CSS variables for theme compatibility - Supports both dark (black/gray) and light (white/azure) themes - Dynamic header offset calculation via CSS custom property --- source/compose.manager/styles/editorModal.css | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 source/compose.manager/styles/editorModal.css diff --git a/source/compose.manager/styles/editorModal.css b/source/compose.manager/styles/editorModal.css new file mode 100644 index 0000000..cd36c77 --- /dev/null +++ b/source/compose.manager/styles/editorModal.css @@ -0,0 +1,76 @@ +/* Editor Modal - Uses unRAID CSS variables where possible */ +.editor-modal-overlay { + --unraid-header-offset: 140px; + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + z-index: 9999; + overflow: auto; + padding: 20px; + padding-top: calc(var(--unraid-header-offset) + 10px); + box-sizing: border-box; +} +.editor-modal-overlay.active { display: flex; justify-content: center; align-items: flex-start; } + +.editor-modal { + width: 95%; + height: calc(100vh - var(--unraid-header-offset) - 40px); + max-width: 1400px; + background-color: var(--background-color, #1c1c1c); + border-radius: 8px; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + border: 1px solid var(--border-color, #3a3a3a); +} + +.editor-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 20px; + background-color: var(--alt-background-color, #252525); + border-bottom: 1px solid var(--border-color, #3a3a3a); + border-radius: 8px 8px 0 0; +} +.editor-modal-title { font-size: 1.4rem; color: var(--brand-orange, #ff8c2f); font-weight: bold; margin: 0; } +.editor-modal-close { background: none; border: none; color: var(--alt-text-color, #888); font-size: 1.5rem; cursor: pointer; padding: 5px 10px; } +.editor-modal-close:hover { color: var(--brand-red, #ff4444); } + +.editor-tabs { display: flex; background-color: var(--background-color, #1e1e1e); border-bottom: 1px solid var(--border-color, #3a3a3a); padding: 0 10px; } +.editor-tab { padding: 12px 20px; background: transparent; border: none; color: var(--alt-text-color, #888); cursor: pointer; font-size: 0.95rem; border-bottom: 2px solid transparent; display: flex; align-items: center; gap: 8px; } +.editor-tab:hover { color: var(--text-color, #ccc); background-color: var(--alt-background-color, #2a2a2a); } +.editor-tab.active { color: var(--brand-orange, #ff8c2f); border-bottom-color: var(--brand-orange, #ff8c2f); background-color: var(--alt-background-color, #2a2a2a); } +.editor-tab i { font-size: 0.85rem; } +.editor-tab-modified { width: 8px; height: 8px; background-color: var(--brand-orange, #ff8c2f); border-radius: 50%; display: none; } +.editor-tab.modified .editor-tab-modified { display: inline-block; } + +.editor-modal-body { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; } +.editor-container { flex: 1; position: relative; display: none; } +.editor-container.active { display: block; } +.editor-container .ace_editor { position: absolute; top: 0; left: 0; right: 0; bottom: 0; } + +.editor-validation { padding: 10px 20px; background-color: var(--background-color, #1a1a1a); border-top: 1px solid var(--border-color, #3a3a3a); font-family: monospace; font-size: 1.1rem; } +.editor-validation.valid { color: #4caf50; } +.editor-validation.error { color: #f44336; } +.editor-validation-icon { margin-right: 8px; } + +.editor-modal-footer { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background-color: var(--alt-background-color, #252525); border-top: 1px solid var(--border-color, #3a3a3a); border-radius: 0 0 8px 8px; } +.editor-footer-left { display: flex; align-items: center; gap: 15px; } +.editor-footer-right { display: flex; gap: 10px; } +.editor-file-info { color: var(--alt-text-color, #888); font-size: 1.1rem; } + +.editor-btn { padding: 10px 25px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.95rem; } +.editor-btn-cancel { background-color: var(--alt-background-color, #3a3a3a); color: var(--text-color, #ccc); } +.editor-btn-cancel:hover { background-color: var(--border-color, #4a4a4a); } +.editor-btn-save { background: var(--button-background, linear-gradient(90deg,#e22828,#ff8c2f)); color: #fff; } +.editor-btn-save:disabled { background: var(--disabled-input-background-color, #5a5a5a); color: var(--disabled-text-color, #888); cursor: not-allowed; } +.editor-btn-save-all { background-color: #4caf50; color: #fff; } +.editor-btn-save-all:hover { background-color: #5cbf60; } + +.editor-shortcuts { color: var(--alt-text-color, #666); font-size: 0.8rem; } +.editor-shortcuts kbd { background-color: var(--alt-background-color, #3a3a3a); padding: 2px 6px; border-radius: 3px; font-family: monospace; } From 990322cbcfdef87e5a81c3bcbe70ea3ec4aa6c58 Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sat, 31 Jan 2026 15:14:02 -0500 Subject: [PATCH 02/13] Add tabbed editor modal for compose files Replace separate Compose/ENV file editing with unified modal: - New full-screen modal with tabs for docker-compose.yml, .env, and override files - Real-time YAML syntax validation using js-yaml - Track unsaved changes with visual indicators on tabs - Keyboard shortcuts: Ctrl+S to save, Escape to close - Unsaved changes warning when closing - Dynamic positioning based on unRAID header height - Consolidates 'Compose File' and 'ENV File' buttons into single 'Edit Files' action The modal uses Ace editor instances for each file type with appropriate syntax highlighting (YAML for compose files, shell for .env files). --- .../php/compose_manager_main.php | 418 +++++++++++++++++- 1 file changed, 400 insertions(+), 18 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 8d07a17..80277e4 100644 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -160,6 +160,7 @@ function createComboButton($text, $id, $onClick, $onClickParams, $items) { const shell_label = ; $('head').append( $('').attr('href', '') ); +$('head').append( $('').attr('href', '') ); if (typeof swal2 === "undefined") { $('head').append( $('').attr('href', '') ); @@ -174,12 +175,107 @@ function dirname( path ) { return path.replace( /\\/g, '/' ).replace( /\/[^\/]*$/, '' ); } +// Editor modal state +var editorModal = { + editors: {}, + currentTab: null, + originalContent: {}, + modifiedTabs: new Set(), + currentProject: null +}; + +// Calculate unRAID header offset dynamically +function updateModalOffset() { + var headerOffset = 0; + var header = document.getElementById('header'); + var menu = document.getElementById('menu'); + var tabs = document.querySelector('div.tabs'); + + if (header) { + headerOffset += header.offsetHeight; + } + if (menu) { + headerOffset += menu.offsetHeight; + } + if (tabs) { + headerOffset += tabs.offsetHeight; + } + + // Add a small buffer + headerOffset += 10; + + // Set CSS custom property + document.documentElement.style.setProperty('--unraid-header-offset', headerOffset + 'px'); + var overlay = document.getElementById('editor-modal-overlay'); + if (overlay) { + overlay.style.setProperty('--unraid-header-offset', headerOffset + 'px'); + } +} + $(function() { - var editor = ace.edit("itemEditor"); - editor.setTheme(aceTheme); - editor.setShowPrintMargin(false); + updateModalOffset(); + $(window).on('resize', updateModalOffset); + initEditorModal(); }) +function initEditorModal() { + // Initialize Ace editors for each tab + ['compose', 'env', 'override'].forEach(function(type) { + var editor = ace.edit('editor-' + type); + editor.setTheme(aceTheme); + editor.setShowPrintMargin(false); + editor.setOptions({ + fontSize: '14px', + tabSize: 2, + useSoftTabs: true, + wrap: true + }); + + // Set mode based on type + if (type === 'env') { + editor.getSession().setMode('ace/mode/sh'); + } else { + editor.getSession().setMode('ace/mode/yaml'); + } + + // Track modifications + editor.on('change', function() { + var currentContent = editor.getValue(); + var originalContent = editorModal.originalContent[type] || ''; + var tabEl = $('#editor-tab-' + type); + + if (currentContent !== originalContent) { + editorModal.modifiedTabs.add(type); + tabEl.addClass('modified'); + } else { + editorModal.modifiedTabs.delete(type); + tabEl.removeClass('modified'); + } + + updateSaveButtonState(); + validateYaml(type, currentContent); + }); + + editorModal.editors[type] = editor; + }); + + // Keyboard shortcuts + $(document).on('keydown', function(e) { + if ($('#editor-modal-overlay').hasClass('active')) { + // Ctrl+S or Cmd+S to save current + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + saveCurrentTab(); + } + // Escape to close + if (e.key === 'Escape') { + e.preventDefault(); + closeEditorModal(); + } + } + }); +} + $(function() { $(".tipsterallowed").show(); $('.ca_nameEdit').tooltipster({ @@ -374,24 +470,20 @@ function applyDesc(myID) { function editStack(myID) { var buttonsList = {}; - buttonsList["compose_file"] = { text: "Compose File" }; - buttonsList["env_file"] = { text: "ENV File" }; + buttonsList["edit_files"] = { text: "Edit Files" }; buttonsList["override_file"] = { text: "UI Labels" }; buttonsList["stack_settings"] = { text: "Stack Settings" }; buttonsList["Cancel"] = { text: "Cancel", value: null, }; swal2({ - title: "Select Stack File to Edit", + title: "Select Action", className: 'edit-stack-form', buttons: buttonsList, }).then((result) => { if (result) { switch(result) { - case 'compose_file': - editComposeFile(myID); - break; - case 'env_file': - editEnv(myID); + case 'edit_files': + openEditorModal(myID); break; case 'override_file': generateOverride(myID); @@ -406,6 +498,236 @@ className: 'edit-stack-form', }); } +// New Editor Modal Functions +function openEditorModal(myID) { + $("#"+myID).tooltipster("close"); + var project = $("#"+myID).attr("data-scriptname"); + var projectName = $("#"+myID).attr("data-namename"); + + editorModal.currentProject = project; + editorModal.modifiedTabs = new Set(); + editorModal.originalContent = {}; + + // Reset all tabs to unmodified state + $('.editor-tab').removeClass('modified active'); + $('.editor-container').removeClass('active'); + + // Set modal title + $('#editor-modal-title').text('Editing: ' + projectName); + $('#editor-file-info').text(compose_root + '/' + project); + + // Show loading state + $('#editor-modal-overlay').addClass('active'); + $('#editor-validation').html(' Loading files...').removeClass('valid error warning'); + + // Load all files + loadEditorFiles(project); +} + +function loadEditorFiles(project) { + var loadPromises = []; + + // Load compose file + loadPromises.push( + $.post(caURL, {action:'getYml', script:project}).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.originalContent['compose'] = response.content || ''; + editorModal.editors['compose'].setValue(response.content || '', -1); + } + }) + ); + + // Load env file + loadPromises.push( + $.post(caURL, {action:'getEnv', script:project}).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.originalContent['env'] = response.content || ''; + editorModal.editors['env'].setValue(response.content || '', -1); + } + }) + ); + + // Load override file + loadPromises.push( + $.post(caURL, {action:'getOverride', script:project}).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.originalContent['override'] = response.content || ''; + editorModal.editors['override'].setValue(response.content || '', -1); + } + }) + ); + + // When all files are loaded + $.when.apply($, loadPromises).then(function() { + // Activate the compose tab by default + switchEditorTab('compose'); + // Run validation on the initial content + validateYaml('compose', editorModal.editors['compose'].getValue()); + }); +} + +function switchEditorTab(tabName) { + // Update tab buttons + $('.editor-tab').removeClass('active'); + $('#editor-tab-' + tabName).addClass('active'); + + // Update editor containers + $('.editor-container').removeClass('active'); + $('#editor-container-' + tabName).addClass('active'); + + // Focus and resize the editor + editorModal.editors[tabName].focus(); + editorModal.editors[tabName].resize(); + + editorModal.currentTab = tabName; + + // Update validation for current tab + validateYaml(tabName, editorModal.editors[tabName].getValue()); +} + +function validateYaml(type, content) { + if (type === 'env') { + // Basic validation for env files + updateValidation(type, content); + return; + } + + try { + if (content.trim()) { + jsyaml.load(content); + } + updateValidation(type, content, true); + } catch (e) { + updateValidation(type, content, false, e.message); + } +} + +function updateValidation(type, content, isValid, errorMsg) { + var validationEl = $('#editor-validation'); + + // Handle env files separately (no YAML validation needed) + if (type === 'env') { + var lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')); + validationEl.html(' ' + lines.length + ' environment variable(s)'); + validationEl.removeClass('error warning').addClass('valid'); + return; + } + + // If isValid is undefined, run actual YAML validation + if (isValid === undefined) { + validateYaml(type, content); + return; + } + + if (isValid) { + validationEl.html(' YAML syntax is valid'); + validationEl.removeClass('error warning').addClass('valid'); + } else { + validationEl.html(' YAML Error: ' + errorMsg); + validationEl.removeClass('valid warning').addClass('error'); + } +} + +function updateSaveButtonState() { + var hasChanges = editorModal.modifiedTabs.size > 0; + $('#editor-btn-save-all').prop('disabled', !hasChanges); + + if (hasChanges) { + $('#editor-btn-save-all').text('Save All (' + editorModal.modifiedTabs.size + ')'); + } else { + $('#editor-btn-save-all').text('Save All'); + } +} + +function saveCurrentTab() { + var currentTab = editorModal.currentTab; + if (!currentTab || !editorModal.modifiedTabs.has(currentTab)) return; + + saveTab(currentTab); +} + +function saveTab(tabName) { + var content = editorModal.editors[tabName].getValue(); + var project = editorModal.currentProject; + var actionStr = null; + + switch(tabName) { + case 'compose': + actionStr = 'saveYml'; + break; + case 'env': + actionStr = 'saveEnv'; + break; + case 'override': + actionStr = 'saveOverride'; + break; + default: + return Promise.reject('Unknown tab'); + } + + return $.post(caURL, {action:actionStr, script:project, scriptContents:content}).then(function(data) { + if (data) { + editorModal.originalContent[tabName] = content; + editorModal.modifiedTabs.delete(tabName); + $('#editor-tab-' + tabName).removeClass('modified'); + updateSaveButtonState(); + + // Regenerate profiles if compose file was saved + if (tabName === 'compose') { + generateProfiles(null, project); + } + + return true; + } + return false; + }); +} + +function saveAllTabs() { + var savePromises = []; + + editorModal.modifiedTabs.forEach(function(tabName) { + savePromises.push(saveTab(tabName)); + }); + + $.when.apply($, savePromises).then(function() { + swal2({ + title: "Saved!", + text: "All changes have been saved.", + icon: "success", + timer: 1500, + buttons: false + }); + }); +} + +function closeEditorModal() { + if (editorModal.modifiedTabs.size > 0) { + swal2({ + title: "Unsaved Changes", + text: "You have unsaved changes. Are you sure you want to close?", + icon: "warning", + buttons: ["Cancel", "Discard Changes"], + dangerMode: true, + }).then((willClose) => { + if (willClose) { + doCloseEditorModal(); + } + }); + } else { + doCloseEditorModal(); + } +} + +function doCloseEditorModal() { + $('#editor-modal-overlay').removeClass('active'); + editorModal.currentProject = null; + editorModal.modifiedTabs = new Set(); +} + function build_override_input_table( id, value, label, placeholder, disable=false) { var disabled = disable ? `disabled` : ``; html = `
`; @@ -774,13 +1096,73 @@ function ComposeLogs(myID) { -