diff --git a/.env.local-test b/.env.local-test new file mode 100644 index 0000000..7c1fabd --- /dev/null +++ b/.env.local-test @@ -0,0 +1,36 @@ +# Local Testing Environment Variables +# Copy this file to .env.local-test and modify as needed + +# Timezone (default: Europe/Brussels) +TZ=Europe/Brussels + +# Currency (default: EUR) +CURRENCY=EUR + +# Timer settings +ROUNDING_MINUTES=1 +SINGLE_ACTIVE_TIMER=true +IDLE_TIMEOUT_MINUTES=30 + +# User management +ALLOW_SELF_REGISTER=true +ADMIN_USERNAMES=admin +# Security (CHANGE THESE FOR PRODUCTION!) +SECRET_KEY=local-test-secret-key-change-this + +# Database (SQLite for local testing) +DATABASE_URL=sqlite:///data/timetracker.db + +# Logging +LOG_FILE=/app/logs/timetracker.log + +# Cookie settings (disabled for local testing) +SESSION_COOKIE_SECURE=false +REMEMBER_COOKIE_SECURE=false + +# Flask environment +FLASK_ENV=development +FLASK_DEBUG=true + +# License server (disabled for local testing) +LICENSE_SERVER_ENABLED=false \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e3ae2a3..5c77215 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y \ tzdata \ bash \ dos2unix \ + gosu \ # Network tools for debugging iproute2 \ net-tools \ @@ -68,10 +69,10 @@ RUN mkdir -p /app/app/static/uploads/logos /app/static/uploads/logos && \ COPY docker/start-fixed.py /app/start.py # Fix line endings for the startup and entrypoint scripts -RUN dos2unix /app/start.py /app/docker/entrypoint_fixed.sh /app/docker/entrypoint.sh /app/docker/entrypoint_simple.sh 2>/dev/null || true +RUN dos2unix /app/start.py /app/docker/entrypoint_fixed.sh /app/docker/entrypoint.sh /app/docker/entrypoint_simple.sh /app/docker/entrypoint-local-test.sh /app/docker/entrypoint-local-test-simple.sh 2>/dev/null || true # Make startup scripts executable and ensure proper line endings -RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/init-database-enhanced.py /app/docker/verify-database.py /app/docker/test-db.py /app/docker/test-routing.py /app/docker/entrypoint.sh /app/docker/entrypoint_fixed.sh /app/docker/entrypoint_simple.sh /app/docker/entrypoint.py /app/docker/startup_with_migration.py /app/docker/test_db_connection.py /app/docker/debug_startup.sh /app/docker/simple_test.sh && \ +RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/init-database-enhanced.py /app/docker/verify-database.py /app/docker/test-db.py /app/docker/test-routing.py /app/docker/entrypoint.sh /app/docker/entrypoint_fixed.sh /app/docker/entrypoint_simple.sh /app/docker/entrypoint-local-test.sh /app/docker/entrypoint-local-test-simple.sh /app/docker/entrypoint.py /app/docker/startup_with_migration.py /app/docker/test_db_connection.py /app/docker/debug_startup.sh /app/docker/simple_test.sh && \ ls -la /app/docker/entrypoint.py && \ head -5 /app/docker/entrypoint.py diff --git a/README.md b/README.md index 04c129e..919dfac 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,12 @@ Multiple Docker configurations are available for different deployment scenarios: - Includes optional Caddy reverse proxy for TLS - Suitable for development and testing +- **`docker-compose.local-test.yml`** - Quick local testing with SQLite + - Uses SQLite database (no separate database container needed) + - Development mode with debug logging enabled + - Perfect for quick testing and development + - See [Local Testing Documentation](docs/LOCAL_TESTING_WITH_SQLITE.md) + ### Remote Deployment - **`docker-compose.remote.yml`** - Production deployment using GitHub Container Registry - Uses pre-built `ghcr.io/drytrix/timetracker:latest` image @@ -297,6 +303,21 @@ docker-compose up -d # Access the application at http://localhost:8080 ``` +#### Quick Start with Local Testing (SQLite) +```bash +# Clone the repository +git clone https://github.com/drytrix/TimeTracker.git +cd TimeTracker + +# Start with SQLite (no database setup needed) +docker-compose -f docker-compose.local-test.yml up --build + +# Access the application at http://localhost:8080 +# Or use the convenience script: +# Windows: scripts\start-local-test.bat +# Linux/macOS: ./scripts/start-local-test.sh +``` + #### Production Deployment with Remote Images ```bash # Use production-ready images from GitHub Container Registry diff --git a/app/__init__.py b/app/__init__.py index 09026e0..ca85f68 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -195,6 +195,24 @@ def create_app(config=None): # Register CLI commands from app.utils.cli import register_cli_commands register_cli_commands(app) + + # Promote configured admin usernames automatically on each request (idempotent) + @app.before_request + def _promote_admin_users_on_request(): + try: + from flask_login import current_user + if not current_user or not getattr(current_user, 'is_authenticated', False): + return + admin_usernames = [u.strip().lower() for u in app.config.get('ADMIN_USERNAMES', ['admin'])] + if current_user.username and current_user.username.lower() in admin_usernames and current_user.role != 'admin': + current_user.role = 'admin' + db.session.commit() + except Exception: + # Non-fatal; avoid breaking requests if this fails + try: + db.session.rollback() + except Exception: + pass # Initialize database on first request def initialize_database(): diff --git a/app/config.py b/app/config.py index ef79a25..2c3f7d1 100644 --- a/app/config.py +++ b/app/config.py @@ -79,7 +79,7 @@ class Config: # License server settings (no license required) # All settings are hardcoded since clients cannot change license server configuration - LICENSE_SERVER_ENABLED = True # Always enabled by default + LICENSE_SERVER_ENABLED = os.getenv('LICENSE_SERVER_ENABLED', 'true').lower() == 'true' LICENSE_SERVER_API_KEY = "no-license-required" # Hardcoded placeholder LICENSE_SERVER_APP_ID = "timetracker" # Hardcoded app identifier LICENSE_SERVER_APP_VERSION = APP_VERSION # Match application version diff --git a/app/routes/admin.py b/app/routes/admin.py index 6972bb7..f77dcb4 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -319,6 +319,16 @@ def remove_logo(): return redirect(url_for('admin.settings')) +# Public route to serve uploaded logos from the static uploads directory +@admin_bp.route('/uploads/logos/') +def serve_uploaded_logo(filename): + """Serve company logo files stored under static/uploads/logos. + This route is intentionally public so logos render on unauthenticated pages + like the login screen and in favicons. + """ + upload_folder = get_upload_folder() + return send_from_directory(upload_folder, filename) + @admin_bp.route('/admin/backup', methods=['GET']) @login_required @admin_required diff --git a/app/routes/api.py b/app/routes/api.py index 856c164..0137843 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, current_app, send_from_directory from flask_login import login_required, current_user from app import db, socketio from app.models import User, Project, TimeEntry, Settings, Task @@ -7,6 +7,9 @@ from app.utils.db import safe_commit from app.utils.timezone import parse_local_datetime, utc_to_local from app.models.time_entry import local_now import json +import os +import uuid +from werkzeug.utils import secure_filename api_bp = Blueprint('api', __name__) @@ -83,11 +86,7 @@ def api_start_timer(): # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - settings = Settings.get_settings() - if settings.single_active_timer: - active_timer.stop_timer() - else: - return jsonify({'error': 'User already has an active timer'}), 400 + return jsonify({'error': 'User already has an active timer'}), 400 # Create new timer from app.models.time_entry import local_now @@ -331,6 +330,18 @@ def update_entry(entry_id): # Recalculate duration entry.calculate_duration() + # Prevent multiple active timers for the same user when editing + if entry.end_time is None: + conflict = ( + TimeEntry.query + .filter(TimeEntry.user_id == entry.user_id) + .filter(TimeEntry.end_time.is_(None)) + .filter(TimeEntry.id != entry.id) + .first() + ) + if conflict: + return jsonify({'error': 'User already has an active timer'}), 400 + # Notes, tags, billable (both admin and owner can change) if 'notes' in data: entry.notes = data['notes'].strip() if data['notes'] else None @@ -370,6 +381,48 @@ def delete_entry(entry_id): return jsonify({'success': True}) +# ================================ +# Editor image uploads +# ================================ + +ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + +def allowed_image_file(filename: str) -> bool: + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + +def get_editor_upload_folder() -> str: + upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'editor') + os.makedirs(upload_folder, exist_ok=True) + return upload_folder + +@api_bp.route('/api/uploads/images', methods=['POST']) +@login_required +def upload_editor_image(): + """Handle image uploads from the markdown editor.""" + if 'image' not in request.files: + return jsonify({'error': 'No image provided'}), 400 + file = request.files['image'] + if not file or file.filename == '': + return jsonify({'error': 'No image provided'}), 400 + if not allowed_image_file(file.filename): + return jsonify({'error': 'Invalid file type'}), 400 + + filename = secure_filename(file.filename) + ext = filename.rsplit('.', 1)[1].lower() + unique_name = f"editor_{uuid.uuid4().hex[:12]}.{ext}" + folder = get_editor_upload_folder() + path = os.path.join(folder, unique_name) + file.save(path) + + url = f"/uploads/editor/{unique_name}" + return jsonify({'success': True, 'url': url}) + +@api_bp.route('/uploads/editor/') +def serve_editor_image(filename): + """Serve uploaded editor images from static/uploads/editor.""" + folder = get_editor_upload_folder() + return send_from_directory(folder, filename) + # WebSocket event handlers @socketio.on('connect') def handle_connect(): diff --git a/app/routes/auth.py b/app/routes/auth.py index 33ff688..63641fa 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -28,6 +28,12 @@ def login(): flash('Username is required', 'error') return render_template('auth/login.html') + # Normalize admin usernames from config + try: + admin_usernames = [u.strip().lower() for u in (Config.ADMIN_USERNAMES or [])] + except Exception: + admin_usernames = ['admin'] + # Check if user exists user = User.query.filter_by(username=username).first() current_app.logger.info("User lookup for '%s': %s", username, 'found' if user else 'not found') @@ -35,8 +41,9 @@ def login(): if not user: # Check if self-registration is allowed if Config.ALLOW_SELF_REGISTER: - # Create new user - user = User(username=username, role='user') + # Create new user, promote to admin if username is configured as admin + role = 'admin' if username in admin_usernames else 'user' + user = User(username=username, role=role) db.session.add(user) if not safe_commit('self_register_user', {'username': username}): current_app.logger.error("Self-registration failed for '%s' due to DB error", username) @@ -47,6 +54,14 @@ def login(): else: flash('User not found. Please contact an administrator.', 'error') return render_template('auth/login.html') + else: + # If existing user matches admin usernames, ensure admin role + if username in admin_usernames and user.role != 'admin': + user.role = 'admin' + if not safe_commit('promote_admin_user', {'username': username}): + current_app.logger.error("Failed to promote '%s' to admin due to DB error", username) + flash('Could not update your account role due to a database error.', 'error') + return render_template('auth/login.html') # Check if user is active if not user.is_active: diff --git a/app/routes/invoices.py b/app/routes/invoices.py index f492359..fdc5da0 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -400,7 +400,7 @@ def export_invoice_pdf(invoice_id): from app.utils.pdf_generator import InvoicePDFGenerator settings = Settings.get_settings() - # Generate PDF + # Generate PDF (primary: WeasyPrint) pdf_generator = InvoicePDFGenerator(invoice, settings=settings) pdf_bytes = pdf_generator.generate_pdf() @@ -413,18 +413,14 @@ def export_invoice_pdf(invoice_id): download_name=filename ) - except ImportError: - flash('PDF generation is not available. Please install WeasyPrint.', 'error') - return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id)) except Exception as e: - # Try fallback PDF generator + # Any failure (including ImportError) -> try ReportLab fallback try: from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback settings = Settings.get_settings() - flash('WeasyPrint failed, using fallback PDF generator. PDF quality may be reduced.', 'warning') + flash('High-quality generator unavailable; using fallback PDF generator.', 'warning') - # Generate PDF using fallback pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings) pdf_bytes = pdf_generator.generate_pdf() diff --git a/app/routes/timer.py b/app/routes/timer.py index e228f9b..66ae79b 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -43,19 +43,9 @@ def start_timer(): # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - # If single active timer is enabled, stop the current one - settings = Settings.get_settings() - if settings.single_active_timer: - try: - active_timer.stop_timer() - flash('Previous timer stopped', 'info') - current_app.logger.info("Stopped previous active timer id=%s for user=%s", active_timer.id, current_user.username) - except Exception as e: - current_app.logger.exception("Error stopping previous timer: %s", e) - else: - flash('You already have an active timer', 'error') - current_app.logger.info("Start timer blocked: user already has active timer and SINGLE_ACTIVE_TIMER disabled") - return redirect(url_for('main.dashboard')) + flash('You already have an active timer. Stop it before starting a new one.', 'error') + current_app.logger.info("Start timer blocked: user already has an active timer") + return redirect(url_for('main.dashboard')) # Create new timer from app.models.time_entry import local_now @@ -112,19 +102,9 @@ def start_timer_for_project(project_id): # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - # If single active timer is enabled, stop the current one - settings = Settings.get_settings() - if settings.single_active_timer: - try: - active_timer.stop_timer() - flash('Previous timer stopped', 'info') - current_app.logger.info("Stopped previous active timer id=%s for user=%s", active_timer.id, current_user.username) - except Exception as e: - current_app.logger.exception("Error stopping previous timer: %s", e) - else: - flash('You already have an active timer', 'error') - current_app.logger.info("Start timer (GET) blocked: user already has active timer and SINGLE_ACTIVE_TIMER disabled") - return redirect(url_for('main.dashboard')) + flash('You already have an active timer. Stop it before starting a new one.', 'error') + current_app.logger.info("Start timer (GET) blocked: user already has an active timer") + return redirect(url_for('main.dashboard')) # Create new timer from app.models.time_entry import local_now diff --git a/app/static/base.css b/app/static/base.css index 06ffc2a..af9d48a 100644 --- a/app/static/base.css +++ b/app/static/base.css @@ -28,6 +28,7 @@ /* component bg tokens */ --navbar-bg: #ffffff; --dropdown-bg: #ffffff; + --navbar-height: 70px; } /* Dark theme variables */ @@ -124,6 +125,25 @@ main { margin-bottom: var(--card-spacing); } +/* Ensure all card variants have consistent border radius */ +.card { + border-radius: var(--border-radius) !important; +} + +.card-header { + border-top-left-radius: var(--border-radius) !important; + border-top-right-radius: var(--border-radius) !important; + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.card-footer { + border-bottom-left-radius: var(--border-radius) !important; + border-bottom-right-radius: var(--border-radius) !important; + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; +} + [data-theme="dark"] .card, [data-theme="dark"] .modal-content, [data-theme="dark"] .dropdown-menu, @@ -155,6 +175,7 @@ main { .card.hover-lift:hover { box-shadow: var(--card-shadow-hover); transform: translateY(-2px); + border-radius: var(--border-radius); /* Ensure border radius is maintained on hover */ } .card a { @@ -194,6 +215,11 @@ main { font-weight: 600; color: var(--text-primary); font-size: 1.1rem; + /* Ensure proper border radius inheritance */ + border-top-left-radius: inherit; + border-top-right-radius: inherit; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } .card-body { @@ -209,13 +235,56 @@ main { border: none; position: relative; font-size: 0.95rem; - min-height: 44px; + min-height: 52px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none; cursor: pointer; user-select: none; +} + +/* Modern header button style */ +.btn-header { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 8px; + border: 1px solid transparent; + transition: all 0.2s ease; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 36px; +} + +.btn-header.btn-primary { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + +.btn-header.btn-primary:hover { + background: var(--primary-dark); + border-color: var(--primary-dark); + color: white; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.btn-header.btn-outline-primary { + background: transparent; + color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-header.btn-outline-primary:hover { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); -webkit-tap-highlight-color: transparent; gap: 0.5rem; } @@ -300,7 +369,7 @@ main { } .btn-outline-primary { - border: 2px solid var(--primary-color); + border: 1px solid var(--primary-color); color: var(--primary-color); background: transparent; } @@ -312,7 +381,7 @@ main { } .btn-outline-secondary { - border: 2px solid var(--border-color); + border: 1px solid var(--border-color); color: var(--text-secondary); background: transparent; } @@ -334,7 +403,7 @@ main { } /* Outline light buttons on dark backgrounds */ [data-theme="dark"] .btn-outline-light { - border: 2px solid #e5e7eb; + border: 1px solid #e5e7eb; color: #e5e7eb; background: transparent; } @@ -363,18 +432,18 @@ main { .btn-lg { padding: 0.9rem 1.25rem; font-size: 1.05rem; - min-height: 50px; + min-height: 56px; } /* Enhanced Form Layout */ .form-control, .form-select { - border: 2px solid var(--border-color); + border: 1px solid var(--border-color); border-radius: var(--border-radius-sm); padding: 1rem 1.25rem; font-size: 1rem; transition: var(--transition); background: white; - min-height: 52px; + min-height: 52px; /* baseline */ } [data-theme="dark"] .form-control, @@ -388,7 +457,96 @@ main { border-color: var(--border-color); } -/* EasyMDE (Markdown editor) dark theme overrides */ +/* EasyMDE (Markdown editor) enhanced theming */ +.EasyMDEContainer { + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + overflow: hidden; + background: transparent; +} + +/* Toolbar */ +.EasyMDEContainer .editor-toolbar { + background: #ffffff !important; + border-bottom: 1px solid var(--border-color); + padding: 0.375rem 0.5rem; + box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05); +} +.EasyMDEContainer .editor-toolbar a { + color: var(--text-secondary) !important; + border-radius: 6px; + height: 32px; + width: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: var(--transition); + opacity: 0.9; +} +.EasyMDEContainer .editor-toolbar a:hover, +.EasyMDEContainer .editor-toolbar a.active { + background: var(--light-color) !important; + color: var(--text-primary) !important; + box-shadow: inset 0 0 0 2px var(--primary-color); + opacity: 1; +} +.EasyMDEContainer .editor-toolbar .separator { + border-left: 1px solid var(--border-color); + margin: 0 0.35rem; +} + +/* Editor surface */ +.EasyMDEContainer .CodeMirror, +.EasyMDEContainer .CodeMirror-scroll, +.EasyMDEContainer .cm-s-easymde.CodeMirror, +.EasyMDEContainer .cm-s-easymde .CodeMirror-scroll { + background: #ffffff !important; + color: var(--text-primary) !important; +} +.EasyMDEContainer .cm-s-easymde.CodeMirror { + border-top: none !important; /* aligns with toolbar */ +} +.EasyMDEContainer .cm-s-easymde .CodeMirror-gutters { + background: #ffffff !important; + border-right: 1px solid var(--border-color) !important; +} +.EasyMDEContainer .CodeMirror { + min-height: 280px; + font-size: 0.95rem; + line-height: 1.55; +} +.EasyMDEContainer .CodeMirror-scroll { padding: 0.75rem 1rem; } +.EasyMDEContainer .CodeMirror pre { color: var(--text-primary) !important; } +.EasyMDEContainer .CodeMirror-cursor { border-left-color: #1e293b !important; } +.EasyMDEContainer .CodeMirror .CodeMirror-placeholder { color: #64748b !important; } +.EasyMDEContainer .CodeMirror-selected { background: rgba(59,130,246,0.15) !important; } +.EasyMDEContainer .CodeMirror-focused { box-shadow: 0 0 0 3px rgba(59,130,246,0.12); } + +/* Status bar and preview */ +.EasyMDEContainer .editor-statusbar { + background: #ffffff !important; + color: var(--text-secondary) !important; + border-top: 1px solid var(--border-color); + padding: 0.375rem 0.75rem; +} +.EasyMDEContainer .editor-preview, +.EasyMDEContainer .editor-preview-side { + background: #ffffff !important; + color: var(--text-primary) !important; +} + +/* Light token colors for readability */ +.EasyMDEContainer .cm-header { color: #1e40af !important; } +.EasyMDEContainer .cm-strong { color: #1e293b !important; } +.EasyMDEContainer .cm-em { color: #dc2626 !important; } +.EasyMDEContainer .cm-quote { color: #059669 !important; } +.EasyMDEContainer .cm-link { color: #2563eb !important; text-decoration: underline; } +.EasyMDEContainer .cm-formatting-header { color: #2563eb !important; } +.EasyMDEContainer .cm-url { color: #2563eb !important; } +.EasyMDEContainer .cm-code { color: #dc2626 !important; } +.EasyMDEContainer .cm-hr { color: #64748b !important; } + +/* Dark theme */ [data-theme="dark"] .EasyMDEContainer .editor-toolbar { background: #0b1220 !important; border-color: var(--border-color) !important; @@ -396,12 +554,14 @@ main { } [data-theme="dark"] .EasyMDEContainer .editor-toolbar a { color: var(--text-secondary) !important; + opacity: 0.95; } [data-theme="dark"] .EasyMDEContainer .editor-toolbar a:hover, [data-theme="dark"] .EasyMDEContainer .editor-toolbar a.active { background: #111827 !important; color: var(--text-primary) !important; } + [data-theme="dark"] .EasyMDEContainer .CodeMirror, [data-theme="dark"] .EasyMDEContainer .CodeMirror-scroll, [data-theme="dark"] .EasyMDEContainer .cm-s-easymde.CodeMirror, @@ -410,33 +570,18 @@ main { color: var(--text-primary) !important; } [data-theme="dark"] .EasyMDEContainer .cm-s-easymde.CodeMirror { - border: 1px solid var(--border-color) !important; - border-top: none !important; /* aligns with toolbar border */ + border-top: none !important; } [data-theme="dark"] .EasyMDEContainer .cm-s-easymde .CodeMirror-gutters { background: #0f172a !important; border-right: 1px solid var(--border-color) !important; } -[data-theme="dark"] .EasyMDEContainer .CodeMirror-selected { - background: rgba(59,130,246,0.25) !important; -} -[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-url { color: #93c5fd !important; } -[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-code { color: #fca5a5 !important; } -[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-hr { color: #475569 !important; } +[data-theme="dark"] .EasyMDEContainer .CodeMirror-selected { background: rgba(59,130,246,0.25) !important; } [data-theme="dark"] .EasyMDEContainer .CodeMirror pre { color: var(--text-primary) !important; } [data-theme="dark"] .EasyMDEContainer .CodeMirror-cursor { border-left-color: #e5e7eb !important; } [data-theme="dark"] .EasyMDEContainer .CodeMirror .CodeMirror-placeholder { color: #64748b !important; } -[data-theme="dark"] .EasyMDEContainer .editor-statusbar { - background: #0b1220 !important; - color: var(--text-secondary) !important; - border-color: var(--border-color) !important; -} -[data-theme="dark"] .EasyMDEContainer .editor-preview, -[data-theme="dark"] .EasyMDEContainer .editor-preview-side { - background: #0f172a !important; - color: var(--text-primary) !important; -} -/* Token colors for dark mode (readability) */ + +/* Dark token colors */ [data-theme="dark"] .EasyMDEContainer .cm-header { color: #93c5fd !important; } [data-theme="dark"] .EasyMDEContainer .cm-strong { color: #e5e7eb !important; } [data-theme="dark"] .EasyMDEContainer .cm-em { color: #fca5a5 !important; } @@ -444,47 +589,6 @@ main { [data-theme="dark"] .EasyMDEContainer .cm-link { color: #60a5fa !important; text-decoration: underline; } [data-theme="dark"] .EasyMDEContainer .cm-formatting-header { color: #60a5fa !important; } -/* EasyMDE global layout enhancements (both themes) */ -.EasyMDEContainer { - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - overflow: hidden; - background: transparent; -} -.EasyMDEContainer .editor-toolbar { - border-bottom: 1px solid var(--border-color); - padding: 0.25rem 0.5rem; -} -.EasyMDEContainer .editor-toolbar a { - border-radius: 6px; - height: 32px; - width: 32px; - display: inline-flex; - align-items: center; - justify-content: center; -} -.EasyMDEContainer .editor-toolbar .separator { - border-left: 1px solid var(--border-color); - margin: 0 0.35rem; -} -.EasyMDEContainer .CodeMirror { - min-height: 260px; - font-size: 0.95rem; - line-height: 1.5; -} -.EasyMDEContainer .editor-preview-side { max-width: 50%; } -.EasyMDEContainer .CodeMirror, .EasyMDEContainer .editor-preview-side { box-sizing: border-box; } -.EasyMDEContainer .CodeMirror-scroll { - padding: 0.75rem 1rem; -} -.EasyMDEContainer .CodeMirror-focused { - box-shadow: 0 0 0 3px rgba(59,130,246,0.15); -} -.EasyMDEContainer .editor-statusbar { - border-top: 1px solid var(--border-color); - padding: 0.375rem 0.75rem; -} - /* ========================================== Markdown Editor Wrapper (Task forms) ========================================== */ @@ -496,49 +600,413 @@ main { } .markdown-editor-wrapper .EasyMDEContainer { - border: none; /* outer border replaced by parent card */ - border-radius: 0; /* inherit */ + border: none !important; /* outer border handled by inner parts */ + border-radius: 12px !important; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .markdown-editor-wrapper .editor-toolbar { - background: var(--light-color); - border-bottom: 1px solid var(--border-color); + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important; + border: 2px solid #e2e8f0 !important; + border-top: none !important; + border-radius: 0 0 12px 12px !important; + position: relative; } .markdown-editor-wrapper .editor-toolbar a { - height: 36px; - width: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 6px; - transition: var(--transition); + height: 34px !important; + width: 34px !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + border-radius: 6px !important; + transition: var(--transition) !important; + color: #64748b !important; + position: relative !important; + overflow: hidden !important; } .markdown-editor-wrapper .editor-toolbar a:hover, .markdown-editor-wrapper .editor-toolbar a.active { - background: var(--light-color); - box-shadow: inset 0 0 0 2px var(--primary-color); + background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%) !important; + box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; + color: var(--primary-color) !important; + transform: translateY(-1px) scale(1.05); } .markdown-editor-wrapper .CodeMirror, .markdown-editor-wrapper .editor-preview-side { - background: white; - padding: 1rem 1.25rem; - font-size: 0.95rem; - line-height: 1.6; + background: #ffffff !important; + padding: 1rem 1.25rem !important; + font-size: 0.95rem !important; + line-height: 1.6 !important; } [data-theme="dark"] .markdown-editor-wrapper .editor-toolbar { - background: #0b1220; - border-color: var(--border-color); + background: linear-gradient(135deg, #0b1220 0%, #1f2a44 100%) !important; + border-color: #1f2a44 !important; } [data-theme="dark"] .markdown-editor-wrapper .CodeMirror, [data-theme="dark"] .markdown-editor-wrapper .editor-preview-side { - background: #0f172a; + background: #0f172a !important; + color: var(--text-primary) !important; +} + +/* Toast UI Editor polish within wrapper */ +.markdown-editor-wrapper .toastui-editor-defaultUI { + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + background: #ffffff; +} +.markdown-editor-wrapper .toastui-editor-defaultUI-toolbar { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid var(--border-color); + padding: 8px 10px; + position: sticky; + top: 0; + z-index: 5; +} +.markdown-editor-wrapper .toastui-editor-defaultUI-toolbar .toastui-editor-toolbar-icons { + border-radius: 6px; + border: 1px solid transparent; + background-color: transparent; + box-shadow: none; +} +.markdown-editor-wrapper .toastui-editor-defaultUI-toolbar .toastui-editor-toolbar-icons:hover { + background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%); +} +.markdown-editor-wrapper .toastui-editor-contents, +.markdown-editor-wrapper .ProseMirror { + font-size: 0.95rem; + line-height: 1.65; +} +.markdown-editor-wrapper .toastui-editor-contents { color: var(--text-primary); } +.markdown-editor-wrapper .toastui-editor-main .toastui-editor-md-container { + border-right: 1px solid var(--border-color); +} +.markdown-editor-wrapper:focus-within .toastui-editor-defaultUI { + box-shadow: 0 0 0 3px rgba(59,130,246,0.15); +} +.markdown-editor-wrapper .toastui-editor-contents pre, +.markdown-editor-wrapper .toastui-editor-contents code { + background: #f8fafc; +} +.markdown-editor-wrapper .toastui-editor-contents pre { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 14px 16px; + overflow-x: auto; +} +.markdown-editor-wrapper .toastui-editor-contents pre code { + background: transparent; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro', monospace; + font-size: 0.92em; +} +.markdown-editor-wrapper .toastui-editor-contents code:not(pre code) { + background: #f1f5f9; + border: 1px solid rgba(0,0,0,0.05); + border-radius: 4px; + padding: 2px 6px; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro', monospace; +} +.markdown-editor-wrapper .toastui-editor-contents blockquote { + border-left: 4px solid var(--primary-color); + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + padding: 12px 16px; + border-radius: 0 8px 8px 0; +} +.markdown-editor-wrapper .toastui-editor-contents hr { + border: none; + height: 2px; + background: linear-gradient(90deg, transparent, var(--border-color), transparent); + margin: 1.25rem 0; +} +.markdown-editor-wrapper .toastui-editor-contents a { + color: var(--primary-color); + text-decoration: underline; +} +.markdown-editor-wrapper .toastui-editor-contents ul, +.markdown-editor-wrapper .toastui-editor-contents ol { + padding-left: 1.25rem; +} +.markdown-editor-wrapper .toastui-editor-contents li + li { + margin-top: 0.25rem; +} +.markdown-editor-wrapper .toastui-editor-contents input[type="checkbox"] { + width: 1rem; + height: 1rem; + margin-right: 0.5rem; +} +.markdown-editor-wrapper .toastui-editor-contents table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} +.markdown-editor-wrapper .toastui-editor-contents th, +.markdown-editor-wrapper .toastui-editor-contents td { + border: 1px solid var(--border-color); + padding: 10px 12px; + text-align: left; +} +.markdown-editor-wrapper .toastui-editor-contents th { + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); + color: var(--text-primary); +} +.markdown-editor-wrapper .toastui-editor-contents tr:nth-child(even) td { + background: rgba(59,130,246,0.03); +} +.markdown-editor-wrapper .toastui-editor-contents img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid var(--border-color); +} +.markdown-editor-wrapper .toastui-editor-toolbar-icons { + transition: transform 0.15s ease; +} +.markdown-editor-wrapper .toastui-editor-toolbar-icons:active { + transform: translateY(1px) scale(0.98); +} + +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-defaultUI { + border-color: var(--border-color); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-defaultUI-toolbar { + background: linear-gradient(135deg, #0b1220 0%, #1f2a44 100%); + border-bottom-color: var(--border-color); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-main .toastui-editor-md-container { + border-right-color: var(--border-color); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents, +[data-theme="dark"] .markdown-editor-wrapper .ProseMirror { + color: var(--text-primary); +} +[data-theme="dark"] .markdown-editor-wrapper .ProseMirror { + caret-color: #e5e7eb; +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents h1, +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents h2, +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents h3, +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents h4, +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents h5, +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents h6 { + color: var(--text-primary); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents a { + color: #93c5fd; +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents pre, +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents code { + background: #0f172a; +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents pre code { + color: #e5e7eb; +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents pre { + border-color: var(--border-color); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents blockquote { + background: linear-gradient(135deg, #1f2a44 0%, #374151 100%); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents code:not(pre code) { + background: #111827; + border-color: var(--border-color); + color: #f472b6; +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents th, +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents td { + color: var(--text-primary); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-defaultUI-toolbar .toastui-editor-toolbar-icons { + color: var(--text-secondary); + opacity: 0.95; +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents th { + background: linear-gradient(135deg, #1f2a44 0%, #374151 100%); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents tr:nth-child(even) td { + background: rgba(96,165,250,0.06); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-contents hr { + background: linear-gradient(90deg, transparent, #1f2a44, transparent); +} +[data-theme="dark"] .markdown-editor-wrapper .toastui-editor-mode-switch { + color: var(--text-secondary); +} + +/* Enhanced editor surface inside wrapper */ +.markdown-editor-wrapper .EasyMDEContainer .CodeMirror { + border-radius: 12px 12px 0 0 !important; + border: 2px solid #e2e8f0 !important; + border-bottom: none !important; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro', monospace !important; + font-size: 14px !important; + line-height: 1.6 !important; + padding: 16px !important; + color: #1e293b !important; + min-height: 300px !important; +} +.markdown-editor-wrapper .EasyMDEContainer .CodeMirror-focused { + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important; +} + +/* Toolbar polish */ +.markdown-editor-wrapper .editor-toolbar::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--primary-color), transparent); +} +.markdown-editor-wrapper .editor-toolbar i.separator { + border-left: 1px solid #cbd5e1 !important; + margin: 0 8px !important; + opacity: 0.6; +} + +/* Dark variants */ +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .CodeMirror { + background: #0f172a !important; + color: #e5e7eb !important; + border-color: #1f2a44 !important; +} +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .CodeMirror-focused { + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2) !important; +} +[data-theme="dark"] .markdown-editor-wrapper .editor-toolbar::before { + background: linear-gradient(90deg, transparent, #60a5fa, transparent); +} +[data-theme="dark"] .markdown-editor-wrapper .editor-toolbar a { + color: #94a3b8 !important; +} +[data-theme="dark"] .markdown-editor-wrapper .editor-toolbar a:hover, +[data-theme="dark"] .markdown-editor-wrapper .editor-toolbar a.active { + background: linear-gradient(135deg, #1f2a44 0%, #374151 100%) !important; + color: var(--primary-color) !important; +} +[data-theme="dark"] .markdown-editor-wrapper .editor-toolbar i.separator { + border-left-color: #374151 !important; +} + +/* Preview polish */ +.markdown-editor-wrapper .EasyMDEContainer .editor-preview { + background: #ffffff !important; + color: #1e293b !important; + padding: 20px !important; + border-radius: 12px !important; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif !important; + line-height: 1.7 !important; +} +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .editor-preview { + background: #0f172a !important; + color: #e5e7eb !important; +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h1, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h2, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h3, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h4, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h5, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h6 { + color: var(--primary-color) !important; + margin-top: 1.5em !important; + margin-bottom: 0.5em !important; + position: relative; +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h1::after, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h2::after, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview h3::after { + content: ''; + position: absolute; + bottom: -4px; + left: 0; + width: 30px; + height: 2px; + background: linear-gradient(90deg, var(--primary-color), transparent); +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview blockquote { + border-left: 4px solid var(--primary-color) !important; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important; + padding: 12px 16px !important; + margin: 16px 0 !important; + border-radius: 0 8px 8px 0 !important; +} +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .editor-preview blockquote { + background: linear-gradient(135deg, #1f2a44 0%, #374151 100%) !important; +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview code { + background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%) !important; + color: #e11d48 !important; + padding: 2px 6px !important; + border-radius: 4px !important; + font-size: 0.9em !important; + font-weight: 600; + border: 1px solid rgba(225, 29, 72, 0.1); +} +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .editor-preview code { + background: linear-gradient(135deg, #1f2a44 0%, #374151 100%) !important; + color: #f472b6 !important; + border-color: rgba(244, 114, 182, 0.2); +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview pre { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important; + border: 1px solid #e2e8f0 !important; + border-radius: 8px !important; + padding: 16px !important; + overflow-x: auto !important; + position: relative; +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview pre::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--primary-color), transparent); +} +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .editor-preview pre { + background: linear-gradient(135deg, #0b1220 0%, #1f2a44 100%) !important; + border-color: #1f2a44 !important; +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview table { + border-collapse: collapse !important; + width: 100% !important; + margin: 16px 0 !important; + border-radius: 8px !important; + overflow: hidden !important; + box-shadow: 0 1px 3px rgba(0,0,0,0.1) !important; +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview th, +.markdown-editor-wrapper .EasyMDEContainer .editor-preview td { + border: 1px solid #e2e8f0 !important; + padding: 8px 12px !important; + text-align: left !important; +} +.markdown-editor-wrapper .EasyMDEContainer .editor-preview th { + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%) !important; + font-weight: 600 !important; + color: var(--primary-color) !important; +} +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .editor-preview th, +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .editor-preview td { + border-color: #1f2a44 !important; +} +[data-theme="dark"] .markdown-editor-wrapper .EasyMDEContainer .editor-preview th { + background: linear-gradient(135deg, #1f2a44 0%, #374151 100%) !important; + color: #60a5fa !important; +} [data-theme="dark"] .input-group-text { background: #111827; color: var(--text-secondary); @@ -597,7 +1065,6 @@ main { .table tbody tr:hover { background: var(--light-color); - transform: scale(1.01); } [data-theme="dark"] .table tbody tr:hover { @@ -805,7 +1272,7 @@ main { padding: 1rem 0; z-index: 1030; position: relative; - min-height: 70px; + min-height: var(--navbar-height); } /* body attribute now handled by CSS vars above */ @@ -860,7 +1327,7 @@ main { transition: var(--transition); position: relative; margin: 0 0.25rem; - min-height: 48px; + min-height: 52px; display: flex; align-items: center; gap: 0.5rem; @@ -882,6 +1349,17 @@ main { box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } +/* ========================================== + Density toggle (compact mode) + ========================================== */ +html.compact .card-body { padding: 0.75rem 1rem; } +html.compact .card-header { padding: 0.75rem 1rem; } +html.compact .card-footer { padding: 0.75rem 1rem; } +html.compact .btn { padding: 0.5rem 0.75rem; min-height: 40px; } +html.compact .table th, html.compact .table td { padding: 0.75rem; } +html.compact .form-control, html.compact .form-select { min-height: 44px; padding: 0.5rem 0.75rem; } +html.compact .navbar { min-height: calc(var(--navbar-height) - 12px); } + /* Enhanced Mobile Layout */ @media (max-width: 768px) { .container { @@ -929,7 +1407,7 @@ main { .navbar { padding: 0.75rem 0; - min-height: 60px; + min-height: calc(var(--navbar-height) - 10px); } .navbar-brand { @@ -940,7 +1418,7 @@ main { padding: 1rem 1.5rem; margin: 0.25rem 0; font-size: 1.1rem; - min-height: 52px; + min-height: var(--mobile-touch-target, 52px); } } @@ -1081,72 +1559,109 @@ h6 { font-size: 1rem; } .hover-lift:hover { transform: translateY(-4px); box-shadow: var(--card-shadow-hover); + border-radius: var(--border-radius); /* Ensure border radius is maintained on hover */ } -/* Ensure dropdown menus are fully opaque and above other elements */ +/* Enhanced dropdown menu styling */ .dropdown-menu { background: var(--dropdown-bg); -webkit-backdrop-filter: none; backdrop-filter: none; - z-index: 1060; /* above navbar (1030) and our backdrop */ + z-index: 1060; border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - box-shadow: var(--card-shadow-hover); + border-radius: 12px !important; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); margin-top: 0.5rem; - overflow: visible; /* allow soft shadow rounding */ - position: absolute !important; /* ensure above backdrop and positioned by Bootstrap */ - pointer-events: auto; /* capture interactions */ - background-clip: padding-box; /* ensure solid fill to rounded corners */ + overflow: hidden; + position: absolute !important; + pointer-events: auto; + background-clip: padding-box; + min-width: 200px; + padding: 0.5rem 0; +} + +/* Dedicated navbar dropdown tweaks for better clarity */ +.navbar .dropdown-menu { + min-width: 220px; + border-radius: 12px !important; + padding: 0.5rem 0; + position: absolute !important; + right: 0; +} +.navbar .dropdown-item { + padding: 0.75rem 1rem; } /* Dark mode dropdown menu container */ [data-theme="dark"] .dropdown-menu { background: #0f172a !important; border-color: var(--border-color); - box-shadow: var(--card-shadow-hover); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); + border-radius: 12px !important; } -[data-theme="dark"] .dropdown-menu .dropdown-item { - padding: 0.75rem 1rem; - border-bottom: 1px solid rgba(255,255,255,0.04); + +/* Dropdown items styling */ +.dropdown-item { + padding: 0.75rem 1.25rem; + color: var(--text-primary); + transition: all 0.2s ease; + border: none; + background: transparent; + display: flex; + align-items: center; + font-weight: 500; } -[data-theme="dark"] .dropdown-menu .dropdown-item:last-child { + +.dropdown-item:hover { + background-color: var(--light-color); + color: var(--text-primary); + transform: translateX(4px); +} + +.dropdown-item:focus { + background-color: var(--light-color); + color: var(--text-primary); + outline: none; +} + +.dropdown-divider { + margin: 0.5rem 0; + border-color: var(--border-color); + opacity: 0.5; +} + +/* Dark mode dropdown items */ +[data-theme="dark"] .dropdown-item { + color: var(--text-secondary) !important; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +[data-theme="dark"] .dropdown-item:last-child { border-bottom: none; } + +[data-theme="dark"] .dropdown-item:hover { + background-color: #111827 !important; + color: var(--text-primary) !important; + transform: translateX(4px); +} + +[data-theme="dark"] .dropdown-item:focus { + background-color: #111827 !important; + color: var(--text-primary) !important; +} + [data-theme="dark"] .dropdown-divider { background-color: var(--border-color) !important; - opacity: 1; + opacity: 0.3; } -[data-theme="dark"] .dropdown-item { color: var(--text-secondary) !important; } -[data-theme="dark"] .dropdown-item:hover { background-color: #111827 !important; color: var(--text-primary) !important; } -/* Light mode dropdown fixes (ensure solid background and proper contrast) */ -[data-theme="light"] .dropdown-menu, -.navbar .dropdown-menu { - --bs-dropdown-bg: #ffffff; - --bs-dropdown-link-color: var(--text-primary); - --bs-dropdown-link-hover-bg: var(--light-color); - --bs-dropdown-link-hover-color: var(--text-primary); - --bs-dropdown-border-color: var(--border-color); +/* Light mode dropdown overrides */ +[data-theme="light"] .dropdown-menu { background-color: #ffffff !important; border: 1px solid var(--border-color); - box-shadow: var(--card-shadow-hover); -} - -[data-theme="light"] .dropdown-item, -.navbar .dropdown-item { - color: var(--text-primary) !important; -} - -[data-theme="light"] .dropdown-item:hover, -.navbar .dropdown-item:hover { - background-color: var(--light-color) !important; - color: var(--text-primary) !important; -} - -[data-theme="light"] .dropdown-divider, -.navbar .dropdown-divider { - background-color: var(--border-color) !important; - opacity: 1; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + border-radius: 12px !important; } /* Ensure dropdowns inside cards stack above adjacent content */ @@ -1180,6 +1695,14 @@ h6 { font-size: 1rem; } background-color: var(--light-color) !important; } +/* Ensure input-group icons match input height */ +.input-group .input-group-text { + min-height: 52px; + display: inline-flex; + align-items: center; + border-radius: var(--border-radius-sm); +} + /* Backdrop to block interactions behind open dropdowns */ /* Removed custom dropdown backdrop; rely on Bootstrap defaults */ @@ -1264,6 +1787,20 @@ h6 { font-size: 1rem; } animation: spin 1s ease-in-out infinite; } +/* Skeleton loader utility */ +.skeleton { + display: inline-block; + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: 8px; +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + @keyframes spin { to { transform: rotate(360deg); } } @@ -1272,12 +1809,17 @@ h6 { font-size: 1rem; } .modal-content { border: 1px solid var(--border-color); border-radius: var(--border-radius); + overflow: hidden; /* ensure rounded corners render on all sides */ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15); } .modal-header { border-bottom: 1px solid var(--border-color); padding: 1.75rem; + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } .modal-body { @@ -1287,6 +1829,10 @@ h6 { font-size: 1rem; } .modal-footer { border-top: 1px solid var(--border-color); padding: 1.75rem; + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + border-top-left-radius: 0; + border-top-right-radius: 0; } @media (max-width: 576px) { @@ -1601,6 +2147,52 @@ h6 { font-size: 1rem; } .page-header .h3 { margin: 0; } +/* ========================================== + Prettier Header Buttons (page/card/modal headers) + ========================================== */ +.card-header .btn, +.page-header .btn, +.modal-header .btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 8px; + border: 1px solid var(--border-color); + background: linear-gradient(180deg, #ffffff, #f8fafc); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + transition: var(--transition); +} + +.card-header .btn:hover, +.page-header .btn:hover, +.modal-header .btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.card-header .btn.btn-outline-primary, +.page-header .btn.btn-outline-primary, +.modal-header .btn.btn-outline-primary { + background: transparent; + color: var(--primary-color); + border-color: var(--primary-color); +} + +.card-header .btn.btn-outline-primary:hover, +.page-header .btn.btn-outline-primary:hover, +.modal-header .btn.btn-outline-primary:hover { + background: var(--primary-color); + color: #ffffff; + border-color: var(--primary-color); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +.card-header .btn i, +.page-header .btn i, +.modal-header .btn i { + margin-right: 0.375rem; +} + /* Consistent badge sizing used next to page titles */ .badge.fs-6 { line-height: 1; @@ -1631,3 +2223,19 @@ h6 { font-size: 1rem; } border-right-color: #f9fafb; } +/* ========================================== + Consistent Navbar Height (Desktop) + ========================================== */ +@media (min-width: 992px) { + .navbar { + height: var(--navbar-height); + padding-top: 0; + padding-bottom: 0; + } + .navbar .container { + height: var(--navbar-height); + display: flex; + align-items: center; + } +} + diff --git a/app/static/mobile.css b/app/static/mobile.css index 909d207..d17b0fa 100644 --- a/app/static/mobile.css +++ b/app/static/mobile.css @@ -124,7 +124,7 @@ padding: 1rem 1.25rem; font-size: 16px; /* Prevents zoom on iOS */ border-radius: var(--mobile-border-radius); - border: 2px solid #e2e8f0; + border: 1px solid #e2e8f0; transition: all 0.3s ease; background: white; width: 100%; @@ -999,8 +999,7 @@ } /* Enhanced Mobile Dark Mode Support */ -@media (max-width: 768px) { - @media (prefers-color-scheme: dark) { +@media (max-width: 768px) and (prefers-color-scheme: dark) { :root { --mobile-bg-primary: #1e293b; --mobile-bg-secondary: #334155; @@ -1087,6 +1086,5 @@ border-color: var(--mobile-text-secondary) !important; color: var(--mobile-text-primary) !important; } - } } diff --git a/app/templates/_components.html b/app/templates/_components.html index 5107377..a1d4b63 100644 --- a/app/templates/_components.html +++ b/app/templates/_components.html @@ -4,10 +4,10 @@

{% if icon_class %}{% endif %}{{ title_text }}

{% if subtitle_text %} -

{{ subtitle_text }}

+

{{ subtitle_text }}

{% endif %}
-
+
{{ actions_html|safe if actions_html }}
@@ -32,4 +32,18 @@ {% endmacro %} +{% macro empty_state(icon_class, title, message, actions_html=None) %} +
+
+ +
+

{{ title }}

+

{{ message }}

+ {% if actions_html %} +
+ {{ actions_html|safe }} +
+ {% endif %} +
+{% endmacro %} diff --git a/app/templates/base.html b/app/templates/base.html index 2443579..440412f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -25,9 +25,9 @@ - - - + + + {% block extra_css %}{% endblock %} +