feat: more changes and fixes

This commit is contained in:
Herculino Trotta
2025-11-05 13:09:31 -03:00
parent 0a4d4c12b9
commit a878af28f1
34 changed files with 595 additions and 556 deletions

66
frontend/src/js/_htmx.js Normal file
View File

@@ -0,0 +1,66 @@
import htmx from "htmx.org";
window.htmx = htmx;
htmx.defineExtension('htmx-download', {
onEvent: function (name, evt) {
if (name === 'htmx:beforeRequest') {
// Set the responseType to 'arraybuffer' to handle binary data
evt.detail.xhr.responseType = 'arraybuffer';
}
if (name === 'htmx:beforeSwap') {
const xhr = evt.detail.xhr;
if (xhr.status === 200) {
// Parse headers
const headers = {};
const headerStr = xhr.getAllResponseHeaders();
const headerArr = headerStr.trim().split(/[\r\n]+/);
headerArr.forEach((line) => {
const parts = line.split(": ");
const header = parts.shift().toLowerCase();
const value = parts.join(": ");
headers[header] = value;
});
// Extract filename
let filename = 'downloaded_file.xlsx';
if (headers['content-disposition']) {
const filenameMatch = headers['content-disposition'].match(/filename\*?=(?:UTF-8'')?"?([^;\n"]+)/i);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
}
}
// Determine MIME type
const mimetype = headers['content-type'] || 'application/octet-stream';
// Create Blob
const blob = new Blob([xhr.response], {type: mimetype});
const url = URL.createObjectURL(blob);
// Trigger download
const link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
setTimeout(() => {
URL.revokeObjectURL(url);
link.remove();
}, 100);
} else {
console.warn(`[htmx-download] Unexpected response status: ${xhr.status}`);
}
// Prevent htmx from swapping content
evt.detail.shouldSwap = false;
}
},
});

37
frontend/src/js/_utils.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* Converts ANY valid CSS color string (oklch, hex, hsl, etc.)
* into a standard RGBA string that Chart.js can understand.
* This method uses a canvas to force the browser to compute the color.
* @param {string} colorString The color string to convert.
* @returns {string} The computed 'rgba(r, g, b, a)' string.
*/
window.convertColorToRgba = function convertColorToRgba(colorString) {
if (!colorString) return 'rgba(0,0,0,0.1)'; // Fallback
console.log(colorString)
// Create a 1x1 pixel canvas
let canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
let ctx = canvas.getContext('2d');
// Set the fillStyle to the color string
// The browser MUST parse the oklch string here
ctx.fillStyle = colorString.trim();
// Draw the pixel
ctx.fillRect(0, 0, 1, 1);
// Get the pixel data. This is ALWAYS returned as [R, G, B, A]
// with values from 0-255.
const data = ctx.getImageData(0, 0, 1, 1).data;
// Convert the 0-255 alpha to a 0-1 float
const a = data[3] / 255;
console.log(data)
// Return the standard rgba string
return `rgba(${data[0]}, ${data[1]}, ${data[2]}, ${a})`;
}

View File

@@ -0,0 +1,11 @@
document.addEventListener("input", function (e) {
// Check if the element that triggered the input event is a <textarea>
if (e.target.tagName.toLowerCase() === "textarea") {
// Reset height to 'auto' to allow the textarea to shrink
e.target.style.height = "auto";
// Set the height to its scrollHeight (the full height of the content)
e.target.style.height = (e.target.scrollHeight + 5) + "px";
}
}, false);

27
frontend/src/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,27 @@
import './_tooltip.js';
import 'bootstrap/js/dist/dropdown';
import Toast from 'bootstrap/js/dist/toast';
import 'bootstrap/js/dist/dropdown';
import 'bootstrap/js/dist/collapse';
import Offcanvas from 'bootstrap/js/dist/offcanvas';
window.Offcanvas = Offcanvas;
function initiateToasts() {
const toastElList = document.querySelectorAll('.toasty');
const toastList = [...toastElList].map(toastEl => new Toast(toastEl)); // eslint-disable-line no-undef
for (let i = 0; i < toastList.length; i++) {
if (toastList[i].isShown() === false) {
toastList[i].show();
toastList[i]._element.addEventListener('hidden.bs.toast', (event) => {
event.target.remove();
});
}
}
}
document.addEventListener('DOMContentLoaded', initiateToasts, false);
document.addEventListener('htmx:afterSwap', initiateToasts, false);
initiateToasts();

View File

@@ -0,0 +1,5 @@
import Chart from 'chart.js/auto';
import {SankeyController, Flow} from 'chartjs-chart-sankey';
Chart.register(SankeyController, Flow);
window.Chart = Chart;

View File

