From 60c698deb2b2ba90ce2f034e552357b6a4863006 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Mon, 6 Apr 2026 11:59:24 +0200 Subject: [PATCH] feat: add Settings FAB button, drag-and-drop positioning, and size/spacing controls - Add Settings quick-access button to both main chat and DM views - Make FAB container draggable via toggle button with position saved to localStorage - Add button size and spacing sliders in Settings > Appearance tab - Use CSS custom properties for dynamic FAB sizing Co-Authored-By: Claude Opus 4.6 --- app/static/css/style.css | 42 ++++++---- app/static/js/app.js | 14 ++++ app/static/js/dm.js | 13 ++++ app/static/js/fab-utils.js | 154 +++++++++++++++++++++++++++++++++++++ app/templates/base.html | 75 ++++++++++++++++++ app/templates/dm.html | 6 ++ app/templates/index.html | 3 + 7 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 app/static/js/fab-utils.js diff --git a/app/static/css/style.css b/app/static/css/style.css index a400e6e..a945d65 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1038,19 +1038,20 @@ main { z-index: 900; /* Lower than offcanvas (1045) but higher than content */ display: flex; flex-direction: column; - gap: 12px; + gap: var(--fab-custom-gap, 12px); + touch-action: none; /* Prevent scroll during drag */ } .fab { position: relative; /* For badge positioning */ - width: 56px; - height: 56px; + width: var(--fab-custom-size, 56px); + height: var(--fab-custom-size, 56px); border-radius: 50%; border: none; display: flex; align-items: center; justify-content: center; - font-size: 1.5rem; + font-size: calc(var(--fab-custom-size, 56px) * 0.43); cursor: pointer; box-shadow: var(--fab-shadow); transition: transform 0.2s ease, box-shadow 0.2s ease; @@ -1110,14 +1111,19 @@ main { transition: transform 0.2s ease; } -/* FAB toggle button (smaller, semi-transparent) */ +/* FAB toggle button (smaller, semi-transparent) — not affected by custom size */ .fab-toggle { - width: 32px; - height: 32px; + width: 32px !important; + height: 32px !important; background: rgba(108, 117, 125, 0.6); color: white; - font-size: 0.85rem; + font-size: 0.85rem !important; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + cursor: grab; +} + +.fab-toggle:active { + cursor: grabbing; } .fab-toggle:hover { @@ -1157,19 +1163,19 @@ main { .fab-container { right: 12px; top: 70px; - gap: 10px; + gap: var(--fab-custom-gap, 10px); } .fab { - width: 48px; - height: 48px; - font-size: 1.25rem; + width: var(--fab-custom-size, 48px); + height: var(--fab-custom-size, 48px); + font-size: calc(var(--fab-custom-size, 48px) * 0.43); } .fab-toggle { - width: 28px; - height: 28px; - font-size: 0.75rem; + width: 28px !important; + height: 28px !important; + font-size: 0.75rem !important; } } @@ -1485,6 +1491,12 @@ main { color: white; } +/* Settings FAB button (warm orange) */ +.fab-settings { + background: linear-gradient(135deg, #fd7e14 0%, #e06b0a 100%); + color: white; +} + /* Filter bar overlay - slides down from top of chat area */ .filter-bar { position: absolute; diff --git a/app/static/js/app.js b/app/static/js/app.js index 8e215d6..3cdb185 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -3931,6 +3931,20 @@ function initializeFabToggle() { const isCollapsed = container.classList.contains('collapsed'); toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons'; }); + + // Drag-and-drop support + initFabDrag('fabContainer', 'fabToggle', 'mc-webui-fab-pos'); + + // Listen for settings open request from DM iframe + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'openSettings') { + const modal = document.getElementById('settingsModal'); + if (modal) { + const bsModal = bootstrap.Modal.getOrCreateInstance(modal); + bsModal.show(); + } + } + }); } // ============================================================================= diff --git a/app/static/js/dm.js b/app/static/js/dm.js index ac6975c..90abfd2 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -236,6 +236,16 @@ document.addEventListener('DOMContentLoaded', async function() { // Initialize FAB toggle initializeDmFabToggle(); + // Settings FAB - open parent's settings modal + const dmSettingsFab = document.getElementById('dmSettingsFab'); + if (dmSettingsFab) { + dmSettingsFab.addEventListener('click', () => { + if (window.parent && window.parent !== window) { + window.parent.postMessage({type: 'openSettings'}, '*'); + } + }); + } + // Load auto-retry config loadAutoRetryConfig(); @@ -1778,6 +1788,9 @@ function initializeDmFabToggle() { const isCollapsed = container.classList.contains('collapsed'); toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons'; }); + + // Drag-and-drop support + initFabDrag('dmFabContainer', 'dmFabToggle', 'mc-webui-fab-pos-dm'); } /** diff --git a/app/static/js/fab-utils.js b/app/static/js/fab-utils.js new file mode 100644 index 0000000..89d1be9 --- /dev/null +++ b/app/static/js/fab-utils.js @@ -0,0 +1,154 @@ +// ============================================================================= +// FAB Container — Drag-and-Drop & Customization Utilities +// ============================================================================= + +/** + * Make a FAB container draggable via its toggle button. + * Short click = toggle collapse, drag = reposition. + * Position is persisted to localStorage. + * + * @param {string} containerId - e.g. 'fabContainer' or 'dmFabContainer' + * @param {string} toggleId - e.g. 'fabToggle' or 'dmFabToggle' + * @param {string} storageKey - localStorage key for position + */ +function initFabDrag(containerId, toggleId, storageKey) { + const container = document.getElementById(containerId); + const toggle = document.getElementById(toggleId); + if (!container || !toggle) return; + + const DRAG_THRESHOLD = 5; // px – movement before drag starts + let dragging = false; + let startX, startY, origLeft, origTop; + let didDrag = false; + + // --- Restore saved position --- + restoreFabPosition(container, storageKey); + + // --- Pointer events on toggle --- + toggle.addEventListener('pointerdown', onPointerDown); + + function onPointerDown(e) { + // Only primary button + if (e.button !== 0) return; + + e.preventDefault(); + toggle.setPointerCapture(e.pointerId); + + const rect = container.getBoundingClientRect(); + startX = e.clientX; + startY = e.clientY; + origLeft = rect.left; + origTop = rect.top; + dragging = false; + didDrag = false; + + toggle.addEventListener('pointermove', onPointerMove); + toggle.addEventListener('pointerup', onPointerUp); + } + + function onPointerMove(e) { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + if (!dragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) { + dragging = true; + didDrag = true; + // Switch container to left/top positioning for drag + container.style.right = 'auto'; + } + + if (dragging) { + const newLeft = origLeft + dx; + const newTop = origTop + dy; + container.style.left = newLeft + 'px'; + container.style.top = newTop + 'px'; + } + } + + function onPointerUp(e) { + toggle.removeEventListener('pointermove', onPointerMove); + toggle.removeEventListener('pointerup', onPointerUp); + + if (didDrag) { + // Clamp to viewport + clampFabPosition(container); + // Save + saveFabPosition(container, storageKey); + } + // If it was not a drag, let the click event fire naturally (toggle collapse) + // If it was a drag, suppress the click + if (didDrag) { + toggle.addEventListener('click', suppressClick, {once: true, capture: true}); + } + } + + function suppressClick(e) { + e.stopImmediatePropagation(); + e.preventDefault(); + } +} + +function clampFabPosition(container) { + const rect = container.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let left = rect.left; + let top = rect.top; + + // Keep at least 20px of the container visible on each edge + if (left + rect.width < 20) left = 20 - rect.width; + if (left > vw - 20) left = vw - 20; + if (top < 0) top = 0; + if (top > vh - 20) top = vh - 20; + + container.style.left = left + 'px'; + container.style.top = top + 'px'; +} + +function saveFabPosition(container, storageKey) { + const rect = container.getBoundingClientRect(); + localStorage.setItem(storageKey, JSON.stringify({ + left: rect.left, + top: rect.top + })); +} + +function restoreFabPosition(container, storageKey) { + const saved = localStorage.getItem(storageKey); + if (!saved) return; + + try { + const pos = JSON.parse(saved); + container.style.right = 'auto'; + container.style.left = pos.left + 'px'; + container.style.top = pos.top + 'px'; + // Re-clamp in case viewport changed + clampFabPosition(container); + } catch (e) { + localStorage.removeItem(storageKey); + } +} + +// ============================================================================= +// FAB Size & Spacing — apply from localStorage +// ============================================================================= + +/** + * Apply saved FAB appearance settings (size, gap). + * Called on page load from both main and DM pages. + */ +function applyFabAppearance() { + const size = localStorage.getItem('mc-webui-fab-size'); + const gap = localStorage.getItem('mc-webui-fab-gap'); + + if (size) { + document.documentElement.style.setProperty('--fab-custom-size', size + 'px'); + } + if (gap) { + document.documentElement.style.setProperty('--fab-custom-gap', gap + 'px'); + } +} + +// Auto-apply on load +document.addEventListener('DOMContentLoaded', applyFabAppearance); diff --git a/app/templates/base.html b/app/templates/base.html index 50b548c..013d8d5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -473,6 +473,33 @@ + +
+
Quick Access Buttons
+ + + + + + + + + + + + + + + +
Button size (px) + + 56 +
Spacing (px) + + 12 +
Position + +
@@ -661,6 +688,9 @@ + + + @@ -716,6 +746,51 @@ document.querySelectorAll('.theme-option').forEach(function(el) { el.classList.toggle('active', el.getAttribute('data-theme-value') === current); }); + + // --- FAB appearance controls --- + var fabSizeSlider = document.getElementById('settFabSize'); + var fabSizeVal = document.getElementById('settFabSizeVal'); + var fabGapSlider = document.getElementById('settFabGap'); + var fabGapVal = document.getElementById('settFabGapVal'); + var fabResetPos = document.getElementById('settFabResetPos'); + + // Load saved values + var savedSize = localStorage.getItem('mc-webui-fab-size'); + var savedGap = localStorage.getItem('mc-webui-fab-gap'); + if (savedSize && fabSizeSlider) { fabSizeSlider.value = savedSize; fabSizeVal.textContent = savedSize; } + if (savedGap && fabGapSlider) { fabGapSlider.value = savedGap; fabGapVal.textContent = savedGap; } + + // Live preview on slider change + if (fabSizeSlider) { + fabSizeSlider.addEventListener('input', function() { + var v = this.value; + fabSizeVal.textContent = v; + document.documentElement.style.setProperty('--fab-custom-size', v + 'px'); + localStorage.setItem('mc-webui-fab-size', v); + }); + } + if (fabGapSlider) { + fabGapSlider.addEventListener('input', function() { + var v = this.value; + fabGapVal.textContent = v; + document.documentElement.style.setProperty('--fab-custom-gap', v + 'px'); + localStorage.setItem('mc-webui-fab-gap', v); + }); + } + + // Reset position button + if (fabResetPos) { + fabResetPos.addEventListener('click', function() { + localStorage.removeItem('mc-webui-fab-pos'); + localStorage.removeItem('mc-webui-fab-pos-dm'); + var container = document.getElementById('fabContainer'); + if (container) { + container.style.left = ''; + container.style.right = '16px'; + container.style.top = '80px'; + } + }); + } }); diff --git a/app/templates/dm.html b/app/templates/dm.html index 6f5c8a6..7cc1401 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -185,6 +185,9 @@ + @@ -356,6 +359,9 @@ + + + diff --git a/app/templates/index.html b/app/templates/index.html index e50dcab..aff42e4 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -124,6 +124,9 @@ +