feat: redmine_tonalmine plugin for default and per-account appearance
Admins set the default mode, contrast and color on Settings > Display, shown as three labelled form rows below the Theme select. Logged-in users' choices are saved on their account and override only the parts they changed; anonymous visitors get the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
77040e24a8
commit
20b7269df2
+155
-35
@@ -1,12 +1,15 @@
|
||||
/*
|
||||
* tonalmine: per-user appearance (mode + color palette).
|
||||
* tonalmine: per-user appearance (mode, contrast, color palette).
|
||||
*
|
||||
* Redmine loads this file in <head>, 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.
|
||||
* 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 (the build checks this).
|
||||
// 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'],
|
||||
@@ -17,16 +20,13 @@
|
||||
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 save(prefs) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch (e) { /* private mode: applies to this page only */ }
|
||||
}
|
||||
|
||||
function apply(prefs) {
|
||||
if (prefs.palette && prefs.palette !== PALETTES[0][0]) root.setAttribute('data-palette', prefs.palette);
|
||||
else root.removeAttribute('data-palette');
|
||||
@@ -61,19 +61,78 @@
|
||||
schemeScript.addEventListener('load', function () { done(window.tonalmineScheme); });
|
||||
}
|
||||
|
||||
var prefs = load();
|
||||
apply(prefs);
|
||||
|
||||
// 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 <head>, 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 '<button type="button" ' + attr + '="' + value + '" aria-pressed="' + pressed + '"' + (extra || '') + '>' + label + '</button>';
|
||||
}
|
||||
|
||||
function pickerHtml() {
|
||||
// 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 '<div class="nm-appearance">' +
|
||||
'<div class="nm-appearance__title">Appearance</div>' +
|
||||
return '<div class="nm-appearance' + (isDefault ? ' nm-appearance--default" data-nm-default' : '"') + '>' +
|
||||
(isDefault ? '' : '<div class="nm-appearance__title">Appearance</div>') +
|
||||
'<div class="nm-segmented" role="group" aria-label="Mode">' +
|
||||
MODES.map(function (m) { return button('data-nm-mode', m[0], m[1], m[0] === mode); }).join('') +
|
||||
'</div>' +
|
||||
@@ -86,53 +145,112 @@
|
||||
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('') +
|
||||
'<label class="nm-swatch nm-swatch--custom" data-nm-palette="custom" aria-pressed="' + (palette === 'custom') + '"' +
|
||||
'<label class="nm-swatch nm-swatch--custom no-css" data-nm-palette="custom" aria-pressed="' + (palette === 'custom') + '"' +
|
||||
(prefs.seed ? ' style="--nm-swatch: ' + prefs.seed + '"' : '') + ' title="Custom color">' +
|
||||
'<input type="color" data-nm-custom value="' + (prefs.seed || PALETTES[0][2].toLowerCase()) + '" aria-label="Custom color"></label>' +
|
||||
'</div></div>';
|
||||
'</div>' +
|
||||
(isDefault ? '' : '<button type="button" class="nm-appearance__reset" data-nm-reset' + (isEmpty(own) ? ' hidden' : '') + '>Reset to default</button>') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function sync() {
|
||||
document.querySelectorAll('[data-nm-mode]').forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-mode') === (prefs.mode || 'auto')));
|
||||
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-contrast]').forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard')));
|
||||
});
|
||||
document.querySelectorAll('[data-nm-palette]').forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0])));
|
||||
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]');
|
||||
var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-palette], [data-nm-reset]');
|
||||
if (!target) return;
|
||||
if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode');
|
||||
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');
|
||||
save(prefs);
|
||||
apply(prefs);
|
||||
sync();
|
||||
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);
|
||||
save(prefs);
|
||||
apply(prefs);
|
||||
sync();
|
||||
document.querySelectorAll('[data-nm-custom]').forEach(function (input) {
|
||||
input.value = seed;
|
||||
input.parentNode.style.setProperty('--nm-swatch', 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 = '<label>' + label + '</label><div class="nm-appearance nm-appearance--default" data-nm-default></div>';
|
||||
row.lastChild.appendChild(groups[i]);
|
||||
return row;
|
||||
});
|
||||
rows[2].insertAdjacentHTML('beforeend', DEFAULT_KEYS.map(function (key) {
|
||||
return '<input type="hidden" name="settings[plugin_redmine_tonalmine][' + key + ']" data-nm-default-input="' + key + '">';
|
||||
}).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 "<a>Tracker #id</a>: subject<br><br>" followed by
|
||||
// "<strong>Label</strong>: value<br>" 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).
|
||||
@@ -169,8 +287,10 @@
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var menu = document.querySelector('#account .dropdown-content .dropdown-divider');
|
||||
if (menu) menu.insertAdjacentHTML('afterend', pickerHtml() + '<div class="dropdown-divider"></div>');
|
||||
if (menu) menu.insertAdjacentHTML('afterend', pickerHtml(effective()) + '<div class="dropdown-divider"></div>');
|
||||
var flyout = document.querySelector('.js-flyout-menu');
|
||||
if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml());
|
||||
if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml(effective()));
|
||||
var select = document.getElementById('settings_ui_theme');
|
||||
if (select && configured) defaultPicker(select);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -584,12 +584,35 @@ html .table-generator td.selected-cell, html .table-generator td:hover { backgro
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Custom color: a native color input stretched invisibly over a rainbow (or the picked color) swatch */
|
||||
.nm-swatch--custom { position: relative; background: var(--nm-swatch, conic-gradient(#e53935, #fdd835, #43a047, #1e88e5, #8e24aa, #e53935)); }
|
||||
/* label.: beats core label.no-css { inline-size: auto } (the class keeps .tabular label styles off) */
|
||||
label.nm-swatch--custom { position: relative; inline-size: 28px; background: var(--nm-swatch, conic-gradient(#e53935, #fdd835, #43a047, #1e88e5, #8e24aa, #e53935)); }
|
||||
.nm-swatch--custom input { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; padding: 0; border: 0; opacity: 0; cursor: pointer; }
|
||||
.nm-swatch--custom:focus-within { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
|
||||
/* theme.js lazy-loads the scheme generator from here; Redmine rewrites url() to the digested path */
|
||||
:root { --nm-scheme-js: url(./scheme.js); }
|
||||
.nm-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--md-sys-color-surface-container), 0 0 0 4px var(--md-sys-color-on-surface); }
|
||||
/* Reset to default: M3 text button, shown once the user changed something */
|
||||
.nm-appearance__reset {
|
||||
margin-block-start: 12px;
|
||||
block-size: 32px;
|
||||
padding-inline: 12px;
|
||||
border: 0;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-primary);
|
||||
font: var(--md-sys-typescale-label-large);
|
||||
cursor: pointer;
|
||||
}
|
||||
.nm-appearance__reset:hover { background-image: var(--nm-state-hover); }
|
||||
/* Default appearance on Administration > Settings > Display (redmine_tonalmine plugin) */
|
||||
.nm-appearance--default { max-inline-size: 280px; padding: 0; }
|
||||
/* Each group is its own form row: 40px tall like the fields (M3 segmented button height), so
|
||||
the label lines up; the row padding spaces them */
|
||||
.nm-appearance--default .nm-segmented { margin: 0; }
|
||||
.nm-appearance--default .nm-segmented button { block-size: 40px; margin-block: 0; }
|
||||
.nm-appearance--default .nm-swatches { min-block-size: 40px; align-items: center; }
|
||||
/* The custom swatch is a <label class="no-css">; also undo .tabular.settings label margins and mobile full width */
|
||||
html .tabular label.nm-swatch--custom { display: block; margin: 0; inline-size: 28px; }
|
||||
html .flyout-menu .nm-appearance { padding-inline: 16px; }
|
||||
html .flyout-menu .nm-appearance__title { color: var(--md-sys-color-on-surface-variant); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user