Parcourir la source

Add global View Logs button and sticky search bar

- Move View Logs button to header (right of dark mode toggle)
- Make logs modal accessible from all pages via base template
- Add sticky search bar on browse page for desktop (lg screens)
- Include sort controls in sticky bar with bidirectional sync
- Remove duplicate logs code from settings.js (~250 lines)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris il y a 1 mois
Parent
commit
1aa48a79f1
4 fichiers modifiés avec 432 ajouts et 304 suppressions
  1. 0 250
      static/js/settings.js
  2. 250 0
      templates/base.html
  3. 182 3
      templates/index.html
  4. 0 51
      templates/settings.html

+ 0 - 250
static/js/settings.js

@@ -2406,253 +2406,3 @@ function initializeTableTypeConfig() {
         }
         }
     }
     }
 }
 }
-
-// ============================================================================
-// Application Logs Modal
-// ============================================================================
-
-let logsWebSocket = null;
-let logsEntries = [];
-const MAX_LOG_ENTRIES = 500;
-
-// Color classes for different log levels
-const LOG_LEVEL_COLORS = {
-    DEBUG: 'text-gray-500 dark:text-gray-400',
-    INFO: 'text-blue-600 dark:text-blue-400',
-    WARNING: 'text-amber-600 dark:text-amber-400',
-    ERROR: 'text-red-600 dark:text-red-400',
-    CRITICAL: 'text-red-700 dark:text-red-300 font-bold'
-};
-
-const LOG_LEVEL_BG = {
-    DEBUG: 'bg-gray-100 dark:bg-gray-700',
-    INFO: 'bg-blue-50 dark:bg-blue-900/30',
-    WARNING: 'bg-amber-50 dark:bg-amber-900/30',
-    ERROR: 'bg-red-50 dark:bg-red-900/30',
-    CRITICAL: 'bg-red-100 dark:bg-red-900/50'
-};
-
-function openLogsModal() {
-    const modal = document.getElementById('logsModal');
-    modal.classList.remove('hidden');
-
-    // Load initial logs
-    loadInitialLogs();
-
-    // Connect to WebSocket for real-time updates
-    connectLogsWebSocket();
-
-    // Close on overlay click
-    modal.addEventListener('click', (e) => {
-        if (e.target === modal) {
-            closeLogsModal();
-        }
-    });
-
-    // Close on Escape key
-    document.addEventListener('keydown', handleLogsEscapeKey);
-}
-
-function closeLogsModal() {
-    const modal = document.getElementById('logsModal');
-    modal.classList.add('hidden');
-
-    // Disconnect WebSocket
-    if (logsWebSocket) {
-        logsWebSocket.close();
-        logsWebSocket = null;
-    }
-
-    // Remove event listener
-    document.removeEventListener('keydown', handleLogsEscapeKey);
-}
-
-function handleLogsEscapeKey(e) {
-    if (e.key === 'Escape') {
-        closeLogsModal();
-    }
-}
-
-async function loadInitialLogs() {
-    const logsContent = document.getElementById('logsContent');
-    logsContent.innerHTML = '<div class="text-gray-500 dark:text-gray-400">Loading logs...</div>';
-
-    try {
-        const response = await fetch('/api/logs?limit=500');
-        const data = await response.json();
-
-        logsEntries = data.logs || [];
-        renderLogs();
-        updateLogsCount();
-
-        // Scroll to bottom
-        const container = document.getElementById('logsContainer');
-        container.scrollTop = container.scrollHeight;
-
-    } catch (error) {
-        console.error('Failed to load logs:', error);
-        logsContent.innerHTML = '<div class="text-red-500">Failed to load logs</div>';
-    }
-}
-
-function connectLogsWebSocket() {
-    const statusEl = document.getElementById('logsConnectionStatus');
-
-    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
-    const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
-
-    try {
-        logsWebSocket = new WebSocket(wsUrl);
-
-        logsWebSocket.onopen = () => {
-            statusEl.textContent = 'Live';
-            statusEl.className = 'text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
-        };
-
-        logsWebSocket.onmessage = (event) => {
-            const message = JSON.parse(event.data);
-
-            if (message.type === 'log_entry') {
-                addLogEntry(message.data);
-            }
-            // Ignore heartbeat messages
-        };
-
-        logsWebSocket.onclose = () => {
-            statusEl.textContent = 'Disconnected';
-            statusEl.className = 'text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300';
-        };
-
-        logsWebSocket.onerror = (error) => {
-            console.error('Logs WebSocket error:', error);
-            statusEl.textContent = 'Error';
-            statusEl.className = 'text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300';
-        };
-
-    } catch (error) {
-        console.error('Failed to connect to logs WebSocket:', error);
-        statusEl.textContent = 'Failed';
-        statusEl.className = 'text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300';
-    }
-}
-
-function addLogEntry(entry) {
-    // Add to beginning (newest first in memory, but we render oldest first in UI)
-    logsEntries.unshift(entry);
-
-    // Trim if exceeds max
-    if (logsEntries.length > MAX_LOG_ENTRIES) {
-        logsEntries.pop();
-    }
-
-    // Add to UI
-    const logsContent = document.getElementById('logsContent');
-    const entryEl = createLogEntryElement(entry);
-
-    // Insert at the end (bottom) for newest
-    logsContent.appendChild(entryEl);
-
-    // Auto-scroll if enabled
-    const autoScroll = document.getElementById('logsAutoScroll').checked;
-    if (autoScroll) {
-        const container = document.getElementById('logsContainer');
-        container.scrollTop = container.scrollHeight;
-    }
-
-    // Apply filter if active
-    const levelFilter = document.getElementById('logLevelFilter').value;
-    if (levelFilter && entry.level !== levelFilter) {
-        entryEl.classList.add('hidden');
-    }
-
-    updateLogsCount();
-}
-
-function createLogEntryElement(entry) {
-    const div = document.createElement('div');
-    div.className = `log-entry flex gap-2 py-1 px-2 rounded ${LOG_LEVEL_BG[entry.level] || 'bg-gray-50 dark:bg-gray-800'}`;
-    div.dataset.level = entry.level;
-
-    // Format timestamp (show only time part for brevity)
-    const timestamp = new Date(entry.timestamp);
-    const timeStr = timestamp.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
-    const msStr = timestamp.getMilliseconds().toString().padStart(3, '0');
-
-    div.innerHTML = `
-        <span class="text-gray-400 dark:text-gray-500 shrink-0">${timeStr}.${msStr}</span>
-        <span class="${LOG_LEVEL_COLORS[entry.level] || 'text-gray-600'} shrink-0 w-16">${entry.level}</span>
-        <span class="text-purple-600 dark:text-purple-400 shrink-0 truncate max-w-32" title="${entry.logger}:${entry.line}">${entry.module}:${entry.line}</span>
-        <span class="text-slate-700 dark:text-gray-200 break-all">${escapeHtml(entry.message)}</span>
-    `;
-
-    return div;
-}
-
-function escapeHtml(text) {
-    const div = document.createElement('div');
-    div.textContent = text;
-    return div.innerHTML;
-}
-
-function renderLogs() {
-    const logsContent = document.getElementById('logsContent');
-    logsContent.innerHTML = '';
-
-    // Render in reverse order (oldest first, newest at bottom)
-    const reversedLogs = [...logsEntries].reverse();
-    const levelFilter = document.getElementById('logLevelFilter').value;
-
-    for (const entry of reversedLogs) {
-        const entryEl = createLogEntryElement(entry);
-        if (levelFilter && entry.level !== levelFilter) {
-            entryEl.classList.add('hidden');
-        }
-        logsContent.appendChild(entryEl);
-    }
-}
-
-function filterLogs() {
-    const levelFilter = document.getElementById('logLevelFilter').value;
-    const entries = document.querySelectorAll('#logsContent .log-entry');
-
-    let visibleCount = 0;
-    entries.forEach(entry => {
-        if (!levelFilter || entry.dataset.level === levelFilter) {
-            entry.classList.remove('hidden');
-            visibleCount++;
-        } else {
-            entry.classList.add('hidden');
-        }
-    });
-
-    // Update count to show filtered amount
-    const countEl = document.getElementById('logsCount');
-    if (levelFilter) {
-        countEl.textContent = `${visibleCount} of ${logsEntries.length} entries (filtered)`;
-    } else {
-        countEl.textContent = `${logsEntries.length} entries`;
-    }
-}
-
-function updateLogsCount() {
-    const countEl = document.getElementById('logsCount');
-    const levelFilter = document.getElementById('logLevelFilter').value;
-
-    if (levelFilter) {
-        const filteredCount = logsEntries.filter(e => e.level === levelFilter).length;
-        countEl.textContent = `${filteredCount} of ${logsEntries.length} entries (filtered)`;
-    } else {
-        countEl.textContent = `${logsEntries.length} entries`;
-    }
-}
-
-async function clearLogs() {
-    try {
-        await fetch('/api/logs', { method: 'DELETE' });
-        logsEntries = [];
-        document.getElementById('logsContent').innerHTML = '<div class="text-gray-500 dark:text-gray-400 text-center py-4">Logs cleared</div>';
-        updateLogsCount();
-    } catch (error) {
-        console.error('Failed to clear logs:', error);
-    }
-}