@@ -0,0 +1,308 @@
import AirDatepicker from 'air-datepicker';
import {createPopper} from '@popperjs/core';
// --- Static Locale Imports ---
// We import all locales statically to ensure Vite transforms them correctly.
import localeAr from 'air-datepicker/locale/ar.js';
import localeBg from 'air-datepicker/locale/bg.js';
import localeCa from 'air-datepicker/locale/ca.js';
import localeCs from 'air-datepicker/locale/cs.js';
import localeDa from 'air-datepicker/locale/da.js';
import localeDe from 'air-datepicker/locale/de.js';
import localeEl from 'air-datepicker/locale/el.js';
import localeEn from 'air-datepicker/locale/en.js';
import localeEs from 'air-datepicker/locale/es.js';
import localeEu from 'air-datepicker/locale/eu.js';
import localeFi from 'air-datepicker/locale/fi.js';
import localeFr from 'air-datepicker/locale/fr.js';
import localeHr from 'air-datepicker/locale/hr.js';
import localeHu from 'air-datepicker/locale/hu.js';
import localeId from 'air-datepicker/locale/id.js';
import localeIt from 'air-datepicker/locale/it.js';
import localeJa from 'air-datepicker/locale/ja.js';
import localeKo from 'air-datepicker/locale/ko.js';
import localeNb from 'air-datepicker/locale/nb.js';
import localeNl from 'air-datepicker/locale/nl.js';
import localePl from 'air-datepicker/locale/pl.js';
import localePtBr from 'air-datepicker/locale/pt-BR.js';
import localePt from 'air-datepicker/locale/pt.js';
import localeRo from 'air-datepicker/locale/ro.js';
import localeRu from 'air-datepicker/locale/ru.js';
import localeSi from 'air-datepicker/locale/si.js';
import localeSk from 'air-datepicker/locale/sk.js';
import localeSl from 'air-datepicker/locale/sl.js';
import localeSv from 'air-datepicker/locale/sv.js';
import localeTh from 'air-datepicker/locale/th.js';
import localeTr from 'air-datepicker/locale/tr.js';
import localeUk from 'air-datepicker/locale/uk.js';
import localeZh from 'air-datepicker/locale/zh.js';
// Map language codes to their imported locale objects
const allLocales = {
'ar': localeAr,
'bg': localeBg,
'ca': localeCa,
'cs': localeCs,
'da': localeDa,
'de': localeDe,
'el': localeEl,
'en': localeEn,
'es': localeEs,
'eu': localeEu,
'fi': localeFi,
'fr': localeFr,
'hr': localeHr,
'hu': localeHu,
'id': localeId,
'it': localeIt,
'ja': localeJa,
'ko': localeKo,
'nb': localeNb,
'nl': localeNl,
'pl': localePl,
'pt-BR': localePtBr,
'pt': localePt,
'ro': localeRo,
'ru': localeRu,
'si': localeSi,
'sk': localeSk,
'sl': localeSl,
'sv': localeSv,
'th': localeTh,
'tr': localeTr,
'uk': localeUk,
'zh': localeZh
};
// --- End of Locale Imports ---
/**
 * Selects a pre-imported language file from the locale map.
 *
 * @param {string} langCode - The two-letter language code (e.g., 'en', 'es').
 * @returns {Promise<object>} A promise that resolves with the locale object.
 */
export const getLocale = async (langCode) => {
const locale = allLocales[langCode];
if (locale) {
return locale;
}
console.warn(`Could not find locale for '${langCode}'. Defaulting to English.`);
return allLocales['en']; // Default to English
};
function isMobileDevice() {
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
return mobileRegex.test(navigator.userAgent);
}
function isTouchDevice() {
return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
}
function isMobile() {
return isMobileDevice() || isTouchDevice();
}
window.DatePicker = async function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.setViewDate(date);
}
};
let isOnMobile = isMobile();
let baseOpts = {
isMobile: isOnMobile,
dateFormat: element.dataset.dateFormat,
timeFormat: element.dataset.timeFormat,
timepicker: element.dataset.timepicker === 'true',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: await getLocale(element.dataset.language),
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
name: 'flip',
options: {
padding: {
top: 64
}
}
},
{
name: 'offset',
options: {
offset: [0, 20]
}
},
{
name: 'arrow',
options: {
element: $pointer
}
}
]
});
return function completeHide() {
popper.destroy();
done();
};
}
} : {};
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [element.dataset.value];
opts["startDate"] = [element.dataset.value];
}
return new AirDatepicker(element, opts);
};
window.MonthYearPicker = async function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.setViewDate(date);
}
};
let isOnMobile = isMobile();
let baseOpts = {
isMobile: isOnMobile,
view: 'months',
minView: 'months',
dateFormat: 'MMMM yyyy',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: await getLocale(element.dataset.language),
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
name: 'flip',
options: {
padding: {
top: 64
}
}
},
{
name: 'offset',
options: {
offset: [0, 20]
}
},
{
name: 'arrow',
options: {
element: $pointer
}
}
]
});
return function completeHide() {
popper.destroy();
done();
};
}
} : {};
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
}
return new AirDatepicker(element, opts);
};
window.YearPicker = async function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.setViewDate(date);
}
};
let isOnMobile = isMobile();
let baseOpts = {
isMobile: isOnMobile,
view: 'years',
minView: 'years',
dateFormat: 'yyyy',
toggleSelected: element.dataset.toggleSelected === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: await getLocale(element.dataset.language),
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
name: 'flip',
options: {
padding: {
top: 64
}
}
},
{
name: 'offset',
options: {
offset: [0, 20]
}
},
{
name: 'arrow',
options: {
element: $pointer
}
}
]
});
return function completeHide() {
popper.destroy();
done();
};
}
} : {};
let opts = {...baseOpts, ...positionConfig};
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
}
return new AirDatepicker(element, opts);
};

