feat: configurable route popup + toast display time and position

Users complained that the route popup under group-chat messages and the
top-of-page notification toasts auto-close before they can read them, and
some users wanted to move the toasts out of the top-left corner.

Adds to Settings modal:
- Group Chat tab: route popup auto-close timeout + "don't close" switch
  (applies to both channel popups and DM route popups)
- New Interface tab: toast auto-close timeout, "don't close" switch, and
  five position options (top-left/top-right/bottom-left/bottom-right/center)

Persisted as chat_settings (extended) and a new ui_settings row in the
app_settings table, with /api/chat/settings and /api/ui/settings endpoints.
Default toast delay bumped from 1.5s to 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-04-15 08:12:51 +02:00
parent 77c3ffa5c2
commit bd0a6b492e
8 changed files with 429 additions and 28 deletions
+98 -4
View File
@@ -272,9 +272,26 @@ DM_RETRY_DEFAULTS = {
}
CHAT_SETTINGS_DEFAULTS = {
'quote_max_bytes': 20, # max UTF-8 bytes for truncated quote
'quote_max_bytes': 20, # max UTF-8 bytes for truncated quote
'path_popup_timeout_sec': 8, # auto-close timeout for route popup (channel + DM)
'path_popup_no_autoclose': False, # when True, route popup stays open until click-outside
}
# Keys in chat_settings that carry a boolean value
CHAT_SETTINGS_BOOL_KEYS = {'path_popup_no_autoclose'}
# =============================================================================
# UI Settings (app-wide interface behavior — toast notifications, etc.)
# =============================================================================
UI_SETTINGS_DEFAULTS = {
'toast_timeout_sec': 2.0, # auto-hide delay for notification toasts
'toast_no_autoclose': False, # when True, toasts stay until dismissed
'toast_position': 'top-left', # one of TOAST_POSITIONS
}
TOAST_POSITIONS = {'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'}
def get_dm_retry_settings() -> dict:
"""Get DM retry settings from database."""
@@ -320,6 +337,28 @@ def save_chat_settings(settings: dict) -> bool:
return False
def get_ui_settings() -> dict:
"""Get UI (interface) settings from database."""
db = _get_db()
if db:
saved = db.get_setting_json('ui_settings', {})
return {**UI_SETTINGS_DEFAULTS, **saved}
return dict(UI_SETTINGS_DEFAULTS)
def save_ui_settings(settings: dict) -> bool:
"""Save UI settings to database."""
db = _get_db()
if not db:
return False
try:
db.set_setting_json('ui_settings', settings)
return True
except Exception as e:
logger.error(f"Failed to save UI settings: {e}")
return False
@api_bp.route('/messages', methods=['GET'])
def get_messages():
"""
@@ -2560,9 +2599,17 @@ def set_chat_config():
valid_keys = set(CHAT_SETTINGS_DEFAULTS.keys())
settings = {}
for key in valid_keys:
if key in data:
val = data[key]
if not isinstance(val, (int, float)) or val < 1:
if key not in data:
continue
val = data[key]
if key in CHAT_SETTINGS_BOOL_KEYS:
if not isinstance(val, bool):
return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400
settings[key] = val
else:
if isinstance(val, bool) or not isinstance(val, (int, float)) or val < 1:
return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400
if key == 'path_popup_timeout_sec' and val > 60:
return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400
settings[key] = int(val)
@@ -2576,6 +2623,53 @@ def set_chat_config():
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/ui/settings', methods=['GET'])
def get_ui_config():
"""Get UI (interface) settings."""
try:
return jsonify(get_ui_settings()), 200
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/ui/settings', methods=['POST'])
def set_ui_config():
"""Update UI (interface) settings."""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Missing JSON body'}), 400
settings = {}
if 'toast_timeout_sec' in data:
val = data['toast_timeout_sec']
if not isinstance(val, (int, float)) or isinstance(val, bool) or val < 1 or val > 60:
return jsonify({'success': False, 'error': 'Invalid value for toast_timeout_sec'}), 400
settings['toast_timeout_sec'] = float(val)
if 'toast_no_autoclose' in data:
val = data['toast_no_autoclose']
if not isinstance(val, bool):
return jsonify({'success': False, 'error': 'Invalid value for toast_no_autoclose'}), 400
settings['toast_no_autoclose'] = val
if 'toast_position' in data:
val = data['toast_position']
if val not in TOAST_POSITIONS:
return jsonify({'success': False, 'error': 'Invalid value for toast_position'}), 400
settings['toast_position'] = val
if not settings:
return jsonify({'success': False, 'error': 'No valid settings provided'}), 400
if save_ui_settings(settings):
return jsonify({**get_ui_settings(), 'success': True}), 200
return jsonify({'success': False, 'error': 'Failed to save settings'}), 500
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/dm/updates', methods=['GET'])
def get_dm_updates():
"""
+141 -10
View File
@@ -1541,9 +1541,15 @@ function showPathsPopup(element, encodedPaths) {
popup.style.left = '0';
}
// Auto-dismiss after 8 seconds or on outside tap
// Auto-dismiss after configured timeout (unless disabled) or on outside tap
const dismiss = () => popup.remove();
setTimeout(dismiss, 8000);
const cfg = window.chatSettingsCache || {};
const noAutoclose = !!cfg.path_popup_no_autoclose;
const timeoutSec = parseInt(cfg.path_popup_timeout_sec, 10);
if (!noAutoclose) {
const ms = (isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 8) * 1000;
setTimeout(dismiss, ms);
}
document.addEventListener('click', function handler(e) {
if (!element.contains(e.target)) {
dismiss();
@@ -2077,20 +2083,32 @@ function openCoordPicker() {
// --- Chat Settings ---
const CHAT_SETTINGS_DEFAULTS = {
quote_max_bytes: 20
quote_max_bytes: 20,
path_popup_timeout_sec: 8,
path_popup_no_autoclose: false
};
const CHAT_SETTINGS_FIELDS = {
quote_max_bytes: 'settQuoteMaxBytes'
const CHAT_SETTINGS_INT_FIELDS = {
quote_max_bytes: 'settQuoteMaxBytes',
path_popup_timeout_sec: 'settPathPopupTimeout'
};
const CHAT_SETTINGS_BOOL_FIELDS = {
path_popup_no_autoclose: 'settPathPopupNoAutoclose'
};
let chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS };
window.chatSettingsCache = chatSettingsCache;
function populateChatSettingsForm(data) {
for (const [key, elId] of Object.entries(CHAT_SETTINGS_FIELDS)) {
for (const [key, elId] of Object.entries(CHAT_SETTINGS_INT_FIELDS)) {
const el = document.getElementById(elId);
if (el) el.value = data[key] ?? CHAT_SETTINGS_DEFAULTS[key];
}
for (const [key, elId] of Object.entries(CHAT_SETTINGS_BOOL_FIELDS)) {
const el = document.getElementById(elId);
if (el) el.checked = !!(data[key] ?? CHAT_SETTINGS_DEFAULTS[key]);
}
}
async function loadChatSettings() {
@@ -2099,6 +2117,7 @@ async function loadChatSettings() {
if (resp.ok) {
const data = await resp.json();
chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data };
window.chatSettingsCache = chatSettingsCache;
populateChatSettingsForm(chatSettingsCache);
}
} catch (e) {
@@ -2108,7 +2127,7 @@ async function loadChatSettings() {
async function saveChatSettings() {
const payload = {};
for (const [key, elId] of Object.entries(CHAT_SETTINGS_FIELDS)) {
for (const [key, elId] of Object.entries(CHAT_SETTINGS_INT_FIELDS)) {
const el = document.getElementById(elId);
const val = parseInt(el.value, 10);
if (isNaN(val) || val < parseInt(el.min) || val > parseInt(el.max)) {
@@ -2118,6 +2137,10 @@ async function saveChatSettings() {
}
payload[key] = val;
}
for (const [key, elId] of Object.entries(CHAT_SETTINGS_BOOL_FIELDS)) {
const el = document.getElementById(elId);
if (el) payload[key] = !!el.checked;
}
try {
const resp = await fetch('/api/chat/settings', {
method: 'POST',
@@ -2127,6 +2150,95 @@ async function saveChatSettings() {
if (resp.ok) {
const data = await resp.json();
chatSettingsCache = { ...CHAT_SETTINGS_DEFAULTS, ...data };
window.chatSettingsCache = chatSettingsCache;
showNotification('Settings saved', 'success');
} else {
const err = await resp.json();
showNotification(err.error || 'Failed to save', 'danger');
}
} catch (e) {
showNotification('Failed to save settings', 'danger');
}
}
// --- UI (Interface) Settings ---
const UI_SETTINGS_DEFAULTS = {
toast_timeout_sec: 2,
toast_no_autoclose: false,
toast_position: 'top-left'
};
const TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
let uiSettingsCache = { ...UI_SETTINGS_DEFAULTS };
window.uiSettingsCache = uiSettingsCache;
function applyToastPosition(position) {
const classes = TOAST_POSITION_CLASSES[position] || TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
window.applyToastPosition = applyToastPosition;
function populateUiSettingsForm(data) {
const t = document.getElementById('settToastTimeout');
if (t) t.value = data.toast_timeout_sec ?? UI_SETTINGS_DEFAULTS.toast_timeout_sec;
const noClose = document.getElementById('settToastNoAutoclose');
if (noClose) noClose.checked = !!(data.toast_no_autoclose ?? UI_SETTINGS_DEFAULTS.toast_no_autoclose);
const pos = document.getElementById('settToastPosition');
if (pos) pos.value = data.toast_position ?? UI_SETTINGS_DEFAULTS.toast_position;
}
async function loadUiSettings() {
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
uiSettingsCache = { ...UI_SETTINGS_DEFAULTS, ...data };
window.uiSettingsCache = uiSettingsCache;
applyToastPosition(uiSettingsCache.toast_position);
populateUiSettingsForm(uiSettingsCache);
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
}
async function saveUiSettings() {
const timeoutEl = document.getElementById('settToastTimeout');
const timeout = parseFloat(timeoutEl.value);
if (isNaN(timeout) || timeout < 1 || timeout > 60) {
showNotification('Invalid auto-close duration', 'danger');
timeoutEl.focus();
return;
}
const payload = {
toast_timeout_sec: timeout,
toast_no_autoclose: !!document.getElementById('settToastNoAutoclose').checked,
toast_position: document.getElementById('settToastPosition').value
};
try {
const resp = await fetch('/api/ui/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
const data = await resp.json();
uiSettingsCache = { ...UI_SETTINGS_DEFAULTS, ...data };
window.uiSettingsCache = uiSettingsCache;
applyToastPosition(uiSettingsCache.toast_position);
showNotification('Settings saved', 'success');
} else {
const err = await resp.json();
@@ -2212,6 +2324,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadDeviceConfig();
loadDmRetrySettings();
loadChatSettings();
loadUiSettings();
});
settingsModal.addEventListener('shown.bs.modal', () => {
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
@@ -2244,6 +2357,18 @@ document.addEventListener('DOMContentLoaded', () => {
populateChatSettingsForm(CHAT_SETTINGS_DEFAULTS);
});
const uiSettingsForm = document.getElementById('uiSettingsForm');
if (uiSettingsForm) {
uiSettingsForm.addEventListener('submit', (e) => {
e.preventDefault();
saveUiSettings();
});
}
document.getElementById('uiSettingsResetBtn')?.addEventListener('click', () => {
populateUiSettingsForm(UI_SETTINGS_DEFAULTS);
});
// --- Device Settings ---
const devicePublicInfoForm = document.getElementById('devicePublicInfoForm');
if (devicePublicInfoForm) {
@@ -2279,8 +2404,9 @@ document.addEventListener('DOMContentLoaded', () => {
bootstrap.Modal.getInstance(document.getElementById('coordPickerModal'))?.hide();
});
// Load chat settings cache on startup (for quote dialog)
// Load settings caches on startup (for quote dialog, path popup, toast behavior)
loadChatSettings();
loadUiSettings();
});
/**
@@ -2648,9 +2774,14 @@ function showNotification(message, type = 'info') {
toastBody.textContent = message;
toastEl.className = `toast bg-${type} text-white`;
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: true,
delay: 1500
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
+46 -3
View File
@@ -10,6 +10,44 @@
* - Mobile-first design
*/
// --- UI settings bootstrap (for standalone contact pages that don't load app.js) ---
(function initContactsUiSettings() {
const TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const ALL_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
function apply(position) {
const classes = TOAST_POSITION_CLASSES[position] || TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
ALL_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
document.addEventListener('DOMContentLoaded', async () => {
// If app.js is on the page, it owns settings loading and will update all
// [data-toast-container] elements (including ours) once its fetch resolves.
if (typeof window.applyToastPosition === 'function') return;
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = data;
apply(data.toast_position || 'top-left');
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
});
})();
// =============================================================================
// Global Navigation Helper
// =============================================================================
@@ -1783,10 +1821,15 @@ function showToast(message, type = 'info') {
toastEl.classList.add('bg-info', 'text-white');
}
// Show toast
// Show toast (honors ui_settings: timeout + no-autoclose; position handled by toast-container classes)
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: true,
delay: 1500
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
+76 -3
View File
@@ -3,6 +3,64 @@
* Full-page DM view functionality
*/
// --- Settings caches (standalone page — no app.js on this route) ---
const DM_CHAT_SETTINGS_DEFAULTS = {
path_popup_timeout_sec: 8,
path_popup_no_autoclose: false
};
const DM_UI_SETTINGS_DEFAULTS = {
toast_timeout_sec: 2,
toast_no_autoclose: false,
toast_position: 'top-left'
};
const DM_TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const DM_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
window.chatSettingsCache = window.chatSettingsCache || { ...DM_CHAT_SETTINGS_DEFAULTS };
window.uiSettingsCache = window.uiSettingsCache || { ...DM_UI_SETTINGS_DEFAULTS };
function dmApplyToastPosition(position) {
const classes = DM_TOAST_POSITION_CLASSES[position] || DM_TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
DM_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
async function loadDmChatSettings() {
try {
const resp = await fetch('/api/chat/settings');
if (resp.ok) {
const data = await resp.json();
window.chatSettingsCache = { ...DM_CHAT_SETTINGS_DEFAULTS, ...data };
}
} catch (e) {
console.error('Failed to load chat settings:', e);
}
}
async function loadDmUiSettings() {
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = { ...DM_UI_SETTINGS_DEFAULTS, ...data };
dmApplyToastPosition(window.uiSettingsCache.toast_position);
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
}
// State variables
let currentConversationId = null;
let currentRecipient = null;
@@ -202,6 +260,10 @@ document.addEventListener('DOMContentLoaded', async function() {
// Force reflow to ensure proper layout calculation
document.body.offsetHeight;
// Load settings caches (path popup timeout + toast behavior/position)
loadDmChatSettings();
loadDmUiSettings();
// Load last seen timestamps from server
await loadDmLastSeenTimestampsFromServer();
@@ -1478,7 +1540,13 @@ function showDmRoutePopup(element, hexPath, hashSize) {
element.appendChild(popup);
const dismiss = () => popup.remove();
setTimeout(dismiss, 8000);
const cfg = window.chatSettingsCache || {};
const noAutoclose = !!cfg.path_popup_no_autoclose;
const timeoutSec = parseInt(cfg.path_popup_timeout_sec, 10);
if (!noAutoclose) {
const ms = (isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 8) * 1000;
setTimeout(dismiss, ms);
}
document.addEventListener('click', function handler(e) {
if (!element.contains(e.target)) {
dismiss();
@@ -1725,9 +1793,14 @@ function showNotification(message, type = 'info') {
}
}
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: true,
delay: 1500
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
+62 -2
View File
@@ -379,6 +379,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChat" type="button">Group Chat</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsInterface" type="button">Interface</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAppearance" type="button">Appearance</button>
</li>
@@ -563,12 +566,69 @@
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2">Route popup</h6>
<p class="text-muted small mb-2">The popup shown when tapping "SNR | Hops" under a message. Also applies to DMs.</p>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Auto-close after (s) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Seconds before the route popup closes automatically"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settPathPopupTimeout" min="1" max="60" value="8"></td>
</tr>
<tr>
<td class="ps-0">Don't close automatically <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Popup stays open until you tap outside it"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settPathPopupNoAutoclose">
</div>
</td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="chatSettingsResetBtn">Reset to defaults</button>
</div>
</form>
</div>
<div class="tab-pane fade" id="tabSettingsInterface">
<form id="uiSettingsForm">
<h6 class="text-muted mb-2">Notifications</h6>
<p class="text-muted small mb-2">Controls the small toasts shown after actions (e.g. "Advert Sent", errors).</p>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Auto-close after (s) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Seconds before a notification closes automatically"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settToastTimeout" min="1" max="60" step="0.5" value="2"></td>
</tr>
<tr>
<td class="ps-0">Don't close automatically <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Notifications stay until dismissed via their close button"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settToastNoAutoclose">
</div>
</td>
</tr>
<tr>
<td class="ps-0">Position on screen</td>
<td class="pe-0">
<select class="form-select form-select-sm" id="settToastPosition">
<option value="top-left">Top left</option>
<option value="top-right">Top right</option>
<option value="bottom-left">Bottom left</option>
<option value="bottom-right">Bottom right</option>
<option value="center">Center</option>
</select>
</td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="uiSettingsResetBtn">Reset to defaults</button>
</div>
</form>
</div>
<div class="tab-pane fade" id="tabSettingsAppearance">
<h6 class="text-muted mb-3">Theme</h6>
<div class="d-flex flex-column gap-2">
@@ -798,8 +858,8 @@
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">mc-webui</strong>
+2 -2
View File
@@ -145,8 +145,8 @@
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" data-toast-container>
<div id="contactToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Contact Management</strong>
+2 -2
View File
@@ -277,8 +277,8 @@
</div>
</div>
<!-- Toast container for notifications (shared across all contact pages) -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<!-- Toast container for notifications (shared across all contact pages; position via JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="contactToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Contact Management</strong>
+2 -2
View File
@@ -331,8 +331,8 @@
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">mc-webui</strong>