+ 250 - 0
templates/base.html

@@ -298,6 +298,15 @@
             >
             >
               <span class="material-icons" id="theme-toggle-icon">dark_mode</span>
               <span class="material-icons" id="theme-toggle-icon">dark_mode</span>
             </button>
             </button>
+            <button
+              id="view-logs-button"
+              class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
+              aria-label="View logs"
+              title="View Application Logs"
+              onclick="openLogsModal()"
+            >
+              <span class="material-icons">article</span>
+            </button>
             <button
             <button
               id="restart-button"
               id="restart-button"
               class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500"
               class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500"
@@ -890,5 +899,246 @@
             </div>
             </div>
         </div>
         </div>
     </div>
     </div>
+
+    <!-- Logs Modal -->
+    <div id="logsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
+      <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
+        <!-- Modal Header -->
+        <div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-gray-600">
+          <div class="flex items-center gap-3">
+            <span class="material-icons text-2xl text-slate-600 dark:text-gray-300">article</span>
+            <h3 class="text-lg font-semibold text-slate-800 dark:text-gray-100">Application Logs</h3>
+            <span id="logsConnectionStatus" class="text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">Connecting...</span>
+          </div>
+          <div class="flex items-center gap-2">
+            <select id="logLevelFilter" onchange="filterLogs()" class="form-select text-xs rounded-lg border-slate-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 py-2 pl-3 pr-10">
+              <option value="">All Levels</option>
+              <option value="DEBUG">Debug</option>
+              <option value="INFO">Info</option>
+              <option value="WARNING">Warning</option>
+              <option value="ERROR">Error</option>
+              <option value="CRITICAL">Critical</option>
+            </select>
+            <button
+              onclick="clearLogs()"
+              class="flex items-center justify-center gap-1 rounded-lg h-8 px-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 text-xs font-medium transition-colors"
+            >
+              <span class="material-icons text-sm">delete_sweep</span>
+              Clear
+            </button>
+            <label class="flex items-center gap-1.5 text-sm text-slate-600 dark:text-gray-300">
+              <input type="checkbox" id="logsAutoScroll" checked class="rounded border-slate-300 dark:border-gray-600">
+              Auto-scroll
+            </label>
+            <button
+              onclick="closeLogsModal()"
+              class="flex items-center justify-center rounded-lg size-8 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 transition-colors"
+            >
+              <span class="material-icons">close</span>
+            </button>
+          </div>
+        </div>
+        <!-- Modal Body -->
+        <div id="logsContainer" class="flex-1 overflow-y-auto p-4 font-mono text-xs bg-slate-50 dark:bg-gray-900">
+          <div id="logsContent" class="space-y-1">
+            <!-- Log entries will be inserted here -->
+          </div>
+        </div>
+        <!-- Modal Footer -->
+        <div class="px-6 py-3 border-t border-slate-200 dark:border-gray-600 flex items-center justify-between text-xs text-slate-500 dark:text-gray-400">
+          <span id="logsCount">0 entries</span>
+          <span id="logsInfo">Showing last 500 log entries</span>
+        </div>
+      </div>
+    </div>
+
+    <script>
+    // ============================================================================
+    // Application Logs Modal
+    // ============================================================================
+    let logsWebSocket = null;
+    let logsEntries = [];
+    const MAX_LOG_ENTRIES = 500;
+
+    const LOG_LEVEL_COLORS = {
+        DEBUG: 'text-gray-500 dark:text-gray-400',
+        INFO: 'text-blue-600 dark:text-blue-400',
+        WARNING: 'text-amber-600 dark:text-amber-400',
+        ERROR: 'text-red-600 dark:text-red-400',
+        CRITICAL: 'text-red-700 dark:text-red-300 font-bold'
+    };
+
+    const LOG_LEVEL_BG = {
+        DEBUG: 'bg-gray-100 dark:bg-gray-700',
+        INFO: 'bg-blue-50 dark:bg-blue-900/30',
+        WARNING: 'bg-amber-50 dark:bg-amber-900/30',
+        ERROR: 'bg-red-50 dark:bg-red-900/30',
+        CRITICAL: 'bg-red-100 dark:bg-red-900/50'
+    };
+
+    function openLogsModal() {
+        const modal = document.getElementById('logsModal');
+        modal.classList.remove('hidden');
+        loadInitialLogs();
+        connectLogsWebSocket();
+        modal.addEventListener('click', (e) => {
+            if (e.target === modal) closeLogsModal();
+        });
+        document.addEventListener('keydown', handleLogsEscapeKey);
+    }
+
+    function closeLogsModal() {
+        const modal = document.getElementById('logsModal');
+        modal.classList.add('hidden');
+        if (logsWebSocket) {
+            logsWebSocket.close();
+            logsWebSocket = null;
+        }
+        document.removeEventListener('keydown', handleLogsEscapeKey);
+    }
+
+    function handleLogsEscapeKey(e) {
+        if (e.key === 'Escape') closeLogsModal();
+    }
+
+    async function loadInitialLogs() {
+        const logsContent = document.getElementById('logsContent');
+        logsContent.innerHTML = '<div class="text-gray-500 dark:text-gray-400">Loading logs...</div>';
+        try {
+            const response = await fetch('/api/logs?limit=500');
+            const data = await response.json();
+            logsEntries = data.logs || [];
+            renderLogs();
+            updateLogsCount();
+            const container = document.getElementById('logsContainer');
+            container.scrollTop = container.scrollHeight;
+        } catch (error) {
+            console.error('Failed to load logs:', error);
+            logsContent.innerHTML = '<div class="text-red-500">Failed to load logs</div>';
+        }
+    }
+
+    function connectLogsWebSocket() {
+        const statusEl = document.getElementById('logsConnectionStatus');
+        const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+        const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
+        try {
+            logsWebSocket = new WebSocket(wsUrl);
+            logsWebSocket.onopen = () => {
+                statusEl.textContent = 'Live';
+                statusEl.className = 'text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
+            };
+            logsWebSocket.onmessage = (event) => {
+                const message = JSON.parse(event.data);
+                if (message.type === 'log_entry') addLogEntry(message.data);
+            };
+            logsWebSocket.onclose = () => {
+                statusEl.textContent = 'Disconnected';
+                statusEl.className = 'text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300';
+            };
+            logsWebSocket.onerror = (error) => {
+                console.error('Logs WebSocket error:', error);
+                statusEl.textContent = 'Error';
+                statusEl.className = 'text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300';
+            };
+        } catch (error) {
+            console.error('Failed to connect to logs WebSocket:', error);
+            statusEl.textContent = 'Failed';
+            statusEl.className = 'text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300';
+        }
+    }
+
+    function addLogEntry(entry) {
+        logsEntries.unshift(entry);
+        if (logsEntries.length > MAX_LOG_ENTRIES) logsEntries.pop();
+        const logsContent = document.getElementById('logsContent');
+        const entryEl = createLogEntryElement(entry);
+        logsContent.appendChild(entryEl);
+        const autoScroll = document.getElementById('logsAutoScroll').checked;
+        if (autoScroll) {
+            const container = document.getElementById('logsContainer');
+            container.scrollTop = container.scrollHeight;
+        }
+        const levelFilter = document.getElementById('logLevelFilter').value;
+        if (levelFilter && entry.level !== levelFilter) entryEl.classList.add('hidden');
+        updateLogsCount();
+    }
+
+    function createLogEntryElement(entry) {
+        const div = document.createElement('div');
+        div.className = `log-entry flex gap-2 py-1 px-2 rounded ${LOG_LEVEL_BG[entry.level] || 'bg-gray-50 dark:bg-gray-800'}`;
+        div.dataset.level = entry.level;
+        const timestamp = new Date(entry.timestamp);
+        const timeStr = timestamp.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
+        const msStr = timestamp.getMilliseconds().toString().padStart(3, '0');
+        div.innerHTML = `
+            <span class="text-gray-400 dark:text-gray-500 shrink-0">${timeStr}.${msStr}</span>
+            <span class="${LOG_LEVEL_COLORS[entry.level] || 'text-gray-600'} shrink-0 w-16">${entry.level}</span>
+            <span class="text-purple-600 dark:text-purple-400 shrink-0 truncate max-w-32" title="${entry.logger}:${entry.line}">${entry.module}:${entry.line}</span>
+            <span class="text-slate-700 dark:text-gray-200 break-all">${escapeHtmlLogs(entry.message)}</span>
+        `;
+        return div;
+    }
+
+    function escapeHtmlLogs(text) {
+        const div = document.createElement('div');
+        div.textContent = text;
+        return div.innerHTML;
+    }
+
+    function renderLogs() {
+        const logsContent = document.getElementById('logsContent');
+        logsContent.innerHTML = '';
+        const reversedLogs = [...logsEntries].reverse();
+        const levelFilter = document.getElementById('logLevelFilter').value;
+        for (const entry of reversedLogs) {
+            const entryEl = createLogEntryElement(entry);
+            if (levelFilter && entry.level !== levelFilter) entryEl.classList.add('hidden');
+            logsContent.appendChild(entryEl);
+        }
+    }
+
+    function filterLogs() {
+        const levelFilter = document.getElementById('logLevelFilter').value;
+        const entries = document.querySelectorAll('#logsContent .log-entry');
+        let visibleCount = 0;
+        entries.forEach(entry => {
+            if (!levelFilter || entry.dataset.level === levelFilter) {
+                entry.classList.remove('hidden');
+                visibleCount++;
+            } else {
+                entry.classList.add('hidden');
+            }
+        });
+        const countEl = document.getElementById('logsCount');
+        if (levelFilter) {
+            countEl.textContent = `${visibleCount} of ${logsEntries.length} entries (filtered)`;
+        } else {
+            countEl.textContent = `${logsEntries.length} entries`;
+        }
+    }
+
+    function updateLogsCount() {
+        const countEl = document.getElementById('logsCount');
+        const levelFilter = document.getElementById('logLevelFilter').value;
+        if (levelFilter) {
+            const filteredCount = logsEntries.filter(e => e.level === levelFilter).length;
+            countEl.textContent = `${filteredCount} of ${logsEntries.length} entries (filtered)`;
+        } else {
+            countEl.textContent = `${logsEntries.length} entries`;
+        }
+    }
+
+    async function clearLogs() {
+        try {
+            await fetch('/api/logs', { method: 'DELETE' });
+            logsEntries = [];
+            document.getElementById('logsContent').innerHTML = '<div class="text-gray-500 dark:text-gray-400 text-center py-4">Logs cleared</div>';
+            updateLogsCount();
+        } catch (error) {
+            console.error('Failed to clear logs:', error);
+        }
+    }
+    </script>
   </body>
   </body>
 </html>
 </html>

