diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7e05c283..0c1df6e8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -36,11 +36,11 @@ "description": "" }, "prompt_summarize_this": { - "message": "Summarize this", + "message": "Summarize", "description": "" }, "prompt_translate_this": { - "message": "Translate this", + "message": "Translate", "description": "" }, "prompt_this": { @@ -853,7 +853,7 @@ "description": "" }, "prompt_add_tags": { - "message": "Add tags to this email", + "message": "Add tags", "description": "" }, "prompt_add_tags_full_text": { @@ -1393,7 +1393,7 @@ "description": "" }, "prompt_spamfilter" : { - "message" : "Detect spam emails", + "message" : "Analyze for spam", "description" : "" }, "prompt_spamfilter_full_text" : { @@ -1500,22 +1500,6 @@ "message": "No", "description": "" }, - "context_menu_mzta-add-tags": { - "message": "Add tags", - "description": "" - }, - "context_menu_mzta-spamfilter": { - "message": "Analyze for spam", - "description": "" - }, - "context_menu_mzta-summarize": { - "message": "Summarize", - "description": "" - }, - "context_menu_mzta-translate": { - "message": "Translate", - "description": "" - }, "noActiveCalendar": { "message": "No editable calendar found!", "description": "" @@ -2274,5 +2258,73 @@ "placeholder_string": { "message": "Placeholder", "description": "" + }, + "menu_order_title": { + "message": "Menu Order", + "description": "Title for the menu order page" + }, + "menu_order_popup_list_title": { + "message": "Popup Menu", + "description": "Header for the popup menu list in the menu order page" + }, + "menu_order_context_list_title": { + "message": "Context Menu", + "description": "Header for the context menu list in the menu order page" + }, + "menu_order_save": { + "message": "Save", + "description": "Save button label in menu order page" + }, + "menu_order_saved": { + "message": "Menu order saved.", + "description": "Message shown after saving menu order" + }, + "menu_order_tab_reading": { + "message": "Reading", + "description": "Sub-tab label for reading view ordering" + }, + "menu_order_tab_composing": { + "message": "Composing", + "description": "Sub-tab label for composing view ordering" + }, + "menu_order_badge_default": { + "message": "Default", + "description": "Badge label for default prompts" + }, + "menu_order_badge_special": { + "message": "Special", + "description": "Badge label for special prompts" + }, + "menu_order_badge_custom": { + "message": "Custom", + "description": "Badge label for custom prompts" + }, + "menu_order_type_reading": { + "message": "Reading", + "description": "Badge label for reading-only prompts" + }, + "menu_order_type_composing": { + "message": "Composing", + "description": "Badge label for composing-only prompts" + }, + "menu_order_type_always": { + "message": "Always", + "description": "Badge label for prompts shown in all contexts" + }, + "menu_order_btn_label": { + "message": "Menu Order", + "description": "Button label to open the menu order page from options" + }, + "menu_order_info": { + "message": "Drag and drop items to reorder them. Use the toggle to show or hide items in each menu.", + "description": "Info text for the menu order page" + }, + "menu_order_active_items": { + "message": "Visible items", + "description": "Section header for active/visible menu items" + }, + "menu_order_hidden_items": { + "message": "Hidden items", + "description": "Section header for hidden menu items" } } \ No newline at end of file diff --git a/js/mzta-menus.js b/js/mzta-menus.js index 1cea4993..b7230250 100644 --- a/js/mzta-menus.js +++ b/js/mzta-menus.js @@ -488,6 +488,7 @@ export class mzta_Menus { is_special: prompt.is_special, position_display: prompt.position_display, position_compose: prompt.position_compose, + position_context: prompt.position_context, }; this.shortcutMenu.push(curr_menu_entry); } @@ -514,8 +515,11 @@ export class mzta_Menus { }, resolve) ); - // Sort alphabetically for context menu + // Sort by position_context if available, otherwise alphabetically contextPrompts.sort((a, b) => { + const posA = a.position_context || 9999; + const posB = b.position_context || 9999; + if (posA !== posB) return posA - posB; const nameA = i18nConditionalGet(a.name); const nameB = i18nConditionalGet(b.name); return nameA.localeCompare(nameB); diff --git a/js/mzta-prompts.js b/js/mzta-prompts.js index 089f7184..3c924af3 100644 --- a/js/mzta-prompts.js +++ b/js/mzta-prompts.js @@ -383,7 +383,7 @@ const specialPrompts = [ api_model: '', is_default: "1", is_special: "1", - show_in: "both", + show_in: "none", }, { id: 'prompt_summarize_email_separator', @@ -400,7 +400,7 @@ const specialPrompts = [ api_model: '', is_default: "1", is_special: "1", - show_in: "both", + show_in: "none", }, { id: 'prompt_translate_this', @@ -488,7 +488,7 @@ export function preparePromptsForExport(prompts, include_api_settings = false){ } if(prompt.is_default == 1){ - let allowedKeys = ['id', 'enabled', 'position_compose', 'position_display', 'need_custom_text', 'show_in']; + let allowedKeys = ['id', 'enabled', 'position_compose', 'position_display', 'position_context', 'need_custom_text', 'show_in']; if(include_api_settings){ allowedKeys.push('api_type'); for (const [integration, options] of Object.entries(integration_options_config)) { @@ -541,6 +541,7 @@ async function getDefaultPrompts_withProps() { prompt.text = browser.i18n.getMessage(prompt.text); prompt.position_display = pos; prompt.position_compose = pos; + prompt.position_context = pos; prompt.enabled = 1; pos++; }) @@ -552,6 +553,7 @@ async function getDefaultPrompts_withProps() { if(prefs._default_prompts_properties?.[prompt.id]){ prompt.position_compose = prefs._default_prompts_properties[prompt.id].position_compose; prompt.position_display = prefs._default_prompts_properties[prompt.id].position_display; + prompt.position_context = prefs._default_prompts_properties[prompt.id]?.position_context || prompt.position_display; prompt.enabled = prefs._default_prompts_properties[prompt.id].enabled; prompt.need_custom_text = prefs._default_prompts_properties[prompt.id].need_custom_text; prompt.chatgpt_web_model = prefs._default_prompts_properties[prompt.id].chatgpt_web_model; @@ -562,6 +564,7 @@ async function getDefaultPrompts_withProps() { }else{ prompt.position_display = pos; prompt.position_compose = pos; + prompt.position_context = pos; prompt.enabled = 1; pos++; } @@ -608,6 +611,7 @@ export async function setDefaultPromptsProperties(prompts) { default_prompts_properties[prompt.id] = { position_compose: (prompt.position_compose === undefined || prompt.position_compose === "undefined") ? "" : prompt.position_compose, position_display: (prompt.position_display === undefined || prompt.position_display === "undefined") ? "" : prompt.position_display, + position_context: (prompt.position_context === undefined || prompt.position_context === "undefined") ? "" : prompt.position_context, enabled: (prompt.enabled === undefined || prompt.enabled === "undefined") ? "" : prompt.enabled, need_custom_text: (prompt.need_custom_text === undefined || prompt.need_custom_text === "undefined") ? "" : prompt.need_custom_text, chatgpt_web_model: (prompt.chatgpt_web_model === undefined || prompt.chatgpt_web_model === "undefined") ? "" : prompt.chatgpt_web_model, @@ -666,6 +670,10 @@ export async function setSpecialPrompts(prompts) { await browser.storage.local.set({_special_prompts: prompts}); } +export function getHiddenSpecialPromptIds() { + return specialPrompts.filter(p => p.show_in === "none").map(p => p.id); +} + export async function getSpamFilterPrompt(){ return (await getSpecialPrompts()).find(prompt => prompt.id == 'prompt_spamfilter'); } diff --git a/options/mzta-options.html b/options/mzta-options.html index ba3391c7..1f3d6eb4 100644 --- a/options/mzta-options.html +++ b/options/mzta-options.html @@ -271,6 +271,7 @@
__MSG_prefs_OptionText_btnManagePrompts_infoline__ __MSG_more_info_string__
+

