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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-04-06 11:59:24 +02:00
parent 6c02220719
commit 60c698deb2
7 changed files with 292 additions and 15 deletions

View File

@@ -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;

View File

@@ -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();
}
}
});
}
// =============================================================================

View File

@@ -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');
}
/**

154
app/static/js/fab-utils.js Normal file
View File

@@ -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);

View File

@@ -473,6 +473,33 @@
</div>
</div>
</div>
<hr>
<h6 class="text-muted mb-3">Quick Access Buttons</h6>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Button size (px)</td>
<td class="pe-0 d-flex align-items-center gap-2" style="width:12rem">
<input type="range" class="form-range flex-grow-1" id="settFabSize" min="28" max="72" step="2" value="56">
<span class="text-muted small" id="settFabSizeVal" style="min-width:2.5rem;text-align:right">56</span>
</td>
</tr>
<tr>
<td class="ps-0">Spacing (px)</td>
<td class="pe-0 d-flex align-items-center gap-2">
<input type="range" class="form-range flex-grow-1" id="settFabGap" min="2" max="24" step="1" value="12">
<span class="text-muted small" id="settFabGapVal" style="min-width:2.5rem;text-align:right">12</span>
</td>
</tr>
<tr>
<td class="ps-0">Position</td>
<td class="pe-0">
<button type="button" class="btn btn-outline-secondary btn-sm" id="settFabResetPos">Reset to default</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -661,6 +688,9 @@
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- FAB Utilities (drag, sizing — must load before app.js) -->
<script src="{{ url_for('static', filename='js/fab-utils.js') }}"></script>
<!-- Custom JS -->
<!-- QR Code generator (for Device Share) -->
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
@@ -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';
}
});
}
});
</script>

View File

@@ -185,6 +185,9 @@
<button class="fab fab-filter" id="dmFilterFab" title="Filter Messages">
<i class="bi bi-funnel-fill"></i>
</button>
<button class="fab fab-settings" id="dmSettingsFab" title="Settings">
<i class="bi bi-gear-fill"></i>
</button>
</div>
</main>
@@ -356,6 +359,9 @@
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- FAB Utilities (drag, sizing) -->
<script src="{{ url_for('static', filename='js/fab-utils.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>

View File

@@ -124,6 +124,9 @@
<button class="fab fab-contacts" data-bs-toggle="modal" data-bs-target="#contactsModal" title="Contact Management">
<i class="bi bi-person-fill"></i>
</button>
<button class="fab fab-settings" data-bs-toggle="modal" data-bs-target="#settingsModal" title="Settings">
<i class="bi bi-gear-fill"></i>
</button>
</div>
<!-- DM Modal (Full Screen) -->