+ 182 - 3
templates/index.html

@@ -183,8 +183,8 @@
 
 
 {% block content %}
 {% block content %}
 <div class="layout-content-container flex flex-col w-full max-w-5xl mb-24">
 <div class="layout-content-container flex flex-col w-full max-w-5xl mb-24">
-    <div class="flex-none bg-gray-50 py-4">
-        <div class="mt-2 sm:mt-8">
+    <div id="browseHeader" class="flex-none bg-gray-50 py-4">
+        <div class="mt-2 sm:mt-8 lg:mt-2">
             <!-- Header Row -->
             <!-- Header Row -->
             <div class="flex items-center justify-between gap-2 mb-4">
             <div class="flex items-center justify-between gap-2 mb-4">
                 <h2 class="text-gray-900 text-3xl font-bold leading-tight tracking-tight">
                 <h2 class="text-gray-900 text-3xl font-bold leading-tight tracking-tight">
@@ -259,7 +259,7 @@
         </div>
         </div>
     </div>
     </div>
     <section class="px-4 py-6">
     <section class="px-4 py-6">
-        <div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-4 sm:gap-5 md:gap-6">
+        <div class="grid gap-4 sm:gap-5 md:gap-6" style="grid-template-columns: repeat(auto-fill, minmax(100px, 128px)); justify-content: center;">
             <!-- All patterns will be populated here -->
             <!-- All patterns will be populated here -->
         </div>
         </div>
         <!-- Spacer to allow scrolling past last patterns -->
         <!-- Spacer to allow scrolling past last patterns -->
