diff .cms/js/admin.js @ 0:78edf6b517a0 draft

24.10
author Coffee CMS <info@coffee-cms.ru>
date Fri, 11 Oct 2024 22:40:23 +0000
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.cms/js/admin.js	Fri Oct 11 22:40:23 2024 +0000
@@ -0,0 +1,678 @@
+// Две функции для чтения и установки cookie.
+// Cookie используются для авторизации на сайте,
+// для запоминания количества отображаемых страниц в пейджере
+// и выбранной темы.
+// Другие модули так же могут их использовать
+// для запоминания своих настроек.
+function get_cookie( name ) {
+    let cookies = document.cookie.split( ";" );
+    for ( let line of cookies ) {
+        let cookie = line.split( "=" );
+        // TODO: Нужно ли decodeURIComponent( cookie[ 0 ].trim() )?
+        // Имена указываются обычно латиницей, без специальных знаков...
+        if ( name == cookie[ 0 ].trim() ) {
+            return decodeURIComponent( cookie[ 1 ] );
+        }
+    }
+    return "";
+}
+
+// ;Path=/ в админке нет смысла указывать, но можно экономить байты куков
+// браузер сам по умолчанию возьмет текущий путь
+function set_cookie( name, value ) {
+    document.cookie = encodeURIComponent( name ) + "=" + encodeURIComponent( value ) + ";SameSite=Lax";
+}
+
+function set_cookie_expires( name, value ) {
+    let expires = ( new Date( Date.now() + 365 * 86400 * 1000 ) ).toUTCString();
+    document.cookie = encodeURIComponent( name ) + "=" + encodeURIComponent( value ) + ";SameSite=Lax;expires=" + expires;
+}
+
+// Чтобы удалить куки нужно установить отрицательную дату истечения срока действия
+function del_cookie( name ) {
+    document.cookie = encodeURIComponent( name ) + "=;SameSite=Lax;max-age=-1";
+}
+
+// Notifications
+
+// Уведомления, отображающиеся в правом верхнем. Обычно в течении 5 сек.
+// Через 5 секунд к уведомлению добавляется класс timeout,
+// благодаря ему происходит исчезновение уведомления.
+function notify( message, classes, msec ) {
+    let bulb = document.createElement( "div" );
+    bulb.innerHTML = message;
+    bulb.className = classes;
+    document.querySelector( ".log-info-box" ).appendChild( bulb );
+    let h = bulb.offsetHeight;
+    // Чтобы анимировать схлопывание
+    bulb.setAttribute( "style", `height:${h}px` );
+    if ( msec ) {
+        setTimeout( function() {
+            bulb.classList.add( "timeout" );
+        }, msec);
+    }
+}
+
+// Translate
+// module: "admin.mod.php" or "pages.mod.php" etc
+
+// Шаблон admin добавляет глобальную js-переменную
+// в которой содержится текущая локаль и переводы,
+// загруженные из файлов .cms/lang/...
+// Поэтому ими можно пользоваться и на стороне админки.
+function __( str, module ) {
+    if ( cms && cms.locale && cms.lang && cms.lang[module] && cms.lang[module][cms.locale] && cms.lang[module][cms.locale][str] ) {
+        return cms.lang[module][cms.locale][str];
+    } else {
+        api( { fn: "no_translation", str: str, module: module } );
+        return str;
+    }
+}
+
+// Call server side API
+
+// Передаваемые на сервер данные упаковываются как положено
+// и можно передавать даже массивы.
+// Массивы вложенные в массивы воспринимаются как объекты
+// и кодируются в JSON.
+// После того, как сервер вернет ответ, вызывается функция rfn
+// И ответ передается ей в качестве параметра.
+function api( data, rfn, efn ) {
+    const formData = new FormData();
+    buildFormData( formData, data );
+    // send data
+    // По умолчанию запросы отправляются асинхронно,
+    // но если нужно дождаться ответа и затем изменить полученные данные,
+    // то перед вызовом обновляющих функций нужно дописать строчку
+    // cms.async_api = false;
+    let ajax = new XMLHttpRequest();
+    ajax.addEventListener( "load", function( event ) {
+        let data = {};
+        try {
+            data = JSON.parse( event.target.responseText );
+        } catch {
+            notify( __( "server_error", "admin.mod.php" ), "info-error", 5000 );
+            if ( efn ) {
+                efn( event );
+            }
+        }
+        if ( rfn ) {
+            rfn( data );
+        }
+    } );
+    ajax.addEventListener( "error", function( event ) {
+        notify( __( "network_error", "admin.mod.php" ), "info-error", 5000 );
+        if ( efn ) {
+            efn( event );
+        }
+    } );
+    ajax.open( "POST", cms.api, cms.async_api );
+	ajax.send( formData );
+    cms.async_api = true;
+}
+function buildFormData( formData, data, parentKey ) {
+    if ( data && typeof data === 'object' && ! ( data instanceof Date ) && ! ( data instanceof File ) ) {
+        Object.keys( data ).forEach( key => {
+            buildFormData( formData, data[key], parentKey ? `${parentKey}[${key}]` : key );
+        } );
+    } else {
+        const value = data == null ? '' : data;
+        formData.append( parentKey, value );
+    }
+}
+
+
+// Create and connect Codemirror
+function codemirror_connect( selector, name, options = {} ) {
+
+    if ( window[name] ) return;
+
+    let default_options = {
+        mode: "application/x-httpd-php",
+        styleActiveLine:   true,
+        lineNumbers:       true,
+        lineWrapping:      true,
+        autoCloseBrackets: true,
+        smartIndent:       true,
+        indentUnit:        4,
+        tabSize:           4,
+        matchBrackets:     true,
+        foldGutter: true,
+        gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
+        autoCloseTags: {
+            whenClosing: true,
+            whenOpening: true,
+            indentTags:  [ "div", "ul", "ol", "script", "style" ],
+        },
+        phrases: {
+            "Search:":                              __( "codemirror_search", "admin.mod.php" ),
+            "(Use /re/ syntax for regexp search)" : __( "codemirror_re", "admin.mod.php" ),
+            "Replace all:":                         __( "codemirror_replace_all", "admin.mod.php" ),
+            "With:":                                __( "codemirror_replace_with", "admin.mod.php" ),
+            "Replace:":                             __( "codemirror_replace_replace", "admin.mod.php" ),
+            "Replace?":                             __( "codemirror_replace_confirm", "admin.mod.php" ),
+            "Yes":                                  __( "codemirror_yes", "admin.mod.php" ),
+            "No":                                   __( "codemirror_no", "admin.mod.php" ),
+            "All":                                  __( "codemirror_all", "admin.mod.php" ),
+            "Stop":                                 __( "codemirror_stop", "admin.mod.php" ),
+        },
+        extraKeys: { "Ctrl-Space": "autocomplete" }
+    }
+
+    let txtarea = document.querySelector( selector );
+    options = Object.assign( default_options, options );
+    window[name] = CodeMirror.fromTextArea( txtarea, options );
+
+    if ( window[name].showHint ) {
+        window[name].on( "keydown", function( editor, event ) {
+            if ( event.ctrlKey == true ) { return } // Ctrl+S call Hint
+            let isAlphaKey = /^[a-zA-Z]$/.test( event.key );
+            if ( window[name].state.completionActive && isAlphaKey ) {
+                return;
+            }
+
+            // Prevent autocompletion in string literals or comments
+            let cursor = window[name].getCursor();
+            let token = window[name].getTokenAt( cursor );
+            if ( token.type === "string" || token.type === "comment" ) {
+                return;
+            }
+            
+            let lineBeforeCursor = window[name].doc.getLine( cursor.line );
+            if ( typeof lineBeforeCursor !== "string" ) {
+                return;
+            }
+            lineBeforeCursor = lineBeforeCursor.substring( 0, cursor.ch );
+
+            // disable autoclose tag before text
+            let charAfterCursor  = window[name].doc.getLine( cursor.line );
+            charAfterCursor = charAfterCursor.substring( cursor.ch, cursor.ch + 1 );
+            window[name].options.autoCloseTags.dontCloseTags = null;
+            if ( charAfterCursor.match( /\S/ ) && charAfterCursor != "<" ) {
+                if ( lineBeforeCursor.match( /<[^>]+$/ ) ) {
+                    let tag = lineBeforeCursor.match( /<(\w+)\b[^>]*$/ );
+                    if ( tag ) {
+                        tag = tag[1];
+                        window[name].options.autoCloseTags.dontCloseTags = [tag];
+                    }
+                }
+            }
+            
+            let m = CodeMirror.innerMode( window[name].getMode(), token.state );
+            let innerMode = m.mode.name;
+            let shouldAutocomplete;
+            if ( innerMode === "html" || innerMode === "xml" ) {
+                shouldAutocomplete = event.key === "<" ||
+                    event.key === "/" && token.type === "tag" ||
+                    isAlphaKey && token.type === "tag" ||
+                    isAlphaKey && token.type === "attribute" ||
+                    token.string === "=" && token.state.htmlState && token.state.htmlState.tagName;
+            } else if ( innerMode === "css" ) {
+                shouldAutocomplete = isAlphaKey ||
+                    event.key === ":" ||
+                    event.key === " " && /:\s+$/.test( lineBeforeCursor );
+            } else if ( innerMode === "javascript" ) {
+                shouldAutocomplete = isAlphaKey || event.key === ".";
+            } else if ( innerMode === "clike" && window[name].options.mode === "php" ) {
+                shouldAutocomplete = token.type === "keyword" || token.type === "variable";
+            }
+            if ( shouldAutocomplete ) {
+                window[name].showHint( { completeSingle: false } );
+            }
+        } );
+    }
+
+    // Replace < with &lt; in code context
+    // https://stackoverflow.com/a/36388061/20443861
+    window[name].on( "inputRead", function( cm, event ) {
+        if ( event.origin == "paste" ) {
+            if ( cm.paste_context == "code" ) {
+                let text = event.text.join( "\n" ); // pasted string
+                let new_text = text.replaceAll( /</g, "&lt;" );
+                new_text = new_text.replaceAll( /&&/g, "&amp;&amp;" );
+                cm.execCommand( "undo" );
+                cm.replaceSelection( new_text );
+            }
+        }
+    } );
+    window[name].on( "paste", async function( editor, event ) {
+        let cursor = window[name].getCursor();
+        let token = window[name].getTokenAt( cursor );
+        if ( token && token.state && token.state.html && token.state.html.htmlState && token.state.html.htmlState.context && token.state.html.htmlState.context.tagName == "code" ) {
+            window[name].paste_context = "code";
+        } else {
+            window[name].paste_context = "";
+        }
+    } );
+
+}
+
+
+document.addEventListener( "DOMContentLoaded", function( event ) {
+
+    function _( str ) {
+        return __( str, "admin.mod.php" );
+    }
+
+    // Mob Menu
+    document.querySelectorAll( "header .burger, .milk" ).forEach( function( el ) {
+        el.onclick = function() {
+            document.body.classList.toggle( "mobile-menu-open" );
+        }
+    } );
+
+    // Навигация стрелками браузера - фикс активного пункта
+    window.addEventListener( "popstate", function( e ) {
+        // Очищаем без всяких проверок чтобы не подсвечивало на #start
+        document.querySelectorAll( "aside a" ).forEach( function( page ) {
+            page.classList.remove( "active" );
+        } );
+        // Выделяем нужную ссылку
+        let target = window.location.hash;
+        let a = document.querySelector( `aside a[href="${target}"]` );
+        if ( a ) {
+            a.classList.add( "active" );
+        }
+        // надо ли непонятно, скопировал из предыдущего обработчика кликов на всякий случай
+        document.body.classList.remove( "mobile-menu-open" );
+    } );
+    
+    // Theme switcher
+    document.querySelectorAll( ".theme-switcher" ).forEach( function( el ) {
+        el.addEventListener( "click", function( event ) {
+            event.preventDefault();
+            let n = get_cookie( "theme" ) || 0;
+            let styles2 = admin_styles[n];
+            let styles = styles2.split( " " );
+            styles.forEach( function( style ) {
+                document.documentElement.classList.remove( style );
+            } );
+            n = (+n+1) % admin_styles.length;
+            styles2 = admin_styles[n];
+            styles = styles2.split( " " );
+            styles.forEach( function( style ) {
+                document.documentElement.classList.add( style );
+            } );
+            notify( admin_styles[n], "info-success", 5000 );
+            set_cookie( "theme" , n );
+            theme_event = new Event( "theme" );
+            document.dispatchEvent( theme_event );
+        } );
+    } );
+
+    // Logout
+    document.querySelectorAll( "[data-logout]" ).forEach( function ( logoutBtn ) {
+        logoutBtn.addEventListener( "click", function() {
+            // Отправляем на бекенд чтобы закрыло сессию
+            api( { fn: "logout" },
+                // Перезагрузка страницы в случае если админка ответила
+                function() {
+                    window.location.reload( true );
+                },
+                // Даже если нет связи, удаляем куки и перезагружаем страницу
+                function() {
+                    del_cookie( "sess" );
+                    window.location.reload( true );
+                }
+             );
+        });
+    } );
+    
+    // Highlight active menu
+    if ( document.body.classList.contains( "logged" ) ) {
+        let page = window.location.hash;
+        // Зачем тут исключение #start?
+        if ( page && page != "#start" ) {
+            let el = document.querySelector( `a[href="${page}"]` );
+            if ( el ) el.classList.add( "active" );
+        } else if ( document.querySelector( "#start a[href='#base']" ) ) {
+            // Приветственная страничка с указанием подключиться к БД
+            window.location.hash = "#start";
+        } else {
+            // Если никакой пункт не выбран, выбираем по умолчанию Страницы
+            window.location.hash = "#pages";
+        }
+    }
+
+    // Clear Cache
+    document.querySelectorAll( ".clear-cache" ).forEach( function( el ) {
+        el.addEventListener( "click", function( e ){
+            e.preventDefault();
+            api( { fn: "clear_cache" }, function( r ){
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                }
+            });
+        });
+    } );
+
+    
+    // Admin section, Save properties
+    document.querySelectorAll( "[data-am-save]" ).forEach( function( saveButton ) {
+        saveButton.addEventListener( "click", function( e ) {
+            let el       = this.closest( "[data-am-item]" );
+            let item     = el.getAttribute( "data-am-item" );
+            let selector = `#admin_menu [data-am-item="${item}"]`;
+            let title = document.querySelector( `${selector} [name=title]` );
+            if ( title ) {
+                title = title.value;
+            }
+            let section = document.querySelector( `${selector} .section-select-grid .field-select` );
+            if ( section ) {
+                section = section.getAttribute( "data-section" );
+            }
+            let data = {
+                fn:      "admin_menu_save",
+                type:    el.getAttribute( "data-am-type" ),
+                module:  el.getAttribute( "data-am-module" ),
+                item:    item,
+                title:   title,
+                sort:    document.querySelector( `${selector} [name=sort]` ).value,
+                section: section,
+                reset:   this.hasAttribute( "data-am-reset" ),
+            }
+            api( data, function( r ) {
+                if ( r.ok == "true" ) {
+                    window.location.reload( true );
+                }
+            } );
+        } );
+    } );
+
+    // Admin section, Delete Container
+    document.querySelectorAll( "[data-am-delete]" ).forEach( function( button ) {
+        button.addEventListener( "click", function( e ){
+            let item = this.closest( "[data-am-item]" ).getAttribute( "data-am-item" );
+            let childs = document.querySelectorAll( `[data-am-childs="${item}"] > div` ).length;
+            if ( childs ) {
+                notify( _( "not_empty_section" ), "info-error", 2000 );
+                return;
+            }
+            if ( ! confirm( _( "confirm_delete" ) ) ) return;
+            let data = {
+                fn: "admin_menu_del",
+                item: item,
+            }
+            api( data, function( r ) {
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                    if ( r.info_time ) {
+                        setTimeout( function() {
+                            window.location.reload( true );
+                        }, r.info_time );
+                    }
+                }
+            } );
+        } );
+    } );
+
+    // Admin section, Hide
+    document.querySelectorAll( "[data-am-sw]" ).forEach( function( button ) {
+        button.addEventListener( "click", function( e ) {
+            let el   = this.closest( "[data-am-item]" );
+            let data = {
+                fn:      "admin_menu_hide",
+                type:    el.getAttribute( "data-am-type" ),
+                module:  el.getAttribute( "data-am-module" ),
+                item:    el.getAttribute( "data-am-item" ),
+                hide:    el.classList.contains( "showed" ),
+            }
+            if ( data.item == "admin_menu" ) {
+                if ( ! confirm( _( "hide_admin_settings" ) ) ) return false;
+            }
+            api( data, function( r ) {
+                if ( r.ok == "true" ) {
+                    window.location.reload( true );
+                }
+            } );
+        } );
+    } );
+
+    // Admin section, Add Section
+    document.querySelectorAll( "#admin_menu .main-footer .add-section" ).forEach( function( button ) {
+        button.addEventListener( "click", function( e ) {
+            api( { fn: "admin_menu_add_section" }, function( r ) {
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                    if ( r.info_time )
+                    setTimeout( function() {
+                        window.location.reload( true );
+                    }, r.info_time );
+                }
+            } );
+        } );
+    } );
+
+
+    // Reset all items in Admin Menu
+    document.querySelectorAll( "#admin_menu .main-footer .reset-all" ).forEach( function( button ) {
+        button.addEventListener( "click", function( e ) {
+            api( { fn: "reset_admin_menu_items" }, function( r ) {
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                    if ( r.info_time )
+                    setTimeout( function() {
+                        window.location.reload( true );
+                    }, r.info_time );
+                }
+            } );
+        } );
+    } );
+
+
+    // Disable Modules
+    document.querySelectorAll( "#modules .module-sw-btn" ).forEach( function( button ) {        
+        button.addEventListener( "click", function( e ) {
+            let closest = this.closest( "[data-module]" );
+            let data = {
+                fn: "module_disable",
+                disable: closest.classList.contains( "enabled" ),
+                module: closest.getAttribute( "data-module" ),
+            }
+            api( data, function( r ) {
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                } else {
+                    window.location.reload( true );
+                }
+            } );
+        } );
+    } );
+
+    // Delete Module
+    document.querySelectorAll( "#modules .module-del-btn" ).forEach( function( button ) {
+        button.addEventListener( "click", function( e ) {
+            let module = this.closest( "[data-module]" ).getAttribute( "data-module" );
+            let data = {
+                fn: "module_del",
+                module:  module,
+            }
+            api( data, function( r ) {
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                }
+            } );
+        } );
+    } );
+
+    // Close Sessions
+    document.querySelectorAll( "#auth [data-login]" ).forEach( function( button ) {
+        button.addEventListener( "click", function( e ) {
+            e.preventDefault();
+            if ( ! confirm( _( "confirm_logout" ) ) ) return;
+            let parent = button.parentElement;
+            var data = {
+                fn: "logout",
+                sess: this.getAttribute( "data-login" ),
+            }
+            api( data, function( r ) {
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                    if ( r.result == "refresh" ) {
+                        // refresh приходит если мы закрываем свою сессию
+                        window.location.reload( true );
+                    } else if ( r.result == "ok" ) {
+                        // Перемещаем сессию в закрытые путем копирования html,
+                        // предварительно удалив класс del-sess чтобы не отображался крестик.
+                        // А старый элемент удаляем.
+                        // При копировании html так же удаляется привязанная функция.
+                        button.classList.remove( "del-sess" );
+                        let html = parent.outerHTML;
+                        parent.remove();
+                        document.querySelector( ".history-sess .sess-table" ).insertAdjacentHTML( "afterbegin", html );
+                    }
+                }
+            } );
+        } );
+    } );
+
+    // Show/Hide password
+    document.querySelectorAll( ".password-eye" ).forEach( function( eye ) {
+        eye.addEventListener( "click", function( e ) {
+            this.classList.toggle( "showed" );
+            let inp = this.previousElementSibling;
+            let t   = inp.getAttribute( "type" );
+            if ( t == "password" ) {
+                inp.setAttribute( "type", "text" );
+            } else {
+                inp.setAttribute( "type", "password" );
+            }
+            inp.focus();
+        } );
+    } );
+
+    // Install module (upload module)
+    let input = document.querySelector( "#module-upload" );
+    if ( input ) input.addEventListener( "change", async function( e ) {
+        const formData = new FormData();
+        formData.append( "fn", "install_module" );
+        for ( let i = 0; i < input.files.length; i++ ) {
+            formData.append( "myfile[]", input.files[i] );
+        }
+        let ajax = new XMLHttpRequest();
+        /*
+        ajax.upload.addEventListener( "progress", function( event ) {
+            let percent = Math.round( (event.loaded / event.total) * 100 );
+            bar.style.width = percent + "%";
+        }, false );
+
+        ajax.addEventListener( "error", function( event ) {
+            notify( _( "error_upload_file" ), "info-error", 3600000 );
+            bar.style = "";
+        }, false );
+        
+        ajax.addEventListener( "abort", function( event ) {
+            notify( _( "error_upload_file" ), "info-error", 3600000 );
+            bar.style = "";
+        }, false );
+        */
+        let google_chrome_fix = this;
+        ajax.addEventListener( "load", function( event ) {
+            google_chrome_fix.value = "";
+            let r = JSON.parse( event.target.responseText );
+            if ( r.info_text ) {
+                notify( r.info_text, r.info_class, r.info_time );
+                if ( r.info_class === "info-success" ) setTimeout( function() {
+                    window.location.reload( true );
+                }, r.info_time );
+            }
+        } );
+        ajax.open( "POST", cms.api );
+	    ajax.send( formData );
+    } );
+
+    // БД. Открытие дополнительных настроек.
+    document.querySelectorAll( "#base .pro-btn" ).forEach( function( pro ) {
+        pro.addEventListener( "click", function( e ) {
+            document.querySelector( "#base form" ).classList.toggle( "show-pro" );
+        } );
+    } );
+
+    // Выбор секции Админского меню. Select
+    document.querySelectorAll( "#admin_menu .field-select" ).forEach( function( select ) {
+        select.addEventListener( "click", function( e ) {
+            e.stopPropagation();
+            this.parentElement.classList.toggle( "open" );
+            
+            // это можно убрать если переставить стили на родителя
+            select.nextElementSibling.classList.toggle( "open" );
+        } );
+    } );
+    
+    // Выбор секции Админского меню. Option
+    document.querySelectorAll( "#admin_menu .field-options .option" ).forEach( function( select ) {
+        select.addEventListener( "click", function( e ) {
+            let input = this.closest( ".section-select-grid" ).querySelector( ".field-select" );
+            input.querySelector( ".value" ).innerText = this.innerText;
+            input.setAttribute( "data-section", this.getAttribute( "value" ) );
+        } );
+    } );
+    // Select
+    // Закрытие выпадающих списков при кликах вне их, а так же по ним
+    document.body.addEventListener( "click", function( e ) {
+        document.querySelectorAll( "#admin_menu .section-select-grid" ).forEach( function( list ) {
+            list.classList.remove( "open" );
+        } );
+
+        // это можно убрать если переставить стили на родителя
+        document.querySelectorAll( "#admin_menu .field-options" ).forEach( function( list ) {
+            list.classList.remove( "open" );
+        } );
+    } );
+
+
+    /* Обновление и проверка файлов */
+
+    document.querySelectorAll( "#modules [data-fn]" ).forEach( function( button ) {
+        button.addEventListener( "click", function() {
+            let fn = this.getAttribute( "data-fn" );
+            let data = {
+                fn: fn
+            }
+            api( data, function( r ) {
+                if ( r.answer ) {
+                    switch ( fn ) {
+                        case "cms_update":
+                            document.querySelector( "#modules .update-window" ).insertAdjacentHTML( "beforeend", r.answer );
+                            break;
+                        case "create_zip":
+                            document.querySelector( "#modules .dev-window" ).insertAdjacentHTML( "beforeend", r.answer );
+                            break;
+                        case "cms_check_update":
+                        case "cms_check_dev_update":
+                            document.querySelector( "#modules .check-answer" ).innerHTML = r.answer;
+                            break;
+                        case "cms_changed_files":
+                            let div = document.querySelector( "#modules .changed-files" );
+                            div.innerHTML = r.answer;
+                            div.style.display = "";
+                            break;
+                    }
+                }
+                if ( r.info_text ) {
+                    notify( r.info_text, r.info_class, r.info_time );
+                }
+                if ( r.reload ) {
+                    setTimeout( function() {
+                        window.location.reload( true );
+                    }, r.info_time );
+                }
+            } );
+        } );
+    } );
+
+    // show update from dev buttons
+    document.querySelectorAll( "#modules [data-show-dev]" ).forEach( function( btn ) {
+        btn.addEventListener( "click", function() {
+            let dev = document.querySelector( "#modules .developers_only" );
+            if ( dev ) {
+                dev.classList.remove( "developers_only" );
+                if ( window.location.host == "dev.coffee-cms.ru" ) {
+                    dev.querySelector( "#modules [data-fn='create_zip']" ).removeAttribute( "style" );
+                }
+            }
+            this.remove();
+        } );
+    } );
+
+} );