/* * tonalmine: per-user appearance (mode, contrast, color palette). * * Redmine loads this file in , so the saved choice is applied before the page renders * (no flash of the default colors). The picker is added to the profile menu and the mobile * flyout. The choice is stored per browser in localStorage. With the redmine_tonalmine plugin, * the plugin calls Tonalmine.configure() right after this file: it adds the administrator's * default, and logged-in users' choices are saved on their account instead. */ (function () { // Ids must match PALETTES in tools/palettes/build_palettes.mjs and // redmine_tonalmine/lib/redmine_tonalmine/appearance.rb (the palettes build checks both). var PALETTES = [ ['purple', 'Purple', '#6750A4'], ['blue', 'Blue', '#0061A4'], ['green', 'Green', '#386A20'], ['orange', 'Orange', '#8B5000'] ]; var MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']]; var CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']]; var KEY = 'tonalmine'; var CUSTOM_SEL = ':root[data-palette="custom"]'; var DEFAULT_KEYS = ['mode', 'contrast', 'palette', 'seed', 'css']; var root = document.documentElement; function load() { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) { return {}; } } function apply(prefs) { if (prefs.palette && prefs.palette !== PALETTES[0][0]) root.setAttribute('data-palette', prefs.palette); else root.removeAttribute('data-palette'); if (prefs.mode === 'light' || prefs.mode === 'dark') root.setAttribute('data-mode', prefs.mode); else root.removeAttribute('data-mode'); if (prefs.contrast === 'medium' || prefs.contrast === 'high') root.setAttribute('data-contrast', prefs.contrast); else root.removeAttribute('data-contrast'); // The custom palette's rules are generated once (see withScheme) and kept with the prefs. if (prefs.palette === 'custom' && prefs.css) { var style = document.getElementById('nm-custom-palette'); if (!style) { style = document.createElement('style'); style.id = 'nm-custom-palette'; document.head.appendChild(style); } style.textContent = prefs.css; } } // The scheme generator (scheme.js, ~90 KB) is only loaded when someone picks a custom color. // Redmine serves theme assets under digested names only, so its URL comes from the CSS // variable --nm-scheme-js (Redmine rewrites url() in CSS to the digested path). var schemeScript; function withScheme(done) { if (window.tonalmineScheme) return done(window.tonalmineScheme); if (!schemeScript) { var url = getComputedStyle(root).getPropertyValue('--nm-scheme-js').match(/url\(["']?([^"')]+)/)[1]; schemeScript = document.createElement('script'); schemeScript.src = url; document.head.appendChild(schemeScript); } schemeScript.addEventListener('load', function () { done(window.tonalmineScheme); }); } // own: the parts this user changed. defaults: the administrator's default (plugin only). // The page shows the defaults overridden by own, part by part. var own = load(); var defaults = {}; var url = null; // set when the user's choice is saved on their account (plugin, logged in) var configured = false; var draft = null; // the default being edited on the Display settings tab // palette, seed and css form one part: a custom palette brings its own generated CSS. function merge(base, top) { var src = top.palette ? top : base; return { mode: top.mode || base.mode, contrast: top.contrast || base.contrast, palette: src.palette, seed: src.seed, css: src.css }; } function effective() { return merge(defaults, own); } function isEmpty(prefs) { return !Object.keys(prefs).length; } function save() { if (!url) { try { localStorage.setItem(KEY, JSON.stringify(own)); } catch (e) { /* private mode: applies to this page only */ } return; } var token = document.querySelector('meta[name="csrf-token"]'); fetch(url, { method: 'PUT', credentials: 'same-origin', keepalive: true, // finish even if the user navigates away right after picking // X-Requested-With: an expired session answers 401 instead of redirecting to the login page. headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': token ? token.content : '' }, body: JSON.stringify(own) }).then(function (response) { if (response.status !== 204) throw new Error('HTTP ' + response.status); }).catch(function (error) { console.warn('tonalmine: appearance not saved', error); }); } // Called by the redmine_tonalmine plugin from , right after this file. window.Tonalmine = { configure: function (config) { defaults = config.defaults || {}; if (config.user) { url = config.url; var local = own; own = config.user; // A choice made in this browser before the plugin was installed moves to the account once. if (isEmpty(own) && !isEmpty(local)) { own = local; save(); } // Logged-in choices live on the account only, so the next person on this browser doesn't inherit them. try { localStorage.removeItem(KEY); } catch (e) { /* nothing stored */ } } configured = true; apply(effective()); } }; apply(effective()); function button(attr, value, label, pressed, extra) { return ''; } // isDefault: the administrator's picker on the Display settings tab (no title, no reset). function pickerHtml(prefs, isDefault) { var mode = prefs.mode || 'auto'; var contrast = prefs.contrast || 'standard'; var palette = prefs.palette || PALETTES[0][0]; return '
' + (isDefault ? '' : '
Appearance
') + '
' + MODES.map(function (m) { return button('data-nm-mode', m[0], m[1], m[0] === mode); }).join('') + '
' + '
Contrast
' + '
' + CONTRASTS.map(function (c) { return button('data-nm-contrast', c[0], c[1], c[0] === contrast); }).join('') + '
' + '
' + PALETTES.map(function (p) { return button('data-nm-palette', p[0], '', p[0] === palette, ' class="nm-swatch" title="' + p[1] + '" aria-label="' + p[1] + '" style="--nm-swatch: ' + p[2] + '"'); }).join('') + '' + '
' + (isDefault ? '' : '') + '
'; } function sync() { document.querySelectorAll('.nm-appearance').forEach(function (picker) { var prefs = picker.hasAttribute('data-nm-default') ? draft : effective(); picker.querySelectorAll('[data-nm-mode]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-mode') === (prefs.mode || 'auto'))); }); picker.querySelectorAll('[data-nm-contrast]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard'))); }); picker.querySelectorAll('[data-nm-palette]').forEach(function (b) { b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0]))); }); if (prefs.seed) { picker.querySelectorAll('[data-nm-custom]').forEach(function (input) { input.value = prefs.seed; input.parentNode.style.setProperty('--nm-swatch', prefs.seed); }); } var reset = picker.querySelector('[data-nm-reset]'); if (reset) reset.hidden = isEmpty(own); }); document.querySelectorAll('[data-nm-default-input]').forEach(function (input) { input.value = draft[input.getAttribute('data-nm-default-input')] || ''; }); } // persist: save the user's choice (the default is saved with the settings form instead). function changed(isDefault, persist) { if (isDefault) apply(draft); // preview else { if (persist) save(); apply(effective()); } sync(); } document.addEventListener('click', function (event) { var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-palette], [data-nm-reset]'); if (!target) return; var isDefault = !!target.closest('[data-nm-default]'); var prefs = isDefault ? draft : own; if (target.hasAttribute('data-nm-reset')) own = {}; else if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode'); else if (target.hasAttribute('data-nm-contrast')) prefs.contrast = target.getAttribute('data-nm-contrast'); // The custom swatch opens the color dialog; it only selects the palette once a color was picked. else if (target.getAttribute('data-nm-palette') !== 'custom' || prefs.css) prefs.palette = target.getAttribute('data-nm-palette'); changed(isDefault, true); }); document.addEventListener('input', function (event) { if (!event.target.matches('[data-nm-custom]')) return; var isDefault = !!event.target.closest('[data-nm-default]'); var seed = event.target.value; withScheme(function (scheme) { var prefs = isDefault ? draft : own; prefs.palette = 'custom'; prefs.seed = seed; prefs.css = scheme.paletteCss(CUSTOM_SEL, seed); // While the user drags, only localStorage keeps up; the account gets the final color on change. changed(isDefault, !url); }); }); document.addEventListener('change', function (event) { if (!url || !event.target.matches('[data-nm-custom]') || event.target.closest('[data-nm-default]')) return; withScheme(save); // queued behind the input event's pending update }); // Display settings tab (admins, plugin installed): edit the default appearance below the theme // select. The hidden inputs are saved by the settings form as Setting.plugin_redmine_tonalmine. function defaultPicker(select) { draft = merge({}, defaults); // One labelled form row per group (mode, contrast, color), each in its own default picker // wrapper so sync() and the click handlers treat it like the whole picker. var picker = document.createElement('div'); picker.innerHTML = pickerHtml(draft, true); var groups = picker.querySelectorAll('[role="group"]'); var rows = ['Default appearance', 'Contrast', 'Color'].map(function (label, i) { var row = document.createElement('p'); row.innerHTML = '
'; row.lastChild.appendChild(groups[i]); return row; }); rows[2].insertAdjacentHTML('beforeend', DEFAULT_KEYS.map(function (key) { return ''; }).join('')); select.closest('p').after(rows[0], rows[1], rows[2]); // Only shown, and only posted, while Tonalmine is selected; otherwise the saved default stays. function toggle() { var on = select.value === 'tonalmine'; rows.forEach(function (row) { row.style.display = on ? '' : 'none'; }); rows[2].querySelectorAll('[data-nm-default-input]').forEach(function (input) { input.disabled = !on; }); } select.addEventListener('change', toggle); toggle(); sync(); } // Calendar/Gantt hover cards: Redmine renders "Tracker #id: subject

" followed by // "Label: value
" lines. Regroup them on first hover into a title and a // label/value grid (nodes are moved, not re-parsed, so links and avatars stay intact). function structureTip(tip) { var lines = [[]]; Array.prototype.slice.call(tip.childNodes).forEach(function (node) { if (node.nodeName === 'BR') lines.push([]); else if (node.nodeType !== 3 || node.nodeValue.trim()) lines[lines.length - 1].push(node); }); var title = document.createElement('span'); var fields = document.createElement('span'); title.className = 'nm-tip__title'; fields.className = 'nm-tip__fields'; lines.filter(function (line) { return line.length; }).forEach(function (line, i) { var head = line[0]; var value = document.createElement('span'); line.slice(1).forEach(function (node) { value.appendChild(node); }); if (value.firstChild && value.firstChild.nodeType === 3) { value.firstChild.nodeValue = value.firstChild.nodeValue.replace(/^\s*:\s*/, ''); } var target = i > 0 && head.nodeName === 'STRONG' ? fields : title; target.appendChild(head); target.appendChild(value); }); tip.replaceChildren(title, fields); tip.classList.add('nm-tip'); } document.addEventListener('mouseover', function (event) { var tip = event.target.closest && event.target.closest('div.tooltip'); tip = tip && tip.querySelector(':scope > span.tip:not(.nm-tip)'); if (tip) structureTip(tip); }); document.addEventListener('DOMContentLoaded', function () { var menu = document.querySelector('#account .dropdown-content .dropdown-divider'); if (menu) menu.insertAdjacentHTML('afterend', pickerHtml(effective()) + ''); var flyout = document.querySelector('.js-flyout-menu'); if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml(effective())); var select = document.getElementById('settings_ui_theme'); if (select && configured) defaultPicker(select); }); })();