@@ -459,5 +459,184 @@ window.loadPatterns = async function() {
     updateDeleteButtonState(null);
     updateDeleteButtonState(null);
   }
   }
 };
 };
+
+// Sticky search bar on desktop
+(function() {
+  // Only apply on large screens (lg: 1024px+)
+  const mediaQuery = window.matchMedia('(min-width: 1024px)');
+  if (!mediaQuery.matches) return;
+
+  // Create a compact fixed search bar
+  const siteHeaderHeight = 56;
+  let fixedBar = null;
+  let isFixed = false;
+  const triggerPoint = 150; // Scroll distance to trigger
+
+  function createFixedBar() {
+    if (fixedBar) return;
+
+    fixedBar = document.createElement('div');
+    fixedBar.id = 'fixedSearchBar';
+    // Get actual header height
+    const siteHeader = document.querySelector('header');
+    const headerHeight = siteHeader ? siteHeader.offsetHeight : 64;
+
+    fixedBar.style.cssText = `
+      position: fixed;
+      top: ${headerHeight}px;
+      left: 0;
+      right: 0;
+      background: #f9fafb;
+      border-bottom: 1px solid #e5e7eb;
+      box-shadow: 0 2px 4px rgba(0,0,0,0.08);
+      z-index: 9;
+      transform: translateY(-100%);
+      transition: transform 0.2s ease;
+    `;
+    if (document.documentElement.classList.contains('dark')) {
+      fixedBar.style.background = '#1a1a1a';
+      fixedBar.style.borderColor = '#404040';
+    }
+
+    fixedBar.innerHTML = `
+      <div class="max-w-5xl mx-auto px-4 py-2 flex items-center gap-3">
+        <div class="flex items-center gap-1 shrink-0">
+          <span class="text-xs font-medium text-gray-700 dark:text-gray-300">Sort:</span>
+          <select id="fixedSortSelect" class="text-xs rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 bg-white text-gray-900 px-2 py-1">
+            <option value="name">Name</option>
+            <option value="date">Date Modified</option>
+            <option value="coordinates">Coordinates</option>
+            <option value="favorite">Favorite</option>
+          </select>
+          <button id="fixedSortDirBtn" class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500">
+            <span class="material-icons text-sm" id="fixedSortDirIcon">arrow_upward</span>
+          </button>
+        </div>
+        <div class="relative flex-1">
+          <input id="fixedSearchInput" class="form-input block w-full h-11 rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 bg-gray-50 py-2.5 p-4 text-gray-900 placeholder-gray-500 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 sm:text-sm" placeholder="Search patterns..." type="search" />
+          <button id="fixedSearchBtn" class="inline-flex absolute top-0 right-0 h-11 w-11 items-center justify-center rounded-lg bg-blue-600 p-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
+            <span class="material-icons text-sm font-semibold text-white">search</span>
+          </button>
+        </div>
+        <button id="fixedAddBtn" class="inline-flex items-center justify-center rounded-lg h-11 w-11 bg-blue-600 text-white shadow-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
+          <span class="material-icons">add</span>
+        </button>
+      </div>
+    `;
+
+    document.body.appendChild(fixedBar);
+
+    // Sync with main controls
+    const mainSearch = document.getElementById('patternSearch');
+    const fixedSearch = fixedBar.querySelector('#fixedSearchInput');
+    const fixedSearchBtn = fixedBar.querySelector('#fixedSearchBtn');
+    const fixedAdd = fixedBar.querySelector('#fixedAddBtn');
+
+    // Search on Enter key
+    fixedSearch.addEventListener('keypress', (e) => {
+      if (e.key === 'Enter') {
+        if (mainSearch) mainSearch.value = fixedSearch.value;
+        if (typeof searchPatterns === 'function') {
+          searchPatterns(fixedSearch.value.trim());
+        }
+      }
+    });
+
+    // Also sync on input for live typing
+    fixedSearch.addEventListener('input', () => {
+      if (mainSearch) mainSearch.value = fixedSearch.value;
+    });
+
+    // Search button click
+    fixedSearchBtn.addEventListener('click', () => {
+      if (mainSearch) mainSearch.value = fixedSearch.value;
+      if (typeof searchPatterns === 'function') {
+        searchPatterns(fixedSearch.value.trim());
+      }
+    });
+
+    fixedAdd.addEventListener('click', () => {
+      document.getElementById('patternFileInput')?.click();
+    });
+
+    // Sort controls sync
+    const mainSortSelect = document.getElementById('browseSortFieldSelect');
+    const mainSortDirBtn = document.getElementById('browseSortDirectionBtn');
+    const mainSortDirIcon = document.getElementById('browseSortDirectionIcon');
+    const fixedSortSelect = fixedBar.querySelector('#fixedSortSelect');
+    const fixedSortDirBtn = fixedBar.querySelector('#fixedSortDirBtn');
+    const fixedSortDirIcon = fixedBar.querySelector('#fixedSortDirIcon');
+
+    // Sync initial values from main controls
+    if (mainSortSelect) {
+      fixedSortSelect.value = mainSortSelect.value;
+    }
+    if (mainSortDirIcon) {
+      fixedSortDirIcon.textContent = mainSortDirIcon.textContent;
+    }
+
+    // Fixed sort select change -> sync to main and trigger sort
+    fixedSortSelect.addEventListener('change', () => {
+      if (mainSortSelect) {
+        mainSortSelect.value = fixedSortSelect.value;
+        mainSortSelect.dispatchEvent(new Event('change'));
+      }
+    });
+
+    // Fixed sort direction button click -> sync to main and trigger sort
+    fixedSortDirBtn.addEventListener('click', () => {
+      if (mainSortDirBtn) {
+        mainSortDirBtn.click();
+        // After click, sync the icon
+        setTimeout(() => {
+          if (mainSortDirIcon) {
+            fixedSortDirIcon.textContent = mainSortDirIcon.textContent;
+          }
+        }, 50);
+      }
+    });
+
+    // Listen for changes on main controls to sync back to fixed
+    if (mainSortSelect) {
+      mainSortSelect.addEventListener('change', () => {
+        fixedSortSelect.value = mainSortSelect.value;
+      });
+    }
+
+    // Create observer to sync sort direction icon changes
+    if (mainSortDirIcon) {
+      const observer = new MutationObserver(() => {
+        fixedSortDirIcon.textContent = mainSortDirIcon.textContent;
+      });
+      observer.observe(mainSortDirIcon, { childList: true, characterData: true, subtree: true });
+    }
+  }
+
+  function handleScroll() {
+    if (!mediaQuery.matches) return;
+
+    if (window.scrollY > triggerPoint && !isFixed) {
+      createFixedBar();
+      requestAnimationFrame(() => {
+        fixedBar.style.transform = 'translateY(0)';
+      });
+      isFixed = true;
+    } else if (window.scrollY <= triggerPoint && isFixed) {
+      if (fixedBar) {
+        fixedBar.style.transform = 'translateY(-100%)';
+      }
+      isFixed = false;
+    }
+  }
+
+  window.addEventListener('scroll', handleScroll, { passive: true });
+  mediaQuery.addEventListener('change', (e) => {
+    if (!e.matches && fixedBar) {
+      fixedBar.remove();
+      fixedBar = null;
+      isFixed = false;
+    }
+  });
+})();
 </script>
 </script>
 {% endblock %}
 {% endblock %}