__MSG_prefsInfoTitle__

diff --git a/options/mzta-options.js b/options/mzta-options.js index 0567e870..5f34710f 100644 --- a/options/mzta-options.js +++ b/options/mzta-options.js @@ -412,6 +412,10 @@ document.addEventListener('DOMContentLoaded', async () => { openTab('/pages/customdataplaceholders/mzta-custom-dataplaceholders.html'); }); + document.getElementById('btnMenuOrder').addEventListener('click', () => { + openTab('/pages/menu_order/mzta-menu-order.html'); + }); + document.getElementById('btnManageTagsInfo').addEventListener('click', () => { openTab('/pages/addtags/mzta-add-tags.html'); }); diff --git a/pages/menu_order/mzta-menu-order.css b/pages/menu_order/mzta-menu-order.css new file mode 100644 index 00000000..1aacf6f4 --- /dev/null +++ b/pages/menu_order/mzta-menu-order.css @@ -0,0 +1,291 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + margin: 20px; + padding: 0; +} + +.page_title { + margin-bottom: 4px; +} + +.info_text { + margin-top: 0; + font-size: 0.9em; + color: #555; +} + +#command_palette { + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +#btnSave { + padding: 6px 20px; + font-size: 1em; + cursor: pointer; +} + +#btnSave:disabled { + opacity: 0.5; + cursor: default; +} + +#msgDisplay { + font-size: 0.9em; + color: #2a7e2a; +} + +#lists_container { + display: flex; + gap: 24px; + align-items: flex-start; +} + +.menu_list_panel { + flex: 1; + min-width: 320px; + border: 1px solid #ccc; + border-radius: 6px; + padding: 12px; +} + +.menu_list_panel h2 { + margin-top: 0; + margin-bottom: 8px; + font-size: 1.1em; +} + +.sub_tabs { + display: flex; + gap: 4px; + margin-bottom: 12px; +} + +.sub_tab { + padding: 4px 14px; + border: 1px solid #ccc; + background: #f5f5f5; + cursor: pointer; + border-radius: 4px 4px 0 0; + font-size: 0.9em; +} + +.sub_tab.active { + background: #fff; + border-bottom-color: #fff; + font-weight: bold; +} + +.section_label { + font-size: 0.85em; + color: #666; + margin: 8px 0 4px 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.hidden_section_label { + border-top: 1px dashed #ccc; + padding-top: 8px; + margin-top: 12px; +} + +.sortable_list, +.hidden_list { + list-style: none; + padding: 0; + margin: 0; + min-height: 40px; +} + +.sortable_item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + margin: 3px 0; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + user-select: none; + transition: background-color 0.15s; +} + +.sortable_item:hover { + background: #f0f4ff; +} + +.sortable_item.dragging { + opacity: 0.4; + border-style: dashed; +} + +.sortable_item.drag-over { + border-top: 2px solid #409df3; +} + +.drag_handle { + cursor: grab; + color: #999; + font-size: 1.1em; + padding: 0 4px; + flex-shrink: 0; +} + +.drag_handle:active { + cursor: grabbing; +} + +.item_toggle { + flex-shrink: 0; + width: 16px; + height: 16px; + cursor: pointer; +} + +.item_name { + flex: 1; + font-size: 0.92em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 0.72em; + font-weight: 600; + text-transform: uppercase; + flex-shrink: 0; +} + +.badge_default { + background: #e0e0e0; + color: #555; +} + +.badge_special { + background: #d4eaff; + color: #1a5fa0; +} + +.badge_custom { + background: #e8f5e9; + color: #2e7d32; +} + +.badge_type { + background: #fff3e0; + color: #e65100; +} + +/* Hidden list items */ +.hidden_list .sortable_item { + opacity: 0.6; + background: #fafafa; +} + +.hidden_list .sortable_item .drag_handle { + visibility: hidden; +} + +.hidden_list .sortable_item:hover { + opacity: 0.8; + background: #f5f5f5; +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + body { + background-color: #1c1b22; + color: rgb(251, 251, 254); + } + + .info_text { + color: #aaa; + } + + #msgDisplay { + color: #5cb85c; + } + + .menu_list_panel { + border-color: #444; + background: #2a2a30; + } + + .sub_tab { + border-color: #555; + background: #333; + color: #ccc; + } + + .sub_tab.active { + background: #2a2a30; + border-bottom-color: #2a2a30; + color: #fff; + } + + .section_label { + color: #999; + } + + .hidden_section_label { + border-top-color: #555; + } + + .sortable_item { + border-color: #444; + background: #333; + color: #eee; + } + + .sortable_item:hover { + background: #3a3a44; + } + + .sortable_item.drag-over { + border-top-color: #409df3; + } + + .drag_handle { + color: #777; + } + + .badge_default { + background: #444; + color: #bbb; + } + + .badge_special { + background: #1a3a5c; + color: #7ab8f5; + } + + .badge_custom { + background: #1b3a1e; + color: #81c784; + } + + .badge_type { + background: #3d2800; + color: #ffb74d; + } + + .hidden_list .sortable_item { + background: #2a2a2e; + } + + .hidden_list .sortable_item:hover { + background: #333; + } + + a:link { color: #409eff; } + a:visited { color: #409eff; } + a:hover { color: #66b1ff; } + a:active { color: #66b1ff; } +} diff --git a/pages/menu_order/mzta-menu-order.html b/pages/menu_order/mzta-menu-order.html new file mode 100644 index 00000000..ad5fe728 --- /dev/null +++ b/pages/menu_order/mzta-menu-order.html @@ -0,0 +1,39 @@ + + + + + ThunderAI - __MSG_menu_order_title__ + + + + +

__MSG_menu_order_title__

+

__MSG_menu_order_info__

+
+ + +
+
+ + +
+ + + + diff --git a/pages/menu_order/mzta-menu-order.js b/pages/menu_order/mzta-menu-order.js new file mode 100644 index 00000000..3335fa0e --- /dev/null +++ b/pages/menu_order/mzta-menu-order.js @@ -0,0 +1,317 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 - 2026 Mic (m@micz.it) + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { getPrompts, setDefaultPromptsProperties, setCustomPrompts, setSpecialPrompts, getHiddenSpecialPromptIds } from '../../js/mzta-prompts.js'; +import { i18nConditionalGet } from '../../js/mzta-utils.js'; + +let allPrompts = []; +let currentPopupView = 'display'; // 'display' or 'compose' +let hasUnsavedChanges = false; + +document.addEventListener('DOMContentLoaded', async () => { + allPrompts = await getPrompts(false, [], true); + + // Exclude special prompts that are defined with show_in: "none" (internal prompts, not user-toggleable) + const hiddenSpecialIds = getHiddenSpecialPromptIds(); + allPrompts = allPrompts.filter(p => !hiddenSpecialIds.includes(p.id)); + + // Resolve i18n names and assign initial position_context if missing + let contextPos = 1; + const sortedForContext = [...allPrompts].sort((a, b) => { + const nameA = i18nConditionalGet(a.name); + const nameB = i18nConditionalGet(b.name); + return nameA.localeCompare(nameB); + }); + sortedForContext.forEach(p => { + if (p.position_context === undefined || p.position_context === '' || p.position_context === 'undefined') { + p.position_context = contextPos; + } + contextPos++; + }); + + // Resolve display names + allPrompts.forEach(p => { + p._displayName = i18nConditionalGet(p.name); + }); + + renderPopupList(); + renderContextList(); + initSubTabs(); + + document.getElementById('btnSave').addEventListener('click', saveAll); + + i18n.updateDocument(); +}); + +// ==================== Sub-tabs ==================== + +function initSubTabs() { + document.querySelectorAll('.sub_tab').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.sub_tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentPopupView = btn.dataset.view; + renderPopupList(); + }); + }); +} + +// ==================== Render Popup List ==================== + +function renderPopupList() { + const posKey = currentPopupView === 'display' ? 'position_display' : 'position_compose'; + + const activeItems = allPrompts.filter(p => { + const showIn = p.show_in || 'popup'; + return showIn === 'popup' || showIn === 'both'; + }); + const hiddenItems = allPrompts.filter(p => { + const showIn = p.show_in || 'popup'; + return showIn !== 'popup' && showIn !== 'both'; + }); + + activeItems.sort((a, b) => (a[posKey] || 9999) - (b[posKey] || 9999)); + hiddenItems.sort((a, b) => a._displayName.localeCompare(b._displayName)); + + const activeList = document.getElementById('popup_list'); + const hiddenList = document.getElementById('popup_list_hidden'); + + renderListItems(activeList, activeItems, 'popup', true); + renderListItems(hiddenList, hiddenItems, 'popup', false); + + initDragAndDrop(activeList, posKey); +} + +// ==================== Render Context List ==================== + +function renderContextList() { + // Only show items with type 0 or 1 (not composing-only) + const contextEligible = allPrompts.filter(p => String(p.type) === '0' || String(p.type) === '1'); + + const activeItems = contextEligible.filter(p => { + const showIn = p.show_in || 'popup'; + return showIn === 'context' || showIn === 'both'; + }); + const hiddenItems = contextEligible.filter(p => { + const showIn = p.show_in || 'popup'; + return showIn !== 'context' && showIn !== 'both'; + }); + + activeItems.sort((a, b) => (a.position_context || 9999) - (b.position_context || 9999)); + hiddenItems.sort((a, b) => a._displayName.localeCompare(b._displayName)); + + const activeList = document.getElementById('context_list'); + const hiddenList = document.getElementById('context_list_hidden'); + + renderListItems(activeList, activeItems, 'context', true); + renderListItems(hiddenList, hiddenItems, 'context', false); + + initDragAndDrop(activeList, 'position_context'); +} + +// ==================== Render List Items ==================== + +function renderListItems(listEl, items, menuType, isActive) { + listEl.innerHTML = ''; + items.forEach(prompt => { + const li = document.createElement('li'); + li.classList.add('sortable_item'); + li.dataset.id = prompt.id; + if (isActive) { + li.draggable = true; + } + + // Drag handle + const handle = document.createElement('span'); + handle.classList.add('drag_handle'); + handle.textContent = '\u2630'; + li.appendChild(handle); + + // Toggle checkbox + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.classList.add('item_toggle'); + toggle.checked = isActive; + toggle.addEventListener('change', () => { + toggleShowIn(prompt, menuType, toggle.checked); + }); + li.appendChild(toggle); + + // Name + const nameSpan = document.createElement('span'); + nameSpan.classList.add('item_name'); + nameSpan.textContent = prompt._displayName; + li.appendChild(nameSpan); + + // Type badge + const typeBadge = document.createElement('span'); + typeBadge.classList.add('badge', 'badge_type'); + if (String(prompt.type) === '1') { + typeBadge.textContent = browser.i18n.getMessage('menu_order_type_reading'); + } else if (String(prompt.type) === '2') { + typeBadge.textContent = browser.i18n.getMessage('menu_order_type_composing'); + } else { + typeBadge.textContent = browser.i18n.getMessage('menu_order_type_always'); + } + li.appendChild(typeBadge); + + // Source badge + const sourceBadge = document.createElement('span'); + sourceBadge.classList.add('badge'); + if (String(prompt.is_special) === '1') { + sourceBadge.classList.add('badge_special'); + sourceBadge.textContent = browser.i18n.getMessage('menu_order_badge_special'); + } else if (String(prompt.is_default) === '1') { + sourceBadge.classList.add('badge_default'); + sourceBadge.textContent = browser.i18n.getMessage('menu_order_badge_default'); + } else { + sourceBadge.classList.add('badge_custom'); + sourceBadge.textContent = browser.i18n.getMessage('menu_order_badge_custom'); + } + li.appendChild(sourceBadge); + + listEl.appendChild(li); + }); +} + +// ==================== Toggle show_in ==================== + +function toggleShowIn(prompt, menuType, isOn) { + const current = prompt.show_in || 'popup'; + + if (menuType === 'popup') { + if (isOn) { + prompt.show_in = (current === 'none') ? 'popup' : (current === 'context') ? 'both' : current; + } else { + prompt.show_in = (current === 'popup') ? 'none' : (current === 'both') ? 'context' : current; + } + } else { // context + if (isOn) { + prompt.show_in = (current === 'none') ? 'context' : (current === 'popup') ? 'both' : current; + } else { + prompt.show_in = (current === 'context') ? 'none' : (current === 'both') ? 'popup' : current; + } + } + + markUnsaved(); + renderPopupList(); + renderContextList(); +} + +// ==================== Drag and Drop ==================== + +function initDragAndDrop(listEl, positionKey) { + let draggedItem = null; + + listEl.addEventListener('dragstart', (e) => { + const li = e.target.closest('.sortable_item'); + if (!li) return; + draggedItem = li; + draggedItem.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', li.dataset.id); + }); + + listEl.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (!draggedItem) return; + + // Remove previous drag-over indicators + listEl.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); + + const afterElement = getDragAfterElement(listEl, e.clientY); + if (afterElement) { + afterElement.classList.add('drag-over'); + listEl.insertBefore(draggedItem, afterElement); + } else { + listEl.appendChild(draggedItem); + } + }); + + listEl.addEventListener('dragleave', (e) => { + if (e.target.classList) { + e.target.classList.remove('drag-over'); + } + }); + + listEl.addEventListener('drop', (e) => { + e.preventDefault(); + listEl.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); + }); + + listEl.addEventListener('dragend', () => { + if (draggedItem) { + draggedItem.classList.remove('dragging'); + updatePositionsFromDOM(listEl, positionKey); + draggedItem = null; + markUnsaved(); + } + }); +} + +function getDragAfterElement(container, y) { + const draggableElements = [...container.querySelectorAll('.sortable_item:not(.dragging)')]; + return draggableElements.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } + return closest; + }, { offset: Number.NEGATIVE_INFINITY }).element; +} + +function updatePositionsFromDOM(listEl, positionKey) { + const items = listEl.querySelectorAll('.sortable_item'); + items.forEach((li, index) => { + const promptId = li.dataset.id; + const prompt = allPrompts.find(p => p.id === promptId); + if (prompt) { + prompt[positionKey] = index + 1; + } + }); +} + +// ==================== Save ==================== + +async function saveAll() { + const btnSave = document.getElementById('btnSave'); + const msgDisplay = document.getElementById('msgDisplay'); + btnSave.disabled = true; + + const defaultPromptsToSave = allPrompts.filter(p => String(p.is_default) === '1' && String(p.is_special) !== '1'); + const customPromptsToSave = allPrompts.filter(p => String(p.is_default) === '0' && String(p.is_special) !== '1'); + const specialPromptsToSave = allPrompts.filter(p => String(p.is_special) === '1'); + + await setDefaultPromptsProperties(defaultPromptsToSave); + await setCustomPrompts(customPromptsToSave); + await setSpecialPrompts(specialPromptsToSave); + + await browser.runtime.sendMessage({ command: "reload_menus" }); + + hasUnsavedChanges = false; + msgDisplay.textContent = browser.i18n.getMessage('menu_order_saved'); + setTimeout(() => { msgDisplay.textContent = ''; }, 3000); +} + +function markUnsaved() { + hasUnsavedChanges = true; + document.getElementById('btnSave').disabled = false; + document.getElementById('msgDisplay').textContent = ''; +}