when expanded */
+ line-height: 16px;
+}
+
+/* Root-files block: remove bottom spacing so next top-level folder starts immediately */
+[data-theme="Classic"] .classic-edit-files .classic-folder.classic-folder-files-root {
+ margin-bottom: 0 !important; /* overrides .classic-folder { margin-bottom: 10px; } */
+ padding-bottom: 0 !important; /* overrides .classic-folder-files-root { padding-bottom: 10px; } */
+}
+
+/* Root UL: remove the generic bottom margin applied by .classic-edit-files ul */
+[data-theme="Classic"] .classic-edit-files
+ .classic-folder.classic-folder-files-root
+ > .classic-folder-list.classic-folder-root-list {
+ margin-bottom: 0 !important; /* overrides .classic-edit-files ul { margin: 0 0 10px 0; } */
+}
+
+/* === Classic edit file tree: normalized spacing + connectors (final override) === */
+[data-theme="Classic"] .classic-edit-files {
+ --classic-tree-bg: #f9f9f9;
+ --classic-tree-line: #999;
+
+ --classic-tree-gutter: 16px;
+ --classic-tree-arm: 12px;
+ --classic-tree-indent: 34px; /* gutter + arm + small gap */
+
+ --classic-tree-row-height: 20px;
+ --classic-tree-row-mid: 10px; /* must match row-height/2 */
+}
+
+/* Remove “mystery gaps” coming from generic list spacing and folder blocks */
+[data-theme="Classic"] .classic-edit-files ul {
+ margin: 0 !important;
+}
+
+[data-theme="Classic"] .classic-edit-files .classic-folder {
+ margin-bottom: 0 !important; /* overrides .classic-folder { margin-bottom: 10px; } */
+}
+
+[data-theme="Classic"] .classic-edit-files .classic-folder-files-root {
+ padding-bottom: 0 !important; /* avoid extra space after root block */
+}
+
+/* Make every tree row consistent */
+[data-theme="Classic"] .classic-edit-files .classic-folder-list > li {
+ margin: 0 !important;
+ padding-left: var(--classic-tree-indent) !important;
+ line-height: var(--classic-tree-row-height);
+ position: relative;
+}
+
+/* File rows + folder rows: same height, same vertical padding */
+[data-theme="Classic"] .classic-edit-files .classic-folder-list li > button.btn-small,
+[data-theme="Classic"] .classic-edit-files .classic-folder-toggle {
+ display: block;
+ width: 100%;
+ margin: 0 !important;
+ padding: 0 8px !important;
+ min-height: var(--classic-tree-row-height);
+ line-height: var(--classic-tree-row-height);
+}
+
+/* Folder toggles sometimes add bottom margin -> kill it for uniform spacing */
+[data-theme="Classic"] .classic-edit-files .classic-folder-toggle {
+ margin-bottom: 0 !important;
+}
+
+/* One connector model for ALL lists in the tree */
+[data-theme="Classic"] .classic-edit-files .classic-folder-list::before {
+ content: "";
+ position: absolute;
+ left: var(--classic-tree-gutter);
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: var(--classic-tree-line);
+}
+
+[data-theme="Classic"] .classic-edit-files .classic-folder-list > li::before {
+ content: "";
+ position: absolute;
+ left: var(--classic-tree-gutter);
+ top: var(--classic-tree-row-mid);
+ width: var(--classic-tree-arm);
+ height: 1px;
+ background: var(--classic-tree-line);
+}
+
+/* Stop the vertical line after last child (works for root + nested) */
+[data-theme="Classic"] .classic-edit-files .classic-folder-list > li:last-child::after {
+ content: "";
+ position: absolute;
+ left: var(--classic-tree-gutter);
+ top: var(--classic-tree-row-mid);
+ bottom: 0;
+ width: 1px;
+ background: var(--classic-tree-bg);
+}
+
+/* Root button -> tree: do NOT draw the extra container line */
+[data-theme="Classic"] .classic-edit-files .classic-folder-files-root::before {
+ content: none !important;
+}
+
+/* Root list vertical line should reach up into the button gap */
+[data-theme="Classic"] .classic-edit-files
+ .classic-folder-files-root
+ > .classic-folder-root-list::before {
+ top: -6px; /* matches .classic-folder-root-toggle { margin-bottom: 6px } */
+}
+
+/* Save button highlight when editor has unsaved changes */
+[data-theme="Classic"] .classic-edit-panel [data-classic-save].classic-save-dirty {
+ background: linear-gradient(#fff7e6, #ffd9a6);
+ border-color: var(--accent-warn);
+ box-shadow: 0 0 0 2px rgba(224, 139, 44, 0.25);
+ color: #222;
+}
+
+/* Confirmation Dialog - Unsaved Changes Warning */
+.classic-confirm-dialog-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+}
+
+.classic-confirm-dialog {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+ width: 90%;
+ max-width: 450px;
+ overflow: hidden;
+}
+
+.classic-confirm-dialog-header {
+ background: linear-gradient(#f0f0f0, #e5e5e5);
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.classic-confirm-dialog-header h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: bold;
+ color: var(--text-primary);
+}
+
+.classic-confirm-dialog-content {
+ padding: 16px;
+ color: var(--text-primary);
+ line-height: 1.5;
+}
+
+.classic-confirm-dialog-content p {
+ margin: 0 0 8px 0;
+}
+
+.classic-confirm-dialog-content p:last-child {
+ margin-bottom: 0;
+}
+
+.classic-confirm-dialog-actions {
+ padding: 12px 16px;
+ border-top: 1px solid var(--border-color);
+ background: #fafafa;
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.classic-confirm-dialog-actions button {
+ cursor: pointer;
+ padding: 6px 14px;
+ font-size: 13px;
+ min-width: 80px;
+}
+
+.classic-confirm-dialog-actions .btn-primary {
+ background: linear-gradient(var(--accent), var(--accent-dark));
+ color: white;
+ border-color: var(--accent-dark);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0 1px 2px var(--shadow);
+}
+
+.classic-confirm-dialog-actions .btn-primary:hover {
+ background: linear-gradient(var(--accent-dark), #1f5a1f);
+ border-color: #1f5a1f;
+ color: white;
+}
+
+/* Database Views */
+[data-theme="Classic"] .classic-db-breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+ font-size: 14px;
+}
+
+[data-theme="Classic"] .classic-db-back-link {
+ color: var(--accent);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+[data-theme="Classic"] .classic-db-back-link:hover {
+ text-decoration: underline;
+}
+
+[data-theme="Classic"] .classic-db-breadcrumb-sep {
+ color: #999;
+ font-size: 16px;
+}
+
+[data-theme="Classic"] .classic-db-tables-list {
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+[data-theme="Classic"] .classic-db-table-row {
+ display: grid;
+ grid-template-columns: 250px 1fr;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: background-color 0.15s;
+ background: white;
+}
+
+[data-theme="Classic"] .classic-db-table-row:last-child {
+ border-bottom: none;
+}
+
+[data-theme="Classic"] .classic-db-table-row:hover {
+ background: #f5f9f5;
+}
+
+[data-theme="Classic"] .classic-db-table-name {
+ font-weight: 600;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+[data-theme="Classic"] .classic-db-table-name i {
+ color: var(--accent);
+}
+
+[data-theme="Classic"] .classic-db-table-fields {
+ color: #666;
+ font-size: 13px;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+[data-theme="Classic"] .classic-table-content {
+ padding: 8px 0;
+}
+
+[data-theme="Classic"] .classic-table-info {
+ margin-bottom: 20px;
+ padding: 16px;
+ background: #f9f9f9;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+}
+
+[data-theme="Classic"] .classic-table-info h4 {
+ margin: 0 0 12px 0;
+ color: var(--text);
+}
+
+[data-theme="Classic"] .classic-table-info p {
+ margin: 8px 0;
+ color: #666;
+}
+
+[data-theme="Classic"] .classic-table-preview {
+ padding: 16px;
+ background: #fffef5;
+ border: 1px solid #e0dfc0;
+ border-radius: 4px;
+}
+
+[data-theme="Classic"] .classic-table-note {
+ margin: 0;
+ color: #666;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ line-height: 1.5;
+}
+
+[data-theme="Classic"] .classic-table-note i {
+ color: #6b9b37;
+ margin-top: 2px;
+}
+
+/* DBAdmin Page Redesign */
+[data-theme="Classic"] .dbadmin {
+ max-width: 100%;
+ padding: 20px;
+}
+
+[data-theme="Classic"] .dbadmin-page .dbadmin-header {
+ padding: 10px 20px 6px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ column-gap: 16px;
+}
+
+[data-theme="Classic"] .dbadmin-page .header-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+[data-theme="Classic"] .dbadmin-page .spinner-top {
+ height: 48px;
+ width: 48px;
+ padding: 0;
+}
+
+[data-theme="Classic"] .dbadmin-page .logo {
+ font-size: 28px;
+ line-height: 1.1;
+ padding: 0;
+}
+
+[data-theme="Classic"] .dbadmin-page .header-right {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ justify-self: end;
+ gap: 10px;
+ max-width: 100%;
+}
+
+/* DBAdmin Navigation Buttons in Header */
+[data-theme="Classic"] .dbadmin-nav-buttons {
+ display: flex;
+ gap: 8px;
+}
+
+[data-theme="Classic"] .dbadmin-page .header-theme {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+[data-theme="Classic"] .dbadmin-page .header-theme label {
+ font-size: 12px;
+}
+
+[data-theme="Classic"] .dbadmin-page .header-theme select {
+ width: 140px;
+}
+
+[data-theme="Classic"] .dbadmin-nav-buttons .header-btn {
+ padding: 6px 12px;
+ font-size: 12px;
+ height: 28px;
+ line-height: 16px;
+ font-weight: 500;
+ cursor: pointer;
+ border: 1px solid #9c9c9c;
+ background: linear-gradient(#f8f8f8, #dedede);
+ border-radius: 3px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 0.2s ease;
+ box-shadow: inset 0 1px 0 #fff, 0 1px 2px var(--shadow);
+}
+
+[data-theme="Classic"] .dbadmin-nav-buttons .header-btn:hover {
+ background: linear-gradient(#f2fff2, #d7f0d7);
+ border-color: var(--accent);
+ color: #1e1e1e;
+}
+
+[data-theme="Classic"] .dbadmin-nav-buttons .header-btn.active {
+ background: linear-gradient(var(--accent), var(--accent-dark));
+ color: white;
+ border-color: var(--accent-dark);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 3px rgba(0, 0, 0, 0.2);
+}
+
+[data-theme="Classic"] .dbadmin-nav-buttons .header-btn.active:hover {
+ background: linear-gradient(#49a049, #2f6f2f);
+ border-color: #2f6f2f;
+ color: white;
+}
+
+[data-theme="Classic"] .dbadmin-nav-buttons .header-btn i {
+ font-size: 13px;
+}
+
+/* DBAdmin Content Area */
+[data-theme="Classic"] .dbadmin-content {
+ padding: 0;
+ background: var(--bg-primary);
+}
+
+[data-theme="Classic"] .dbadmin-content h2 {
+ margin: 0 0 20px 0;
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+ padding-bottom: 12px;
+ border-bottom: 2px solid var(--accent);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+[data-theme="Classic"] .dbadmin-content h2::before {
+ content: '\f1c0';
+ font-family: 'Font Awesome 5 Free';
+ font-weight: 900;
+ color: var(--accent);
+ font-size: 18px;
+}
+
+/* DBAdmin Grid Wrapper */
+[data-theme="Classic"] .dbadmin-content > div,
+[data-theme="Classic"] .dbadmin-content .grid-wrapper {
+ background: white;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ overflow: hidden;
+}
+
+/* Grid Table Styling */
+[data-theme="Classic"] .dbadmin table {
+ width: 100%;
+ border-collapse: collapse;
+ background: white;
+}
+
+[data-theme="Classic"] .dbadmin thead {
+ background: linear-gradient(#f8f8f8, #ececec);
+ border-bottom: 2px solid var(--border-color);
+}
+
+[data-theme="Classic"] .dbadmin thead th {
+ padding: 12px 16px;
+ text-align: left;
+ font-weight: 600;
+ font-size: 13px;
+ color: #444;
+ border-right: 1px solid #ddd;
+ white-space: nowrap;
+}
+
+[data-theme="Classic"] .dbadmin thead th:last-child {
+ border-right: none;
+}
+
+[data-theme="Classic"] .dbadmin thead th a {
+ color: #444;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+[data-theme="Classic"] .dbadmin thead th a:hover {
+ color: var(--accent);
+}
+
+[data-theme="Classic"] .dbadmin tbody tr {
+ border-bottom: 1px solid #e8e8e8;
+ transition: background-color 0.15s ease;
+}
+
+[data-theme="Classic"] .dbadmin tbody tr:last-child {
+ border-bottom: none;
+}
+
+[data-theme="Classic"] .dbadmin tbody tr:hover {
+ background: #f5f9f5;
+}
+
+[data-theme="Classic"] .dbadmin tbody td {
+ padding: 10px 16px;
+ font-size: 13px;
+ color: #333;
+ border-right: 1px solid #f0f0f0;
+}
+
+[data-theme="Classic"] .dbadmin tbody td:last-child {
+ border-right: none;
+}
+
+/* Grid Action Links */
+[data-theme="Classic"] .dbadmin tbody td a {
+ color: var(--accent);
+ text-decoration: none;
+ font-weight: 500;
+}
+
+[data-theme="Classic"] .dbadmin tbody td a:hover {
+ text-decoration: underline;
+ color: var(--accent-dark);
+}
+
+/* Grid Action Buttons */
+[data-theme="Classic"] .dbadmin .grid-action-buttons {
+ display: flex;
+ gap: 6px;
+}
+
+[data-theme="Classic"] .dbadmin .grid-action-buttons a,
+[data-theme="Classic"] .dbadmin .grid-action-buttons button {
+ padding: 4px 10px;
+ font-size: 12px;
+ border-radius: 3px;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ transition: all 0.15s ease;
+}
+
+[data-theme="Classic"] .dbadmin .grid-action-buttons a:hover,
+[data-theme="Classic"] .dbadmin .grid-action-buttons button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
+}
+
+/* Grid Search/Filter Area */
+[data-theme="Classic"] .dbadmin .grid-header {
+ padding: 16px 20px;
+ background: #fafafa;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+[data-theme="Classic"] .dbadmin .grid-search {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+[data-theme="Classic"] .dbadmin .grid-search input[type="text"] {
+ flex: 1;
+ padding: 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+[data-theme="Classic"] .dbadmin .grid-search input[type="text"]:focus {
+ border-color: var(--accent);
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(58, 141, 58, 0.1);
+}
+
+[data-theme="Classic"] .dbadmin .grid-search button {
+ padding: 8px 16px;
+ font-size: 13px;
+}
+
+/* Grid Pagination */
+[data-theme="Classic"] .dbadmin .grid-footer {
+ padding: 12px 20px;
+ background: #fafafa;
+ border-top: 1px solid #e0e0e0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+[data-theme="Classic"] .dbadmin .grid-pagination {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+[data-theme="Classic"] .dbadmin .grid-pagination a,
+[data-theme="Classic"] .dbadmin .grid-pagination span,
+[data-theme="Classic"] .dbadmin .grid-pagination .grid-pagination-button,
+[data-theme="Classic"] .dbadmin .grid-pagination .grid-pagination-button-current {
+ min-width: 38px;
+ height: 30px;
+ padding: 0 10px;
+ border: 1px solid var(--border-color);
+ border-radius: 3px;
+ font-size: 12px;
+ line-height: 28px;
+ text-align: center;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ text-decoration: none;
+ background: white;
+ color: var(--text-primary);
+ transition: all 0.15s ease;
+ box-shadow: none;
+}
+
+[data-theme="Classic"] .dbadmin .grid-pagination a:hover,
+[data-theme="Classic"] .dbadmin .grid-pagination .grid-pagination-button:hover {
+ background: linear-gradient(#f2fff2, #d7f0d7);
+ border-color: var(--accent);
+ color: var(--accent-dark);
+}
+
+[data-theme="Classic"] .dbadmin .grid-pagination span.active,
+[data-theme="Classic"] .dbadmin .grid-pagination .grid-pagination-button-current {
+ background: linear-gradient(var(--accent), var(--accent-dark));
+ color: white;
+ border-color: var(--accent-dark);
+ font-weight: 600;
+}
+
+[data-theme="Classic"] .dbadmin .grid-pagination span:not(.active):not(.grid-pagination-button-current) {
+ min-width: 0;
+ height: auto;
+ padding: 0 4px;
+ border: none;
+ background: transparent;
+ color: #666;
+ line-height: 1;
+}
+
+[data-theme="Classic"] .dbadmin .grid-info {
+ font-size: 13px;
+ color: #666;
+}
+
+/* Empty State */
+[data-theme="Classic"] .dbadmin .grid-empty {
+ padding: 40px 20px;
+ text-align: center;
+ color: #999;
+ font-size: 14px;
+}
+
+[data-theme="Classic"] .dbadmin .grid-empty i {
+ font-size: 48px;
+ color: #ddd;
+ margin-bottom: 16px;
+ display: block;
+}
\ No newline at end of file
diff --git a/apps/_dashboard/static/themes/Classic/theme.js b/apps/_dashboard/static/themes/Classic/theme.js
new file mode 100644
index 00000000..15547440
--- /dev/null
+++ b/apps/_dashboard/static/themes/Classic/theme.js
@@ -0,0 +1,1149 @@
+(() => {
+ const getAppBase = () => {
+ const parts = window.location.pathname.split("/").filter(Boolean);
+ return parts.length ? `/${parts[0]}` : "";
+ };
+
+ const encodePath = (path) =>
+ path.split("/").map(encodeURIComponent).join("/");
+
+ const setEditMode = (enabled) => {
+ const layout = document.querySelector(".classic-main-layout");
+ if (layout) {
+ layout.classList.toggle("classic-edit-mode", enabled);
+ }
+ document.body.classList.toggle("classic-edit-mode", enabled);
+ queueClassicLayoutResize();
+ };
+
+ const updateClassicEditHeight = () => {
+ const layout = document.querySelector(".classic-main-layout");
+ if (!layout) return;
+
+ const inEditMode =
+ document.body.classList.contains("classic-edit-mode") ||
+ layout.classList.contains("classic-edit-mode");
+
+ if (!inEditMode) {
+ layout.style.removeProperty("--classic-edit-height");
+ return;
+ }
+
+ const viewportHeight = window.visualViewport?.height || window.innerHeight;
+ const rect = layout.getBoundingClientRect();
+ const bottomGap = 20;
+ const availableHeight = Math.max(320, Math.floor(viewportHeight - rect.top - bottomGap));
+
+ layout.style.setProperty("--classic-edit-height", `${availableHeight}px`);
+ };
+
+ let resizeFrame = null;
+ const queueClassicLayoutResize = () => {
+ if (resizeFrame) return;
+ resizeFrame = window.requestAnimationFrame(() => {
+ resizeFrame = null;
+ updateClassicEditHeight();
+ });
+ };
+
+ let currentApp = null;
+
+ const renderEditLayout = (payload, view = 'files') => {
+ const leftPanel = document.querySelector(".classic-left-panel");
+ if (!leftPanel) return;
+
+ currentApp = payload;
+ setEditMode(true);
+
+ const actionButtons = `
+
+
+
+
+ `;
+
+ leftPanel.innerHTML = `
+
+
+
+ ${renderViewContent(payload, view)}
+
+
+ `;
+
+ queueClassicLayoutResize();
+ setupEventListeners(payload);
+ };
+
+ const renderViewContent = (payload, view) => {
+ switch (view) {
+ case 'files':
+ return renderFilesView(payload);
+ case 'databases':
+ return renderDatabasesView(payload);
+ case 'routes':
+ return renderRoutesView(payload);
+ case 'tickets':
+ return renderTicketsView(payload);
+ default:
+ return renderFilesView(payload);
+ }
+ };
+
+ let aceEditor = null;
+
+ // Track last opened file so we can reload + reopen after tree refresh
+ let classicReopenFilePath = null;
+
+ // Global dialog function accessible from all scopes
+ const showConfirmDialog = (title, message, confirmText, cancelText) => {
+ return new Promise((resolve) => {
+ const dialog = document.createElement("div");
+ dialog.className = "classic-confirm-dialog-overlay";
+ dialog.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(dialog);
+
+ const confirmBtn = dialog.querySelector("[data-confirm-action]");
+ const cancelBtn = dialog.querySelector("[data-confirm-cancel]");
+
+ const cleanup = () => {
+ dialog.remove();
+ };
+
+ confirmBtn.addEventListener("click", () => {
+ cleanup();
+ resolve(true);
+ });
+
+ cancelBtn.addEventListener("click", () => {
+ cleanup();
+ resolve(false);
+ });
+ });
+ };
+
+ // Check if editor has unsaved changes
+ const checkIfDirty = () => {
+ if (!aceEditor) return false;
+ const dirtyState = aceEditor.__classicDirtyState;
+ return Boolean(dirtyState) &&
+ aceEditor.getValue() !== dirtyState.lastLoadedText;
+ };
+
+ // Confirm if there are unsaved changes
+ const confirmIfDirtyGlobal = async () => {
+ if (!checkIfDirty()) {
+ return true; // No unsaved changes, proceed
+ }
+
+ return showConfirmDialog(
+ "Unsaved Changes",
+ "You have unsaved changes in the current file. Do you want to lose these changes or keep editing the file?",
+ "Lose Changes",
+ "Keep Editing"
+ );
+ };
+
+ // (FIX) Missing in current file: renderFilesView is referenced by renderViewContent()
+ const renderFilesView = (payload) => {
+ const sections = payload.sections || {};
+ const sectionOrder = Object.keys(sections);
+
+ // Build hierarchy: treat "" as root; top-level folders are children of ""
+ const hierarchy = { "": [] };
+
+ sectionOrder.forEach((section) => {
+ if (!section) return; // root
+ const parts = section.split("/");
+ const parentPath = parts.length === 1 ? "" : parts.slice(0, -1).join("/");
+ (hierarchy[parentPath] ||= []).push(section);
+ });
+
+ const createFolderTree = (folderPath, isRoot = false) => {
+ const folderName = folderPath.split("/").pop() || "root";
+ const files = sections[folderPath] || [];
+ const children = hierarchy[folderPath] || [];
+ const hasChildren = children.length > 0;
+
+ if (isRoot) {
+ let html = ``;
+
+ if (files.length > 0) {
+ html += files
+ .map(
+ (f) =>
+ ``
+ )
+ .join("");
+ }
+
+ if (hasChildren) {
+ children.forEach((childPath) => {
+ html += createFolderTree(childPath, false);
+ });
+ }
+
+ html += `
`;
+ return html;
+ }
+
+ let html = `-
+
+
+ `;
+
+ if (files.length > 0) {
+ html += files
+ .map(
+ (f) =>
+ ``
+ )
+ .join("");
+ }
+
+ if (hasChildren) {
+ children.forEach((childPath) => {
+ html += createFolderTree(childPath, false);
+ });
+ }
+
+ html += `
`;
+ return html;
+ };
+
+ const rootHasAny =
+ (sections[""]?.length || 0) + ((hierarchy[""] || []).length || 0);
+
+ const sectionHtml = rootHasAny
+ ? `
+
+
+ ${createFolderTree("", true)}
+
+ `
+ : `No files found.
`;
+
+ return `
+
+ ${sectionHtml}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ };
+
+ const renderDatabasesView = (payload) => {
+ return `
+
+
+
Loading databases...
+
+ `;
+ };
+
+ const renderRoutesView = (payload) => {
+ return `
+
+
+
Routes for ${payload.name}
+
+
+
Loading routes...
+
+ `;
+ };
+
+ const renderTicketsView = (payload) => {
+ return `
+
+
+
Tickets for ${payload.name}
+
+
+
+
+
+
Loading tickets...
+
+ `;
+ };
+
+ const setupEventListeners = (payload) => {
+ const leftPanel = document.querySelector(".classic-left-panel");
+ if (!leftPanel) return;
+
+ const view = leftPanel.querySelector("[data-edit-view]")?.dataset.editView;
+
+ leftPanel.querySelectorAll("[data-view]").forEach((btn) => {
+ btn.addEventListener("click", async (e) => {
+ e.preventDefault();
+ const shouldProceed = await confirmIfDirtyGlobal();
+ if (!shouldProceed) return;
+
+ const newView = btn.dataset.view;
+ renderEditLayout(payload, newView);
+ if (newView === "databases") loadDatabases(payload.name);
+ if (newView === "routes") loadRoutes(payload.name);
+ if (newView === "tickets") loadTickets(payload.name);
+ });
+ });
+
+ const backBtn = leftPanel.querySelector("[data-classic-edit-back]");
+ if (backBtn) {
+ backBtn.addEventListener("click", async (e) => {
+ e.preventDefault();
+ const shouldProceed = await confirmIfDirtyGlobal();
+ if (!shouldProceed) return;
+ window.location.reload();
+ });
+ }
+
+ const gitlogBtn = leftPanel.querySelector("[data-classic-edit-gitlog]");
+ if (gitlogBtn) {
+ gitlogBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ window.open(`../gitlog/${encodeURIComponent(payload.name)}`);
+ });
+ }
+
+ const translationsBtn = leftPanel.querySelector("[data-classic-edit-translations]");
+ if (translationsBtn) {
+ translationsBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ window.open(`../translations/${encodeURIComponent(payload.name)}`);
+ });
+ }
+
+ if (view === "files") setupFilesView(payload);
+ if (view === "databases") loadDatabases(payload.name);
+ if (view === "routes") {
+ loadRoutes(payload.name);
+ const routesReloadBtn = leftPanel.querySelector("[data-classic-routes-reload]");
+ if (routesReloadBtn) {
+ routesReloadBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ reloadAppRoutes(payload.name);
+ });
+ }
+ }
+ if (view === "tickets") {
+ loadTickets(payload.name);
+ const ticketsReloadBtn = leftPanel.querySelector("[data-classic-tickets-reload]");
+ const ticketsClearBtn = leftPanel.querySelector("[data-classic-tickets-clear]");
+ if (ticketsReloadBtn) {
+ ticketsReloadBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ reloadTicketsForApp(payload.name);
+ });
+ }
+ if (ticketsClearBtn) {
+ ticketsClearBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ clearTicketsForApp(payload.name);
+ });
+ }
+ }
+ };
+
+ const setupFilesView = (payload) => {
+ const leftPanel = document.querySelector(".classic-left-panel");
+ if (!leftPanel) return;
+
+ window.classicRefreshCurrentFiles = async () => {
+ const appBase = getAppBase();
+ try {
+ const res = await fetch(`${appBase}/app_detail/${encodeURIComponent(payload.name)}`);
+ if (!res.ok) throw new Error("Refresh failed");
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Refresh failed");
+ renderEditLayout(data.payload, "files");
+ } catch (err) {
+ console.error("[Classic Theme] Refresh failed:", err);
+ }
+ };
+
+ const editorEl = leftPanel.querySelector("[data-classic-editor]");
+ const saveBtn = leftPanel.querySelector("[data-classic-save]");
+ const reloadBtn = leftPanel.querySelector("[data-classic-reload]");
+ const deleteBtn = leftPanel.querySelector("[data-classic-delete]");
+ const addBtn = leftPanel.querySelector("[data-classic-add-file]");
+ const addFolderBtn = leftPanel.querySelector("[data-classic-add-folder]");
+ const uploadFileBtn = leftPanel.querySelector("[data-classic-upload-file]");
+ const status = leftPanel.querySelector("[data-classic-edit-status]");
+
+ // (Re)bind Ace if DOM changed
+ if (editorEl) {
+ if (aceEditor && aceEditor.container !== editorEl) {
+ try { aceEditor.destroy(); } catch (_) {}
+ aceEditor = null;
+ }
+ if (!aceEditor) {
+ aceEditor = ace.edit(editorEl);
+ aceEditor.setTheme("ace/theme/chrome");
+ aceEditor.setOptions({ fontSize: "12px", showPrintMargin: false, wrap: true });
+ }
+ }
+
+ const dirtyState =
+ aceEditor &&
+ (aceEditor.__classicDirtyState ||= { lastLoadedText: "", suppress: false });
+
+ let currentFolder = "";
+ let selectedFolder = null;
+ let currentFilePath = null;
+ let selectedFileElement = null;
+ let selectedFolderElement = null;
+
+ const clearSelection = () => {
+ if (selectedFileElement) selectedFileElement.classList.remove("selected-file");
+ if (selectedFolderElement) selectedFolderElement.classList.remove("selected-folder");
+ selectedFileElement = null;
+ selectedFolderElement = null;
+ };
+
+ const isDirty = () => {
+ return Boolean(aceEditor) &&
+ Boolean(dirtyState) &&
+ aceEditor.getValue() !== dirtyState.lastLoadedText;
+ };
+
+ const confirmIfDirty = async () => {
+ if (!isDirty()) {
+ return true; // No unsaved changes, proceed
+ }
+
+ return showConfirmDialog(
+ "Unsaved Changes",
+ "You have unsaved changes in the current file. Do you want to lose these changes or keep editing the file?",
+ "Lose Changes",
+ "Keep Editing"
+ );
+ };
+
+ const setDirtyUi = (dirty) => {
+ if (!saveBtn) return;
+ saveBtn.classList.toggle("classic-save-dirty", Boolean(dirty) && !saveBtn.disabled);
+ };
+
+ const disableSave = () => {
+ if (!saveBtn) return;
+ saveBtn.disabled = true;
+ saveBtn.classList.remove("classic-save-dirty");
+ };
+
+ const disableReload = () => {
+ if (reloadBtn) reloadBtn.disabled = true;
+ };
+
+ const clearEditor = () => {
+ if (!aceEditor) return;
+ if (dirtyState) dirtyState.suppress = true;
+ aceEditor.setValue("", -1);
+ if (dirtyState) {
+ dirtyState.lastLoadedText = "";
+ dirtyState.suppress = false;
+ }
+ setDirtyUi(false);
+ };
+
+ // Hook dirty tracking once
+ if (aceEditor && !aceEditor.__classicDirtyHooked) {
+ aceEditor.__classicDirtyHooked = true;
+ aceEditor.session.on("change", () => {
+ const state = aceEditor.__classicDirtyState;
+ if (!state || state.suppress) return;
+ setDirtyUi(aceEditor.getValue() !== state.lastLoadedText);
+ });
+ }
+
+ const loadFile = async (section, file, buttonElement) => {
+ selectedFolder = null;
+ currentFolder = section || "";
+ currentFilePath = null;
+
+ clearSelection();
+ if (buttonElement) {
+ buttonElement.classList.add("selected-file");
+ selectedFileElement = buttonElement;
+ }
+
+ const appBase = getAppBase();
+ const filePath = section ? `${payload.name}/${section}/${file}` : `${payload.name}/${file}`;
+ classicReopenFilePath = filePath;
+
+ if (status) status.textContent = `Loading ${filePath}...`;
+ disableSave();
+ disableReload();
+ if (deleteBtn) deleteBtn.disabled = true;
+
+ try {
+ const res = await fetch(`${appBase}/load/${encodePath(filePath)}`);
+ if (!res.ok) throw new Error("Load failed");
+ const data = await res.json();
+
+ if (aceEditor) {
+ const modelist = ace.require("ace/ext/modelist");
+ aceEditor.session.setMode(modelist.getModeForPath(filePath).mode);
+
+ if (dirtyState) dirtyState.suppress = true;
+ if (dirtyState) dirtyState.lastLoadedText = data.payload || "";
+ aceEditor.setValue(dirtyState ? dirtyState.lastLoadedText : (data.payload || ""), -1);
+ if (dirtyState) dirtyState.suppress = false;
+
+ setDirtyUi(false);
+ }
+
+ currentFilePath = filePath;
+ if (status) status.textContent = filePath;
+ if (saveBtn) saveBtn.disabled = false;
+ if (reloadBtn) reloadBtn.disabled = false;
+ if (deleteBtn) deleteBtn.disabled = false;
+ } catch (err) {
+ if (status) status.textContent = `Error: ${err.message}`;
+ }
+ };
+
+ const selectFolderUi = (folder, buttonElement, labelPrefix) => {
+ selectedFolder = folder;
+ currentFolder = folder;
+ currentFilePath = null;
+ classicReopenFilePath = null;
+
+ clearSelection();
+ if (buttonElement) {
+ buttonElement.classList.add("selected-folder");
+ selectedFolderElement = buttonElement;
+ }
+
+ if (status) status.textContent = `${labelPrefix}: ${folder}`;
+ disableSave();
+ disableReload();
+ if (deleteBtn) deleteBtn.disabled = false;
+ clearEditor();
+ };
+
+ const saveFile = async () => {
+ if (!currentFilePath || !aceEditor) return;
+
+ const appBase = getAppBase();
+ if (status) status.textContent = `Saving ${currentFilePath}...`;
+
+ try {
+ const res = await fetch(`${appBase}/save/${encodePath(currentFilePath)}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(aceEditor.getValue()),
+ });
+ if (!res.ok) throw new Error("Save failed");
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Save failed");
+
+ if (dirtyState) dirtyState.lastLoadedText = aceEditor.getValue();
+ setDirtyUi(false);
+ if (status) status.textContent = `Saved ${currentFilePath}`;
+ } catch (err) {
+ if (status) status.textContent = `Error: ${err.message}`;
+ }
+ };
+
+ const deleteSelected = async () => {
+ const appBase = getAppBase();
+
+ // delete folder if folder selected and no file open
+ if (!currentFilePath && selectedFolder != null) {
+ const folderPath = `${payload.name}/${selectedFolder}`;
+ if (!confirm(`Delete folder "${selectedFolder}"?`)) return;
+
+ if (status) status.textContent = `Deleting folder: ${selectedFolder}...`;
+ try {
+ const res = await fetch(`${appBase}/delete/${encodePath(folderPath)}`, { method: "POST" });
+ if (!res.ok) throw new Error("Delete failed");
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Delete failed");
+
+ // refresh tree after delete
+ const res2 = await fetch(`${appBase}/app_detail/${encodeURIComponent(payload.name)}`);
+ const data2 = await res2.json();
+ if (data2.status === "success") renderEditLayout(data2.payload, "files");
+ } catch (err) {
+ if (status) status.textContent = `Error: ${err.message}`;
+ }
+ return;
+ }
+
+ // delete file
+ if (!currentFilePath) return;
+ if (!confirm(`Delete ${currentFilePath}?`)) return;
+
+ if (status) status.textContent = `Deleting ${currentFilePath}...`;
+ try {
+ const res = await fetch(`${appBase}/delete/${encodePath(currentFilePath)}`, { method: "POST" });
+ if (!res.ok) throw new Error("Delete failed");
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Delete failed");
+
+ clearEditor();
+ currentFilePath = null;
+ classicReopenFilePath = null;
+ disableSave();
+ disableReload();
+ if (deleteBtn) deleteBtn.disabled = true;
+
+ const res2 = await fetch(`${appBase}/app_detail/${encodeURIComponent(payload.name)}`);
+ const data2 = await res2.json();
+ if (data2.status === "success") renderEditLayout(data2.payload, "files");
+ } catch (err) {
+ if (status) status.textContent = `Error: ${err.message}`;
+ }
+ };
+
+ const addFile = async () => {
+ const hint = currentFolder ? ` in ${currentFolder}/` : " in root";
+ const name = prompt(`Enter new file name${hint} (e.g., myfile.py):`);
+ if (!name) return;
+
+ const appBase = getAppBase();
+ const fullPath = currentFolder ? `${currentFolder}/${name}` : name;
+
+ if (status) status.textContent = `Creating file: ${fullPath}...`;
+ try {
+ const res = await fetch(
+ `${appBase}/new_file/${encodeURIComponent(payload.name)}/${encodePath(fullPath)}`,
+ { method: "POST" }
+ );
+ if (!res.ok) throw new Error("Create failed");
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Create failed");
+
+ const res2 = await fetch(`${appBase}/app_detail/${encodeURIComponent(payload.name)}`);
+ const data2 = await res2.json();
+ if (data2.status === "success") renderEditLayout(data2.payload, "files");
+ } catch (err) {
+ if (status) status.textContent = `Error: ${err.message}`;
+ }
+ };
+
+ const addFolder = async () => {
+ const hint = currentFolder ? ` in ${currentFolder}/` : " in root";
+ const name = prompt(`Enter new folder name${hint} (e.g., newfolder):`);
+ if (!name) return;
+
+ const appBase = getAppBase();
+ const fullPath = currentFolder ? `${currentFolder}/${name}` : name;
+
+ if (status) status.textContent = `Creating folder: ${fullPath}...`;
+ try {
+ const res = await fetch(
+ `${appBase}/new_file/${encodeURIComponent(payload.name)}/${encodePath(fullPath)}?folder=1`,
+ { method: "POST" }
+ );
+ if (!res.ok) throw new Error("Create failed");
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Create failed");
+
+ const res2 = await fetch(`${appBase}/app_detail/${encodeURIComponent(payload.name)}`);
+ const data2 = await res2.json();
+ if (data2.status === "success") renderEditLayout(data2.payload, "files");
+ } catch (err) {
+ if (status) status.textContent = `Error: ${err.message}`;
+ }
+ };
+
+ // Wire file clicks
+ leftPanel.querySelectorAll("[data-classic-file]").forEach((btn) => {
+ btn.addEventListener("click", async (e) => {
+ e.preventDefault();
+ const shouldProceed = await confirmIfDirty();
+ if (shouldProceed) {
+ loadFile(btn.dataset.section || "", btn.dataset.file, btn);
+ }
+ });
+ });
+
+ // Wire folder toggles
+ leftPanel.querySelectorAll(".classic-folder-toggle").forEach((btn) => {
+ btn.addEventListener("click", async (e) => {
+ e.preventDefault();
+ const folder = btn.dataset.folder || "";
+ const isEmpty = btn.dataset.emptyFolder === "true";
+ currentFolder = folder;
+
+ if (isEmpty) {
+ const shouldProceed = await confirmIfDirty();
+ if (shouldProceed) {
+ selectFolderUi(folder, btn, "Selected folder");
+ }
+ return;
+ }
+
+ const shouldProceed = await confirmIfDirty();
+ if (shouldProceed) {
+ const list = leftPanel.querySelector(`[data-folder-list="${folder}"]`);
+ if (list) list.classList.toggle("collapsed");
+ btn.classList.toggle("collapsed");
+ selectFolderUi(folder, btn, "Current folder");
+ }
+ });
+ });
+
+ if (saveBtn) saveBtn.addEventListener("click", (e) => { e.preventDefault(); saveFile(); });
+ if (deleteBtn) deleteBtn.addEventListener("click", (e) => { e.preventDefault(); deleteSelected(); });
+ if (addBtn) addBtn.addEventListener("click", (e) => { e.preventDefault(); addFile(); });
+ if (addFolderBtn) addFolderBtn.addEventListener("click", (e) => { e.preventDefault(); addFolder(); });
+ if (uploadFileBtn) {
+ uploadFileBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const dashboardApp = window.py4webDashboardApp;
+ if (!dashboardApp || typeof dashboardApp.upload_new_file !== "function") {
+ if (status) status.textContent = "Error: Upload action is not available.";
+ return;
+ }
+ dashboardApp.selected_app = { name: payload.name };
+ dashboardApp.selected_folder = currentFolder
+ ? `${payload.name}/${currentFolder}`
+ : null;
+ dashboardApp.upload_new_file();
+ });
+ }
+
+ if (reloadBtn) {
+ reloadBtn.addEventListener("click", async (e) => {
+ e.preventDefault();
+ const fileToReopen = classicReopenFilePath;
+ if (!fileToReopen) return;
+
+ const isDirtyFlag =
+ Boolean(aceEditor) &&
+ Boolean(dirtyState) &&
+ aceEditor.getValue() !== dirtyState.lastLoadedText;
+
+ if (isDirtyFlag) {
+ const shouldProceed = await showConfirmDialog(
+ "Discard Changes and Reload?",
+ "Discard unsaved changes and reload from disk?",
+ "Reload",
+ "Cancel"
+ );
+ if (!shouldProceed) return;
+ }
+
+ const appBase = getAppBase();
+ try {
+ const res = await fetch(`${appBase}/app_detail/${encodeURIComponent(payload.name)}`);
+ if (!res.ok) throw new Error("Reload failed");
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Reload failed");
+
+ classicReopenFilePath = fileToReopen;
+ renderEditLayout(data.payload, "files");
+ } catch (err) {
+ if (status) status.textContent = `Error: ${err.message}`;
+ }
+ });
+ }
+
+ // Auto-select: reopen file after Reload, else prefer __init__.py, else first file
+ setTimeout(() => {
+ if (classicReopenFilePath) {
+ const prefix = `${payload.name}/`;
+ const rel = classicReopenFilePath.startsWith(prefix)
+ ? classicReopenFilePath.slice(prefix.length)
+ : classicReopenFilePath;
+
+ const parts = rel.split("/");
+ const file = parts.pop();
+ const section = parts.join("/");
+
+ const candidates = leftPanel.querySelectorAll("[data-classic-file]");
+ for (const el of candidates) {
+ if ((el.dataset.section || "") === (section || "") && el.dataset.file === file) {
+ el.click();
+ return;
+ }
+ }
+ }
+
+ const init = leftPanel.querySelector('[data-classic-file][data-file="__init__.py"]');
+ if (init) { init.click(); return; }
+
+ const first = leftPanel.querySelector("[data-classic-file]");
+ if (first) first.click();
+ }, 0);
+ };
+
+ // Re-add missing async loaders for non-files views
+ const loadDatabases = async (appName) => {
+ const container = document.querySelector("[data-databases-container]");
+ const breadcrumb = document.querySelector("[data-db-breadcrumb]");
+ if (!container) return;
+
+ const appBase = getAppBase();
+ try {
+ const res = await fetch(`${appBase}/rest/${encodeURIComponent(appName)}`);
+ const data = await res.json();
+
+ if (data.status === "success" && data.databases) {
+ if (data.databases.length === 0) {
+ if (breadcrumb) breadcrumb.innerHTML = `Databases for ${appName}
`;
+ container.innerHTML = "No databases found for this app.
";
+ return;
+ }
+
+ if (breadcrumb) breadcrumb.innerHTML = `Databases for ${appName}
`;
+
+ // Flatten all tables from all databases
+ const allTables = [];
+ data.databases.forEach((db) => {
+ db.tables.forEach((table) => {
+ allTables.push({
+ dbName: db.name,
+ tableName: table.name,
+ fields: table.fields,
+ });
+ });
+ });
+
+ if (allTables.length === 0) {
+ container.innerHTML = "No tables found in databases.
";
+ return;
+ }
+
+ container.innerHTML = `
+
+ ${allTables
+ .map(
+ (table) => `
+
+
+ ${table.dbName}.${table.tableName}
+
+
+ ${table.fields.join(", ")}
+
+
+ `
+ )
+ .join("")}
+
+ `;
+
+ // Add click handlers to table rows
+ container.querySelectorAll(".classic-db-table-row").forEach((row) => {
+ row.addEventListener("click", () => {
+ const dbName = row.getAttribute("data-db");
+ const tableName = row.getAttribute("data-table");
+ const dbadminUrl = `${appBase}/dbadmin/${encodeURIComponent(appName)}/${encodeURIComponent(dbName)}/${encodeURIComponent(tableName)}`;
+ window.location.href = dbadminUrl;
+ });
+ });
+ } else {
+ if (breadcrumb) breadcrumb.innerHTML = `Databases for ${appName}
`;
+ container.innerHTML = "Could not load databases.
";
+ }
+ } catch (err) {
+ if (breadcrumb) breadcrumb.innerHTML = `Databases for ${appName}
`;
+ container.innerHTML = `Error: ${err.message}
`;
+ }
+ };
+
+ const loadRoutes = async (appName) => {
+ const container = document.querySelector("[data-routes-container]");
+ if (!container) return;
+
+ const appBase = getAppBase();
+ try {
+ const res = await fetch(`${appBase}/routes`);
+ const data = await res.json();
+
+ if (data.status === "success" && data.payload) {
+ const routes = data.payload[appName] || [];
+ if (routes.length === 0) {
+ container.innerHTML = "No routes found for this app.
";
+ return;
+ }
+ const routeSortState = { key: "rule", dir: "asc" };
+ const metricKeys = new Set(["time", "calls", "errors"]);
+ const formatMetric = (value) => {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric.toFixed(2) : "0.00";
+ };
+ const sortedRoutes = (routeList) => {
+ const sortKey = routeSortState.key;
+ const sortDir = routeSortState.dir === "asc" ? 1 : -1;
+ return [...routeList].sort((left, right) => {
+ let leftValue = left?.[sortKey];
+ let rightValue = right?.[sortKey];
+ if (metricKeys.has(sortKey)) {
+ leftValue = Number(leftValue || 0);
+ rightValue = Number(rightValue || 0);
+ } else {
+ leftValue = String(leftValue || "").toLowerCase();
+ rightValue = String(rightValue || "").toLowerCase();
+ }
+ if (leftValue < rightValue) return -1 * sortDir;
+ if (leftValue > rightValue) return 1 * sortDir;
+ return 0;
+ });
+ };
+
+ const sortMarker = (key) => {
+ if (routeSortState.key !== key) return "";
+ return routeSortState.dir === "asc" ? " ▲" : " ▼";
+ };
+
+ const renderRoutesTable = () => {
+ const ordered = sortedRoutes(routes);
+ container.innerHTML = `
+
+ `;
+
+ container.querySelectorAll("th[data-sort-key]").forEach((header) => {
+ header.addEventListener("click", () => {
+ const key = header.getAttribute("data-sort-key");
+ if (routeSortState.key === key) {
+ routeSortState.dir = routeSortState.dir === "asc" ? "desc" : "asc";
+ } else {
+ routeSortState.key = key;
+ routeSortState.dir = metricKeys.has(key) ? "desc" : "asc";
+ }
+ renderRoutesTable();
+ });
+ });
+ };
+
+ renderRoutesTable();
+ } else {
+ container.innerHTML = "Could not load routes.
";
+ }
+ } catch (err) {
+ container.innerHTML = `Error: ${err.message}
`;
+ }
+ };
+
+ const reloadAppRoutes = async (appName) => {
+ const appBase = getAppBase();
+ const button = document.querySelector("[data-classic-routes-reload]");
+ const container = document.querySelector("[data-routes-container]");
+ if (button) button.disabled = true;
+ if (container) container.innerHTML = "Reloading app routes...
";
+
+ try {
+ const res = await fetch(`${appBase}/reload/${encodeURIComponent(appName)}`);
+ if (!res.ok) throw new Error("Reload failed");
+
+ let payload = null;
+ try {
+ payload = await res.json();
+ } catch (_err) {
+ payload = null;
+ }
+
+ if (payload && payload.status === "error") {
+ throw new Error(payload.message || "Reload failed");
+ }
+
+ await loadRoutes(appName);
+ } catch (err) {
+ if (container) {
+ container.innerHTML = `Error: ${err.message}
`;
+ }
+ } finally {
+ if (button) button.disabled = false;
+ }
+ };
+
+ const loadTickets = async (appName) => {
+ const container = document.querySelector("[data-tickets-container]");
+ if (!container) return;
+
+ const appBase = getAppBase();
+ try {
+ const res = await fetch(`${appBase}/tickets`);
+ const data = await res.json();
+
+ if (data.status === "success" && data.payload) {
+ const tickets = data.payload.filter((t) => t.app_name === appName);
+ if (tickets.length === 0) {
+ container.innerHTML = "No tickets found for this app.
";
+ return;
+ }
+ container.innerHTML = `
+
+
+
+ | Count |
+ Error |
+ Path |
+ Timestamp |
+
+
+
+ ${tickets
+ .map(
+ (t) => `
+
+ | ${t.count} |
+ ${t.error} |
+ ${t.path} |
+ ${t.timestamp} |
+
+ `
+ )
+ .join("")}
+
+
+ `;
+ } else {
+ container.innerHTML = "Could not load tickets.
";
+ }
+ } catch (err) {
+ container.innerHTML = `Error: ${err.message}
`;
+ }
+ };
+
+ const reloadTicketsForApp = async (appName) => {
+ const button = document.querySelector("[data-classic-tickets-reload]");
+ const container = document.querySelector("[data-tickets-container]");
+ if (button) button.disabled = true;
+ if (container) container.innerHTML = "Reloading tickets...
";
+
+ try {
+ await loadTickets(appName);
+ } finally {
+ if (button) button.disabled = false;
+ }
+ };
+
+ const clearTicketsForApp = async (appName) => {
+ const reloadButton = document.querySelector("[data-classic-tickets-reload]");
+ const clearButton = document.querySelector("[data-classic-tickets-clear]");
+ const container = document.querySelector("[data-tickets-container]");
+ if (reloadButton) reloadButton.disabled = true;
+ if (clearButton) clearButton.disabled = true;
+ if (container) container.innerHTML = "Clearing tickets...
";
+
+ try {
+ const appBase = getAppBase();
+ const res = await fetch(`${appBase}/clear`);
+ if (!res.ok) throw new Error("Clear tickets failed");
+ await loadTickets(appName);
+ } catch (err) {
+ if (container) {
+ container.innerHTML = `Error: ${err.message}
`;
+ }
+ } finally {
+ if (reloadButton) reloadButton.disabled = false;
+ if (clearButton) clearButton.disabled = false;
+ }
+ };
+
+ const handleEditClick = async (appName, view = 'files') => {
+ if (!appName) return;
+ const appBase = getAppBase();
+ const editUrl = `${appBase}/app_detail/${encodeURIComponent(appName)}`;
+
+ try {
+ const res = await fetch(editUrl);
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
+ const data = await res.json();
+ if (data.status !== "success") throw new Error(data.message || "Request failed");
+ renderEditLayout(data.payload, view);
+ } catch (err) {
+ console.error("[Classic Theme] Edit failed:", err);
+ alert("Edit failed: " + err.message);
+ }
+ };
+
+ window.addEventListener("resize", queueClassicLayoutResize);
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener("resize", queueClassicLayoutResize);
+ }
+
+ window.classicEditHandler = handleEditClick;
+})();
diff --git a/apps/_dashboard/static/themes/Classic/theme.toml b/apps/_dashboard/static/themes/Classic/theme.toml
new file mode 100644
index 00000000..31cd29a3
--- /dev/null
+++ b/apps/_dashboard/static/themes/Classic/theme.toml
@@ -0,0 +1,7 @@
+name = "Classic"
+description = "Light web2py admin inspired theme with gray chrome, green and orange accents."
+version = "1.0.0"
+author = "py4web team"
+homepage = "https://py4web.com"
+screenshot = "widget.gif"
+headerButtons = "themes/Classic/header-buttons.html"
diff --git a/apps/_dashboard/static/themes/Classic/widget.gif b/apps/_dashboard/static/themes/Classic/widget.gif
new file mode 100644
index 00000000..51323538
Binary files /dev/null and b/apps/_dashboard/static/themes/Classic/widget.gif differ
diff --git a/apps/_dashboard/templates/dbadmin.html b/apps/_dashboard/templates/dbadmin.html
index aef7d976..6440514b 100644
--- a/apps/_dashboard/templates/dbadmin.html
+++ b/apps/_dashboard/templates/dbadmin.html
@@ -1,6 +1,67 @@
-[[extend "layout.html"]]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
Application "[[=app_name]]" - Table "[[=table_name]]"
-[[=grid]]
-
+
+
+
+
Application "[[=app_name]]" - Table "[[=table_name]]"
+ [[=grid]]
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/_dashboard/templates/gitlog.html b/apps/_dashboard/templates/gitlog.html
index 4d612454..a1509546 100644
--- a/apps/_dashboard/templates/gitlog.html
+++ b/apps/_dashboard/templates/gitlog.html
@@ -1,17 +1,28 @@
+ [[if selected_theme == "Classic":]]
+
+ [[pass]]