diff --git a/apps/_dashboard/.gitignore b/apps/_dashboard/.gitignore new file mode 100644 index 00000000..ca5f38ef --- /dev/null +++ b/apps/_dashboard/.gitignore @@ -0,0 +1 @@ +user_settings.toml diff --git a/apps/_dashboard/DASHBOARD_GUIDE.md b/apps/_dashboard/DASHBOARD_GUIDE.md index 866cbbcf..cf13a5e4 100644 --- a/apps/_dashboard/DASHBOARD_GUIDE.md +++ b/apps/_dashboard/DASHBOARD_GUIDE.md @@ -149,154 +149,181 @@ export PY4WEB_PASSWORD_FILE=password.txt ## Theming -The dashboard supports multiple themes using a CSS override pattern. This allows users to switch between themes dynamically without reloading the page, with theme preference persisted in browser localStorage. +The dashboard supports multiple built-in themes (AlienDark, AlienLight, Classic) with a fully extensible theming system. Users can switch themes dynamically without page reload, with preferences persisted to the backend. + +**For comprehensive theming documentation**, see [THEMES_GUIDE.md](THEMES_GUIDE.md). + +### Quick Overview + +**Currently Available Themes:** +- **AlienDark** - Modern dark theme with cyan accents (default) +- **AlienLight** - Professional light theme with blue accents +- **Classic** - Legacy-inspired dashboard appearance + +**Key Features:** +- Dynamic theme discovery from `static/themes/` folder +- Canonical page templates remain in `templates/index.html` and `templates/dbadmin.html` +- CSS custom properties (variables) for styling +- Optional theme-specific JavaScript initialization +- Theme metadata in `theme.toml` +- User preference persisted to `user_settings.toml` ### Theme System Architecture -**Folder Structure:** ``` -static/ -├── css/ -│ ├── future.css # Dark base stylesheet (main dashboard) -│ ├── no.css # Light base stylesheet (dbadmin pages) -│ └── ... -├── js/ -│ ├── theme-selector.js # Theme switching logic -│ └── ... -├── themes/ -│ ├── AlienDark/ -│ │ └── theme.css # Dark theme overrides -│ └── AlienLight/ -│ └── theme.css # Light theme overrides -└── ... +static/themes/{ThemeName}/ +├── theme.toml # Metadata (name, description, author, etc.) +├── theme.css # Styling with CSS variables +├── theme.js # Optional JS initialization +├── favicon.ico # Browser tab icon +├── widget.gif # Loading spinner +└── templates/ + └── partials/ # Optional hook overrides, not full pages + ├── index_header_actions.html + ├── dbadmin_nav.html + └── dbadmin_footer_back.html ``` -### How Theming Works - -1. **Base Stylesheets:** Dashboard loads a base stylesheet (`future.css` for main dashboard, `no.css` for dbadmin) that defines the default dark theme -2. **Theme CSS Variables:** Each theme defines CSS custom properties (variables) for colors and styling -3. **Dynamic Theme Loading:** JavaScript (`theme-selector.js`) dynamically loads theme CSS files by updating the `href` of a `` tag with id `dashboard-theme` -4. **Local Storage Persistence:** Selected theme is stored in browser localStorage under key `py4web-dashboard-theme` -5. **Auto-Apply:** On page load, theme-selector.js automatically applies the saved theme preference - -### Available Themes - -#### AlienDark -- **Description:** Dark theme with cyan accents -- **CSS Variables:** - - `--bg-primary: black` - Main background - - `--text-primary: #d1d1d1` - Primary text color - - `--accent: #33BFFF` - Accent color (cyan) - - `--accent-dark: #007a99` - Dark accent variant - - `--bg-secondary: #1a1a1a` - Secondary background - - `--border-color: #333` - Border color -- **File:** `static/themes/AlienDark/theme.css` - -#### AlienLight -- **Description:** Light theme with blue accents -- **CSS Variables:** - - `--bg-primary: white` - Main background - - `--text-primary: #333` - Primary text color - - `--accent: #0074d9` - Accent color (blue) - - `--accent-dark: #003d74` - Dark accent variant - - `--bg-secondary: #f5f5f5` - Secondary background - - `--border-color: #ddd` - Border color -- **File:** `static/themes/AlienLight/theme.css` - -### Using CSS Variables in Theme Files - -Each theme file uses CSS custom properties for consistent styling across components: - -```css -:root { - --bg-primary: black; - --text-primary: #d1d1d1; - --accent: #33BFFF; - /* ... other variables ... */ -} - -/* Override specific elements using variables */ -body { - background: var(--bg-primary); - color: var(--text-primary); -} - -button { - background: var(--accent); - color: var(--bg-primary); -} -``` +**Frontend System:** +- `js/theme-selector.js` - Manages all client-side theme switching +- Loads theme CSS dynamically by updating `` +- Persists selection to localStorage and backend +- Synchronizes multiple theme selectors on same page + +**Backend System:** +- `get_available_themes()` - Scans `static/themes/` folder +- `normalize_selected_theme()` - Validates stored/requested theme values against available themes +- `load_user_settings()` / `save_user_settings()` - Persists theme selection +- `@action("save_theme")` - HTTP endpoint for theme persistence + +### Adding a Custom Theme + +1. **Create folder:** `static/themes/MyTheme/` +2. **Create `theme.toml`:** + ```toml + name = "My Theme" + description = "Brief description" + version = "1.0.0" + author = "Your Name" + ``` +3. **Create `theme.css`:** + ```css + :root { + --bg-primary: white; + --text-primary: #333; + --accent: #0074d9; + --accent-dark: #0052a3; + --bg-secondary: #f5f5f5; + --border-color: #d1d1d1; + } + body { background: var(--bg-primary); color: var(--text-primary); } + ``` +4. **Optional - Add assets:** `favicon.ico`, `widget.gif`, `templates/partials/` hook overrides +5. **Optional - Add behavior:** `theme.js` for theme-specific JavaScript + +**That's it!** The theme automatically appears in the dropdown. ### Theme Selector UI -The theme selector is visible on the main dashboard (`index.html`): +Dropdowns appear on all dashboard pages: ```html - + [[for theme in themes:]] + + [[pass]] ``` -**Features:** -- Dropdown positioned in top-right corner of header -- Uses `data-theme-selector` attribute for synchronization -- Calls `setDashboardTheme()` function from `theme-selector.js` -- Multiple selectors on the same page stay synchronized +**Multiple selectors on the same page stay synchronized** via `data-theme-selector` attribute. -### JavaScript Theme Switching (theme-selector.js) +### Best Practices -The theme selector module is now fully dynamic, detecting themes from the select element. See [theme-selector.js](static/js/theme-selector.js) for the implementation. +1. **Use CSS Variables** - Define all colors in `:root`, never hardcode +2. **Foundation Pattern** - Override only the styles you need, let defaults cascade +3. **Metadata Matters** - Complete `theme.toml` helps users understand your theme +4. **Test All Pages** - Verify on main dashboard, dbadmin, and other pages +5. **Accessibility** - Ensure sufficient text contrast (WCAG AA minimum) +6. **Dynamic Features** - Use `theme.js` for ACE editor config, custom UI hooks, etc. -**Key Features:** -- `getAvailableThemes()` - Dynamically reads themes from select element options (no hardcoded list) -- `getDefaultTheme()` - Returns "AlienDark" if available, otherwise the first theme alphabetically -- `applyTheme(theme)` - Loads theme CSS and persists selection in localStorage -- Auto-applies saved theme on page load, or default theme if none saved -- Multiple selectors on the same page stay synchronized -- Automatically detects new themes when they're added to the select element +### How Theming Works (Flow Diagram) -### Adding a New Theme +``` +Page loads + ↓ +Backend loads theme from user_settings.toml + ↓ +Renders HTML with SELECTED_THEME variable + ↓ +theme-selector.js runs + ↓ +getStoredTheme() checks: Backend → localStorage → default + ↓ +loadThemeConfig() fetches theme.toml + ↓ +applyTheme() orchestrates: + • Update CSS link href + • Update favicon + • Load theme.js + • Save to localStorage + backend + • Sync all selectors + ↓ +Theme is now active +``` -To create a new theme (e.g., `MyCustomTheme`): +### Persistence -1. **Create theme folder:** - ``` - static/themes/MyCustomTheme/ - ``` +Theme selection is saved in two places: -2. **Create `theme.css` with CSS variables:** - ```css - :root { - --bg-primary: #your-bg-color; - --text-primary: #your-text-color; - --accent: #your-accent-color; - --accent-dark: #your-accent-dark-color; - --bg-secondary: #your-secondary-bg; - --border-color: #your-border-color; - } - - /* Override styles using variables */ - body { - background: var(--bg-primary); - color: var(--text-primary); - } - /* ... more overrides ... */ +1. **Backend** - `apps/_dashboard/user_settings.toml` + ```toml + selected_theme = "AlienDark" ``` + Survives browser cache clear / private browsing + +2. **Browser** - localStorage key: `py4web-dashboard-theme` + Provides immediate fallback if backend unavailable + +**Selection Priority:** +1. Backend setting (from `user_settings.toml`) +2. Browser storage (from localStorage) +3. Default theme (AlienDark if available, else first alphabetically) + +Themes should not ship full-page replacements for the dashboard main pages; keep the main `index.html` in `apps/_dashboard/templates/` and use `templates/partials/` hooks for small structural overrides. + +### Troubleshooting Themes + +**Theme not appearing in dropdown:** +- Verify folder exists: `static/themes/MyTheme/` +- Hard refresh browser (Ctrl+Shift+R) +- Restart dashboard to rescan folder + +**CSS not loading:** +- Check Network tab for 4xx errors on `themes/MyTheme/theme.css` +- Verify `:root` block has all required CSS variables +- Check Browser Console for JavaScript errors in theme.js + +**JavaScript not running:** +- Verify `theme.js` is valid with a linter +- Use retry logic if depending on Vue app (may not be ready yet) +- Check Console for syntax errors + +**Settings not persisting:** +- Verify `apps/_dashboard/user_settings.toml` is writable +- Ensure you're logged in (theme save requires `USER_ID`) +- Check Network tab - POST `/save_theme` should return `{"status": "success"}` + +--- -3. **Add to theme selector in templates:** - The theme selector options are now **dynamically generated** from available theme folders in the backend. When you create a new theme folder, it's automatically listed in the select dropdown on both `index.html` and dbadmin pages via Python template iteration. +## Advanced Theming -### Best Practices for Theme Development +For advanced features like: +- Theme partial hooks (`templates/partials/`) +- ACE editor theme configuration +- Favicon management +- Complex initialization patterns -1. **Use CSS Variables:** Always reference theme colors via custom properties, never hardcode colors -2. **Minimal Overrides:** Only include CSS rules that differ from the base stylesheet -3. **Test Both Locations:** Verify theme works on main dashboard (`index.html`) and dbadmin pages (`layout.html`) -4. **Check Full Viewport:** Ensure backgrounds extend to full viewport height, no black/white strips at bottom -5. **Text Contrast:** Verify text colors have sufficient contrast with background colors for accessibility -6. **Form Elements:** Ensure all form inputs (text, select, button, checkbox) are styled consistently +See [THEMES_GUIDE.md](THEMES_GUIDE.md). --- diff --git a/apps/_dashboard/THEMES_GUIDE.md b/apps/_dashboard/THEMES_GUIDE.md new file mode 100644 index 00000000..39a65d7a --- /dev/null +++ b/apps/_dashboard/THEMES_GUIDE.md @@ -0,0 +1,223 @@ +# PY4WEB Dashboard Themes Guide + +## Overview + +The `_dashboard` app supports dynamic theme switching with persisted user preference. +Themes are discovered from `static/themes/` and can be switched without a page reload. + +Current built-in themes: +- **AlienDark** (default dark UI) +- **AlienLight** (light UI) +- **Classic** (legacy-inspired dashboard style) + +--- + +## How It Works + +### Runtime Flow + +1. Backend loads `selected_theme` from `user_settings.toml` +2. Backend normalizes it against currently available themes +3. Template renders: + - `themes` list + - `selected_theme` + - `SELECTED_THEME` JavaScript global +4. `static/js/theme-selector.js` initializes and applies the selected theme +5. Selection is stored in: + - backend (`user_settings.toml`) + - browser (`localStorage`) + +### Theme Discovery + +Themes are discovered by scanning folders in: + +`apps/_dashboard/static/themes/` + +Any subdirectory (not starting with `.`) is treated as a theme and appears in the selector. + +--- + +## Theme Folder Structure + +A minimal theme folder: + +```text +static/themes/MyTheme/ +├── theme.toml +├── theme.css +├── theme.js # optional +├── favicon.ico # optional +├── widget.gif # optional +└── templates/ + └── partials/ # optional (small structural overrides) + ├── index_header_actions.html + ├── dbadmin_nav.html + └── dbadmin_footer_back.html +``` + +### `theme.toml` + +Example: + +```toml +name = "MyTheme" +description = "Custom dashboard theme" +version = "1.0.0" +author = "Your Name" +homepage = "https://example.com" +screenshot = "widget.gif" +``` + +Optional keys used by the selector if present: +- `favicon` +- `widget` +- `appFavicon` + +If omitted, defaults are inferred from the current theme folder. + +### `theme.css` + +Use CSS custom properties for consistency: + +```css +:root { + --bg-primary: #ffffff; + --text-primary: #222; + --accent: #0074d9; + --accent-dark: #0052a3; + --bg-secondary: #f5f5f5; + --border-color: #d1d1d1; +} +``` + +### `theme.js` (optional) + +Loaded dynamically when the theme is activated. +Use this for behavior that cannot be expressed in CSS (for example ACE editor integration). + +--- + +## Frontend Components + +Main file: `apps/_dashboard/static/js/theme-selector.js` + +Key responsibilities: +- detect available themes from the selector +- determine initial theme (`SELECTED_THEME` → localStorage → default) +- load `theme.toml` +- swap `theme.css` at runtime +- update favicon/spinner/app icons +- load optional `theme.js` +- persist selection via `POST ../save_theme` +- keep all selectors (`[data-theme-selector]`) synchronized + +Public API: +- `window.setDashboardTheme(theme)` + +--- + +## Backend Components + +Main file: `apps/_dashboard/__init__.py` + +Key functions: +- `get_available_themes()` +- `normalize_selected_theme(selected_theme, available_themes=None)` +- `load_user_settings()` / `save_user_settings()` +- `@action("save_theme", method="POST")` + +### Theme Validation + +Backend normalization guarantees that stale values (for example removed themes) do not break rendering. +If a stored theme is unavailable: +1. use `AlienDark` if present +2. otherwise use the first available theme +3. otherwise fall back to `AlienDark` + +`save_theme` also validates that the requested theme exists. + +--- + +## Template Requirements + +The dashboard keeps canonical page templates in: + +- `apps/_dashboard/templates/index.html` +- `apps/_dashboard/templates/dbadmin.html` + +The revised structure keeps the main `apps/_dashboard/templates/index.html` as the single source of truth. +Avoid creating per-theme full page templates (for example a separate `index.html` for each theme), and use optional partial hooks instead. + +### Partial Override Resolution + +For each supported hook, the dashboard resolves in this order: + +1. `apps/_dashboard/static/themes/{SelectedTheme}/templates/partials/{hook}.html` +2. `apps/_dashboard/templates/partials/{hook}.html` + +If neither exists, the hook renders empty safely. + +Current hook names: + +- `index_header_actions` +- `dbadmin_nav` +- `dbadmin_footer_back` + +In dashboard templates, keep these elements: + +```html + + + + + +``` + +Important: always bind the CSS link to `selected_theme` from backend context; do not hardcode a theme path. + +--- + +## Creating a Custom Theme + +1. Create `static/themes/MyTheme/` +2. Add `theme.toml` +3. Add `theme.css` +4. Optionally add `theme.js`, `favicon.ico`, `widget.gif` +5. Refresh dashboard and select the theme + +No backend code changes are required. + +--- + +## Troubleshooting + +### Theme not in dropdown +- verify folder exists under `static/themes/` +- verify folder name does not start with `.` +- hard refresh browser + +### Theme not applied on first load +- verify template CSS link uses `selected_theme` +- check rendered HTML for `id="dashboard-theme"` + +### Theme selection not persisted +- ensure user is logged in +- verify `POST ../save_theme` succeeds +- check `apps/_dashboard/user_settings.toml` is writable + +### Theme script not running +- verify `theme.js` exists and has valid JavaScript +- check browser console for syntax/runtime errors + +--- + +## Notes + +- The dashboard uses canonical page templates plus optional hook partials. +- Theme-specific customization should stay primarily in `theme.css` and optional `theme.js`; use hook partials only for small structural differences. diff --git a/apps/_dashboard/__init__.py b/apps/_dashboard/__init__.py index 26ab02d7..bf371fdf 100644 --- a/apps/_dashboard/__init__.py +++ b/apps/_dashboard/__init__.py @@ -5,6 +5,7 @@ import io import json import os +import platform import shutil import subprocess import sys @@ -16,6 +17,7 @@ import pathspec import requests +from yatl.helpers import XML from pydal.restapi import Policy, RestAPI from pydal.validators import CRYPT @@ -68,6 +70,91 @@ def wrapper(*args, **kwargs): return wrapper +def change_password_handler(old_password, new_password): + """Change the dashboard password""" + password_file = os.environ.get("PY4WEB_PASSWORD_FILE") + if not password_file: + return {"status": "error", "message": "PY4WEB_PASSWORD_FILE environment variable not set"} + if not os.path.exists(password_file): + return {"status": "error", "message": f"Password file not found: {password_file}"} + + try: + # Read current password from file + with open(password_file, "r") as fp: + stored_password = fp.read().strip() + + # Verify old password matches + if CRYPT()(old_password)[0] != stored_password: + return {"status": "error", "message": "Current password is incorrect"} + + # Encrypt new password + encrypted_password = str(CRYPT()(new_password)[0]) + if not encrypted_password: + return {"status": "error", "message": "Failed to encrypt password"} + + # Write to temporary file first, then rename to avoid data loss + temp_fd, temp_path = tempfile.mkstemp(text=True) + try: + with os.fdopen(temp_fd, 'w') as fp: + fp.write(encrypted_password) + # Only rename if write was successful + shutil.move(temp_path, password_file) + except Exception as temp_error: + # Clean up temp file if it exists + try: + os.unlink(temp_path) + except: + pass + raise temp_error + + return {"status": "success", "message": "Password changed successfully"} + except Exception as e: + return {"status": "error", "message": f"Error: {str(e)}"} + + +def load_user_settings(): + """Load user settings from user_settings.toml""" + settings_file = os.path.join(settings.APP_FOLDER, "user_settings.toml") + default_settings = {"selected_theme": "AlienDark"} + + if not os.path.exists(settings_file): + # Create default settings file + try: + with open(settings_file, "w") as fp: + fp.write('selected_theme = "AlienDark"\n') + return default_settings + except Exception: + return default_settings + + try: + with open(settings_file, "r") as fp: + content = fp.read() + # Simple TOML parser for key = "value" format + parsed_settings = {} + for line in content.split('\n'): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + parsed_settings[key] = value + return parsed_settings if parsed_settings else default_settings + except Exception: + return default_settings + + +def save_user_settings(settings_dict): + """Save user settings to user_settings.toml""" + settings_file = os.path.join(settings.APP_FOLDER, "user_settings.toml") + try: + with open(settings_file, "w") as fp: + for key, value in settings_dict.items(): + fp.write(f'{key} = "{value}"\n') + return {"status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + def get_available_themes(): """Get list of available themes by reading static/themes/ folder""" themes_dir = os.path.join(settings.APP_FOLDER, "static", "themes") @@ -84,7 +171,66 @@ def get_available_themes(): return themes except (OSError, IOError): pass - return ["AlienDark", "AlienLight"] # Fallback + return [] + + +def normalize_selected_theme(selected_theme, available_themes=None): + """Normalize a selected theme against currently available themes.""" + themes = available_themes if available_themes is not None else get_available_themes() + if selected_theme and selected_theme in themes: + return selected_theme + if "AlienDark" in themes: + return "AlienDark" + if themes: + return themes[0] + return "AlienDark" + + +def _safe_name(value): + """Normalize untrusted filesystem names to a conservative subset.""" + if not value: + return "" + return "".join(char for char in str(value) if char.isalnum() or char in ("-", "_")) + + +def load_theme_partial(selected_theme, partial_name): + """Load an optional HTML partial for a theme with a safe default fallback.""" + theme_name = _safe_name(selected_theme) + partial = _safe_name(partial_name) + if not partial: + return XML("") + + candidate_paths = [] + if theme_name: + candidate_paths.append( + os.path.join( + settings.APP_FOLDER, + "static", + "themes", + theme_name, + "templates", + "partials", + f"{partial}.html", + ) + ) + candidate_paths.append( + os.path.join(settings.APP_FOLDER, "templates", "partials", f"{partial}.html") + ) + + for filename in candidate_paths: + try: + with open(filename, "r", encoding="utf-8") as fp: + return XML(fp.read()) + except (OSError, IOError): + continue + return XML("") + + +def build_theme_partials(selected_theme, partial_names): + """Build a mapping of partial names to rendered HTML fragments.""" + return { + name: load_theme_partial(selected_theme, name) for name in partial_names or [] + } session = Session() @@ -103,13 +249,23 @@ def version(): @action("index") @action.uses("index.html", session, T) def index(): + themes = get_available_themes() + user_settings = load_user_settings() + selected_theme = normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ) + theme_partials = build_theme_partials(selected_theme, ["index_header_actions"]) + return dict( languages=dumps(getattr(T.local, "language", {})), mode=MODE, user_id=(session.get("user") or {}).get("id"), - themes=get_available_themes(), + themes=themes, + selected_theme=selected_theme, + theme_partials=theme_partials, ) + @action("login", method="POST") @action.uses(session) def login(): @@ -133,10 +289,44 @@ def logout(): session["user"] = None return dict() + @action("change_password", method="POST") + @action.uses(Logged(session)) + @catch_errors + def change_password(): + old_password = request.json.get("old_password") + password = request.json.get("password") + if not old_password: + return {"status": "error", "message": "Current password is required"} + if not password: + return {"status": "error", "message": "New password is required"} + return change_password_handler(old_password, password) + + @action("save_theme", method="POST") + @action.uses(Logged(session)) + @catch_errors + def save_theme(): + theme = request.json.get("theme") + if not theme: + return {"status": "error", "message": "Theme name is required"} + themes = get_available_themes() + if theme not in themes: + return {"status": "error", "message": "Theme is not available"} + user_settings = load_user_settings() + user_settings["selected_theme"] = theme + return save_user_settings(user_settings) + @action("tickets/search") @action.uses(Logged(session), "dbadmin.html") def dbadmin(): db = error_logger.database_logger.db + themes = get_available_themes() + user_settings = load_user_settings() + selected_theme = normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ) + theme_partials = build_theme_partials( + selected_theme, ["dbadmin_nav", "dbadmin_footer_back"] + ) def make_grid(): make_safe(db) @@ -157,12 +347,26 @@ def make_grid(): ) grid = action.uses(db)(make_grid)() - return dict(table_name="py4web_error", grid=grid, themes=get_available_themes()) + return dict( + app_name="", + table_name="py4web_error", + grid=grid, + themes=themes, + selected_theme=selected_theme, + theme_partials=theme_partials, + ) @action("dbadmin///") @action.uses(Logged(session), "dbadmin.html") def dbadmin(app_name, db_name, table_name): themes = get_available_themes() + user_settings = load_user_settings() + selected_theme = normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ) + theme_partials = build_theme_partials( + selected_theme, ["dbadmin_nav", "dbadmin_footer_back"] + ) module = Reloader.MODULES.get(app_name) db = getattr(module, db_name) @@ -198,13 +402,26 @@ def make_grid(): return Grid(table, columns=columns) grid = action.uses(db)(make_grid)() - return dict(app_name=app_name, table_name=table_name, grid=grid, themes=themes) + return dict( + app_name=app_name, + table_name=table_name, + grid=grid, + themes=themes, + selected_theme=selected_theme, + theme_partials=theme_partials, + ) @action("info") @session_secured @catch_errors def info(): - vars = [{"name": "python", "version": sys.version}] + # Start with OS information + os_info = f"{platform.system()} {platform.release()}" + vars = [ + {"name": "os", "version": os_info}, + {"name": "py4web", "version": __version__}, + {"name": "python", "version": sys.version} + ] for module in sorted(sys.modules): if not "." in module: try: @@ -220,10 +437,18 @@ def info(): @catch_errors def routes(): """Returns current registered routes""" - sorted_routes = { - name: list(sorted(routes, key=lambda route: route["rule"])) - for name, routes in Reloader.ROUTES.items() - } + sorted_routes = {} + for name, app_routes in Reloader.ROUTES.items(): + normalized_routes = [] + for route in app_routes: + normalized_route = dict(route) + normalized_route.setdefault("time", 0.0) + normalized_route.setdefault("calls", 0.0) + normalized_route.setdefault("errors", 0.0) + normalized_routes.append(normalized_route) + sorted_routes[name] = list( + sorted(normalized_routes, key=lambda route: route["rule"]) + ) return {"payload": sorted_routes, "status": "success"} @action("apps") @@ -234,7 +459,12 @@ def apps(): apps = os.listdir(FOLDER) exposed_names = APP_NAMES and APP_NAMES.split(",") apps = [ - {"name": app, "error": Reloader.ERRORS.get(app)} + { + "name": app, + "error": Reloader.ERRORS.get(app), + "running": app in Reloader.ROUTES and not Reloader.ERRORS.get(app), + "edit_url": URL("app_detail", app), + } for app in apps if os.path.isdir(os.path.join(FOLDER, app)) and not app.startswith("__") @@ -244,6 +474,72 @@ def apps(): apps.sort(key=lambda item: item["name"]) return {"payload": apps, "status": "success"} + @action("app_detail/") + @session_secured + @catch_errors + def app_detail(name): + """Returns a focused payload for app edit layout.""" + app_path = os.path.join(FOLDER, name) + if not os.path.isdir(app_path): + return {"status": "error", "message": "App does not exist"} + + def normalize_path(path): + """Convert Windows backslashes to forward slashes for consistent frontend handling""" + return path.replace(os.sep, '/') + + def list_files_recursive(base_path, relative_path=""): + """Recursively list all folders and their files""" + sections = {} + full_path = os.path.join(base_path, relative_path) if relative_path else base_path + + try: + for item in os.listdir(full_path): + if item.startswith(".") or item == "__pycache__": + continue + + item_path = os.path.join(full_path, item) + # Normalize to forward slashes for frontend + item_relative = os.path.join(relative_path, item) if relative_path else item + item_relative = normalize_path(item_relative) + + if os.path.isdir(item_path): + # Add this directory as a section + files_in_dir = [ + f for f in os.listdir(item_path) + if not f.startswith(".") + and os.path.isfile(os.path.join(item_path, f)) + ] + sections[item_relative] = sorted(files_in_dir) + + # Recursively process subdirectories + subsections = list_files_recursive(base_path, os.path.join(relative_path, item) if relative_path else item) + sections.update(subsections) + elif os.path.isfile(item_path) and not relative_path: + # Root-level files + if "" not in sections: + sections[""] = [] + sections[""].append(item) + except (OSError, IOError): + pass + + return sections + + all_sections = list_files_recursive(app_path) + + # Sort root files if they exist + if "" in all_sections: + all_sections[""] = sorted(all_sections[""]) + + payload = { + "name": name, + "path": app_path, + "error": Reloader.ERRORS.get(name), + "running": name in Reloader.ROUTES and not Reloader.ERRORS.get(name), + "edit_layout": True, + "sections": all_sections, + } + return {"status": "success", "payload": payload} + @action("delete_app/", method="POST") @session_secured @catch_errors @@ -263,14 +559,44 @@ def delete_app(name): @action("new_file//", method="POST") @session_secured def new_file(name, file_name): - """creates a new file""" + """creates a new file or folder""" path = os.path.join(FOLDER, name) - form = request.json + payload = request.json if isinstance(request.json, dict) else {} + + # Check if this is a folder creation request (query parameter) + is_folder = request.query.get("folder") == "1" + if not os.path.exists(path): return {"status": "success", "payload": "App does not exist"} + full_path = os.path.join(path, file_name) if not full_path.startswith(path + os.sep): return {"status": "success", "payload": "Invalid path"} + + # Create folder + if is_folder: + try: + os.makedirs(full_path, exist_ok=True) + return {"status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + # File upload (base64 payload) + file_data = payload.get("file") + if file_data: + if os.path.exists(full_path): + return {"status": "error", "message": "File already exists"} + parent = os.path.dirname(full_path) + if not os.path.exists(parent): + os.makedirs(parent) + try: + with open(full_path, "wb") as fp: + fp.write(base64.b64decode(file_data)) + return {"status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + # File creation (existing logic) if os.path.exists(full_path): return {"status": "success", "payload": "File already exists"} parent = os.path.dirname(full_path) @@ -308,7 +634,6 @@ def visible(root, name, filter=filter): or name.endswith("~") or name[-4:] in (".pyc", ".pyo") or name == "__pycache__" - or os.path.basename(root) == "uploads" ) if not os.path.exists(top) or not os.path.isdir(top): @@ -390,14 +715,20 @@ def clear_tickets(): @action.uses("ticket.html") @session_secured def error_ticket(ticket_uuid): + themes = get_available_themes() + user_settings = load_user_settings() + selected_theme = normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ) if MODE != "demo": return dict( ticket=safely( lambda: error_logger.database_logger.get(ticket_uuid=ticket_uuid) - ) + ), + selected_theme=selected_theme ) else: - return dict(ticket=None) + return dict(ticket=None, selected_theme=selected_theme) @action("rest/", method=["GET", "POST", "PUT", "DELETE"]) @session_secured @@ -497,8 +828,15 @@ def save(path, reload_app=True): @session_secured @catch_errors def delete(path): - """Deletes a file""" + """Deletes a file or empty folder""" fullpath = safe_join(FOLDER, path) or abort() + + # Check if it's a directory + if os.path.isdir(fullpath): + # Only allow deletion if directory is empty + if os.listdir(fullpath): + return {"status": "error", "message": "Cannot delete non-empty folder. Delete all files first."} + recursive_unlink(fullpath) return {"status": "success"} @@ -581,6 +919,8 @@ def new_app(): @action.uses(Logged(session), "gitlog.html") @catch_errors def gitlog(project): + themes = get_available_themes() + user_settings = load_user_settings() if not is_git_repo(os.path.join(FOLDER, project)): return "Project is not a GIT repo" branches = get_branches(cwd=os.path.join(FOLDER, project)) @@ -591,6 +931,9 @@ def gitlog(project): checkout=checkout, project=project, branches=branches, + selected_theme=normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ), ) @authenticated.callback() @@ -635,11 +978,18 @@ def gitshow(project, commit): @action.uses(Logged(session), "translations.html") def translations(name): """returns a json with all translations for all languages""" + themes = get_available_themes() + user_settings = load_user_settings() folder = os.path.join(FOLDER, name, "translations") if not os.path.exists(folder): os.makedirs(folder) t = Translator(folder) - return t.languages + return dict( + languages=t.languages, + selected_theme=normalize_selected_theme( + user_settings.get("selected_theme", "AlienDark"), themes + ), + ) @action("api/translations/", method="GET") diff --git a/apps/_dashboard/static/css/base.css b/apps/_dashboard/static/css/base.css new file mode 100644 index 00000000..0bc3b350 --- /dev/null +++ b/apps/_dashboard/static/css/base.css @@ -0,0 +1,500 @@ +/* ============================================================= + base.css - Dashboard Base Styles + + This file provides common, theme-agnostic styles for the + py4web Dashboard. All layouts, spacing, form structure, + and typography are defined here. Theme-specific colors, + backgrounds, and accents are defined in theme.css files. + + CSS Variables (defined by each theme): + - --bg-primary, --bg-secondary, --bg-tertiary + - --text-primary, --text-secondary, --text-light + - --accent-color, --accent-hover + - --border-color, --border-light + - --header-bg, --button-bg, --input-bg + - etc. + ============================================================= */ + +/******* Reset & Global *******/ +* { + border: 0; + margin: 0; + padding: 0; + box-sizing: inherit; + color: inherit; +} + +html, body { + max-width: 100vw; + overflow-x: hidden; + box-sizing: border-box; + font-size: 14px; +} + +body { + font-family: "Roboto", Helvetica, Arial, sans-serif; + line-height: 1.8em; + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr auto; +} + +[v-cloak] { display: none; } + +/******* Typography *******/ +h1, h2, h3, h4, h5, h6 { + font-weight: bold; + line-height: 1em; + padding: 5px 10px; + width: 100%; + margin-bottom: 0.5em; +} + +h1 { font-size: 4em; margin: 1.0em 0 0.25em 0; } +h2 { font-size: 2.4em; margin: 0.9em 0 0.25em 0; } +h3 { font-size: 1.8em; margin: 0.8em 0 0.25em 0; } +h4 { font-size: 1.6em; margin: 0.7em 0 0.30em 0; } +h5 { font-size: 1.4em; margin: 0.6em 0 0.40em 0; } +h6 { font-size: 1.2em; margin: 0.5em 0 0.50em 0; } + +p { + margin-bottom: 10px; + text-align: justify; +} + +b, label, strong { + font-weight: bold; +} + +a { + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +a:hover { + cursor: pointer; +} + +em, i { + font-style: italic; +} + +code { + border-radius: 0.4rem; + font-size: 90%; + margin: 0 0.2rem; + padding: 0.2rem 0.5rem; + white-space: nowrap; + font-family: monospace; +} + +blockquote { + padding: 10px 20px; + margin: 10px 0; + border-left: 4px solid; +} + +ul { + list-style-type: none; + padding-left: 20px; +} + +li { + margin-bottom: 0.5em; +} + +/******* Layout *******/ +header, footer { + display: block; + width: 100%; +} + +body > header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + gap: 10px; +} + +body > footer { + padding: 20px; + text-align: center; + font-size: 0.9em; +} + +body > main { + padding: 20px; + overflow-y: auto; +} + +/******* Flexbox Grid Utilities *******/ +.columns { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.columns > * { + flex: 1; + min-width: 200px; +} + +.col { + display: flex; + flex-direction: column; +} + +.c25 { width: calc(25% - 8px); } +.c33 { width: calc(33.333% - 7px); } +.c50 { width: calc(50% - 5px); } +.c66 { width: calc(66.666% - 7px); } +.c75 { width: calc(75% - 8px); } + +/******* Table Styles *******/ +table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1.0rem; +} + +thead { + display: table-header-group; +} + +tbody { + display: table-row-group; +} + +tbody tr:hover { + background-color: opacity; + transition: background-color 0.2s; +} + + +td, th { + padding: 4px 8px; + text-align: left; + vertical-align: top; +} + +thead th { + vertical-align: bottom; + font-weight: bold; + white-space: nowrap; +} + +td.middle { + vertical-align: middle; +} + +/******* Button Styles *******/ +[role="button"], +button, +input[type='button'], +input[type='reset'], +input[type='submit'] { + border-radius: 5px; + margin-right: 5px; + margin-bottom: 5px; + cursor: pointer; + display: inline-block; + font-size: 1rem; + font-weight: 300; + height: 1.8rem; + line-height: 1.8rem; + padding: 0 1.0rem; + text-align: center; + text-decoration: none; + white-space: nowrap; + min-width: 50px; + border: 2px solid; + transition: all 0.4s ease; + box-sizing: border-box; +} + +[role="button"]:focus, +[role="button"]:hover, +button:focus, +button:hover, +input[type='button']:focus, +input[type='button']:hover, +input[type='reset']:focus, +input[type='reset']:hover, +input[type='submit']:focus, +input[type='submit']:hover { + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); +} + +button.btn-app { + width: 200px; + padding: 4px 20px 6px 10px; + text-align: left; +} + +/******* Form Styles *******/ +input[type='color'], +input[type='date'], +input[type='datetime'], +input[type='time'], +input[type='datetime-local'], +input[type='email'], +input[type='month'], +input[type='number'], +input[type='password'], +input[type='search'], +input[type='tel'], +input[type='text'], +input[type='url'], +input[type='week'], +input:not([type]), +textarea, +select { + border: 0.1rem solid; + border-radius: 5px; + box-shadow: none; + box-sizing: inherit; + font-family: monospace; + font-size: 1em; + padding: 0.5em 1.0em; + width: 100%; + width: -moz-available; + width: -webkit-fill-available; + margin-bottom: 1.0rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +input[type='color']:focus, +input[type='date']:focus, +input[type='time']:focus, +input[type='datetime']:focus, +input[type='datetime-local']:focus, +input[type='email']:focus, +input[type='month']:focus, +input[type='number']:focus, +input[type='password']:focus, +input[type='search']:focus, +input[type='tel']:focus, +input[type='text']:focus, +input[type='url']:focus, +input[type='week']:focus, +input:not([type]):focus, +textarea:focus, +select:focus { + outline: 0; +} + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding-right: 2em; + background-size: 1.5em; + background-position: center right 8px; + background-repeat: no-repeat; +} + +select[multiple] { + background: none; + height: auto; +} + +textarea { + min-height: 6.5rem; + resize: vertical; +} + +fieldset { + border-width: 0; + padding: 0; + margin-bottom: 1.0rem; +} + +legend { + font-weight: bold; + margin-bottom: 0.5em; +} + +label { + display: block; + margin-bottom: 0.5em; +} + +/******* Panel Styles *******/ +.panel { + background: transparent; + text-align: left; + vertical-align: top; + border: 0; + padding: 0; + margin: 5px 15px 5px 5px; + padding: 30px 5px 2px 5px; + position: relative; + text-overflow: ellipsis; +} + +.panel > label { + width: 98%; + white-space: nowrap; + font-size: 24px; + font-weight: normal; + border: 4px solid; + color: black; + padding: 2px 6px; + opacity: 1.0; + position: absolute; + top: 0; + left: 0; +} + +/******* Accordion *******/ +.accordion > * { + margin-bottom: 10px; +} + +.accordion > label { + cursor: pointer; + display: block; + font-weight: bold; + padding: 10px; + user-select: none; +} + +.accordion > div { + max-height: 500px; + overflow: hidden; + transition: max-height 0.3s; +} + +.accordion > div.close { + max-height: 0; +} + +/******* Tags List *******/ +.tag { + border-radius: 5px; + padding: 3px 10px; + margin-right: 5px; + margin-bottom: 5px; + font-size: 14px; + display: inline-block; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +/******* Scrolling *******/ +.scrollx { + overflow: auto; +} + +iframe { + width: 100%; + height: 90vh; + background-color: white; +} + +/******* Code Editor *******/ + +/******* Highlights *******/ + +/******* Utilities *******/ +.fill { + width: 100%; + height: 100%; +} + +.padded { + padding: 20px; +} + +.hidden { + display: none !important; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-left { + display: flex; + align-items: center; +} + +.header-right { + display: flex; + align-items: center; + gap: 8px; + padding-right: 140px; +} + +.spinner-top { + height: 80px; + padding: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.logo { + padding: 0 0 0 8px; + font-size: 64px; + position: static; + line-height: 1; + display: inline-block; +} + +/******* Responsive Design *******/ +@media (max-width: 768px) { + body { + font-size: 13px; + } + + .header-right { + padding-right: 80px; + } + + .columns { + flex-direction: column; + } + + .columns > * { + min-width: 100%; + } + + .c25, .c33, .c50, .c66, .c75 { + width: 100%; + } + + table { + font-size: 0.9em; + } + + td, th { + padding: 2px 4px; + } +} + +@media (max-width: 480px) { + body { + font-size: 12px; + } + + h1 { font-size: 2em; } + h2 { font-size: 1.5em; } + h3 { font-size: 1.2em; } + + button, + input[type='button'], + input[type='reset'], + input[type='submit'] { + min-width: 40px; + padding: 0 0.5rem; + } +} diff --git a/apps/_dashboard/static/css/future.css b/apps/_dashboard/static/css/future.css index d7cbf164..e29f4e4d 100644 --- a/apps/_dashboard/static/css/future.css +++ b/apps/_dashboard/static/css/future.css @@ -27,6 +27,9 @@ p { height: 80px; padding: 10px; } +.header-right { + align-items: center; +} .ace-pastel-on-dark { background-color: black !important; } @@ -52,9 +55,6 @@ th { td.middle { vertical-align: middle; } -tbody { - border-bottom: 1px solid #33BFFF; -} button { background-color: black; color: #33BFFF; @@ -238,11 +238,11 @@ label { border: 2px solid #33BFFF; padding:10px 20px; transition: all .5s ease; - width: 210px; + width: 231px; } .login input[type=password]:focus { padding:10px 40px; - width: 250px; + width: 275px; } .loading { z-index: 100; @@ -252,7 +252,7 @@ label { right:0; height: 100vh; background-color: black; - background-image: url(../images/widget.gif); + background-image: url(../themes/AlienDark/widget.gif); background-repeat: no-repeat; background-position: center center; } @@ -271,6 +271,9 @@ label { margin: 20px 0; font-size: 1.2em; } +.classic-main-layout { + display: none; +} .right { text-align: right; margin-bottom: 10px; diff --git a/apps/_dashboard/static/css/no.css b/apps/_dashboard/static/css/no.css index c3aa291f..d0758d2f 100644 --- a/apps/_dashboard/static/css/no.css +++ b/apps/_dashboard/static/css/no.css @@ -600,6 +600,103 @@ nav { width: auto; } .padded { max-width: unset; padding-top: 0; } +/* DBAdmin Page: Header Layout for All Themes */ +body.dbadmin-page .header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 10px 20px; +} + +body.dbadmin-page .header-left { + display: flex; + align-items: center; + gap: 10px; +} + +body.dbadmin-page .header-right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +body.dbadmin-page .header-theme { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +body.dbadmin-page .header-theme label { + font-size: 12px; + margin: 0; +} + +body.dbadmin-page .header-theme select { + width: 140px; + height: auto; + padding: 4px 8px; + margin: 0; +} + +body.dbadmin-page .spinner-top { + height: 64px; + width: 64px; + padding: 0; +} + +body.dbadmin-page .logo { + font-size: 32px; + line-height: 1.1; + padding: 0; +} + +body.dbadmin-page { + display: block; + height: 100vh; + overflow: hidden; +} + +@supports (height: 100dvh) { + body.dbadmin-page { + height: 100dvh; + } +} + +body.dbadmin-page .my-effects { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +@supports (height: 100dvh) { + body.dbadmin-page .my-effects { + height: 100%; + } +} + +body.dbadmin-page .dbadmin { + flex: 1 1 auto; + min-height: 0; + display: flex; + overflow: hidden; +} + +body.dbadmin-page .dbadmin .dbadmin-content { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +body.dbadmin-page footer { + flex: 0 0 auto; +} + .dbadmin { width: 100%;} .dbadmin .grid-search-form {width:100%; max-width: 100%} .dbadmin tbody tr:hover { background-color:#002233 } diff --git a/apps/_dashboard/static/js/index.js b/apps/_dashboard/static/js/index.js index 9b6ab5f1..312899d1 100644 --- a/apps/_dashboard/static/js/index.js +++ b/apps/_dashboard/static/js/index.js @@ -26,7 +26,7 @@ const TreeFiles = { `, methods: { select_file(path, name) { this.$root.select_filename(path + '/' + name); }, - select_folder(path, name) { return true; }, + select_folder(path, name) { this.$root.select_folder(path + '/' + name); return true; }, combine(path, name) { return path + '/' + name; }, combineb64(path, name) { return btoa(path + '/' + name); }, } @@ -48,7 +48,9 @@ const app = Vue.createApp({ routes: [], walk: { files: [], dirs: [] }, databases: {}, + reloadingApps: false, toggled_folders: {}, + selected_folder: null, selected_app: null, selected_filename: null, selected_type: 'text', @@ -58,7 +60,15 @@ const app = Vue.createApp({ modal: null, editor: null, modelist: null, - last_error: "" + last_error: "", + show_system_info: false, + show_classic_tickets: false, + classic_tickets_loading: false, + classic_tickets_error: "", + classic_tickets_sort_key: 'timestamp', + classic_tickets_sort_dir: 'desc', + routes_sort_key: 'rule', + routes_sort_dir: 'asc' }; }, @@ -67,28 +77,84 @@ const app = Vue.createApp({ }, methods: { + go_to_main_dashboard() { + window.location.href = '../index?_=' + Date.now(); + }, + + is_classic_theme() { + const htmlTheme = document.documentElement.getAttribute('data-theme'); + return (htmlTheme || SELECTED_THEME || '') === 'Classic'; + }, + select(appobj) { this.selected_app = appobj; + this.selected_folder = null; this.reload_files(); }, + + select_folder(path) { + this.selected_folder = path || null; + }, to_json(r) { let json = {}; try { - console.log(r.data); json = r.json(); - console.log(json); } catch (e) { app.vue.last_error = "Invalid JSON:\n" + r.data; return {}; } - if (json.status === "error") { app.vue.last_error = json.traceback; return {}; } + if (json.status === "error") { + app.vue.last_error = json.traceback || json.message || "Unknown error"; + return json; // Return json to preserve message field + } return json; }, + + format_route_metric(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric.toFixed(2) : '0.00'; + }, + + set_routes_sort(key) { + if (this.routes_sort_key === key) { + this.routes_sort_dir = this.routes_sort_dir === 'asc' ? 'desc' : 'asc'; + return; + } + this.routes_sort_key = key; + this.routes_sort_dir = (key === 'time' || key === 'calls' || key === 'errors') ? 'desc' : 'asc'; + }, + + get_sorted_routes_for_selected_app() { + const selectedName = this.selected_app && this.selected_app.name; + const routeList = (selectedName && this.routes[selectedName]) ? this.routes[selectedName] : []; + const sortKey = this.routes_sort_key; + const sortDir = this.routes_sort_dir === 'asc' ? 1 : -1; + const metricKeys = new Set(['time', 'calls', 'errors']); + 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; + }); + }, + activate_editor(path, payload) { this.files[path] = payload; if (!this.editor) { this.editor = ace.edit("editor"); - this.editor.setTheme("ace/theme/pastel_on_dark"); + var dashboardTheme = document.documentElement.getAttribute("data-theme") || ""; + var aceTheme = dashboardTheme === "AlienLight" + ? "ace/theme/chrome" + : "ace/theme/pastel_on_dark"; + this.editor.setTheme(aceTheme); this.editor.$blockScrolling = Infinity; } this.editor.session.setValue(payload); @@ -105,6 +171,9 @@ const app = Vue.createApp({ if(this.selected_filename) this.files[this.selected_filename] = this.editor.getValue(); + const lastSlash = path.lastIndexOf('/'); + this.selected_folder = lastSlash > 0 ? path.substring(0, lastSlash) : null; + let lpath = path.toLowerCase(); if(lpath.endsWith('.mp4','.mov','.mpg','.mpeg')) this.selected_type = 'video'; else if(lpath.endsWith('.wav') || lpath.endsWith('.mp3') || lpath.endsWith('.ogg')) @@ -133,10 +202,183 @@ const app = Vue.createApp({ this.modal = null; }, + change_password() { + if (!this.modal || !this.modal.form) return; + const old_pwd = this.modal.form.old_password; + const pwd = this.modal.form.password; + const pwd_confirm = this.modal.form.password_confirm; + + if (!old_pwd) { + alert('Current password is required'); + return; + } + if (!pwd) { + alert('New password cannot be empty'); + return; + } + if (pwd !== pwd_confirm) { + alert('Passwords do not match'); + return; + } + + Q.post('../change_password', { old_password: old_pwd, password: pwd }) + .then(r => { + const response = this.to_json(r); + if (response.status === 'success') { + alert('Password changed successfully'); + this.modal_dismiss(); + } else { + alert('Failed to change password: ' + (response.message || 'Unknown error')); + } + }) + .catch((e) => { + alert('Error changing password: ' + e); + }); + }, + + open_change_password_modal() { + this.modal = { + title: 'Change Dashboard Password', + color: 'orange', + message: 'Enter your current password and new password', + form_name: 'change-password', + form: {old_password: '', password: '', password_confirm: ''}, + buttons: [ + {text: 'Change', onclick: () => {this.change_password();}}, + {text: 'Cancel', onclick: () => {this.modal_dismiss();}} + ] + }; + }, + + open_forgotten_password_modal() { + this.modal = { + title: 'Forgotten Administrator Password', + color: 'orange', + message: 'Run "py4web set_password" from your py4web project folder and follow the prompt to create a new administrator password. Then return to this page and sign in with the new password.', + buttons: [ + {text: 'Close', onclick: () => {this.modal_dismiss();}} + ] + }; + }, + + show_message(title, message, color='blue') { + this.modal = { + title: title, + color: color, + message: message, + buttons: [{text: 'Close', onclick: () => {this.modal_dismiss();}}] + }; + }, + + build_system_info_text() { + if (!this.info || this.info.length === 0) return ''; + let text = 'System Information\n'; + text += '==================\n\n'; + for (let i = 0; i < this.info.length; i++) { + text += this.info[i].name + ': ' + this.info[i].version + '\n'; + } + return text; + }, + + copy_system_info() { + const text = this.build_system_info_text(); + if (!text) return; + + // Copy to clipboard + navigator.clipboard.writeText(text).then(() => { + alert('System information copied to clipboard'); + }).catch(() => { + alert('Failed to copy to clipboard'); + }); + }, + + async save_system_info() { + const text = this.build_system_info_text(); + if (!text) { + this.show_message('Save details', 'No system information available to save', 'orange'); + return; + } + + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + const filename = 'py4web-system-details-' + + now.getFullYear() + + pad(now.getMonth() + 1) + + pad(now.getDate()) + '-' + + pad(now.getHours()) + + pad(now.getMinutes()) + + pad(now.getSeconds()) + + '.txt'; + + if (window.showSaveFilePicker && window.isSecureContext) { + try { + const handle = await window.showSaveFilePicker({ + suggestedName: filename, + types: [ + { + description: 'Text Files', + accept: {'text/plain': ['.txt']} + } + ] + }); + const writable = await handle.createWritable(); + await writable.write(text); + await writable.close(); + this.show_message( + 'Save details', + 'System information saved locally as ' + (handle.name || filename), + 'green' + ); + } catch (e) { + if (e && e.name === 'AbortError') return; + this.show_message('Save details', 'Failed to save system information locally', 'red'); + } + return; + } + + try { + const blob = new Blob([text], {type: 'text/plain;charset=utf-8'}); + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + this.show_message( + 'Save details', + 'Download started. If your browser does not ask for a location, check your default Downloads folder for ' + filename, + 'blue' + ); + } catch (e) { + this.show_message('Save details', 'Failed to save system information locally', 'red'); + } + }, + reload(name) { this.modal_dismiss(); - this.loading = true; - Q.get(name?'../reload/'+name:'../reload').then(()=>this.init()); + this.loading = true; + this.reloadingApps = true; + const endpoint = name ? '../reload/' + name : '../reload'; + Q.get(endpoint) + .then((r) => { + const payload = this.to_json(r); + if (payload && payload.status === 'error') { + this.show_message('Reload', payload.message || 'Reload failed', 'orange'); + } + }) + .catch(() => { + this.show_message( + 'Reload', + 'Reload endpoint is unavailable in this mode. Refreshed dashboard data only.', + 'orange' + ); + }) + .finally(() => { + this.reloadingApps = false; + this.init(); + }); }, gitlog(name) { @@ -182,7 +424,6 @@ const app = Vue.createApp({ process_new_app() { let form = this.modal.form; - console.log(form); if(!form.name) alert('An app name must be provided'); else if(!form.name.match(/^[\w_]+$/)) alert('Invalid file name'); else if(form.mode=='new' && this.apps.map((a)=>{return a.name;}).indexOf(form.name)>=0) { @@ -206,8 +447,53 @@ const app = Vue.createApp({ }); }, - handle_upload_file() { - Q.upload_helper('upload-file', (name, data)=>{this.modal.form.file=data; this.modal.form.type = "upload";}); + handle_upload_file(inputId = 'upload-file') { + Q.upload_helper(inputId, (name, data)=>{ + if (!this.modal || !this.modal.form) return; + this.modal.form.file = data || ''; + if (name) { + const filename = this.modal.form.filename || ''; + if (!filename) { + this.modal.form.filename = name; + } else if (filename.endsWith('/')) { + this.modal.form.filename = filename + name; + } + } + if (this.modal.form_name === 'create-app') { + this.modal.form.type = "upload"; + } + }); + }, + + process_upload_new_file() { + const app_name = this.selected_app && this.selected_app.name; + const form = this.modal && this.modal.form ? this.modal.form : {}; + if (!app_name) { + alert('No application selected'); + return; + } + if (!form.filename) { + alert('A target file path is required'); + return; + } + if (!form.file) { + alert('Please choose a local file to upload'); + return; + } + + Q.post('../new_file/' + app_name + '/' + form.filename, { file: form.file }).then((r) => { + const response = this.to_json(r); + if (response.status === 'error') { + alert(response.message || 'Upload failed'); + return; + } + this.modal_dismiss(); + this.reload_files(); + const classicRefresh = window.classicRefreshCurrentFiles; + if (this.is_classic_theme() && typeof classicRefresh === 'function') { + classicRefresh(); + } + }); }, upload_new_app() { @@ -236,7 +522,34 @@ const app = Vue.createApp({ }, upload_new_file() { - this.modal ={title:'Upload New File', color:'blue',message:'[WORK IN PROGRESS]'}; + const app_name = this.selected_app && this.selected_app.name; + if (!app_name) { + alert('Please select an app first'); + return; + } + + const appPrefix = app_name + '/'; + let defaultPath = ''; + if (this.selected_folder) { + defaultPath = this.selected_folder.startsWith(appPrefix) + ? this.selected_folder.substring(appPrefix.length) + : this.selected_folder; + } + const defaultFilename = defaultPath + ? (defaultPath.endsWith('/') ? defaultPath : defaultPath + '/') + : ''; + + this.modal = { + title: 'Upload New File', + color: 'blue', + message: 'Choose a local file and destination path under ' + app_name, + form_name: 'upload-file', + form: { filename: defaultFilename, file: '' }, + buttons: [ + { text: 'Upload', onclick: () => { this.process_upload_new_file(); } }, + { text: 'Close', onclick: this.modal_dismiss } + ] + }; }, delete_selected_file() { @@ -291,8 +604,10 @@ const app = Vue.createApp({ return; } this.walk = { files: [], dirs: [] }; // Always reset to an object + this.selected_folder = null; let name = this.selected_app.name; - Q.get('../walk/' + name).then(r => { + const walkUrl = '../walk/' + name + '?_=' + Date.now(); + Q.get(walkUrl).then(r => { const payload = this.to_json(r).payload; if (payload && Array.isArray(payload.files) && Array.isArray(payload.dirs)) { this.walk = payload; @@ -308,6 +623,69 @@ const app = Vue.createApp({ this.tickets = []; Q.get('../clear').then(()=>this.reload_tickets()); }, + + load_classic_tickets() { + this.classic_tickets_loading = true; + this.classic_tickets_error = ""; + this.tickets = []; + Q.get('../tickets') + .then((r) => { + const response = this.to_json(r); + if (response.status === 'error') { + this.classic_tickets_error = response.message || 'Unable to load tickets'; + return; + } + this.tickets = response.payload || []; + }) + .catch(() => { + this.classic_tickets_error = 'Unable to load tickets'; + }) + .finally(() => { + this.classic_tickets_loading = false; + }); + }, + + set_classic_tickets_sort(key) { + if (this.classic_tickets_sort_key === key) { + this.classic_tickets_sort_dir = this.classic_tickets_sort_dir === 'asc' ? 'desc' : 'asc'; + return; + } + this.classic_tickets_sort_key = key; + this.classic_tickets_sort_dir = key === 'timestamp' ? 'desc' : 'asc'; + }, + + get_sorted_classic_tickets() { + const sortKey = this.classic_tickets_sort_key; + const sortDir = this.classic_tickets_sort_dir === 'asc' ? 1 : -1; + return [...this.tickets].sort((left, right) => { + let leftValue = left?.[sortKey]; + let rightValue = right?.[sortKey]; + + if (sortKey === 'count') { + 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; + }); + }, + + open_classic_tickets_view() { + this.show_system_info = false; + this.show_classic_tickets = true; + this.load_classic_tickets(); + }, + + back_classic_tickets_view() { + this.show_classic_tickets = false; + this.classic_tickets_loading = false; + this.classic_tickets_error = ""; + }, login() { Q.post('../login', { password: this.password }) @@ -334,6 +712,19 @@ const app = Vue.createApp({ this.reload_tickets(); this.reload_files(); setTimeout(()=>{this.loading=false;}, 1000); + }, + + handleEdit(appName) { + this.show_classic_tickets = false; + if (typeof window.classicEditHandler === 'function') { + window.classicEditHandler(appName, 'files'); + return; + } + const selected = this.apps.filter((appItem) => appItem.name === appName)[0] || null; + if (!selected) { + return; + } + this.select(selected); } }, @@ -345,4 +736,5 @@ const app = Vue.createApp({ // **Register globally for recursion to work** app.component('TreeFiles', TreeFiles); -app.vue = app.mount('#target'); \ No newline at end of file +app.vue = app.mount('#target'); +window.py4webDashboardApp = app.vue; \ No newline at end of file diff --git a/apps/_dashboard/static/js/theme-selector.js b/apps/_dashboard/static/js/theme-selector.js index 8d71b03b..0f3acaad 100644 --- a/apps/_dashboard/static/js/theme-selector.js +++ b/apps/_dashboard/static/js/theme-selector.js @@ -16,6 +16,99 @@ "use strict"; var STORAGE_KEY = "py4web-dashboard-theme"; + var CONFIG_CACHE = {}; + var ACTIVE_THEME = null; + var ACTIVE_THEME_CONFIG = null; + var DEFAULT_THEME_CONFIG = { + favicon: "themes/AlienDark/favicon.ico", + widget: "themes/AlienDark/widget.gif", + appFavicon: "", + description: "", + version: "", + author: "", + homepage: "", + screenshot: "" + }; + + function resolveAssetUrl(path) { + try { + return new URL(path, document.baseURI).toString(); + } catch (err) { + return path; + } + } + + function applyThemeToAppIcons(parent, config) { + if (!config || !config.appFavicon) { + return; + } + var appIconUrl = resolveAssetUrl(config.appFavicon); + var scope = parent && typeof parent.querySelectorAll === "function" ? parent : document; + var icons = scope.querySelectorAll("button.btn-app img[src*='favicon']"); + for (var i = 0; i < icons.length; i += 1) { + var icon = icons[i]; + if (!icon.getAttribute("data-original-src")) { + icon.setAttribute("data-original-src", icon.getAttribute("src")); + } + if (icon.getAttribute("src") !== appIconUrl) { + icon.setAttribute("src", appIconUrl); + } + } + } + + function parseThemeToml(text) { + var config = {}; + var lines = text.split(/\r?\n/); + for (var i = 0; i < lines.length; i += 1) { + var line = lines[i].trim(); + if (!line || line[0] === "#") { + continue; + } + var parts = line.split("="); + if (parts.length < 2) { + continue; + } + var key = parts[0].trim(); + var raw = parts.slice(1).join("=").trim(); + if ((raw[0] === '"' && raw[raw.length - 1] === '"') || (raw[0] === "'" && raw[raw.length - 1] === "'")) { + raw = raw.slice(1, -1); + } + config[key] = raw; + } + return config; + } + + function loadThemeConfig(theme) { + if (CONFIG_CACHE[theme]) { + return Promise.resolve(CONFIG_CACHE[theme]); + } + return fetch("themes/" + theme + "/theme.toml", { cache: "no-cache" }) + .then(function (response) { + if (!response.ok) { + throw new Error("Theme config not found"); + } + return response.text(); + }) + .then(function (text) { + var parsed = parseThemeToml(text); + var merged = Object.assign({}, DEFAULT_THEME_CONFIG, parsed); + if (!parsed.favicon) { + merged.favicon = "themes/" + theme + "/favicon.ico"; + } + if (!parsed.widget) { + merged.widget = "themes/" + theme + "/widget.gif"; + } + if (!parsed.appFavicon) { + merged.appFavicon = "themes/" + theme + "/favicon.ico"; + } + CONFIG_CACHE[theme] = merged; + return merged; + }) + .catch(function () { + CONFIG_CACHE[theme] = DEFAULT_THEME_CONFIG; + return DEFAULT_THEME_CONFIG; + }); + } /** * Retrieves all available themes by reading options from the theme selector dropdown. @@ -31,10 +124,13 @@ for (var i = 0; i < selector.options.length; i += 1) { themes.push(selector.options[i].value); } - return themes.length > 0 ? themes : ["AlienDark", "AlienLight"]; + return themes.length > 0 ? themes : ["AlienDark", "AlienLight", "Classic"]; + } + if (typeof SELECTED_THEME !== "undefined" && SELECTED_THEME) { + return [SELECTED_THEME, "AlienDark", "AlienLight", "Classic"]; } // Fallback to known themes if selector not found (useful during page load) - return ["AlienDark", "AlienLight"]; + return ["AlienDark", "AlienLight", "Classic"]; } /** @@ -49,25 +145,39 @@ */ function getDefaultTheme() { var themes = getAvailableThemes(); + if (themes.length === 0) { + return null; + } // Prefer AlienDark if available, otherwise use first alphabetically if (themes.indexOf("AlienDark") !== -1) { return "AlienDark"; } - return themes.length > 0 ? themes[0] : "AlienDark"; + var fallback = themes.length > 0 ? themes[0] : "AlienDark"; + return fallback; } /** - * Retrieves the previously stored theme from browser localStorage, if valid. - * Validates that the stored theme is still in the available themes list - * (handles case where a theme was removed after being selected). + * Retrieves the previously stored theme, prioritizing backend settings. + * Order of preference: + * 1. SELECTED_THEME from backend (set in user_settings.toml) + * 2. localStorage (browser fallback) + * 3. null (will use default theme) * - * @returns {string|null} The stored theme name if valid and localStorage accessible, - * null otherwise (will fallback to default theme) + * @returns {string|null} The stored theme name if valid, null otherwise */ function getStoredTheme() { + var themes = getAvailableThemes(); + + // First check backend-provided theme (from user_settings.toml) + if (typeof SELECTED_THEME !== 'undefined' && SELECTED_THEME) { + if (themes.indexOf(SELECTED_THEME) !== -1) { + return SELECTED_THEME; + } + } + + // Fallback to localStorage try { var stored = localStorage.getItem(STORAGE_KEY); - var themes = getAvailableThemes(); if (stored && themes.indexOf(stored) !== -1) { return stored; } @@ -77,6 +187,50 @@ return null; } + /** + * Dynamically loads theme-specific JavaScript file if it exists. + * Themes can provide custom JavaScript code that executes when the theme is activated. + * This allows themes to modify behavior, initialize custom components, etc. + * + * @param {string} theme - The theme name to load JavaScript for + */ + function loadThemeScript(theme) { + var scriptPath = "themes/" + theme + "/theme.js"; + + // Check if the script is already loaded to avoid duplicates + var existingScript = document.querySelector('script[data-theme-script="' + theme + '"]'); + if (existingScript) { + // Script already loaded, don't load again + return; + } + + // Remove any previously loaded theme scripts (from other themes) + var oldScripts = document.querySelectorAll('script[data-theme-script]'); + for (var i = 0; i < oldScripts.length; i += 1) { + var oldScript = oldScripts[i]; + if (oldScript.getAttribute("data-theme-script") !== theme) { + oldScript.parentNode.removeChild(oldScript); + } + } + + // Create and inject the script + var script = document.createElement('script'); + script.setAttribute('data-theme-script', theme); + script.src = scriptPath + "?t=" + new Date().getTime(); // Cache bust + script.defer = false; + + // Optionally handle script load/error events + script.onload = function () { + // Theme script loaded successfully + }; + + script.onerror = function () { + // Theme script doesn't exist or failed to load - that's OK, not all themes have JS + }; + + document.head.appendChild(script); + } + /** * Applies a theme by: * 1. Validating the requested theme against available options @@ -94,87 +248,81 @@ var defaultTheme = getDefaultTheme(); var selected = themes.indexOf(theme) !== -1 ? theme : defaultTheme; - // Load theme CSS file by updating the link element href - var link = document.getElementById("dashboard-theme"); - if (link) { - link.setAttribute("href", "themes/" + selected + "/theme.css"); - } - - // Update browser favicon based on theme - var favicon = document.querySelector("link[rel='shortcut icon']"); - if (favicon) { - if (selected === "AlienLight") { - favicon.setAttribute("href", "favicon_green.ico"); - } else { - favicon.setAttribute("href", "favicon.ico"); + loadThemeConfig(selected).then(function (config) { + // Load theme CSS file by updating the link element href + var link = document.getElementById("dashboard-theme"); + if (link) { + var newHref = "themes/" + selected + "/theme.css?v=" + new Date().getTime(); + link.setAttribute("href", newHref); + } + + // Update browser favicon based on theme config + var favicon = document.querySelector("link[rel='shortcut icon']"); + if (favicon) { + var faviconUrl = config.favicon || DEFAULT_THEME_CONFIG.favicon; + favicon.setAttribute("href", faviconUrl); } - } - // Update the top-left spinner image for light theme - var spinner = document.querySelector("img.spinner-top"); - if (spinner) { - var originalSpinnerSrc = spinner.getAttribute("data-original-src"); - if (!originalSpinnerSrc) { - originalSpinnerSrc = spinner.getAttribute("src"); - spinner.setAttribute("data-original-src", originalSpinnerSrc); + // Update the top-left spinner image based on theme config + var spinner = document.querySelector("img.spinner-top"); + if (spinner) { + var originalSpinnerSrc = spinner.getAttribute("data-original-src"); + if (!originalSpinnerSrc) { + originalSpinnerSrc = spinner.getAttribute("src"); + spinner.setAttribute("data-original-src", originalSpinnerSrc); + } + var spinnerUrl = config.widget || originalSpinnerSrc; + spinner.setAttribute("src", spinnerUrl); } - if (selected === "AlienLight") { - spinner.setAttribute("src", "images/widget-transparent.gif"); - } else { - spinner.setAttribute("src", originalSpinnerSrc); + + ACTIVE_THEME = selected; + ACTIVE_THEME_CONFIG = config; + applyThemeToAppIcons(document, config); + + // Set data attribute for CSS selectors that might use it + document.documentElement.setAttribute("data-theme", selected); + + // Dynamically load theme-specific JavaScript if it exists + loadThemeScript(selected); + + // Persist theme selection to localStorage (for immediate fallback) + try { + localStorage.setItem(STORAGE_KEY, selected); + } catch (err) { } - } - - // Update app icons based on theme (images with favicon.ico src) - var appIcons = document.querySelectorAll("img[src*='favicon']"); - for (var i = 0; i < appIcons.length; i += 1) { - var img = appIcons[i]; - var currentSrc = img.getAttribute("src"); - // Skip if not a favicon icon - if (!currentSrc.includes("favicon")) continue; + // Persist theme selection to backend (user_settings.toml) + saveThemeToBackend(selected); - if (selected === "AlienLight") { - // Store original src if not already stored - if (!img.getAttribute("data-original-src")) { - img.setAttribute("data-original-src", currentSrc); - } - // Point all app icons to the dashboard's green favicon - img.setAttribute("src", "/_dashboard/static/favicon_green.ico"); - } else { - // Restore original favicon path - var originalSrc = img.getAttribute("data-original-src"); - if (originalSrc && originalSrc !== "/_dashboard/static/favicon_green.ico") { - // Use stored original - img.setAttribute("src", originalSrc); - } else if (currentSrc.includes("_dashboard") && currentSrc.includes("favicon_green")) { - // Currently pointing to green, extract the original app path - // For dashboard: /static/favicon.ico or /{app}/static/favicon.ico - var parts = document.location.pathname.split("/"); - if (parts[1] && parts[1] !== "_dashboard") { - img.setAttribute("src", "/" + parts[1] + "/static/favicon.ico"); - } else { - img.setAttribute("src", "/static/favicon.ico"); - } - } else if (currentSrc.includes("favicon_green")) { - // Try to reconstruct original from URL pattern - img.setAttribute("src", "/static/favicon.ico"); - } + // Update all theme selector dropdowns to reflect current theme + syncSelectors(selected); + if (typeof SELECTED_THEME !== 'undefined') { + SELECTED_THEME = selected; } + }); + } + + /** + * Saves theme selection to backend via save_theme endpoint. + * Silently fails if backend is unavailable or user not logged in. + * + * @param {string} theme - The theme name to save + */ + function saveThemeToBackend(theme) { + // Only save if user is logged in (USER_ID is defined) + if (typeof USER_ID === 'undefined' || !USER_ID) { + return; } - // Set data attribute for CSS selectors that might use it - document.documentElement.setAttribute("data-theme", selected); - - // Persist theme selection to localStorage - try { - localStorage.setItem(STORAGE_KEY, selected); - } catch (err) { - // Ignore storage errors (private browsing, full storage, etc.) - } - - // Update all theme selector dropdowns to reflect current theme - syncSelectors(selected); + fetch('../save_theme', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ theme: theme }) + }).catch(function () { + // Silently ignore errors (e.g., not logged in, network issues) + }); } /** @@ -193,20 +341,76 @@ /** * Initializes the theme system on page load. * Stores original favicon src values before applying any theme. + * Watches for dynamically-rendered icons (from Vue) and initializes them. * Loads the previously saved theme, or applies the default if none saved. */ function init() { - // Store all original favicon srcs before applying theme - var appIcons = document.querySelectorAll("img[src*='favicon']"); - for (var i = 0; i < appIcons.length; i += 1) { - var img = appIcons[i]; - var currentSrc = img.getAttribute("src"); - if (currentSrc && !img.getAttribute("data-original-src")) { - img.setAttribute("data-original-src", currentSrc); + var themes = getAvailableThemes(); + if (themes.length === 0) { + throw new Error("No dashboard themes found. Add a theme folder under static/themes."); + } + + // Helper function to initialize favicon icons with proper tracking + var initFaviconIcons = function(parent) { + var appIcons = parent.querySelectorAll("img[src*='favicon']"); + for (var i = 0; i < appIcons.length; i += 1) { + var img = appIcons[i]; + var currentSrc = img.getAttribute("src"); + // Ensure data-original-src is set (either from attribute or from current src) + if (!img.getAttribute("data-original-src") && currentSrc) { + img.setAttribute("data-original-src", currentSrc); + } } + }; + + // Initialize static icons + initFaviconIcons(document); + + // Watch for dynamically-rendered icons and initialize them + // This handles Vue.js rendering the app list after the script loads + if (typeof MutationObserver !== 'undefined') { + var observer = new MutationObserver(function(mutations) { + // Check if any added nodes contain favicon images + for (var i = 0; i < mutations.length; i += 1) { + var mutation = mutations[i]; + if (mutation.addedNodes.length > 0) { + for (var j = 0; j < mutation.addedNodes.length; j += 1) { + var node = mutation.addedNodes[j]; + // Only process element nodes + if (node.nodeType === 1) { + // Check if the node itself is an img with favicon + if (node.tagName === 'IMG' && node.src && node.src.includes('favicon')) { + if (!node.getAttribute('data-original-src')) { + node.setAttribute('data-original-src', node.getAttribute('src')); + } + if (ACTIVE_THEME_CONFIG) { + applyThemeToAppIcons(node.parentNode || document, ACTIVE_THEME_CONFIG); + } + } + // Check if the node contains img children with favicon + if (typeof node.querySelectorAll === 'function') { + initFaviconIcons(node); + if (ACTIVE_THEME_CONFIG) { + applyThemeToAppIcons(node, ACTIVE_THEME_CONFIG); + } + } + } + } + } + } + }); + + // Watch the document body for changes + observer.observe(document.body, { + childList: true, + subtree: true + }); } var initial = getStoredTheme() || getDefaultTheme(); + if (!initial) { + throw new Error("Unable to determine a default dashboard theme."); + } applyTheme(initial); } diff --git a/apps/_dashboard/static/favicon.ico b/apps/_dashboard/static/themes/AlienDark/favicon.ico similarity index 100% rename from apps/_dashboard/static/favicon.ico rename to apps/_dashboard/static/themes/AlienDark/favicon.ico diff --git a/apps/_dashboard/static/themes/AlienDark/theme.css b/apps/_dashboard/static/themes/AlienDark/theme.css index e60327a5..d8ed2300 100644 --- a/apps/_dashboard/static/themes/AlienDark/theme.css +++ b/apps/_dashboard/static/themes/AlienDark/theme.css @@ -117,10 +117,6 @@ textarea { color: var(--accent); } -tbody { - border-bottom: 1px solid var(--accent); -} - /* ============================================================================ 5. TABLES & DATABASE GRID - Grid and DBAdmin styling ========================================================================== */ @@ -130,8 +126,6 @@ tbody { thead>tr { background: var(--bg-primary); - border-top: 2px solid var(--accent); - border-bottom: 2px solid var(--accent); } /* ============================================================================ @@ -167,3 +161,65 @@ select option { background-color: var(--bg-secondary); color: white; } + +/* Files accordion: reduce vertical spacing between folders */ +.my-effects .files li, +.my-effects .dirs li, +.my-effects .accordion .files li, +.my-effects .accordion .dirs li, +.my-effects .accordion li { + margin-bottom: 0 !important; + padding-bottom: 0 !important; + line-height: 1.2 !important; +} + +.my-effects .files ul, +.my-effects .dirs ul { + margin: 0 !important; + padding: 0 !important; +} + +/* Folder rows: reduce accordion spacing inside dirs tree */ +.my-effects .dirs .accordion > * { + margin-bottom: 2px !important; +} + +.my-effects .dirs .accordion > label { + padding: 2px 0 !important; +} + +.my-effects .subfolder { + margin: 0 !important; + padding: 0 !important; +} + +/* Database table buttons: vertically center labels */ +.my-effects .panel.accordion input#databases ~ div button { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1.2 !important; + padding: 4px 10px !important; + height: auto !important; +} + +/* Database table rows: center buttons within row height */ +.my-effects .panel.accordion input#databases ~ div td { + vertical-align: middle !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +/* App buttons: vertically center labels */ +button.btn-app { + display: inline-flex; + align-items: center; + gap: 6px; + height: auto !important; + line-height: 1.2 !important; + padding: 4px 10px !important; +} + +button.btn-app i { + align-self: center; +} diff --git a/apps/_dashboard/static/themes/AlienDark/theme.js b/apps/_dashboard/static/themes/AlienDark/theme.js new file mode 100644 index 00000000..e6fd1046 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienDark/theme.js @@ -0,0 +1,15 @@ +/* AlienDark Theme JavaScript + + This file can contain theme-specific JavaScript code that needs to run + when the AlienDark theme is active. Currently, it's a placeholder for + future functionality. + + To enable theme-specific code: + 1. Add your JavaScript here + 2. The theme-selector.js will automatically load this file when AlienDark + is selected as the active theme +*/ + +(function() { + // Theme initialization code can go here +})(); diff --git a/apps/_dashboard/static/themes/AlienDark/theme.toml b/apps/_dashboard/static/themes/AlienDark/theme.toml new file mode 100644 index 00000000..ec2f48f7 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienDark/theme.toml @@ -0,0 +1,6 @@ +name = "AlienDark" +description = "Sleek dark theme with cyan accents for the py4web dashboard." +version = "1.0.0" +author = "py4web team" +homepage = "https://py4web.com" +screenshot = "widget.gif" diff --git a/apps/_dashboard/static/images/widget.gif b/apps/_dashboard/static/themes/AlienDark/widget.gif similarity index 100% rename from apps/_dashboard/static/images/widget.gif rename to apps/_dashboard/static/themes/AlienDark/widget.gif diff --git a/apps/_dashboard/static/favicon_green.ico b/apps/_dashboard/static/themes/AlienLight/favicon.ico similarity index 100% rename from apps/_dashboard/static/favicon_green.ico rename to apps/_dashboard/static/themes/AlienLight/favicon.ico diff --git a/apps/_dashboard/static/themes/AlienLight/theme.css b/apps/_dashboard/static/themes/AlienLight/theme.css index c485077c..9d239987 100644 --- a/apps/_dashboard/static/themes/AlienLight/theme.css +++ b/apps/_dashboard/static/themes/AlienLight/theme.css @@ -117,6 +117,68 @@ textarea:focus { background-color: white; } +/* Files accordion: reduce vertical spacing between folders */ +.my-effects .files li, +.my-effects .dirs li, +.my-effects .accordion .files li, +.my-effects .accordion .dirs li, +.my-effects .accordion li { + margin-bottom: 0 !important; + padding-bottom: 0 !important; + line-height: 1.2 !important; +} + +.my-effects .files ul, +.my-effects .dirs ul { + margin: 0 !important; + padding: 0 !important; +} + +/* Folder rows: reduce accordion spacing inside dirs tree */ +.my-effects .dirs .accordion > * { + margin-bottom: 2px !important; +} + +.my-effects .dirs .accordion > label { + padding: 2px 0 !important; +} + +.my-effects .subfolder { + margin: 0 !important; + padding: 0 !important; +} + +/* Database table buttons: vertically center labels */ +.my-effects .panel.accordion input#databases ~ div button { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1.2 !important; + padding: 4px 10px !important; + height: auto !important; +} + +/* Database table rows: center buttons within row height */ +.my-effects .panel.accordion input#databases ~ div td { + vertical-align: middle !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +/* App buttons: vertically center labels */ +button.btn-app { + display: inline-flex; + align-items: center; + gap: 6px; + height: auto !important; + line-height: 1.2 !important; + padding: 4px 10px !important; +} + +button.btn-app i { + align-self: center; +} + /* ============================================================================ 5. CONTENT - Login and Interactive Elements ========================================================================== */ @@ -138,10 +200,7 @@ textarea:focus { .loading { background-color: var(--bg-primary); -} - -tbody { - border-bottom: 1px solid var(--border-color); + background-image: url(widget.gif); } tbody tr:hover { @@ -157,14 +216,11 @@ tbody tr:hover { thead tr { background: var(--bg-secondary); - border-top: 1px solid var(--border-color); - border-bottom: 1px solid var(--border-color); } thead th { background-color: var(--bg-secondary); color: var(--text-primary); - border-bottom: 1px solid var(--border-color); } button, a[role=button], input[type=submit], input[type=button] { @@ -329,7 +385,3 @@ p, span, div { color: var(--text-primary); } -/* Style favicon icons to green */ -img[src*="favicon.ico"] { - filter: hue-rotate(110deg) saturate(1.5) brightness(0.8); -} diff --git a/apps/_dashboard/static/themes/AlienLight/theme.js b/apps/_dashboard/static/themes/AlienLight/theme.js new file mode 100644 index 00000000..3223e7b1 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienLight/theme.js @@ -0,0 +1,50 @@ +/* AlienLight Theme JavaScript + + This file can contain theme-specific JavaScript code that needs to run + when the AlienLight theme is active. Currently, it's a placeholder for + future functionality. + + To enable theme-specific code: + 1. Add your JavaScript here + 2. The theme-selector.js will automatically load this file when AlienLight + is selected as the active theme +*/ + +(function() { + var ACE_THEME = "ace/theme/chrome"; + + var applyAceTheme = function() { + if (!window.app || !app.vue) { + return false; + } + + if (app.vue.editor && typeof app.vue.editor.setTheme === "function") { + app.vue.editor.setTheme(ACE_THEME); + } + + if (!app.vue.__alienLightAceHooked && typeof app.vue.activate_editor === "function") { + var originalActivate = app.vue.activate_editor.bind(app.vue); + app.vue.activate_editor = function(path, payload) { + originalActivate(path, payload); + if (app.vue.editor && typeof app.vue.editor.setTheme === "function") { + app.vue.editor.setTheme(ACE_THEME); + } + }; + app.vue.__alienLightAceHooked = true; + } + + return true; + }; + + var attempts = 0; + var maxAttempts = 20; + var retry = function() { + attempts += 1; + if (applyAceTheme() || attempts >= maxAttempts) { + return; + } + setTimeout(retry, 150); + }; + + retry(); +})(); diff --git a/apps/_dashboard/static/themes/AlienLight/theme.toml b/apps/_dashboard/static/themes/AlienLight/theme.toml new file mode 100644 index 00000000..76b4a0c3 --- /dev/null +++ b/apps/_dashboard/static/themes/AlienLight/theme.toml @@ -0,0 +1,6 @@ +name = "AlienLight" +description = "Bright light theme with blue accents and high readability." +version = "1.0.0" +author = "py4web team" +homepage = "https://py4web.com" +screenshot = "widget.gif" diff --git a/apps/_dashboard/static/images/widget-transparent.gif b/apps/_dashboard/static/themes/AlienLight/widget.gif similarity index 100% rename from apps/_dashboard/static/images/widget-transparent.gif rename to apps/_dashboard/static/themes/AlienLight/widget.gif diff --git a/apps/_dashboard/static/themes/Classic/favicon.ico b/apps/_dashboard/static/themes/Classic/favicon.ico new file mode 100644 index 00000000..8481eb83 Binary files /dev/null and b/apps/_dashboard/static/themes/Classic/favicon.ico differ diff --git a/apps/_dashboard/static/themes/Classic/header-buttons.html b/apps/_dashboard/static/themes/Classic/header-buttons.html new file mode 100644 index 00000000..13e660c6 --- /dev/null +++ b/apps/_dashboard/static/themes/Classic/header-buttons.html @@ -0,0 +1,3 @@ +py4web home +Help + diff --git a/apps/_dashboard/static/themes/Classic/logo.png b/apps/_dashboard/static/themes/Classic/logo.png new file mode 100644 index 00000000..bc244a92 Binary files /dev/null and b/apps/_dashboard/static/themes/Classic/logo.png differ diff --git a/apps/_dashboard/static/themes/Classic/logo_min.png b/apps/_dashboard/static/themes/Classic/logo_min.png new file mode 100644 index 00000000..fe04d343 Binary files /dev/null and b/apps/_dashboard/static/themes/Classic/logo_min.png differ diff --git a/apps/_dashboard/static/themes/Classic/templates/partials/dbadmin_footer_back.html b/apps/_dashboard/static/themes/Classic/templates/partials/dbadmin_footer_back.html new file mode 100644 index 00000000..00a4506b --- /dev/null +++ b/apps/_dashboard/static/themes/Classic/templates/partials/dbadmin_footer_back.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/apps/_dashboard/static/themes/Classic/templates/partials/dbadmin_nav.html b/apps/_dashboard/static/themes/Classic/templates/partials/dbadmin_nav.html new file mode 100644 index 00000000..586867ca --- /dev/null +++ b/apps/_dashboard/static/themes/Classic/templates/partials/dbadmin_nav.html @@ -0,0 +1,14 @@ +
+ + + + +
\ No newline at end of file diff --git a/apps/_dashboard/static/themes/Classic/templates/partials/index_header_actions.html b/apps/_dashboard/static/themes/Classic/templates/partials/index_header_actions.html new file mode 100644 index 00000000..2f7fd283 --- /dev/null +++ b/apps/_dashboard/static/themes/Classic/templates/partials/index_header_actions.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/_dashboard/static/themes/Classic/theme.css b/apps/_dashboard/static/themes/Classic/theme.css new file mode 100644 index 00000000..287b5f6f --- /dev/null +++ b/apps/_dashboard/static/themes/Classic/theme.css @@ -0,0 +1,1981 @@ +/* + * Classic Theme - web2py admin inspired + * Light UI with gray chrome, green accents, and orange highlights. + */ +:root { + --bg-primary: #f8f8f8; + --bg-secondary: #e3e3e3; + --text-primary: #222; + --border-color: #b5b5b5; + --accent: #3a8d3a; + --accent-dark: #2f6f2f; + --accent-warn: #e08b2c; + --shadow: rgba(0, 0, 0, 0.15); +} + +html, body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: "Tahoma", "Verdana", "Arial", sans-serif; +} + +.my-effects { + background: var(--bg-primary); + min-height: 100vh; +} + +.my-effects a, .my-effects a:hover, .my-effects a:visited { + color: var(--accent); +} + +.my-effects a:not(.btn):after { + background-color: var(--accent); +} + +/* Buttons */ +button, a[role=button], input[type=submit], input[type=button] { + background: linear-gradient(#f8f8f8, #dedede); + color: var(--text-primary); + border: 1px solid #9c9c9c; + border-radius: 3px; + box-shadow: inset 0 1px 0 #fff, 0 1px 2px var(--shadow); +} + +button:hover, a[role=button]:hover, input[type=submit]:hover, input[type=button]:hover { + background: linear-gradient(#f2fff2, #d7f0d7); + border-color: var(--accent); + color: #1e1e1e; +} + +[data-theme="Classic"] .header-btn { + display: inline-flex; + align-items: center; + justify-content: center; +} + +[data-theme="Classic"] .header-left { + transition: opacity 0.2s; +} + +[data-theme="Classic"] .header-left:hover { + opacity: 0.7; +} + +/* Inputs */ +input[type=text], +input[type=password], +input[type=number], +input[type=date], +input[type=time], +input[type=datetime-local], +input[type=file], +select, +textarea { + background-color: white; + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +input[type=text]:focus, +input[type=password]:focus, +input[type=number]:focus, +input[type=date]:focus, +input[type=time]:focus, +input[type=datetime-local]:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(58, 141, 58, 0.15); +} + +select { + background-color: white !important; + color: var(--text-primary) !important; +} + +select option { + background-color: white; + color: var(--text-primary); +} + +/* Panels */ +.panel > label { + border: 1px solid #8f8f8f; + border-left: 6px solid var(--accent); + background: linear-gradient(#f2f2f2, #dcdcdc); + color: var(--text-primary); +} + +label ~ div { + background-color: white; + border: 1px solid #c6c6c6; +} + +.tag { + background-color: var(--accent-warn); + color: white; +} + +tbody tr:hover { + background-color: #f6f6f6; +} + +thead tr { + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); +} + +thead th { + background-color: var(--bg-secondary); + color: var(--text-primary); +} + +/* Modal */ +.modal-inner { + border: 2px solid var(--accent); + background-color: white; +} + +/* Login */ +[data-theme="Classic"] .login { + background: #f0f0f0; + color: var(--text-primary); + box-sizing: border-box; + padding: 10px 15px 0 15px; +} + +[data-theme="Classic"] .classic-login-shell { + width: min(760px, 100%); + margin: 14vh auto 0 auto; +} + +[data-theme="Classic"] .classic-login-top { + width: 100%; + margin: 0 0 12px 0; + border: 1px solid var(--border-color); + border-radius: 3px; + background: linear-gradient(#f3f3f3, #e2e2e2); + box-shadow: inset 0 1px 0 #fff, 0 1px 2px var(--shadow); +} + +[data-theme="Classic"] .classic-login-top .header-left { + display: flex; + align-items: center; +} + +[data-theme="Classic"] .classic-login-top .spinner-top { + height: 56px; + padding: 8px; +} + +[data-theme="Classic"] .classic-login-top .logo { + font-size: 42px; +} + +[data-theme="Classic"] .classic-login-panel { + background: white; + border: 1px solid var(--border-color); + border-radius: 3px; + box-shadow: inset 0 1px 0 #fff, 0 1px 2px var(--shadow); + padding: 24px 28px 20px 28px; +} + +[data-theme="Classic"] .classic-login-panel h1 { + color: var(--text-primary); + font-size: 32px; + margin: 0 0 18px 0; + text-align: left; +} + +[data-theme="Classic"] .classic-login-panel label { + display: block; + font-size: 14px; + margin-bottom: 8px; + color: var(--text-primary); +} + +[data-theme="Classic"] .classic-login-panel input[type=password] { + border: 1px solid var(--border-color); + background-color: white; + color: var(--text-primary); + width: 100%; + max-width: 100%; + font-size: 16px; + padding: 10px 12px; + border-radius: 2px; +} + +[data-theme="Classic"] .classic-forgot-link { + display: block; + width: 100%; + text-align: right; + margin-top: 12px; + font-size: 13px; +} + +[data-theme="Classic"] .classic-demo-note { + margin-top: 10px; + color: #555; + font-size: 13px; +} + +[data-theme="Classic"] .classic-login-footer-panel { + margin-top: 12px; + background: white; + border: 1px solid var(--border-color); + border-radius: 3px; + box-shadow: inset 0 1px 0 #fff, 0 1px 2px var(--shadow); + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +[data-theme="Classic"] .classic-login-footer-panel .right { + margin-bottom: 0; + text-align: right; +} + +[data-theme="Classic"] .login, +[data-theme="Classic"] .login * { + animation: none !important; +} + +[data-theme="Classic"] .modal { + z-index: 220; +} + +[data-theme="Classic"] .modal-inner { + z-index: 221; +} + +[data-theme="Classic"] .loading { + display: none !important; +} + +[data-theme="Classic"] .login input[type=password], +[data-theme="Classic"] .login input[type=password]:focus, +[data-theme="Classic"] .classic-forgot-link, +[data-theme="Classic"] .classic-forgot-link:after { + transition: none !important; +} + +.loading { + background-color: var(--bg-primary); +} + +/* Header / nav / footer */ +header, +nav, +footer, +header.black, +footer.black, +nav.black { + background-color: var(--bg-secondary); + color: var(--text-primary); +} + +nav.black a, +footer.black a, +nav a, +footer a { + color: var(--accent); +} + +nav li:hover { + background-color: #ededed; +} + +/* Accordion-free layout for Classic */ +.my-effects .accordion > input { + display: none; +} + +.my-effects .accordion > label:before, +.my-effects .accordion > input:checked ~ label:before { + content: ""; +} + +.my-effects .accordion > *:not(label), +.my-effects .accordion > input:checked ~ *:not(label) { + max-height: none !important; + overflow: visible !important; + padding-top: 10px; +} + +.my-effects .accordion > label { + cursor: default; +} + +/* Classic Main Layout (70/30 split) */ +[data-theme="Classic"] .classic-main-layout { + display: grid !important; + grid-template-columns: 70% 30%; + gap: 20px; + margin: 20px; + min-height: calc(100vh - 180px); +} + +[data-theme="Classic"] .classic-left-panel { + background: white; + border: 1px solid var(--border-color); + padding: 20px; + border-radius: 3px; + height: 100%; +} + +[data-theme="Classic"] .classic-left-panel h2 { + margin-top: 0; + padding: 0; + background: none; + color: var(--text-primary); + border-bottom: 2px solid var(--accent); + padding-bottom: 10px; + font-size: 1.4em; +} + +[data-theme="Classic"] .classic-apps-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +[data-theme="Classic"] .classic-apps-actions { + display: flex; + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + white-space: nowrap; + flex-shrink: 0; + gap: 8px; +} + +[data-theme="Classic"] .classic-apps-header h2 { + margin: 0; + border-bottom: none; + padding-bottom: 0; +} + +[data-theme="Classic"] .btn-create-upload, +[data-theme="Classic"] .btn-reload { + font-size: 12px; + padding: 4px 10px; + height: 26px; + display: inline-flex; + align-items: center; + white-space: nowrap; + margin-bottom: 0; + gap: 6px; +} + +[data-theme="Classic"] .apps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 255px); + overflow-y: auto; + overflow-x: hidden; + padding-right: 6px; +} + +[data-theme="Classic"] .app-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 12px; + background: #f9f9f9; + border: 1px solid #e0e0e0; + border-radius: 3px; +} + +[data-theme="Classic"] .app-row.app-row-error { + background: #fdeaea; + border-color: #efb3b3; +} + +[data-theme="Classic"] .app-name { + font-weight: bold; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +[data-theme="Classic"] .app-name-text { + white-space: nowrap; +} + +[data-theme="Classic"] .app-status-dot { + width: 14px; + height: 14px; + border-radius: 50%; + display: inline-block; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); +} + +[data-theme="Classic"] .app-status-running { + background-color: #7ccf7c; +} + +[data-theme="Classic"] .app-status-stopped { + background-color: #ff4d4d; + animation: app-status-blink 1s infinite; + box-shadow: 0 0 6px rgba(255, 77, 77, 0.9); +} + +@keyframes app-status-blink { + 0% { opacity: 1; } + 50% { opacity: 0.4; } + 100% { opacity: 1; } +} + +[data-theme="Classic"] .app-buttons { + display: inline-flex; + gap: 6px; + flex-wrap: nowrap; + align-items: center; + margin-left: 8px; + white-space: nowrap; +} + +[data-theme="Classic"] .btn-small { + padding: 4px 12px; + font-size: 11px; + height: auto; + line-height: normal; + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; +} + +[data-theme="Classic"] .btn-delete { + background: #f5f5f5; + border-color: #b5b5b5; +} + +[data-theme="Classic"] .btn-delete:hover { + background: #ffe0e0; + border-color: #d9534f; +} + +[data-theme="Classic"] .classic-right-panel { + background: white; + border: 1px solid var(--border-color); + padding: 20px; + border-radius: 3px; + height: 100%; +} + +[data-theme="Classic"] .classic-right-panel h2 { + margin-top: 0; + padding: 0; + background: none; + color: var(--text-primary); + border-bottom: 2px solid var(--accent); + padding-bottom: 10px; + font-size: 1.4em; +} + +[data-theme="Classic"] .info-section { + margin: 20px 0; +} + +[data-theme="Classic"] .info-section h3 { + font-size: 0.9em; + color: var(--text-primary); + margin: 10px 0 5px 0; + font-weight: bold; +} + +[data-theme="Classic"] .info-table { + width: 100%; + font-size: 12px; +} + +[data-theme="Classic"] .info-table td { + padding: 4px 8px; +} + +[data-theme="Classic"] .info-table tr:hover { + background-color: transparent; +} + +[data-theme="Classic"] .btn-details { + width: 100%; + margin-top: 20px; +} + +[data-theme="Classic"] .recent-tickets-section { + margin: 20px 0 0 0; + padding-top: 16px; + border-top: 1px solid var(--border-color); +} + +[data-theme="Classic"] .recent-tickets-section h3 { + font-size: 0.9em; + color: var(--text-primary); + margin: 0 0 10px 0; + font-weight: bold; +} + +[data-theme="Classic"] .btn-show-all { + width: 100%; +} + +[data-theme="Classic"] .classic-tickets-view { + display: flex; + flex-direction: column; + gap: 12px; +} + +[data-theme="Classic"] .classic-tickets-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +[data-theme="Classic"] .classic-tickets-header h2 { + margin: 0; + border-bottom: none; + padding-bottom: 0; +} + +[data-theme="Classic"] .classic-tickets-table { + width: 100%; + border-collapse: collapse; +} + +[data-theme="Classic"] .classic-tickets-table th { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: 8px; + text-align: left; + font-weight: bold; +} + +[data-theme="Classic"] .classic-tickets-table th.sortable { + cursor: pointer; + user-select: none; +} + +[data-theme="Classic"] .classic-tickets-table td { + border: 1px solid var(--border-color); + padding: 6px 8px; + vertical-align: top; +} + +[data-theme="Classic"] .password-section { + margin: 30px 0 0 0; + padding-top: 20px; + border-top: 1px solid var(--border-color); +} + +[data-theme="Classic"] .password-section h3 { + font-size: 0.9em; + color: var(--text-primary); + margin: 0 0 10px 0; + font-weight: bold; +} + +[data-theme="Classic"] .btn-password { + width: 100%; +} + +[data-theme="Classic"] .classic-system-info-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +[data-theme="Classic"] .classic-system-info-header h2 { + margin: 0; + border-bottom: none; + padding-bottom: 0; +} + +[data-theme="Classic"] .system-info-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 8px; + margin-top: 0; +} + +[data-theme="Classic"] .system-info-buttons button { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + gap: 6px; +} + +[data-theme="Classic"] .btn-ok { + flex: 1; +} + +[data-theme="Classic"] .btn-copy { + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +[data-theme="Classic"] .btn-save-details { + flex: 0 0 auto; +} + +[data-theme="Classic"] .btn-back { + flex: 0 0 auto; +} + +[data-theme="Classic"] .os-info { + background-color: #f0f8f0; + border: 1px solid var(--accent); + border-radius: 4px; + padding: 15px; + margin: 15px 0 20px 0; +} + +[data-theme="Classic"] .os-info h3 { + font-size: 0.9em; + color: var(--accent); + margin: 0 0 8px 0; + font-weight: bold; + text-transform: uppercase; +} + +[data-theme="Classic"] .os-info p { + margin: 0; + font-size: 14px; + color: var(--text-primary); +} + +[data-theme="Classic"] .modules-table { + width: 100%; + font-size: 12px; + border-collapse: collapse; + margin: 20px 0; +} + +[data-theme="Classic"] .modules-table th { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: 8px; + text-align: left; + font-weight: bold; +} + +[data-theme="Classic"] .modules-table td { + border: 1px solid var(--border-color); + padding: 6px 8px; +} + +[data-theme="Classic"] .modules-table tbody tr:hover { + background-color: #f9f9f9; +} + +/* Classic Edit Layout (single-column, focused on selected app) */ +[data-theme="Classic"].classic-edit-mode .classic-main-layout, +[data-theme="Classic"] .classic-main-layout.classic-edit-mode { + grid-template-columns: 1fr; + height: var(--classic-edit-height, calc(100vh - 140px)); + min-height: 0; + max-height: var(--classic-edit-height, calc(100vh - 140px)); + overflow: hidden; +} + +[data-theme="Classic"].classic-edit-mode .classic-right-panel, +[data-theme="Classic"] .classic-main-layout.classic-edit-mode .classic-right-panel { + display: none; +} + +[data-theme="Classic"].classic-edit-mode .classic-left-panel, +[data-theme="Classic"] .classic-main-layout.classic-edit-mode .classic-left-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +[data-theme="Classic"] .classic-edit-panel { + background: white; + border: 1px solid var(--border-color); + padding: 16px; + border-radius: 3px; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +[data-theme="Classic"] .classic-edit-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 2px solid var(--accent); + padding-bottom: 10px; + margin-bottom: 12px; +} + +[data-theme="Classic"] .classic-edit-title { + margin: 0; + font-size: 1.3em; + color: var(--text-primary); +} + +[data-theme="Classic"] .classic-edit-actions { + display: flex; + gap: 8px; + align-items: center; +} + +[data-theme="Classic"] .classic-edit-action-btn { + padding: 6px 12px; + font-size: 11px; + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + line-height: 16px; +} + +[data-theme="Classic"] .classic-edit-action-btn i { + font-size: 13px; +} + +[data-theme="Classic"] .classic-edit-action-btn.active { + background: var(--accent); + color: white; + border-color: var(--accent-dark); +} + +[data-theme="Classic"] [data-classic-edit-back] { + padding: 6px 12px; + font-size: 11px; + height: 28px; + line-height: 16px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +[data-theme="Classic"] .classic-edit-content { + display: grid; + grid-template-columns: 260px 1fr; + gap: 16px; + flex: 1 1 auto; + min-height: 0; + align-items: stretch; +} + +/* Non-files views should use full width */ +[data-theme="Classic"] .classic-edit-content[data-edit-view="routes"], +[data-theme="Classic"] .classic-edit-content[data-edit-view="databases"], +[data-theme="Classic"] .classic-edit-content[data-edit-view="tickets"] { + display: block; + min-height: 0; + height: 100%; +} + +[data-theme="Classic"] .classic-edit-content[data-edit-view="routes"] > div, +[data-theme="Classic"] .classic-edit-content[data-edit-view="databases"] > div, +[data-theme="Classic"] .classic-edit-content[data-edit-view="tickets"] > div { + height: 100%; + min-height: 0; + overflow: auto; + box-sizing: border-box; +} + +[data-theme="Classic"] .classic-edit-files { + border: 1px solid var(--border-color); + background: #f9f9f9; + padding: 10px; + border-radius: 3px; + height: 100%; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +[data-theme="Classic"] .classic-edit-files h3 { + margin: 8px 0 6px 0; + font-size: 0.9em; + color: var(--text-primary); + font-weight: bold; +} + +[data-theme="Classic"] .classic-edit-files ul { + list-style: none; + padding: 0; + margin: 0 0 10px 0; +} + +[data-theme="Classic"] .classic-edit-files li { + margin: 4px 0; +} + +[data-theme="Classic"] .classic-edit-files button { + width: 100%; + text-align: left; + padding: 4px 8px; + font-size: 12px; +} + +[data-theme="Classic"] .classic-edit-editor { + border: 1px solid var(--border-color); + background: #fff; + padding: 10px; + border-radius: 3px; + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + min-height: 0; +} + +[data-theme="Classic"] .classic-edit-editor textarea { + width: 100%; + min-height: 320px; + resize: vertical; + font-family: "Courier New", monospace; + font-size: 12px; +} + +[data-theme="Classic"] .classic-edit-status { + font-size: 12px; + color: var(--text-primary); +} + +[data-theme="Classic"] .classic-folder { + margin-bottom: 10px; + position: relative; +} + +[data-theme="Classic"] .classic-folder-toggle { + width: 100%; + text-align: left; + padding: 6px 8px; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 6px; + background: #e8e8e8; + border: none; + cursor: pointer; + margin-bottom: 2px; + position: relative; + z-index: 1; +} + +[data-theme="Classic"] .classic-folder-toggle:hover { + background: #d3d3d3; +} + +[data-theme="Classic"] .classic-folder-toggle i { + font-size: 14px; + color: var(--accent); + font-weight: bold; +} + +[data-theme="Classic"] .classic-folder-toggle.collapsed i { + transform: rotate(-90deg); +} + +[data-theme="Classic"] .classic-folder-toggle::before { + content: "▼"; + display: inline-block; + margin-right: 6px; + font-size: 12px; + color: var(--accent); + transition: transform 0.2s; + min-width: 12px; +} + +[data-theme="Classic"] .classic-folder-toggle.collapsed::before { + transform: rotate(-90deg); + transform-origin: center; +} + +/* Tree structure with connector lines - Windows Explorer style */ +[data-theme="Classic"] .classic-folder-list { + list-style: none; + padding: 0; + margin: 0; + position: relative; +} + +[data-theme="Classic"] .classic-folder-list.collapsed { + display: none; +} + +[data-theme="Classic"] .classic-folder-list li { + margin: 0; + padding: 0; + position: relative; + line-height: 20px; +} + +/* Vertical line from root folder button down through all items */ +[data-theme="Classic"] .classic-folder-files-root::before { + content: ""; + position: absolute; + left: 14px; + top: 40px; + bottom: 10px; + width: 1px; + background: #999; + z-index: 0; +} + +/* Root folder container */ +[data-theme="Classic"] .classic-folder-files-root { + position: relative; + padding-bottom: 10px; +} + +/* Root folder button styling with app name inside */ +[data-theme="Classic"] .classic-folder-root-toggle { + width: 100%; + text-align: left; + padding: 8px 12px; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: 10px; + background: #ff8c00; + border: 1px solid #d97000; + cursor: pointer; + margin-bottom: 6px; + position: relative; + z-index: 1; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +[data-theme="Classic"] .classic-folder-root-toggle:hover { + background: #ff9a1a; + border-color: #d97000; +} + +[data-theme="Classic"] .classic-folder-root-toggle i { + font-size: 18px; + color: #000; + font-weight: bold; +} + +[data-theme="Classic"] .classic-folder-root-toggle span { + color: #000; + font-weight: bold; + font-size: 14px; +} + +/* ROOT CONNECTORS - High specificity to override any conflicting rules */ +/* Vertical line for root list */ +[data-theme="Classic"] .classic-folder.classic-folder-files-root > .classic-folder-list.classic-folder-root-list::before { + content: ""; + position: absolute; + left: 9px; + top: 0; + bottom: 0; + width: 1px; + background: #999 !important; + display: block !important; +} + +/* Horizontal connectors for ALL root list items */ +[data-theme="Classic"] .classic-folder.classic-folder-files-root > .classic-folder-list.classic-folder-root-list > li::before { + content: ""; + position: absolute; + left: 9px; + top: 10px; + width: 11px; + height: 1px; + background: #999 !important; + display: block !important; +} + +/* L-connector stopper for last root item */ +[data-theme="Classic"] .classic-folder.classic-folder-files-root > .classic-folder-list.classic-folder-root-list > li:last-child::after { + content: ""; + position: absolute; + left: 9px; + top: 10px; + bottom: 0; + width: 1px; + background: #f9f9f9 !important; + display: block !important; +} + +/* Indent root items for connectors */ +[data-theme="Classic"] .classic-folder.classic-folder-files-root > .classic-folder-list.classic-folder-root-list > li { + padding-left: 20px !important; +} + +/* Hide vertical line below last child (└─) */ +[data-theme="Classic"] .classic-subfolder > .classic-folder-list > li:last-child::after { + content: ""; + position: absolute; + left: 9px; + top: 10px; + bottom: 0; + width: 1px; + background: #f9f9f9; +} + +/* No connectors for top-level non-nested folders outside root */ +[data-theme="Classic"] .classic-folder > .classic-folder-list:not(.classic-subfolder .classic-folder-list)::before, +[data-theme="Classic"] .classic-folder > .classic-folder-list:not(.classic-subfolder .classic-folder-list) > li::before, +[data-theme="Classic"] .classic-folder > .classic-folder-list:not(.classic-subfolder .classic-folder-list) > li::after { + display: none; +} + +[data-theme="Classic"] .classic-folder > .classic-folder-list:not(.classic-subfolder .classic-folder-list) > li { + padding-left: 0 !important; +} + +[data-theme="Classic"] .classic-folder-list li button { + width: auto; + display: inline-block; + margin: 0; + background: transparent; + border: none; + cursor: pointer; + color: #000; + box-shadow: none; + font-weight: normal; +} + +[data-theme="Classic"] .classic-folder-list li button:hover { + background: #e0f0e0; + border: none; + color: #000; +} + +/* Selected file/folder highlighting */ +[data-theme="Classic"] .classic-folder-list li button.selected-file, +[data-theme="Classic"] .classic-folder-toggle.selected-folder { + background: #c8e6c8 !important; + border: 1px solid var(--accent) !important; + font-weight: bold; + box-shadow: none !important; + color: #000 !important; +} + +[data-theme="Classic"] .classic-folder-toggle.selected-folder { + background: #c8e6c8 !important; +} + +/* Hide accordion panels for Classic theme */ +[data-theme="Classic"] .panel.accordion { + display: none; +} + +/* Hide classic layout for other themes */ +[data-theme="AlienDark"] .classic-main-layout, +[data-theme="AlienLight"] .classic-main-layout { + display: none; +} + +[data-theme="Classic"] .classic-ace-editor { + width: 100%; + min-height: 0; + height: 100%; + flex: 1 1 auto; + border: 1px solid var(--border-color); + border-radius: 3px; + background: #fff; +} + +/* Routes table scroll container */ +[data-theme="Classic"] .classic-routes-scroll { + display: block; + max-height: none; + min-height: 0; + height: 100%; + width: calc(100% - 10px); + overflow-y: auto; + overflow-x: hidden; + border: 1px solid var(--border-color); + box-sizing: border-box; +} + +[data-theme="Classic"] .classic-routes-scroll table { + width: 100%; + border-collapse: collapse; +} + +[data-theme="Classic"] .classic-ace-editor .ace_scroller, +[data-theme="Classic"] .classic-ace-editor .ace_content { + background: #fff; + color: #222; +} + +[data-theme="Classic"] .classic-ace-editor .ace_gutter { + background: #f0f0f0; + color: #666; + border-right: 1px solid var(--border-color); +} + +[data-theme="Classic"] .classic-folder-empty { + color: #999; + font-style: italic; + font-size: 11px; + padding: 2px 8px; +} + +[data-theme="Classic"] .classic-folder-delete-btn { + padding: 2px 6px; + font-size: 10px; + margin-left: 8px; + background: #fff; + border: 1px solid #ccc; + border-radius: 2px; + cursor: pointer; + color: #d9534f; +} + +[data-theme="Classic"] .classic-folder-delete-btn:hover { + background: #ffe0e0; + border-color: #d9534f; +} + +[data-theme="Classic"] .classic-subfolder { + position: relative; +} + +[data-theme="Classic"] .classic-subfolder-toggle { + background: #e0e0e0; + font-size: 11px; + padding: 3px 6px; +} + +[data-theme="Classic"] .classic-subfolder-toggle:hover { + background: #d0d0d0; +} + +[data-theme="Classic"] .classic-folder-files-root { + position: relative; + padding-bottom: 10px; +} + +/* Root list alignment + connectors */ +[data-theme="Classic"] .classic-folder-files-root > .classic-folder-list { + padding-left: 0; +} + +[data-theme="Classic"] .classic-folder-files-root > .classic-folder-list > li { + padding-left: 22px; +} + +[data-theme="Classic"] .classic-folder-files-root > .classic-folder-list::before { + left: 16px; +} + +[data-theme="Classic"] .classic-folder-files-root > .classic-folder-list > li::before, +[data-theme="Classic"] .classic-folder-files-root > .classic-folder-list > li:last-child::after { + left: 16px; +} + +/* ===== Tree connector geometry (override) ===== */ +[data-theme="Classic"] .classic-edit-files { + /* keep gutter/arm as-is, but make indent > gutter+arm to avoid overlap */ + --classic-tree-gutter: 16px; + --classic-tree-arm: 12px; + --classic-tree-indent: 34px; /* 16 + 12 + 6px gap */ + --classic-tree-line: #999; + --classic-tree-bg: #f9f9f9; +} + +/* Ensure the LI padding actually wins everywhere in the Classic tree */ +[data-theme="Classic"] .classic-edit-files .classic-folder-list > li { + padding-left: var(--classic-tree-indent) !important; +} + +/* Root list: same geometry, explicitly (helps when older rules w/ !important exist) */ +[data-theme="Classic"] .classic-edit-files .classic-folder-files-root > .classic-folder-list > li { + padding-left: var(--classic-tree-indent) !important; +} + +/* Tree file buttons: override .btn-small padding so text aligns consistently */ +[data-theme="Classic"] .classic-edit-files .classic-folder-list li > button.btn-small { + padding: 2px 6px !important; + font-size: 11px; + line-height: 16px; +} + +/* --- Nested lists (subfolders) --- */ +[data-theme="Classic"] .classic-subfolder > .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-subfolder > .classic-folder-list > li::before { + content: ""; + position: absolute; + left: var(--classic-tree-gutter); + top: 10px; + width: var(--classic-tree-arm); + height: 1px; + background: var(--classic-tree-line); +} + +[data-theme="Classic"] .classic-subfolder > .classic-folder-list > li:last-child::after { + content: ""; + position: absolute; + left: var(--classic-tree-gutter); + top: 10px; + bottom: 0; + width: 1px; + background: var(--classic-tree-bg); +} + +/* --- FINAL override: root UL rules with old "!important" must be neutralized --- */ +[data-theme="Classic"] .classic-edit-files + .classic-folder.classic-folder-files-root + > .classic-folder-list.classic-folder-root-list::before { + left: var(--classic-tree-gutter) !important; + background: var(--classic-tree-line) !important; +} + +[data-theme="Classic"] .classic-edit-files + .classic-folder.classic-folder-files-root + > .classic-folder-list.classic-folder-root-list + > li::before { + left: var(--classic-tree-gutter) !important; + width: var(--classic-tree-arm) !important; + background: var(--classic-tree-line) !important; +} + +[data-theme="Classic"] .classic-edit-files + .classic-folder.classic-folder-files-root + > .classic-folder-list.classic-folder-root-list + > li:last-child::after { + left: var(--classic-tree-gutter) !important; + background: var(--classic-tree-bg) !important; +} + +/* Root files/folders indentation (beats the earlier 20px !important) */ +[data-theme="Classic"] .classic-edit-files + .classic-folder.classic-folder-files-root + > .classic-folder-list.classic-folder-root-list + > li { + padding-left: var(--classic-tree-indent) !important; +} + +/* Keep the root-button-to-tree vertical line aligned with the same gutter */ +[data-theme="Classic"] .classic-edit-files .classic-folder-files-root::before { + left: var(--classic-tree-gutter) !important; + background: var(--classic-tree-line) !important; +} + +/* --- Fix: remove stray vertical bar below last root file (container connector) --- */ +[data-theme="Classic"] .classic-edit-files .classic-folder-files-root::before { + content: none !important; /* kills the extra line that can extend past the root list */ +} + +/* Extend the root UL connector slightly upward to meet the root button gap */ +[data-theme="Classic"] .classic-edit-files + .classic-folder-files-root + > .classic-folder-list.classic-folder-root-list::before { + top: -6px; /* matches .classic-folder-root-toggle { margin-bottom: 6px } */ +} + +/* Reduce vertical gap in ROOT list (even tighter) */ +[data-theme="Classic"] .classic-edit-files .classic-folder-root-list > li { + margin: 0 !important; + line-height: 18px; /* slightly tighter than 20px */ +} + +/* Root files: make the clickable row a bit shorter */ +[data-theme="Classic"] .classic-edit-files .classic-folder-root-list > li > button.btn-small { + padding-top: 1px !important; + padding-bottom: 1px !important; + line-height: 16px; +} + +/* Root folders: tighten the folder toggle row so it matches file rows better */ +[data-theme="Classic"] .classic-edit-files .classic-folder-root-list > li.classic-subfolder > button.classic-folder-toggle { + padding-top: 4px !important; + padding-bottom: 4px !important; + margin-bottom: 0 !important; /* avoid extra space before its child
    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 = ` +
    +
    +

    Edit: ${payload.name}

    +
    + ${actionButtons} + + + +
    +
    +
    + ${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 = ` +
    +
    +

    ${title}

    +
    +
    +

    ${message}

    +
    +
    + + +
    +
    + `; + + 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 = ` +
    + + + + + + + + + + + + + + ${ordered + .map( + (r) => ` + + + + + + + + + + ` + ) + .join("")} + +
    Rule${sortMarker("rule")}Method${sortMarker("method")}Filename${sortMarker("filename")}Action${sortMarker("action")}Time(s)${sortMarker("time")}Calls/s${sortMarker("calls")}Errors/s${sortMarker("errors")}
    + ${r.rule} + ${r.method}${r.filename || ""}${r.action}${formatMetric(r.time)}${formatMetric(r.calls)}${formatMetric(r.errors)}
    +
    + `; + + 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 = ` + + + + + + + + + + + ${tickets + .map( + (t) => ` + + + + + + + ` + ) + .join("")} + +
    CountErrorPathTimestamp
    ${t.count}${t.error}${t.path}${t.timestamp}
    + `; + } 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"]] + + + + + + + + + + + + + + +
    + +
    +
    + + +
    +
    + [[=theme_partials.get("dbadmin_nav", "")]] +
    + + +
    +
    +
    -
    -

    Application "[[=app_name]]" - Table "[[=table_name]]"

    -[[=grid]] -
    + +
    +
    +

    Application "[[=app_name]]" - Table "[[=table_name]]"

    + [[=grid]] +
    +
    + + +
    + [[=theme_partials.get("dbadmin_footer_back", "")]] +
    +
    + + + + + 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]]