+ 0 - 51
templates/settings.html

@@ -1548,56 +1548,5 @@ input:checked + .slider:before {
   </section>
   </section>
 </div>
 </div>
 {% endblock %} {% block scripts %}
 {% endblock %} {% block scripts %}
-<!-- Logs Modal - placed in scripts block to be outside main content container -->
-<div id="logsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
-  <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
-    <!-- Modal Header -->
-    <div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-gray-600">
-      <div class="flex items-center gap-3">
-        <span class="material-icons text-2xl text-slate-600 dark:text-gray-300">article</span>
-        <h3 class="text-lg font-semibold text-slate-800 dark:text-gray-100">Application Logs</h3>
-        <span id="logsConnectionStatus" class="text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">Connecting...</span>
-      </div>
-      <div class="flex items-center gap-2">
-        <select id="logLevelFilter" onchange="filterLogs()" class="form-select text-sm rounded-lg border-slate-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 h-8 px-2">
-          <option value="">All Levels</option>
-          <option value="DEBUG">Debug</option>
-          <option value="INFO">Info</option>
-          <option value="WARNING">Warning</option>
-          <option value="ERROR">Error</option>
-          <option value="CRITICAL">Critical</option>
-        </select>
-        <button
-          onclick="clearLogs()"
-          class="flex items-center justify-center gap-1 rounded-lg h-8 px-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 text-xs font-medium transition-colors"
-        >
-          <span class="material-icons text-sm">delete_sweep</span>
-          Clear
-        </button>
-        <label class="flex items-center gap-1.5 text-sm text-slate-600 dark:text-gray-300">
-          <input type="checkbox" id="logsAutoScroll" checked class="rounded border-slate-300 dark:border-gray-600">
-          Auto-scroll
-        </label>
-        <button
-          onclick="closeLogsModal()"
-          class="flex items-center justify-center rounded-lg size-8 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 transition-colors"
-        >
-          <span class="material-icons">close</span>
-        </button>
-      </div>
-    </div>
-    <!-- Modal Body -->
-    <div id="logsContainer" class="flex-1 overflow-y-auto p-4 font-mono text-xs bg-slate-50 dark:bg-gray-900">
-      <div id="logsContent" class="space-y-1">
-        <!-- Log entries will be inserted here -->
-      </div>
-    </div>
-    <!-- Modal Footer -->
-    <div class="px-6 py-3 border-t border-slate-200 dark:border-gray-600 flex items-center justify-between text-xs text-slate-500 dark:text-gray-400">
-      <span id="logsCount">0 entries</span>
-      <span id="logsInfo">Showing last 500 log entries</span>
-    </div>
-  </div>
-</div>
 <script src="/static/js/settings.js"></script>
 <script src="/static/js/settings.js"></script>
 {% endblock %}
 {% endblock %}