Bootstrap 5 & remove jQuery (#661)

* build: move frontend assets to Bootstrap 5.3 and Tom Select

* feat: migrate templates and scripts to Bootstrap 5, dropping jQuery

Templates
- Rename Bootstrap 4 classes and data attributes (data-bs-*, ms-/me-/ps-/pe-,
  text-start/end, form-group -> mb-3, input-group wrappers removed, badges,
  custom controls -> form-check/form-select, media -> d-flex, btn-close, ...)
- Dark mode now uses Bootstrap 5.3 colour modes (data-bs-theme on <html>),
  set from <head> to avoid a flash of the light theme
- Remove the Bootstrap 4 /bootstrap/ demo page
- Contextual table classes (table-success etc.) still work as plain
  backgrounds for the rigboard key

JavaScript
- Remove jQuery: src.js/interaction.js and every inline template script are
  rewritten in plain JS on top of small helpers (helpers.js, bundled with
  Bootstrap into jpop.js so inline scripts can use them)
- Replace bootstrap-select/ajax-bootstrap-select with Tom Select. The remote
  search now sends the `q` parameter and reads `text`, which is what the
  API actually uses (the old picker sent `term` and read `label`)
- HTML fragments are fetched with an X-Requested-With header so views keep
  rendering their modal variants, and fetched scripts are re-executed
- Remove the Bootstrap 4 plugin script includes

* fix: stop rigboard header buttons stretching to full width under Bootstrap 5

* fix: restore dark-mode brand colours and contextual table colours under Bootstrap 5

* fix: brighten dark-mode links and make coloured card outlines visible

* fix: calendar layout and grid borders, navbar search button width, guard missing modal links

* fix: readable text in dark-mode Tom Select inputs and placeholders, restore form-control column widths

* build: split the Dockerfile into cached stages with prod and dev targets

* deploy: make compose.yml development-only with live reload, and document it

* fix: keep badge text colour for links inside badges

* fix: restore table-bordered grid lines in dark mode

* fix: include Bootstrap's progress styles and keep progress labels readable
This commit is contained in:
Joe Banks
2026-10-02 16:28:11 +01:00
committed by GitHub
parent 08171767a0
commit a29384a314
124 changed files with 1538 additions and 4137 deletions

View File

@@ -1,101 +1,138 @@
function changeSelectedValue(obj,pk,text,update_url) { //Pass in JQuery object and new parameters
//console.log('Changing selected value');
obj.find('option').remove(); //Remove all the available options
obj[0].add(new Option(text, pk, true, true)); // Add new option
//obj.selectpicker('val', pk); //Set the new value to be selected
obj.selectpicker('refresh');
obj.change(); //Trigger the change function manually
//console.log(obj);
// Select widgets, built on Tom Select.
// * <select class="ts-select" data-sourceurl="..."> loads its options remotely as the user types
// * <select class="ts-select"> without a source URL is a plain (optionally multiple) select
var clearSelectionLabel = '(no selection)';
function changeSelectedValue(select, pk, text, update_url) { // Pass in the <select> and the new option's parameters
var ts = select.tomselect;
if (!ts) {
return;
}
ts.clear(true);
ts.clearOptions();
ts.addOption({value: pk, text: text, update_url: update_url || ''});
ts.setValue(pk, true);
select.dispatchEvent(new Event('change', {bubbles: true})); // Trigger the change function manually
}
function refreshUpdateHref(obj) {
//console.log('Refreshing Update URL');
targetObject = $('#'+obj.attr('id')+'-update');
update_url = $('option:selected', obj).data('update_url');
function selectedUpdateUrl(select) {
var ts = select.tomselect;
var value = ts.getValue();
if (!value) {
return '';
}
var option = ts.options[value];
if (option && option.update_url !== undefined) {
return option.update_url;
}
var domOption = select.querySelector('option[value="' + CSS.escape(value) + '"]');
return domOption && domOption.dataset.update_url ? domOption.dataset.update_url : null;
}
if (update_url=="") { //Probably "clear selection" has been chosen
//console.log('Trying to disable');
targetObject.removeAttr('href');
targetObject.addClass('disabled');
} else {
targetObject.prop('href', update_url);
targetObject.removeClass('disabled');
function refreshUpdateHref(select) {
var target = document.getElementById(select.id + '-update');
if (!target) {
return;
}
var update_url = selectedUpdateUrl(select);
if (update_url === '') { // Nothing is selected
target.removeAttribute('href');
target.classList.add('disabled');
} else if (update_url !== null) {
target.setAttribute('href', update_url);
target.classList.remove('disabled');
}
}
function initPicker(obj) {
var options = {
ajax: {
url: obj.data('sourceurl'),
type: 'GET',
dataType: 'json',
// Use "{{{q}}}" as a placeholder and Ajax Bootstrap Select will
// automatically replace it with the value of the search query.
data: {
term: '{{{q}}}'
}
},
locale: {
emptyTitle: ''
},
clearOnEmpty:false,
//log: 3,
preprocessData: function (data) {
var i, l = data.length, array = [];
if (!obj.data('noclear')) {
array.push({
text: clearSelectionLabel,
value: '',
data:{
update_url: '',
subtext:''
}
});
}
function initPicker(select) {
var noClear = 'noclear' in select.dataset;
var multiple = select.multiple;
var url = select.dataset.sourceurl;
var plugins = [];
if (multiple) {
plugins.push('remove_button');
} else if (!noClear) {
plugins.push('clear_button');
}
if (l) {
for(i = 0; i < l; i++){
array.push($.extend(true, data[i], {
text: data[i]['label'],
value: data[i]['pk'],
data:{
update_url: data[i]['update'],
subtext:''
}
}));
}
}
return array;
}
var settings = {
plugins: plugins,
valueField: 'value',
labelField: 'text',
searchField: ['text'],
allowEmptyOption: false,
placeholder: select.dataset.noneSelectedText || '',
maxOptions: null,
hideSelected: false,
closeAfterSelect: !multiple
};
//console.log(obj.data);
if (!obj.data('noclear')) {
obj.prepend($("<option></option>")
.attr("value",'')
.text(clearSelectionLabel)
.data('update_url','')); //Add "clear selection" option
if (url) {
Object.assign(settings, {
// Results come from the server so don't filter them locally
score: function () { return function () { return 1; }; },
shouldLoad: function (query) { return query.length > 0; },
load: function (query, callback) {
var separator = url.indexOf('?') === -1 ? '?' : '&';
fetch(url + separator + 'q=' + encodeURIComponent(query), {credentials: 'same-origin'})
.then(function (response) { return response.json(); })
.then(function (data) {
callback(data.map(function (item) {
return {value: item.pk, text: item.text, update_url: item.update};
}));
})
.catch(function () { callback(); });
}
});
}
var update_urls = {};
select.querySelectorAll('option[data-update_url]').forEach(function (option) {
update_urls[option.value] = option.dataset.update_url;
});
obj.selectpicker().ajaxSelectPicker(options); //Initiaise selectPicker
obj.change(function(){ //on change, update the edit button href
//console.log('Selectbox Changed');
refreshUpdateHref(obj);
});
refreshUpdateHref(obj); //Ensure href is correct at the beginning
}
$(document).ready(function() {
clearSelectionLabel = '(no selection)';
$(".selectpicker").each(function(){initPicker($(this))});
//When update/edit modal box submitted
$('#modal').on('hide.bs.modal', function (e) {
if (modaltarget != undefined && modalobject != "") {
//Update the selector with new values
changeSelectedValue($(modaltarget),modalobject[0]['pk'],modalobject[0]['fields']['name'],modalobject[0]['update_url']);
var ts = new TomSelect(select, settings);
Object.keys(update_urls).forEach(function (value) {
if (ts.options[value]) {
ts.options[value].update_url = update_urls[value];
}
});
if (select.id && document.getElementById(select.id + '-update')) {
select.addEventListener('change', function () { // on change, update the edit button href
refreshUpdateHref(select);
});
refreshUpdateHref(select); // Ensure href is correct at the beginning
}
}
function initSelects(root) {
if (typeof TomSelect === 'undefined') {
return;
}
(root || document).querySelectorAll('select.ts-select').forEach(function (select) {
if (!select.tomselect) {
initPicker(select);
}
});
}
ready(function () {
initSelects();
//When update/edit modal box submitted
var modal = document.getElementById('modal');
if (modal) {
modal.addEventListener('hide.bs.modal', function () {
if (typeof modaltarget !== 'undefined' && modaltarget && typeof modalobject !== 'undefined' && modalobject !== "") {
//Update the selector with new values
var select = document.querySelector(modaltarget);
if (select) {
changeSelectedValue(select, modalobject[0]['pk'], modalobject[0]['fields']['name'], modalobject[0]['update_url']);
}
}
});
}
});

View File

@@ -0,0 +1,138 @@
// Helpers used by inline template scripts, so this is loaded in <head> (bundled with Bootstrap as jpop.js)
// Run fn once the DOM is ready
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
// Delegated event handler: on(document, 'click', '.selector', function (e) { this === matched element })
function on(root, type, selector, handler) {
root.addEventListener(type, function (e) {
var target = e.target instanceof Element ? e.target.closest(selector) : null;
if (target && root.contains(target)) {
handler.call(target, e);
}
});
}
// fetch() for endpoints that return HTML fragments: the X-Requested-With header makes the server
// render the ajax (modal) variant of a page, as jQuery's .load()/.post() used to
function ajaxFetch(url, options) {
options = Object.assign({credentials: 'same-origin'}, options);
options.headers = Object.assign({'X-Requested-With': 'XMLHttpRequest'}, options.headers);
return fetch(url, options);
}
// Replace the contents of an element with an HTML string, executing any <script>s it contains
function setHtml(element, html) {
element.innerHTML = html;
element.querySelectorAll('script').forEach(function (old) {
var script = document.createElement('script');
Array.from(old.attributes).forEach(function (attr) { script.setAttribute(attr.name, attr.value); });
script.text = old.text;
old.replaceWith(script);
});
initTooltips(element);
initPopovers(element);
if (typeof initSelects === 'function') {
initSelects(element);
}
}
function initTooltips(root) {
(root || document).querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) {
bootstrap.Tooltip.getOrCreateInstance(el);
});
}
// Popovers may contain <ins>/<del> (used by the activity feed's diffs)
function popoverOptions() {
var allowList = bootstrap.Tooltip.Default.allowList;
allowList.ins = [];
allowList.del = [];
return {allowList: allowList};
}
function initPopovers(root) {
(root || document).querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
bootstrap.Popover.getOrCreateInstance(el, popoverOptions());
});
}
// Load a URL into the shared modal and show it
function loadModal(url) {
var modal = document.getElementById('modal');
return ajaxFetch(url)
.then(function (response) { return response.text(); })
.then(function (html) {
setHtml(modal, html);
showModal(modal);
});
}
function showModal(selectorOrElement) {
var el = typeof selectorOrElement === 'string' ? document.querySelector(selectorOrElement) : selectorOrElement;
bootstrap.Modal.getOrCreateInstance(el).show();
}
function hideModal(selectorOrElement) {
var el = typeof selectorOrElement === 'string' ? document.querySelector(selectorOrElement) : selectorOrElement;
var modal = bootstrap.Modal.getInstance(el);
if (modal) {
modal.hide();
}
}
// Animated show/hide for form sections (replacing jQuery's slideDown/slideUp)
function resetSlide(el) {
window.clearTimeout(el._slideTimer);
['height', 'overflow', 'transition', 'opacity'].forEach(function (p) { el.style.removeProperty(p); });
}
function slideUp(el, duration) {
resetSlide(el);
if (duration === 0 || isHidden(el)) {
el.style.display = 'none';
return;
}
el.style.overflow = 'hidden';
el.style.height = el.offsetHeight + 'px';
el.offsetHeight; // force reflow
el.style.transition = 'height ' + duration + 'ms ease, opacity ' + duration + 'ms ease';
el.style.height = '0';
el.style.opacity = '0';
el._slideTimer = window.setTimeout(function () {
resetSlide(el);
el.style.display = 'none';
}, duration);
}
function slideDown(el, duration) {
resetSlide(el);
el.hidden = false;
el.style.removeProperty('display');
if (isHidden(el)) {
el.style.display = 'block'; // not hidden by an inline style, so fall back to a block element
}
if (duration === 0) {
return;
}
var height = el.scrollHeight;
el.style.overflow = 'hidden';
el.style.height = '0';
el.style.opacity = '0';
el.offsetHeight; // force reflow
el.style.transition = 'height ' + duration + 'ms ease, opacity ' + duration + 'ms ease';
el.style.height = height + 'px';
el.style.opacity = '1';
el._slideTimer = window.setTimeout(function () {
resetSlide(el);
}, duration);
}
function isHidden(el) {
return getComputedStyle(el).display === 'none';
}

View File

@@ -1,13 +1,15 @@
function setupItemTable(items_json) {
objectitems = JSON.parse(items_json)
$.each(objectitems, function (key, val) {
objectitems[key] = JSON.parse(val);
})
Object.keys(objectitems).forEach(function (key) {
objectitems[key] = JSON.parse(objectitems[key]);
});
newitem = -1;
}
function escapeHtml(str) {
return $('<div/>').text(str).html();
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function updatePrices() {
@@ -16,116 +18,144 @@ function updatePrices() {
for (var pk in objectitems) {
var fields = objectitems[pk].fields;
var sub = fields.cost * fields.quantity;
$('#item-' + pk + ' .sub-total').html(parseFloat(sub).toFixed(2)).data('subtotal', sub);
var subTotal = document.querySelector('#item-' + pk + ' .sub-total');
if (subTotal) {
subTotal.textContent = parseFloat(sub).toFixed(2);
subTotal.dataset.subtotal = sub;
}
sum += Number(sub);
}
$('#sumtotal').text(parseFloat(sum).toFixed(2));
var vat = sum * Number($('#vat-rate').data('rate'));
$('#vat').text(parseFloat(vat).toFixed(2));
$('#total').text(parseFloat(sum + vat).toFixed(2));
document.getElementById('sumtotal').textContent = parseFloat(sum).toFixed(2);
var vat = sum * Number(document.getElementById('vat-rate').dataset.rate);
document.getElementById('vat').textContent = parseFloat(vat).toFixed(2);
document.getElementById('total').textContent = parseFloat(sum + vat).toFixed(2);
}
function setupMDE(selector) {
var element = document.querySelector(selector);
editor = new EasyMDE({
autoDownloadFontAwesome: false,
element: $(selector)[0],
element: element,
forceSync: true,
toolbar: ["bold", "italic", "strikethrough", "|", "unordered-list", "ordered-list", "|", "link", "|", "preview", "guide"],
status: true,
});
$(selector).data('mde_editor',editor);
element.mde_editor = editor;
}
$('#item-table').on('click', '.item-delete', function () {
delete objectitems[$(this).data('pk')]
$('#item-' + $(this).data('pk')).remove();
updatePrices();
});
(function () {
var itemTable = document.getElementById('item-table');
var itemForm = document.getElementById('item-form');
$('#item-table').on('click', '.item-add', function () {
$('#item-form').data('pk', newitem);
function field(id) {
return document.getElementById(id);
}
// Set the form values
$('#item_name').val('');
$('#item_description').val('');
$('#item_quantity').val('');
$('#item_cost').val('');
if (itemTable) {
on(itemTable, 'click', '.item-delete', function () {
delete objectitems[this.dataset.pk];
var row = document.getElementById('item-' + this.dataset.pk);
if (row) {
row.remove();
}
updatePrices();
});
$($(this).data('target')).modal('show');
});
on(itemTable, 'click', '.item-add', function () {
itemForm.dataset.pk = newitem;
$('#item-table').on('click', '.item-edit', function () {
// set the pk as we will need this later
var pk = $(this).data('pk');
$('#item-form').data('pk', pk);
// Set the form values
field('item_name').value = '';
field('item_description').value = '';
field('item_quantity').value = '';
field('item_cost').value = '';
// Set the form values
var fields = objectitems[pk].fields;
$('#item_name').val(fields.name);
$('#item_description').val(fields.description);
$('#item_quantity').val(fields.quantity);
$('#item_cost').val(fields.cost);
showModal(this.dataset.modalTarget);
});
$($(this).data('target')).modal('show');
});
on(itemTable, 'click', '.item-edit', function () {
// set the pk as we will need this later
var pk = this.dataset.pk;
itemForm.dataset.pk = pk;
$('body').on('submit', '#item-form', function (e) {
e.preventDefault();
var pk = $(this).data('pk');
$('#itemModal').modal('hide');
// Set the form values
var fields = objectitems[pk].fields;
field('item_name').value = fields.name;
field('item_description').value = fields.description;
field('item_quantity').value = fields.quantity;
field('item_cost').value = fields.cost;
var fields;
if (pk == newitem--) {
// Create the new data structure and add it on.
fields = new Object();
fields['name'] = $('#item_name').val()
fields['description'] = $('#item_description').val();
fields['cost'] = $('#item_cost').val();
fields['quantity'] = $('#item_quantity').val();
showModal(this.dataset.modalTarget);
});
}
var order = 0;
for (item in objectitems) {
order++;
on(document, 'submit', '#item-form', function (e) {
e.preventDefault();
var pk = this.dataset.pk;
hideModal('#itemModal');
var fields;
if (pk == newitem--) {
// Create the new data structure and add it on.
fields = new Object();
fields['name'] = field('item_name').value;
fields['description'] = field('item_description').value;
fields['cost'] = field('item_cost').value;
fields['quantity'] = field('item_quantity').value;
var order = 0;
for (var item in objectitems) {
order++;
}
fields['order'] = order;
objectitems[pk] = new Object();
objectitems[pk]['fields'] = fields;
// Add the new table row
var row = field('new-item-row').cloneNode(true);
row.id = 'item-' + pk;
row.dataset.pk = pk;
field('item-table-body').appendChild(row);
row.querySelectorAll('.item-delete, .item-edit').forEach(function (button) {
button.dataset.pk = pk;
});
} else {
// Existing item
// update data structure
fields = objectitems[pk].fields;
fields.name = field('item_name').value;
fields.description = field('item_description').value;
fields.cost = field('item_cost').value;
fields.quantity = field('item_quantity').value;
objectitems[pk].fields = fields;
}
// update the table
var tableRow = field('item-' + pk);
tableRow.querySelector('.name').innerHTML = escapeHtml(fields.name);
tableRow.querySelector('.description').innerHTML = fields.description;
tableRow.querySelector('.cost').innerHTML = parseFloat(fields.cost).toFixed(2);
tableRow.querySelector('.quantity').innerHTML = fields.quantity;
fields['order'] = order;
updatePrices();
});
objectitems[pk] = new Object();
objectitems[pk]['fields'] = fields;
// Add the new table
$('#new-item-row').clone().attr('id', 'item-' + pk).data('pk', pk).appendTo('#item-table-body');
$('#item-'+pk+' .item-delete, #item-'+pk+' .item-edit').data('pk', pk)
} else {
// Existing item
// update data structure
fields = objectitems[pk].fields;
fields.name = $('#item_name').val()
fields.description = $('#item_description').val();
fields.cost = $('#item_cost').val();
fields.quantity = $('#item_quantity').val();
objectitems[pk].fields = fields;
on(document, 'submit', '.itemised_form', function () {
field('id_items_json').value = JSON.stringify(objectitems);
});
if (itemTable && typeof sortable === 'function') {
var sortables = sortable("#item-table tbody");
if (sortables.length) {
sortables[0].addEventListener('sortupdate', function (e) {
var items = e.detail.destination.items;
for (var i in items) {
objectitems[items[i].dataset.pk].fields.order = i;
}
});
}
}
// update the table
$row = $('#item-' + pk);
$row.find('.name').html(escapeHtml(fields.name));
$row.find('.description').html(fields.description);
$row.find('.cost').html(parseFloat(fields.cost).toFixed(2));
$row.find('.quantity').html(fields.quantity);
updatePrices();
});
$('body').on('submit', '.itemised_form', function (e) {
$('#id_items_json').val(JSON.stringify(objectitems));
});
sortable("#item-table tbody")[0].addEventListener('sortupdate', function (e) {
var items = e.detail.destination.items;
for(var i in items) {
objectitems[items[i].dataset.pk].fields.order = i;
}
});
})();

View File

@@ -4,19 +4,30 @@ Date.prototype.getISOString = function () {
var dd = this.getDate().toString();
return yyyy + '-' + (mm[1] ? mm : "0" + mm[0]) + '-' + (dd[1] ? dd : "0" + dd[0]); // padding
};
jQuery(document).ready(function () {
jQuery(document).on('click', '.modal-href', function (e) {
$link = jQuery(this);
ready(function () {
// Links that load their target into the shared modal
on(document, 'click', '.modal-href', function (e) {
// Anti modal inception
if ($link.parents('#modal').length == 0) {
if (!this.closest('#modal')) {
e.preventDefault();
modaltarget = $link.data('target');
modaltarget = this.dataset.selectTarget;
modalobject = "";
jQuery('#modal').load($link.attr('href'), function (e) {
jQuery('#modal').modal();
});
loadModal(this.getAttribute('href'));
}
});
// Forms inside the modal are submitted over AJAX and the response replaces the modal contents
on(document, 'submit', '#modal form', function (e) {
e.preventDefault();
var form = this;
ajaxFetch(form.getAttribute('action') || window.location.href, {
method: 'POST',
body: new FormData(form)
}).then(function (response) { return response.text(); })
.then(function (html) { setHtml(document.getElementById('modal'), html); });
});
var easter_egg = new Konami(function () {
var s = document.createElement('script');
s.type = 'text/javascript';
@@ -24,7 +35,12 @@ jQuery(document).ready(function () {
s.src = '/static/js/asteroids.min.js';
});
easter_egg.load();
initTooltips();
initPopovers();
document.querySelectorAll('.navbar-collapse').forEach(function (el) { el.classList.add('collapse'); });
});
//CTRL-Enter form submission
document.body.addEventListener('keydown', function(e) {
if(e.keyCode == 13 && (e.metaKey || e.ctrlKey)) {
@@ -34,4 +50,3 @@ document.body.addEventListener('keydown', function(e) {
}
}
});
$('.navbar-collapse').addClass('collapse');

View File

@@ -1,12 +1,14 @@
$font-family-sans-serif: "Open Sans", sans-serif;
$font-family-sans-serif: "Open Sans", sans-serif;
//Make it look less primary school
$font-size-base: 0.85rem;
$theme-colors: (
"success": #5cb85c,
"warning": #f0ad4e,
"danger": #d9534f,
"info": #5bc0de,
"primary": #3A52A2
) !default;
$enable-shadows: true;
$alert-color-level: 10;
$enable-rounded: false;
$primary: #3A52A2;
$success: #5cb85c;
$warning: #f0ad4e;
$danger: #d9534f;
$info: #5bc0de;
// Pre-computed: Sass serialises the derived (tinted) colour as rgb(...%), which breaks the url-encoded SVG
$form-switch-focus-color: #9da9d1;

View File

@@ -1,158 +0,0 @@
[data-theme='dark'] {
$theme-colors: (
"success": #3AB54A,
"warning": #FFE89B,
"danger": #BF1E2E,
"info": #25AAE2,
"primary": #3A52A2
);
@import "custom-variables";
@import 'node_modules/@forevolve/bootstrap-dark/scss/bootstrap-dark.scss';
background: #222;
color: $gray-100;
$darktheme: #121416;
.navbar {
background-color: #111111 !important;
}
.dropdown-menu {
box-shadow: 0 0.5rem 1rem black;
color: $gray-100;
background: $darktheme;
}
.form-control:not(.btn) {
background-color: $darktheme;
color: $gray-100;
}
.btn-light, .popover, .popover-header, .popover-body, .status {
background-color: $darktheme !important;
color: $gray-100 !important;
border-color: $darktheme;
}
.btn-link {
color: white;
}
.bs-popover-right > .arrow::after {
border-right-color: $darktheme;
}
.bs-popover-left > .arrow::after {
border-left-color: $darktheme;
}
.bs-popover-top > .arrow::after {
border-top-color: $darktheme;
}
.bs-popover-bottom > .arrow::after {
border-bottom-color: $darktheme;
}
a {
color: $blue;
}
.badge, .btn-success, .bg-warning {
color: black;
}
.badge-dark, .badge-secondary, .btn-primary {
color: $gray-100;
}
.input-group-text {
border-color: $darktheme;
}
.btn-secondary, .btn-info {
color: white;
}
.page-item.disabled {
opacity: 0.5;
}
.bg-light {
color: black;
background-color: $gray-200 !important;
border-radius: 0.2em;
}
.fc-day-today {
background-color: transparent !important;
border: 0.2em solid $info !important;
}
.fc-daygrid-dot-event {
color: white !important;
}
.table {
border-collapse: separate !important;
border-spacing: 0;
}
@each $color, $value in $theme-colors {
table.table-#{$color} {
> td,th {
border: 0.3em solid theme-color-level($color, -6) !important;
}
> * {
color: white !important;
background-color: #222 !important;
}
}
#event_row.table-#{$color} {
border: 0.3em solid theme-color-level($color, -6) !important;
background-color: #222 !important;
color: white !important;
}
}
del {
color: black;
background-color: $danger;
border-radius: 3px;
}
ins {
color: black;
background-color: $success;
border-radius: 3px;
}
pre {
background: $gray-600;
color: white;
}
.custom-control-input:focus ~ .custom-control-label::before {
box-shadow: 0 0 0 $input-focus-width rgba($primary, 0.7) !important;
}
.source {
color: white !important;
}
.embed_container {
border-color: #3853a4 !important;
background: #222;
color: $gray-100;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
border: 1px solid $info;
-webkit-text-fill-color: white;
-webkit-box-shadow: 0 0 0px 1000px rgba($info, .3) inset;
transition: background-color 5000s ease-in-out 0s;
}
.editor-toolbar > a {
color: white !important;
}
.editor-toolbar > a:hover {
background: transparent !important;
}
.editor-toolbar > a.active {
background: $info !important;
}
.cm-s-easymde {
color: white;
background-color: $darktheme;
border-color: #bbb;
}
.CodeMirror-cursor {
border-color: white !important;
}
.modal {
overflow-y: auto !important; //Bootstrap Dark Theme overrides this to none for some insane reason so we need to change it back
}
.text-muted {
color: #c9c9c9 !important;
}
}

View File

@@ -1,33 +1,40 @@
// Bootstrap 5 is consumed via @import (the Sass module system is not yet supported by Bootstrap's own build)
@import "custom-variables";
//Required
@import "node_modules/bootstrap/scss/bootstrap-reboot";
@import "node_modules/bootstrap/scss/bootstrap-grid";
@import "node_modules/bootstrap/scss/functions";
@import "node_modules/bootstrap/scss/variables";
@import "node_modules/bootstrap/scss/variables-dark";
@import "node_modules/bootstrap/scss/maps";
@import "node_modules/bootstrap/scss/mixins";
@import "node_modules/bootstrap/scss/utilities";
//Optional
@import "node_modules/bootstrap/scss/root";
@import "node_modules/bootstrap/scss/reboot";
@import "node_modules/bootstrap/scss/type";
@import "node_modules/bootstrap/scss/images";
@import "node_modules/bootstrap/scss/containers";
@import "node_modules/bootstrap/scss/grid";
@import "node_modules/bootstrap/scss/tables";
@import "node_modules/bootstrap/scss/forms";
@import "node_modules/bootstrap/scss/buttons";
@import "node_modules/bootstrap/scss/transitions";
@import "node_modules/bootstrap/scss/dropdown";
@import "node_modules/bootstrap/scss/button-group";
@import "node_modules/bootstrap/scss/input-group";
@import "node_modules/bootstrap/scss/custom-forms";
@import "node_modules/bootstrap/scss/nav";
@import "node_modules/bootstrap/scss/navbar";
@import "node_modules/bootstrap/scss/card";
@import "node_modules/bootstrap/scss/pagination";
@import "node_modules/bootstrap/scss/badge";
@import "node_modules/bootstrap/scss/alert";
@import "node_modules/bootstrap/scss/media";
@import "node_modules/bootstrap/scss/list-group";
@import "node_modules/bootstrap/scss/close";
@import "node_modules/bootstrap/scss/modal";
@import "node_modules/bootstrap/scss/tooltip";
@import "node_modules/bootstrap/scss/popover";
@import "node_modules/bootstrap/scss/progress";
@import "node_modules/bootstrap/scss/spinners";
@import "node_modules/bootstrap/scss/utilities";
@import "node_modules/bootstrap/scss/helpers";
@import "node_modules/bootstrap/scss/utilities/api";
//FontAwesome
$fa-font-path: '/static/fonts';
@import "node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
@@ -96,6 +103,38 @@ html {
}
}
// Bootstrap 4's contextual table classes also worked as background colours on non-table elements
@each $color, $value in $table-variants {
.table-#{$color}:not(table):not(tr):not(td):not(th) {
background-color: var(--bs-table-bg);
}
}
// Bootstrap 4 columns used max-width, so `.form-control.col-sm-6` stayed narrow; Bootstrap 5's
// `.form-control { width: 100% }` now wins over the column width
@each $breakpoint, $_min in $grid-breakpoints {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
@for $i from 1 through $grid-columns {
.form-control.col#{$infix}-#{$i},
.form-select.col#{$infix}-#{$i} {
width: calc(#{$i} / #{$grid-columns} * 100%);
}
}
}
}
// Links inside badges (e.g. "Reviewed by ...") sit on a coloured background, so keep the badge's text colour
.badge a {
color: inherit;
text-decoration: underline;
}
// Bootstrap 4 gave labels a bottom margin, Bootstrap 5 expects .form-label
label {
margin-bottom: 0.5rem;
}
.table tbody > tr > td.vert-align {
vertical-align: middle;
}
@@ -152,7 +191,7 @@ ins {
}
.btn-light {
background-color: $gray-200 !important;
--bs-btn-bg: #{$gray-200};
}
.skip-link {
@@ -272,14 +311,12 @@ html.embedded {
}
}
.card, .card-header, .btn, input, select, .CodeMirror, .editor-toolbar, .card-img-top {
.CodeMirror, .editor-toolbar {
border-radius: 0 !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.bootstrap-select, button.btn.dropdown-toggle.bs-placeholder.btn-light {
padding-right: 1rem !important;
.ts-wrapper.form-select, .ts-wrapper.form-control {
padding: 0;
}
// New implementation of class dropped in Bootstrap 3
.dl-horizontal {
@@ -290,3 +327,221 @@ html.embedded {
.dl-horizontal > dd, .dl-horizontal .markdown > p {
margin-bottom: 0 !important;
}
// Dark mode: Bootstrap 5.3 colour modes (set via data-bs-theme on <html>), with a few project-specific tweaks
$darktheme: #121416;
// Brighter brand colours for dark mode (the light-mode ones are pastel tints on a dark background)
$dark-theme-colors: (
"success": #3AB54A,
"warning": #FFE89B,
"danger": #BF1E2E,
"info": #25AAE2,
"primary": #3A52A2,
"secondary": $secondary,
"light": $light,
"dark": $dark
);
[data-bs-theme="dark"] {
@each $name, $value in $dark-theme-colors {
--bs-#{$name}: #{$value};
--bs-#{$name}-rgb: #{to-rgb($value)};
.btn-#{$name} {
@include button-variant($value, $value);
}
.btn-outline-#{$name} {
@include button-outline-variant($value);
}
.text-bg-#{$name} {
color: color-contrast($value) !important;
background-color: RGBA(to-rgb($value), var(--bs-bg-opacity, 1)) !important;
}
// Contextual table classes (tables, rows, cells and the plain-element variant used for the rigboard key)
.table-#{$name} {
--bs-table-color: #{color-contrast(shade-color($value, 55%))};
--bs-table-bg: #{shade-color($value, 55%)};
--bs-table-border-color: #{shade-color($value, 35%)};
--bs-table-striped-bg: #{shade-color($value, 50%)};
--bs-table-hover-bg: #{shade-color($value, 45%)};
color: var(--bs-table-color);
border-color: var(--bs-table-border-color);
}
}
--bs-body-bg: #222;
--bs-body-color: #{$gray-100};
// Bootstrap derives these from $primary, which is too dim on the dark background
--bs-link-color: #6ea8fe;
--bs-link-color-rgb: #{to-rgb(#6ea8fe)};
--bs-link-hover-color: #8bb9fe;
--bs-link-hover-color-rgb: #{to-rgb(#8bb9fe)};
--bs-secondary-color: #c9c9c9;
--bs-tertiary-bg: #{$darktheme};
.navbar {
background-color: #111111 !important;
}
// Coloured card outlines (training levels): make them visible, including the "dark" and "light" ones
.card[class*="border-"] {
--bs-card-border-width: 2px;
}
.border-dark {
border-color: $gray-500 !important;
}
.dropdown-menu, .popover {
box-shadow: 0 0.5rem 1rem black;
--bs-dropdown-bg: #{$darktheme};
--bs-popover-bg: #{$darktheme};
}
.form-control:not(.btn), .form-select, .ts-control, .ts-dropdown {
background-color: $darktheme;
color: $gray-100;
}
// Tom Select hard-codes dark text colours
.ts-wrapper .ts-control,
.ts-wrapper .ts-control input,
.ts-dropdown,
.ts-dropdown .option,
.ts-dropdown .optgroup-header,
.ts-dropdown .no-results,
.ts-dropdown .create {
color: $gray-100;
}
.ts-wrapper .ts-control input::placeholder {
color: $gray-500;
}
.ts-wrapper.multi .ts-control > div {
background: $gray-700;
color: $gray-100;
border-color: $gray-600;
}
.ts-wrapper.multi .ts-control > div.active {
background: $primary;
color: #fff;
}
.ts-dropdown .active {
background-color: $gray-800;
color: $gray-100;
}
.ts-dropdown .active.create {
color: $gray-100;
}
.ts-dropdown .optgroup-header {
background: $darktheme;
}
::placeholder {
color: $gray-500;
opacity: 1;
}
.btn-light, .status {
--bs-btn-bg: #{$darktheme};
--bs-btn-color: #{$gray-100};
--bs-btn-border-color: #{$darktheme};
--bs-btn-hover-bg: #{tint-color($darktheme, 8%)};
--bs-btn-hover-color: #{$gray-100};
--bs-btn-hover-border-color: #{$darktheme};
background-color: $darktheme !important;
color: $gray-100 !important;
border-color: $darktheme;
}
.btn-link {
color: white;
}
.input-group-text {
border-color: $darktheme;
}
.page-item.disabled {
opacity: 0.5;
}
.bg-light {
color: black;
background-color: $gray-200 !important;
border-radius: 0.2em;
}
.fc-day-today {
background-color: transparent !important;
border: 0.2em solid $info !important;
}
.fc-daygrid-dot-event {
color: white !important;
}
@each $color, $value in $dark-theme-colors {
#event_row.table-#{$color} {
border: 0.3em solid shade-color($value, 60%) !important;
background-color: #222 !important;
color: white !important;
}
}
del {
color: black;
background-color: $danger;
border-radius: 3px;
}
ins {
color: black;
background-color: $success;
border-radius: 3px;
}
pre {
background: $gray-600;
color: white;
}
.source {
color: white !important;
}
.embed_container {
border-color: #3853a4 !important;
background: #222;
color: $gray-100;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
border: 1px solid $info;
-webkit-text-fill-color: white;
-webkit-box-shadow: 0 0 0px 1000px rgba($info, .3) inset;
transition: background-color 5000s ease-in-out 0s;
}
.editor-toolbar > a {
color: white !important;
}
.editor-toolbar > a:hover {
background: transparent !important;
}
.editor-toolbar > a.active {
background: $info !important;
}
.cm-s-easymde {
color: white;
background-color: $darktheme;
border-color: #bbb;
}
.CodeMirror-cursor {
border-color: white !important;
}
}
.ts-wrapper.has-items .ts-control > input::placeholder {
color: transparent;
}
// FullCalendar (standard theme) colours, matching the site's light/dark modes
.fc {
--fc-border-color: var(--bs-border-color);
--fc-page-bg-color: var(--bs-body-bg);
--fc-neutral-bg-color: var(--bs-tertiary-bg);
--fc-today-bg-color: rgba(var(--bs-warning-rgb), 0.15);
--fc-list-event-hover-bg-color: var(--bs-tertiary-bg);
}
.fc a {
color: inherit;
text-decoration: none;
}