diff --git a/app/css/preferences.css b/app/css/preferences.css index 36579cd83..70692dd0a 100644 --- a/app/css/preferences.css +++ b/app/css/preferences.css @@ -83,6 +83,74 @@ body > div > :last-child { margin-right: 32px; } +.custom-messages-section > p { + margin: 0; + max-width: 420px; +} + +.custom-messages-group + .custom-messages-group { + margin-top: 24px; +} + +.custom-messages-group > h3 { + font-size: 13px; + font-weight: normal; + margin: 0 0 12px; +} + +.custom-messages-table { + border-collapse: collapse; + margin-bottom: 12px; + width: 100%; +} + +.custom-messages-table th, +.custom-messages-table td { + border-bottom: 1px solid var(--hr-color); + padding: 8px 12px 8px 0; + text-align: left; + vertical-align: middle; +} + +body[dir=rtl] .custom-messages-table th, +body[dir=rtl] .custom-messages-table td { + padding: 8px 0 8px 12px; + text-align: right; +} + +.custom-messages-table th { + font-weight: normal; +} + +.custom-messages-table th:nth-child(2), +.custom-messages-table td:nth-child(2), +.custom-messages-table th:nth-child(3), +.custom-messages-table td:nth-child(3) { + white-space: nowrap; + width: 1%; +} + +.custom-messages-table input[type="text"] { + box-sizing: border-box; + width: 100%; +} + +.custom-messages-enabled { + align-items: center; + display: inline-flex; + justify-content: center; + min-width: 18px; +} + +.custom-messages-enabled > label { + display: inline-block; +} + +.custom-messages-add, +.custom-messages-delete { + white-space: nowrap; +} + .schedule { display: grid; font-size: 13px; diff --git a/app/custom-messages.js b/app/custom-messages.js new file mode 100644 index 000000000..aaa2b4583 --- /dev/null +++ b/app/custom-messages.js @@ -0,0 +1,214 @@ +const DEFAULT_LONG_BREAK_TITLE = 'Custom' + +function normalizeMicrobreakIdeas (ideas = []) { + return ideas.map(idea => ({ + data: typeof idea?.data === 'string' ? idea.data : '', + enabled: idea?.enabled !== false + })) +} + +function normalizeBreakIdeas (ideas = []) { + return ideas.map(idea => { + const data = Array.isArray(idea?.data) ? idea.data : ['', ''] + return { + data: [data[0] || '', data[1] || ''], + enabled: idea?.enabled !== false + } + }) +} + +function createTableCell (content) { + const cell = document.createElement('td') + if (content && typeof content.nodeType === 'number') { + cell.appendChild(content) + } else { + cell.textContent = content + } + return cell +} + +export default class CustomMessages { + constructor ({ root, settings, saveSettings, translate, onHeightChange }) { + this.root = root + this.saveSettings = saveSettings + this.translate = translate + this.onHeightChange = onHeightChange + this.microbreakIdeas = normalizeMicrobreakIdeas(settings.microbreakIdeas) + this.breakIdeas = normalizeBreakIdeas(settings.breakIdeas) + } + + async render () { + this.root.replaceChildren( + await this.#createSection({ + titleKey: 'preferences.settings.microbreakMessages', + titleFallback: 'Mini break messages', + ideas: this.microbreakIdeas, + onAdd: async () => { + this.microbreakIdeas.push({ data: '', enabled: true }) + this.saveSettings('microbreakIdeas', this.microbreakIdeas) + await this.render() + this.root.querySelector('[data-autofocus="microbreakIdeas"]')?.focus() + }, + renderRow: async (idea, index) => this.#createMicrobreakRow(idea, index) + }), + await this.#createSection({ + titleKey: 'preferences.settings.longBreakMessages', + titleFallback: 'Long break messages', + ideas: this.breakIdeas, + onAdd: async () => { + this.breakIdeas.push({ data: [DEFAULT_LONG_BREAK_TITLE, ''], enabled: true }) + this.saveSettings('breakIdeas', this.breakIdeas) + await this.render() + this.root.querySelector('[data-autofocus="breakIdeas"]')?.focus() + }, + renderRow: async (idea, index) => this.#createBreakRow(idea, index) + }) + ) + + this.onHeightChange() + } + + async #createSection ({ titleKey, titleFallback, ideas, onAdd, renderRow }) { + const section = document.createElement('section') + section.className = 'custom-messages-group' + + const title = document.createElement('h3') + title.textContent = await this.translate(titleKey, titleFallback) + + const table = document.createElement('table') + table.className = 'custom-messages-table' + + const head = document.createElement('thead') + const headRow = document.createElement('tr') + headRow.append( + createTableCell(await this.translate('preferences.settings.customMessagesMessage', 'Message')), + createTableCell(await this.translate('preferences.settings.customMessagesEnabled', 'Enabled')), + createTableCell(await this.translate('preferences.settings.customMessagesDelete', 'Delete')) + ) + head.appendChild(headRow) + + const body = document.createElement('tbody') + for (const [index, idea] of ideas.entries()) { + body.appendChild(await renderRow(idea, index)) + } + + table.append(head, body) + + const addButton = document.createElement('button') + addButton.type = 'button' + addButton.className = 'custom-messages-add' + addButton.textContent = await this.translate('preferences.settings.addNewMessage', 'Add new message') + addButton.onclick = onAdd + + section.append(title, table, addButton) + return section + } + + async #createMicrobreakRow (idea, index) { + const row = document.createElement('tr') + + const messageInput = document.createElement('input') + messageInput.type = 'text' + messageInput.value = idea.data + if (idea.data === '') { + messageInput.dataset.autofocus = 'microbreakIdeas' + } + messageInput.oninput = () => { + this.microbreakIdeas[index].data = messageInput.value + this.saveSettings('microbreakIdeas', this.microbreakIdeas) + } + + const enabledControl = await this.#createEnabledControl({ + checked: idea.enabled, + scope: 'microbreakIdeas', + index, + onChange: checked => { + this.microbreakIdeas[index].enabled = checked + this.saveSettings('microbreakIdeas', this.microbreakIdeas) + } + }) + + const deleteButton = document.createElement('button') + deleteButton.type = 'button' + deleteButton.className = 'custom-messages-delete' + deleteButton.textContent = await this.translate('preferences.settings.customMessagesDelete', 'Delete') + deleteButton.onclick = async () => { + this.microbreakIdeas.splice(index, 1) + this.saveSettings('microbreakIdeas', this.microbreakIdeas) + await this.render() + } + + row.append( + createTableCell(messageInput), + createTableCell(enabledControl), + createTableCell(deleteButton) + ) + + return row + } + + async #createBreakRow (idea, index) { + const row = document.createElement('tr') + + const messageInput = document.createElement('input') + messageInput.type = 'text' + messageInput.value = idea.data[1] + if (idea.data[1] === '') { + messageInput.dataset.autofocus = 'breakIdeas' + } + messageInput.oninput = () => { + this.breakIdeas[index].data[1] = messageInput.value + this.saveSettings('breakIdeas', this.breakIdeas) + } + + const enabledControl = await this.#createEnabledControl({ + checked: idea.enabled, + scope: 'breakIdeas', + index, + onChange: checked => { + this.breakIdeas[index].enabled = checked + this.saveSettings('breakIdeas', this.breakIdeas) + } + }) + + const deleteButton = document.createElement('button') + deleteButton.type = 'button' + deleteButton.className = 'custom-messages-delete' + deleteButton.textContent = await this.translate('preferences.settings.customMessagesDelete', 'Delete') + deleteButton.onclick = async () => { + this.breakIdeas.splice(index, 1) + this.saveSettings('breakIdeas', this.breakIdeas) + await this.render() + } + + row.append( + createTableCell(messageInput), + createTableCell(enabledControl), + createTableCell(deleteButton) + ) + + return row + } + + async #createEnabledControl ({ checked, scope, index, onChange }) { + const wrapper = document.createElement('div') + wrapper.className = 'custom-messages-enabled' + + const checkboxId = `${scope}-enabled-${index}` + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.id = checkboxId + checkbox.dataset.customMessageControl = 'true' + checkbox.checked = checked + checkbox.onchange = () => { + onChange(checkbox.checked) + } + + const label = document.createElement('label') + label.htmlFor = checkboxId + label.title = await this.translate('preferences.settings.customMessagesEnabled', 'Enabled') + + wrapper.append(checkbox, label) + return wrapper + } +} diff --git a/app/locales/en.json b/app/locales/en.json index 70da3ae1c..510063f0d 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -46,6 +46,15 @@ "window": "Window", "fullscreen": "Full screen", "showIdeas": "Show exercise tips during breaks", + "useCustomMessages": "Use custom messages", + "customMessages": "Custom Messages", + "customMessagesInfo": "Edit the existing custom mini break and long break messages stored in the configuration.", + "microbreakMessages": "Mini break messages", + "longBreakMessages": "Long break messages", + "customMessagesMessage": "Message", + "customMessagesEnabled": "Enabled", + "customMessagesDelete": "Delete", + "addNewMessage": "Add new message", "allScreens": "Shows breaks on all monitors", "monitorIdleTime": "Monitor system idle time (breaks are paused if system is idle).", "monitorDnd": "Show breaks even in Do Not Disturb mode", @@ -517,4 +526,4 @@ "text": "Try the 5-4-3-2-1 Grounding Technique: Identify 5 things you can see, 4 things you can touch, 3 things you can hear, 2 things you can smell, and 1 thing you can taste." } } -} \ No newline at end of file +} diff --git a/app/main.js b/app/main.js index b9daaf85a..6d98ea603 100644 --- a/app/main.js +++ b/app/main.js @@ -1556,6 +1556,10 @@ ipcMain.on('save-setting', function (event, key, value) { settings.set(key, value) + if (['microbreakIdeas', 'breakIdeas', 'useIdeasFromSettings'].includes(key)) { + loadIdeas() + } + updateTray() }) diff --git a/app/preferences-renderer.js b/app/preferences-renderer.js index c721933a3..980da845d 100644 --- a/app/preferences-renderer.js +++ b/app/preferences-renderer.js @@ -1,6 +1,7 @@ import VersionChecker from './utils/versionChecker.js' import { setSameWidths } from './utils/sameWidths.js' import HtmlTranslate from './utils/htmlTranslate.js' +import CustomMessages from './custom-messages.js' import './platform.js' @@ -31,6 +32,22 @@ window.onload = async (e) => { setWindowHeight() setTimeout(() => { eventsAttached = true }, 500) + const customMessages = new CustomMessages({ + root: document.querySelector('#customMessages'), + settings, + saveSettings: (key, value) => window.settings.saveSettings(key, value), + translate: async (key, fallback) => { + const translated = await window.i18next.t(key) + return translated === key ? fallback : translated + }, + onHeightChange: () => { + setSameWidths() + setWindowHeight() + } + }) + + await customMessages.render() + if (settings.customPreferencesMessage) { const customMessageDiv = document.createElement('div') customMessageDiv.className = 'custom-message' @@ -100,6 +117,7 @@ window.onload = async (e) => { window.stretchly.onTranslate(async () => { new HtmlTranslate(document).translate() + await customMessages.render() document.querySelectorAll('input[type="range"]').forEach(async range => { const settings = await window.settings.currentSettings() const divisor = range.dataset.divisor @@ -178,7 +196,7 @@ window.onload = async (e) => { } }) - document.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + document.querySelectorAll('input[type="checkbox"]:not([data-custom-message-control])').forEach(checkbox => { const isNegative = checkbox.classList.contains('negative') checkbox.checked = isNegative ? !settings[checkbox.value] : settings[checkbox.value] if (!eventsAttached) { @@ -265,7 +283,7 @@ window.onload = async (e) => { } }) - document.querySelector('.settings > div > button').onclick = (event) => { + document.querySelector('.settings > div:last-child > button').onclick = (event) => { window.stretchly.restoreDefaults() } diff --git a/app/preferences.html b/app/preferences.html index ca8c74c5d..f16e4c666 100644 --- a/app/preferences.html +++ b/app/preferences.html @@ -65,6 +65,22 @@ +
+ + +
+
+
+
+
+ +
+
+

+
+
+
+
@@ -482,4 +498,4 @@ - \ No newline at end of file +