40
frontend/src/js/htmx.js Normal file
View File

@@ -0,0 +1,40 @@
import _hyperscript from 'hyperscript.org/dist/_hyperscript.min';
import './_htmx.js';
import Alpine from "alpinejs";
import mask from '@alpinejs/mask';
import {create, all} from 'mathjs';
window.Alpine = Alpine;
window._hyperscript = _hyperscript;
window.math = create(all, {
number: 'BigNumber', // Default type of number:
// 'number' (default), 'BigNumber', or 'Fraction'
precision: 64, // Number of significant digits for BigNumbers
relTol: 1e-60,
absTol: 1e-63
});
Alpine.plugin(mask);
Alpine.start();
_hyperscript.browserInit();
const successAudio = new Audio("/static/sounds/success.mp3");
const popAudio = new Audio("/static/sounds/pop.mp3");
window.paidSound = successAudio;
window.unpaidSound = popAudio;
/**
* Parse a localized number to a float.
* @param {string} stringNumber - the localized number
* @param {string} locale - [optional] the locale that the number is represented in. Omit this parameter to use the current locale.
*/
window.parseLocaleNumber = function parseLocaleNumber(stringNumber, locale) {
let thousandSeparator = Intl.NumberFormat(locale).format(11111).replace(/\d/g, '');
let decimalSeparator = Intl.NumberFormat(locale).format(1.1).replace(/\d/g, '');
return parseFloat(stringNumber
.replace(new RegExp('\\' + thousandSeparator, 'g'), '')
.replace(new RegExp('\\' + decimalSeparator), '.')
);
};

3
frontend/src/js/jquery.js vendored Normal file
View File

@@ -0,0 +1,3 @@
const $ = require('jquery');
window.jQuery = $;
window.$ = $;

103
frontend/src/js/select.js Normal file
View File

@@ -0,0 +1,103 @@
// import 'tom-select/dist/css/tom-select.default.min.css';
import TomSelect from "tom-select";
import * as Popper from "@popperjs/core";
window.TomSelect = function createDynamicTomSelect(element) {
// Basic configuration
const config = {
plugins: {},
maxOptions: null,
// Extract 'create' option from data attribute
create: element.dataset.create === 'true',
copyClassesToDropdown: true,
allowEmptyOption: element.dataset.allowEmptyOption === 'true',
render: {
no_results: function () {
return `<div class="no-results">${element.dataset.txtNoResults || 'No results...'}</div>`;
},
option_create: function (data, escape) {
return `<div class="create">${element.dataset.txtCreate || 'Add'} <strong>${escape(data.input)}</strong>&hellip;</div>`;
},
},
onInitialize: function () {
// Move dropdown to body to escape stacking context issues
document.body.appendChild(this.dropdown);
this.popper = Popper.createPopper(this.control, this.dropdown, {
placement: "bottom-start",
strategy: "fixed",
modifiers: [
{
name: "sameWidth",
enabled: true,
fn: ({ state }) => {
state.styles.popper.width = `${state.rects.reference.width}px`;
},
phase: "beforeWrite",
requires: ["computeStyles"],
},
{
name: 'flip',
options: {
fallbackPlacements: ['top-start'],
},
},
{
name: 'preventOverflow',
options: {
boundary: 'viewport',
padding: 8,
},
},
]
});
},
onDropdownOpen: function () {
this.popper.update();
},
onDropdownClose: function () {
// Optional: move back to wrapper to keep DOM clean, but not necessary
}
};
if (element.dataset.checkboxes === 'true') {
config.plugins.checkbox_options = {
'checkedClassNames': ['ts-checked'],
'uncheckedClassNames': ['ts-unchecked'],
};
}
if (element.dataset.clearButton === 'true') {
config.plugins.clear_button = {
'title': element.dataset.txtClear || 'Clear',
};
}
if (element.dataset.removeButton === 'true') {
config.plugins.remove_button = {
'title': element.dataset.txtRemove || 'Remove',
};
}
if (element.dataset.load) {
config.load = function (query, callback) {
let url = element.dataset.load + '?q=' + encodeURIComponent(query);
fetch(url)
.then(response => response.json())
.then(json => {
callback(json);
}).catch(() => {
callback();
});
};
}
// Create and return the TomSelect instance
return new TomSelect(element, config);
};

4
frontend/src/js/style.js Normal file
View File

@@ -0,0 +1,4 @@
import '@fontsource-variable/jetbrains-mono/wght-italic.css';
import '@fontsource-variable/jetbrains-mono';
import '../styles/tailwind.css';
import '../styles/style.scss';

View File

@@ -0,0 +1,2 @@
import Swal from 'sweetalert2';
window.Swal = Swal;