Chore: Bump version to v1.1.0 and implement automated release system

This commit is contained in:
dyzulk
2026-01-17 13:01:05 +07:00
parent 64609a5821
commit 5b0b6de2dc
69 changed files with 3157 additions and 2375 deletions

View File

@@ -791,15 +791,21 @@ body {
.form-label {
margin-bottom: 0.25rem;
display: block;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 500;
color: var(--accents-5);
font-size: 0.75rem;
line-height: 1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accents-6);
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.form-label:is(.dark *) {
color: var(--accents-3);
}
.glass-label, .modal-glass .form-label {
font-weight: 600;
color: var(--foreground);
@@ -1114,12 +1120,15 @@ input:-webkit-autofill,
}
.checkbox:checked {
border-color: var(--foreground);
background-color: var(--foreground);
--tw-border-opacity: 1;
border-color: rgb(37 99 235 / var(--tw-border-opacity, 1));
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
}
.checkbox:hover {
border-color: var(--foreground);
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
@@ -1131,7 +1140,7 @@ input:-webkit-autofill,
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
--tw-ring-color: var(--accents-2);
--tw-ring-color: rgb(37 99 235 / 0.2);
}
.checkbox:disabled {
@@ -1140,7 +1149,13 @@ input:-webkit-autofill,
}
.checkbox:is(.dark *) {
background-color: rgb(255 255 255 / 0.05);
border-color: rgb(255 255 255 / 0.3);
background-color: rgb(255 255 255 / 0.1);
}
.checkbox:hover:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(96 165 250 / var(--tw-border-opacity, 1));
}
.checkbox {
@@ -1150,11 +1165,11 @@ input:-webkit-autofill,
}
.checkbox:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
.dark .checkbox:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='black' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
.card, .glass-card {
@@ -1233,6 +1248,7 @@ input:-webkit-autofill,
pointer-events: none;
visibility: hidden;
position: absolute;
top: 100%;
z-index: 50;
margin-top: 0.25rem;
display: flex;
@@ -1280,6 +1296,21 @@ input:-webkit-autofill,
opacity: 1;
}
.custom-select-dropdown.dropdown-up {
bottom: 100%;
top: auto;
margin-bottom: 0.25rem;
margin-top: 0px;
transform-origin: bottom;
--tw-translate-y: 0.5rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.custom-select-dropdown.dropdown-up.open {
--tw-translate-y: 0px;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
/* Premium Control Pill & Segmented Switch */
.control-pill {
@@ -1454,6 +1485,28 @@ input:-webkit-autofill,
--tw-ring-color: var(--accents-3);
}
/* Dropdown Bridge to prevent accidental closure on margin gaps */
.dropdown-bridge::before {
content: "";
position: absolute;
top: -1.25rem;
left: 0;
right: 0;
height: 1.25rem;
background: transparent;
z-index: -1;
}
/* Specific Bridge expansion for Notification to make it more "sticky" */
#notification-dropdown.dropdown-bridge::before {
inset: -2rem -3rem;
/* Expand 32px top/bottom, 48px left/right */
top: -2.5rem;
/* Ensure it covers the gap to the button */
}
/* Glassmorphism Table */
.table-container {
@@ -1571,14 +1624,6 @@ input:-webkit-autofill,
opacity: 1;
}
.modal-title {
font-size: 1.125rem;
line-height: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--foreground);
}
.sr-only {
position: absolute;
width: 1px;
@@ -1673,6 +1718,10 @@ input:-webkit-autofill,
top: -20%;
}
.bottom-1 {
bottom: 0.25rem;
}
.bottom-6 {
bottom: 1.5rem;
}
@@ -1693,6 +1742,10 @@ input:-webkit-autofill,
right: 0px;
}
.right-1 {
right: 0.25rem;
}
.right-2 {
right: 0.5rem;
}
@@ -1705,16 +1758,12 @@ input:-webkit-autofill,
right: 1rem;
}
.right-6 {
right: 1.5rem;
}
.top-0 {
top: 0px;
}
.top-1\/2 {
top: 50%;
.top-1 {
top: 0.25rem;
}
.top-2 {
@@ -1795,11 +1844,6 @@ input:-webkit-autofill,
margin-right: 0.25rem;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
@@ -2132,10 +2176,6 @@ input:-webkit-autofill,
width: 5rem;
}
.w-24 {
width: 6rem;
}
.w-3 {
width: 0.75rem;
}
@@ -2192,6 +2232,10 @@ input:-webkit-autofill,
width: calc(100% - 1.5rem);
}
.w-\[calc\(50\%-4px\)\] {
width: calc(50% - 4px);
}
.w-auto {
width: auto;
}
@@ -2225,10 +2269,6 @@ input:-webkit-autofill,
max-width: 56rem;
}
.max-w-5xl {
max-width: 64rem;
}
.max-w-7xl {
max-width: 80rem;
}
@@ -2289,6 +2329,14 @@ input:-webkit-autofill,
flex-grow: 1;
}
.origin-bottom-left {
transform-origin: bottom left;
}
.origin-bottom-right {
transform-origin: bottom right;
}
.origin-right {
transform-origin: right;
}
@@ -2315,11 +2363,6 @@ input:-webkit-autofill,
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.-translate-y-1\/2 {
--tw-translate-y: -50%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.translate-x-full {
--tw-translate-x: 100%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
@@ -2520,10 +2563,10 @@ input:-webkit-autofill,
gap: 2rem;
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
@@ -2608,6 +2651,10 @@ input:-webkit-autofill,
overflow: hidden;
}
.overflow-visible {
overflow: visible;
}
.overflow-x-auto {
overflow-x: auto;
}
@@ -2697,9 +2744,9 @@ input:-webkit-autofill,
border-bottom-right-radius: 0px;
}
.rounded-t-md {
border-top-left-radius: 0.375rem;
border-top-right-radius: 0.375rem;
.rounded-t-xl {
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
}
.border {
@@ -2722,6 +2769,10 @@ input:-webkit-autofill,
border-bottom-width: 2px;
}
.border-l {
border-left-width: 1px;
}
.border-l-0 {
border-left-width: 0px;
}
@@ -2822,6 +2873,10 @@ input:-webkit-autofill,
border-color: rgb(255 255 255 / 0.2);
}
.border-white\/5 {
border-color: rgb(255 255 255 / 0.05);
}
.\!bg-red-50\/50 {
background-color: rgb(254 242 242 / 0.5) !important;
}
@@ -3067,6 +3122,11 @@ input:-webkit-autofill,
object-fit: contain;
}
.object-cover {
-o-object-fit: cover;
object-fit: cover;
}
.\!p-0 {
padding: 0px !important;
}
@@ -3234,6 +3294,14 @@ input:-webkit-autofill,
padding-left: 0.75rem;
}
.pl-4 {
padding-left: 1rem;
}
.pl-6 {
padding-left: 1.5rem;
}
.pl-9 {
padding-left: 2.25rem;
}
@@ -3250,6 +3318,10 @@ input:-webkit-autofill,
padding-right: 0.75rem;
}
.pr-6 {
padding-right: 1.5rem;
}
.pr-8 {
padding-right: 2rem;
}
@@ -3577,6 +3649,11 @@ input:-webkit-autofill,
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.text-yellow-400 {
--tw-text-opacity: 1;
color: rgb(250 204 21 / var(--tw-text-opacity, 1));
}
.text-yellow-500 {
--tw-text-opacity: 1;
color: rgb(234 179 8 / var(--tw-text-opacity, 1));
@@ -3611,10 +3688,6 @@ input:-webkit-autofill,
opacity: 0.5;
}
.opacity-60 {
opacity: 0.6;
}
.opacity-70 {
opacity: 0.7;
}
@@ -3709,6 +3782,11 @@ input:-webkit-autofill,
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.backdrop-blur-2xl {
--tw-backdrop-blur: blur(40px);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.backdrop-blur-\[40px\] {
--tw-backdrop-blur: blur(40px);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
@@ -3867,6 +3945,15 @@ div.swal2-popup {
padding: 1.5rem !important;
}
div:where(.swal2-container) {
z-index: 9999 !important;
}
div:where(.swal2-popup).swal-wide {
width: auto !important;
max-width: 900px !important;
}
/* Dark mode background fix for glassmorphism */
.dark div.swal2-popup {
@@ -4203,6 +4290,66 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
border-color: rgba(255, 255, 255, 0.1) !important;
}
/* SweetAlert2 Premium Input Styles Override */
.swal2-premium-card .form-label {
margin-bottom: 0.5rem;
font-weight: 700;
color: var(--accents-8);
}
.swal2-premium-card .form-label:is(.dark *) {
color: var(--accents-2);
}
.swal2-premium-card input:not([type="checkbox"]):not([type="radio"]),
.swal2-premium-card select,
.swal2-premium-card textarea {
display: block;
width: 100%;
border-radius: 0.5rem;
border-width: 1px;
border-color: var(--accents-2);
background-color: rgb(255 255 255 / 0.5);
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--foreground);
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.swal2-premium-card input:not([type="checkbox"]):not([type="radio"]):focus,
.swal2-premium-card select:focus,
.swal2-premium-card textarea:focus {
border-color: var(--foreground);
background-color: rgb(255 255 255 / 0.8);
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
--tw-ring-color: var(--accents-2);
}
.dark .swal2-premium-card input:not([type="checkbox"]):not([type="radio"]),
.dark .swal2-premium-card select,
.dark .swal2-premium-card textarea {
background-color: rgb(0 0 0 / 0.2);
border-color: var(--accents-2);
}
.dark .swal2-premium-card input:not([type="checkbox"]):not([type="radio"]):focus,
.dark .swal2-premium-card select:focus,
.dark .swal2-premium-card textarea:focus {
background-color: rgb(0 0 0 / 0.4);
border-color: var(--foreground);
}
.selection\:bg-accents-2 *::-moz-selection {
background-color: var(--accents-2);
}
@@ -4251,14 +4398,6 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
color: var(--foreground);
}
.placeholder\:text-accents-3::-moz-placeholder {
color: var(--accents-3);
}
.placeholder\:text-accents-3::placeholder {
color: var(--accents-3);
}
.after\:absolute::after {
content: var(--tw-content);
position: absolute;
@@ -4310,6 +4449,10 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.hover\:border-accents-2:hover {
border-color: var(--accents-2);
}
.hover\:border-foreground:hover {
border-color: var(--foreground);
}
@@ -4349,6 +4492,11 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
}
.hover\:bg-emerald-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(5 150 105 / var(--tw-bg-opacity, 1));
@@ -4998,6 +5146,10 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
grid-column: span 2 / span 2;
}
.lg\:block {
display: block;
}
.lg\:h-\[calc\(100vh-8rem\)\] {
height: calc(100vh - 8rem);
}

View File

@@ -1,94 +1,107 @@
class SimpleDataTable {
constructor(tableSelector, options = {}) {
/**
* Mivo Component: Datatable
* A simple, lightweight, client-side datatable.
*/
class DataTable {
constructor(tableSelector, options = {}) {
this.table = document.querySelector(tableSelector);
if (!this.table) return;
this.tbody = this.table.querySelector('tbody');
this.rows = Array.from(this.tbody.querySelectorAll('tr'));
this.originalRows = [...this.rows]; // Keep copy
this.originalRows = [...this.rows];
this.options = {
itemsPerPage: 10,
searchable: true,
pagination: true,
filters: [], // Array of { index: number, label: string }
filters: [],
...options
};
this.currentPage = 1;
this.searchQuery = '';
this.activeFilters = {}; // { columnIndex: value }
this.activeFilters = {};
this.filteredRows = [...this.originalRows];
// Wait for translations to load if i18n is used
if (window.i18n && window.i18n.ready) {
window.i18n.ready.then(() => this.init());
} else {
this.init();
// Listen for language changes via Mivo
if (window.Mivo) {
window.Mivo.on('languageChanged', () => {
this.reTranslate();
this.render();
});
}
// Listen for language change
window.addEventListener('languageChanged', () => {
this.reTranslate();
this.render();
});
// Wait for I18n readiness if available
if (window.i18n && window.i18n.ready) {
window.i18n.ready.then(() => this.init());
} else {
this.init();
}
}
reTranslate() {
// Update perPage label
const labels = this.wrapper.querySelectorAll('span.text-accents-5');
labels.forEach(label => {
if (label.textContent.includes('entries per page') || (window.i18n && label.textContent === window.i18n.t('common.table.entries_per_page'))) {
label.textContent = window.i18n ? window.i18n.t('common.table.entries_per_page') : 'entries per page';
}
});
const i18n = window.Mivo?.modules?.I18n || window.i18n;
if (!i18n) return;
// Update search placeholder
const searchInput = this.wrapper.querySelector('input[type="text"]');
if (searchInput) {
searchInput.placeholder = window.i18n ? window.i18n.t('common.table.search_placeholder') : 'Search...';
}
// Labels
const labels = this.wrapper.querySelectorAll('.datatable-label');
labels.forEach(l => l.textContent = i18n.t('common.table.entries_per_page'));
// Update All option
const perPageSelect = this.wrapper.querySelector('select');
// Placeholder
const searchInput = this.wrapper.querySelector('input.form-input-search');
if (searchInput) searchInput.placeholder = i18n.t('common.table.search_placeholder');
// "All" option
const perPageSelect = this.wrapper.querySelector('select.form-filter');
if (perPageSelect) {
const allOption = Array.from(perPageSelect.options).find(opt => opt.value === "-1");
if (allOption) {
allOption.text = window.i18n ? window.i18n.t('common.table.all') : 'All';
allOption.text = i18n.t('common.table.all');
// Refresh custom select UI if needed
if (window.Mivo?.components?.Select) {
const instance = window.Mivo.components.Select.get(perPageSelect.id || '');
if (instance) instance.refresh();
}
}
}
}
init() {
// Create Wrapper
const i18n = window.Mivo?.modules?.I18n || window.i18n;
// Wrapper
this.wrapper = document.createElement('div');
this.wrapper.className = 'datatable-wrapper space-y-4';
this.table.parentNode.insertBefore(this.wrapper, this.table);
// Create Controls Header
// Header Controls
const header = document.createElement('div');
header.className = 'flex flex-col sm:flex-row justify-between items-center gap-4 mb-4';
// Show Entries Wrapper
// Left Controls
const controlsLeft = document.createElement('div');
controlsLeft.className = 'flex items-center gap-3 w-full sm:w-auto flex-wrap';
// Per Page Select
const perPageSelect = document.createElement('select');
perPageSelect.className = 'form-filter w-20';
perPageSelect.className = 'form-filter w-20';
// Add ID for CustomSelect registry if needed
perPageSelect.id = 'dt-perpage-' + Math.random().toString(36).substr(2, 9);
[5, 10, 25, 50, 100].forEach(num => {
const option = document.createElement('option');
option.value = num;
option.text = num;
if (num === this.options.itemsPerPage) option.selected = true;
perPageSelect.appendChild(option);
const opt = document.createElement('option');
opt.value = num;
opt.text = num;
if (num === this.options.itemsPerPage) opt.selected = true;
perPageSelect.appendChild(opt);
});
// All option
const allOption = document.createElement('option');
allOption.value = -1;
allOption.text = window.i18n ? window.i18n.t('common.table.all') : 'All';
perPageSelect.appendChild(allOption);
// All Option
const allOpt = document.createElement('option');
allOpt.value = -1;
allOpt.text = i18n ? i18n.t('common.table.all') : 'All';
perPageSelect.appendChild(allOpt);
perPageSelect.addEventListener('change', (e) => {
const val = parseInt(e.target.value);
@@ -99,31 +112,30 @@ class SimpleDataTable {
// Label
const label = document.createElement('span');
label.className = 'text-sm text-accents-5 whitespace-nowrap';
label.textContent = window.i18n ? window.i18n.t('common.table.entries_per_page') : 'entries per page';
label.className = 'text-sm text-accents-5 whitespace-nowrap datatable-label';
label.textContent = i18n ? i18n.t('common.table.entries_per_page') : 'entries per page';
controlsLeft.appendChild(perPageSelect);
controlsLeft.appendChild(label);
// Initialize Filters if provided
// Init Custom Select using Mivo Component
if (window.Mivo?.components?.Select) {
new window.Mivo.components.Select(perPageSelect);
}
// Filters
if (this.options.filters && this.options.filters.length > 0) {
this.options.filters.forEach(filterConfig => {
this.initFilter(filterConfig, controlsLeft); // Append to Left Controls
});
this.options.filters.forEach(config => this.initFilter(config, controlsLeft));
}
header.appendChild(controlsLeft);
// Initialize CustomSelect if available (for perPage)
if (typeof CustomSelect !== 'undefined') {
new CustomSelect(perPageSelect);
}
// Search Input
// Search
if (this.options.searchable) {
const searchWrapper = document.createElement('div');
searchWrapper.className = 'input-group sm:w-64 z-10';
const placeholder = window.i18n ? window.i18n.t('common.table.search_placeholder') : 'Search...';
const placeholder = i18n ? i18n.t('common.table.search_placeholder') : 'Search...';
searchWrapper.innerHTML = `
<div class="input-icon">
<i data-lucide="search" class="w-4 h-4"></i>
@@ -137,21 +149,15 @@ class SimpleDataTable {
this.wrapper.appendChild(header);
// Move Table into Wrapper
// Move Table into Wrapper
// Table Container
this.tableWrapper = document.createElement('div');
this.tableWrapper.className = 'rounded-md border border-accents-2 overflow-x-auto bg-white/30 dark:bg-black/30 backdrop-blur-sm'; // overflow-x-auto for responsiveness
this.tableWrapper.className = 'rounded-md border border-accents-2 overflow-x-auto bg-white/30 dark:bg-black/30 backdrop-blur-sm';
this.tableWrapper.appendChild(this.table);
this.wrapper.appendChild(this.tableWrapper);
// Render Icons for Header Controls
if (typeof lucide !== 'undefined') {
lucide.createIcons({
root: header
});
}
if (typeof lucide !== 'undefined') lucide.createIcons({ root: header });
// Pagination Controls
// Pagination
if (this.options.pagination) {
this.paginationContainer = document.createElement('div');
this.paginationContainer.className = 'flex items-center justify-between px-2';
@@ -162,29 +168,23 @@ class SimpleDataTable {
}
initFilter(config, container) {
// config = { index: number, label: string }
const colIndex = config.index;
// Get unique values
const values = new Set();
this.originalRows.forEach(row => {
const cell = row.cells[colIndex];
if (cell) {
const text = cell.textContent.trim();
// Basic cleanup: remove extra whitespace
const text = cell.innerText.trim();
if(text && text !== '-' && text !== '') values.add(text);
}
});
// Create Select
const select = document.createElement('select');
select.className = 'form-filter datatable-select'; // Use a different class to avoid auto-init by custom-select.js
select.className = 'form-filter datatable-select';
// Default Option
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.text = config.label;
select.appendChild(defaultOption);
const defOpt = document.createElement('option');
defOpt.value = '';
defOpt.text = config.label;
select.appendChild(defOpt);
Array.from(values).sort().forEach(val => {
const opt = document.createElement('option');
@@ -193,14 +193,11 @@ class SimpleDataTable {
select.appendChild(opt);
});
// Event Listener
select.addEventListener('change', (e) => {
const val = e.target.value;
if (val === '') {
delete this.activeFilters[colIndex];
} else {
this.activeFilters[colIndex] = val;
}
if (val === '') delete this.activeFilters[colIndex];
else this.activeFilters[colIndex] = val;
this.currentPage = 1;
this.filterRows();
this.render();
@@ -208,8 +205,8 @@ class SimpleDataTable {
container.appendChild(select);
if (typeof CustomSelect !== 'undefined') {
new CustomSelect(select);
if (window.Mivo?.components?.Select) {
new window.Mivo.components.Select(select);
}
}
@@ -222,23 +219,15 @@ class SimpleDataTable {
filterRows() {
this.filteredRows = this.originalRows.filter(row => {
// 1. Text Search
let matchesSearch = true;
if (this.searchQuery) {
const text = row.textContent.toLowerCase();
matchesSearch = text.includes(this.searchQuery);
matchesSearch = row.innerText.toLowerCase().includes(this.searchQuery);
}
// 2. Column Filters
let matchesFilters = true;
for (const [colIndex, filterValue] of Object.entries(this.activeFilters)) {
const cell = row.cells[colIndex];
if (!cell) {
matchesFilters = false;
break;
}
// Exact match (trimmed)
if (cell.textContent.trim() !== filterValue) {
if (!cell || cell.innerText.trim() !== filterValue) {
matchesFilters = false;
break;
}
@@ -249,11 +238,10 @@ class SimpleDataTable {
}
render() {
// Calculate pagination
const i18n = window.Mivo?.modules?.I18n || window.i18n;
const totalItems = this.filteredRows.length;
const totalPages = Math.ceil(totalItems / this.options.itemsPerPage);
// Ensure current page is valid
if (this.currentPage > totalPages) this.currentPage = totalPages || 1;
if (this.currentPage < 1) this.currentPage = 1;
@@ -261,14 +249,12 @@ class SimpleDataTable {
const end = start + this.options.itemsPerPage;
const currentItems = this.filteredRows.slice(start, end);
// Clear and Re-append rows
this.tbody.innerHTML = '';
if (currentItems.length > 0) {
currentItems.forEach(row => this.tbody.appendChild(row));
} else {
// Empty State
const emptyRow = document.createElement('tr');
const noMatchText = window.i18n ? window.i18n.t('common.table.no_match') : 'No match found.';
const noMatchText = i18n ? i18n.t('common.table.no_match') : 'No match found.';
emptyRow.innerHTML = `
<td colspan="100%" class="px-6 py-12 text-center text-accents-5">
<span class="text-sm">${noMatchText}</span>
@@ -277,27 +263,23 @@ class SimpleDataTable {
this.tbody.appendChild(emptyRow);
}
// Render Pagination
if (this.options.pagination) {
this.renderPagination(totalItems, totalPages, start + 1, Math.min(end, totalItems));
this.renderPagination(totalItems, totalPages, start + 1, Math.min(end, totalItems), i18n);
}
// Re-initialize icons if Lucide is available
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
renderPagination(totalItems, totalPages, start, end) {
renderPagination(totalItems, totalPages, start, end, i18n) {
if (totalItems === 0) {
this.paginationContainer.innerHTML = '';
return;
}
const showingText = window.i18n ? window.i18n.t('common.table.showing', {start, end, total: totalItems}) : `Showing ${start} to ${end} of ${totalItems}`;
const previousText = window.i18n ? window.i18n.t('common.previous') : 'Previous';
const nextText = window.i18n ? window.i18n.t('common.next') : 'Next';
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: totalPages}) : `Page ${this.currentPage} of ${totalPages}`;
const showingText = i18n ? i18n.t('common.table.showing', {start, end, total: totalItems}) : `Showing ${start} to ${end} of ${totalItems}`;
const previousText = i18n ? i18n.t('common.previous') : 'Previous';
const nextText = i18n ? i18n.t('common.next') : 'Next';
const pageText = i18n ? i18n.t('common.page_of', {current: this.currentPage, total: totalPages}) : `Page ${this.currentPage} of ${totalPages}`;
this.paginationContainer.innerHTML = `
<div class="text-sm text-accents-5">
@@ -330,7 +312,11 @@ class SimpleDataTable {
}
}
// Export if using modules, otherwise it's global
if (typeof module !== 'undefined' && module.exports) {
module.exports = SimpleDataTable;
// Register as Mivo Component
if (window.Mivo) {
window.Mivo.registerComponent('Datatable', DataTable);
// Expose as window global for simpler backward compatibility if typically invoked via new SimpleDataTable()
window.SimpleDataTable = DataTable;
} else {
window.SimpleDataTable = DataTable;
}

View File

@@ -0,0 +1,252 @@
/**
* Mivo Component: Select
* Standardized Custom Select for Forms, Filters, and Navigation.
*/
class CustomSelect {
static instances = [];
static get(elementOrId) {
if (typeof elementOrId === 'string') {
return CustomSelect.instances.find(i => i.originalSelect.id === elementOrId);
}
return CustomSelect.instances.find(i => i.originalSelect === elementOrId);
}
constructor(selectElement) {
if (selectElement.dataset.customSelectInitialized === 'true') return;
selectElement.dataset.customSelectInitialized = 'true';
this.originalSelect = selectElement;
this.originalSelect.style.display = 'none';
this.options = Array.from(this.originalSelect.options);
// Determine Variant
this.variant = this.originalSelect.dataset.variant || 'default';
if (this.originalSelect.classList.contains('form-filter')) this.variant = 'filter';
if (this.originalSelect.classList.contains('nav-select')) this.variant = 'nav';
this.wrapper = document.createElement('div');
this.buildWrapperClasses();
this.init();
CustomSelect.instances.push(this);
}
buildWrapperClasses() {
let base = 'custom-select-wrapper relative active-select';
// Copy width classes
const widthClass = Array.from(this.originalSelect.classList).find(c => c.startsWith('w-') && c !== 'w-full');
const isFullWidth = this.originalSelect.classList.contains('w-full') ||
this.originalSelect.classList.contains('form-control') ||
this.originalSelect.classList.contains('form-input');
if (widthClass) base += ' ' + widthClass;
else if (isFullWidth) base += ' w-full';
else base += ' w-fit';
this.wrapper.className = base;
}
init() {
this.trigger = document.createElement('div');
// Variant Styling
let triggerClass = 'flex items-center justify-between cursor-pointer pr-3 transition-all duration-200';
if (this.variant === 'filter') {
triggerClass += ' form-filter';
} else if (this.variant === 'nav') {
// New Nav variant for transparent/header usage
triggerClass += ' text-sm font-medium hover:bg-accents-2/50 rounded-lg px-2 py-1.5 border border-transparent hover:border-accents-2';
} else {
triggerClass += ' form-input';
}
// Inherit non-structural classes
const inherited = Array.from(this.originalSelect.classList)
.filter(c => !['custom-select', 'hidden', 'form-filter', 'form-input', 'w-full'].includes(c))
.join(' ');
if (inherited) triggerClass += ' ' + inherited;
this.trigger.className = triggerClass;
this.renderTrigger();
// Dropdown Menu
this.menu = document.createElement('div');
this.menu.className = 'custom-select-dropdown';
this.listContainer = document.createElement('div');
this.listContainer.className = 'overflow-y-auto flex-1 py-1 custom-scrollbar';
if (this.originalSelect.dataset.search === 'true') {
this.buildSearch();
}
this.buildOptions();
this.menu.appendChild(this.listContainer);
this.wrapper.appendChild(this.trigger);
this.wrapper.appendChild(this.menu);
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect);
this.bindEvents();
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.wrapper });
}
renderTrigger() {
const option = this.originalSelect.options[this.originalSelect.selectedIndex];
const text = option ? option.text : '';
const icon = option?.dataset.icon;
const image = option?.dataset.image;
const flag = option?.dataset.flag;
let html = '';
if (image) html += `<img src="${image}" class="w-5 h-5 mr-2 rounded-full object-cover">`;
else if (flag) html += `<span class="fi fi-${flag} mr-2 rounded-sm shadow-sm"></span>`;
else if (icon) html += `<i data-lucide="${icon}" class="w-4 h-4 mr-2 opacity-70"></i>`;
html += `<span class="truncate flex-1 text-left select-none">${text}</span>`;
html += `<i data-lucide="chevron-down" class="custom-select-icon w-4 h-4 ml-2 opacity-70 transition-transform duration-200"></i>`;
this.trigger.innerHTML = html;
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.trigger });
}
buildSearch() {
const div = document.createElement('div');
div.className = 'p-2 bg-background z-10 border-b border-accents-2 rounded-t-xl sticky top-0';
const input = document.createElement('input');
input.type = 'text';
input.className = 'w-full px-2 py-1.5 text-xs bg-accents-1 border border-accents-2 rounded-md focus:outline-none focus:ring-1 focus:ring-foreground transition-all';
input.placeholder = 'Search...';
input.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
Array.from(this.listContainer.children).forEach(el => {
el.style.display = el.textContent.toLowerCase().includes(term) ? 'flex' : 'none';
});
});
input.addEventListener('click', e => e.stopPropagation());
div.appendChild(input);
this.menu.appendChild(div);
this.searchInput = input;
}
buildOptions() {
this.listContainer.innerHTML = '';
this.options.forEach((opt, idx) => {
const el = document.createElement('div');
el.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center gap-2 relative';
if (opt.selected) el.classList.add('bg-accents-1', 'font-medium');
// Icon/Image Logic
if (opt.dataset.image) el.innerHTML += `<img src="${opt.dataset.image}" class="w-5 h-5 rounded-full object-cover">`;
else if (opt.dataset.flag) el.innerHTML += `<span class="fi fi-${opt.dataset.flag} rounded-sm shadow-sm"></span>`;
else if (opt.dataset.icon) el.innerHTML += `<i data-lucide="${opt.dataset.icon}" class="w-4 h-4 opacity-70"></i>`;
el.innerHTML += `<span class="truncate">${opt.text}</span>`;
// Selected Checkmark
if (opt.selected) {
el.innerHTML += `<i data-lucide="check" class="w-3 h-3 ml-auto text-foreground absolute right-3"></i>`;
}
el.addEventListener('click', () => this.select(idx));
this.listContainer.appendChild(el);
});
}
bindEvents() {
this.trigger.addEventListener('click', e => {
e.stopPropagation();
this.toggle();
});
document.addEventListener('click', e => {
if (!this.wrapper.contains(e.target)) this.close();
});
}
toggle() {
this.menu.classList.contains('open') ? this.close() : this.open();
}
open() {
// Close others
CustomSelect.instances.forEach(i => i !== this && i.close());
// Smart Position
const rect = this.wrapper.getBoundingClientRect();
const menuHeight = 260; // Max-h-60 (240px) + padding + search if exists
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
// Reset positioning classes
this.menu.classList.remove(
'right-0', 'left-0',
'origin-top-right', 'origin-top-left',
'origin-bottom-right', 'origin-bottom-left',
'dropdown-up'
);
// Vertical check
const goUp = spaceBelow < menuHeight && spaceAbove > spaceBelow;
if (goUp) {
this.menu.classList.add('dropdown-up');
}
// Horizontal check
const isRightAligned = window.innerWidth - rect.left < 250;
if (isRightAligned) {
this.menu.classList.add('right-0');
} else {
this.menu.classList.add('left-0');
}
// Apply correct Origin for animation
const originY = goUp ? 'bottom' : 'top';
const originX = isRightAligned ? 'right' : 'left';
this.menu.classList.add(`origin-${originY}-${originX}`);
this.menu.classList.add('open');
this.trigger.classList.add('ring-1', 'ring-foreground');
this.trigger.querySelector('.custom-select-icon')?.classList.add('rotate-180');
if (this.searchInput) setTimeout(() => this.searchInput.focus(), 50);
}
close() {
this.menu.classList.remove('open');
this.trigger.classList.remove('ring-1', 'ring-foreground');
this.trigger.querySelector('.custom-select-icon')?.classList.remove('rotate-180');
}
select(index) {
this.originalSelect.selectedIndex = index;
this.renderTrigger();
this.buildOptions(); // Rebuild to move checkmark
this.close();
this.originalSelect.dispatchEvent(new Event('change', { bubbles: true }));
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.wrapper });
}
refresh() {
this.options = Array.from(this.originalSelect.options);
this.buildOptions();
this.renderTrigger();
}
}
// Register to Mivo Framework
if (window.Mivo) {
window.Mivo.registerComponent('Select', CustomSelect);
// Auto-init on load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('select.custom-select').forEach(el => new CustomSelect(el));
});
}

View File

@@ -1,261 +0,0 @@
class CustomSelect {
static instances = [];
constructor(selectElement) {
if (selectElement.dataset.customSelectInitialized === 'true') {
return;
}
selectElement.dataset.customSelectInitialized = 'true';
this.originalSelect = selectElement;
this.originalSelect.style.display = 'none';
this.options = Array.from(this.originalSelect.options);
// Settings
this.wrapper = document.createElement('div');
// Standard classes
let wrapperClasses = 'custom-select-wrapper relative active-select';
// Intelligent Width:
// If original select expects full width, wrapper must be full width.
// Otherwise, use w-fit (Crucial for Right-Alignment in toolbars to work).
const widthClass = Array.from(this.originalSelect.classList).find(c => c.startsWith('w-') && c !== 'w-full');
const isFullWidth = this.originalSelect.classList.contains('w-full') ||
this.originalSelect.classList.contains('form-control') ||
this.originalSelect.classList.contains('form-input');
if (widthClass) {
wrapperClasses += ' ' + widthClass;
} else if (isFullWidth) {
wrapperClasses += ' w-full';
} else {
wrapperClasses += ' w-fit';
}
this.wrapper.className = wrapperClasses;
this.init();
// Store instance
if (!CustomSelect.instances) CustomSelect.instances = [];
CustomSelect.instances.push(this);
}
init() {
// Create Trigger
this.trigger = document.createElement('div');
const isFilter = this.originalSelect.classList.contains('form-filter');
const baseClass = isFilter ? 'form-filter' : 'form-input';
this.trigger.className = `${baseClass} flex items-center justify-between cursor-pointer pr-3`;
this.trigger.style.paddingLeft = '0.75rem';
this.trigger.innerHTML = `
<span class="custom-select-value truncate text-foreground flex-1 text-left">${this.originalSelect.options[this.originalSelect.selectedIndex].text}</span>
<div class="custom-select-icon flex-shrink-0 ml-2 transition-transform duration-200 transform">
<i data-lucide="chevron-down" class="w-4 h-4 text-foreground opacity-70"></i>
</div>
`;
// Inherit classes from original select (excluding custom-select marker)
if (this.originalSelect.classList.length > 0) {
const inheritedClasses = Array.from(this.originalSelect.classList)
.filter(c => c !== 'custom-select' && c !== 'hidden')
.join(' ');
if (inheritedClasses) {
this.trigger.className += ' ' + inheritedClasses;
}
}
// Final sanity check for full width
if (this.wrapper.classList.contains('w-full')) {
this.trigger.classList.add('w-full');
}
// Create Options Menu Wrapper (No Scroll Here)
this.menu = document.createElement('div');
// Create Options Menu Wrapper (No Scroll Here)
// Create Options Menu Wrapper (No Scroll Here)
this.menu = document.createElement('div');
this.menu.className = 'custom-select-dropdown';
// Create Scrollable List Container
this.listContainer = document.createElement('div');
this.listContainer.className = 'overflow-y-auto flex-1 py-1 custom-scrollbar';
// Search Functionality
if (this.originalSelect.dataset.search === 'true') {
const searchContainer = document.createElement('div');
searchContainer.className = 'p-2 bg-background z-10 border-b border-accents-2 flex-shrink-0 rounded-t-md';
this.searchInput = document.createElement('input');
this.searchInput.type = 'text';
this.searchInput.className = 'w-full px-2 py-1 text-sm bg-accents-1 border border-accents-2 rounded focus:outline-none focus:ring-1 focus:ring-foreground';
this.searchInput.placeholder = 'Search...';
searchContainer.appendChild(this.searchInput);
this.menu.appendChild(searchContainer);
// Search Event
this.searchInput.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
this.options.forEach((option, index) => {
const item = this.listContainer.querySelector(`[data-index="${index}"]`);
if (item) {
const text = option.text.toLowerCase();
item.style.display = text.includes(term) ? 'flex' : 'none';
}
});
});
this.searchInput.addEventListener('click', (e) => e.stopPropagation());
}
// Build Options
this.options.forEach((option, index) => {
const item = document.createElement('div');
item.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center justify-between whitespace-nowrap';
if(option.selected) item.classList.add('bg-accents-1', 'font-medium');
item.textContent = option.text;
item.dataset.value = option.value;
item.dataset.index = index;
item.addEventListener('click', () => {
this.select(index);
});
this.listContainer.appendChild(item);
});
// Append List to Menu
this.menu.appendChild(this.listContainer);
// Append to wrapper
this.wrapper.appendChild(this.trigger);
this.wrapper.appendChild(this.menu);
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect);
// Event Listeners
this.trigger.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
document.addEventListener('click', (e) => {
if (!this.wrapper.contains(e.target)) {
this.close();
}
});
if (typeof lucide !== 'undefined') {
lucide.createIcons({ root: this.trigger });
}
}
toggle() {
if (!this.menu.classList.contains('open')) {
this.open();
} else {
this.close();
}
}
open() {
CustomSelect.instances.forEach(instance => {
if (instance !== this) instance.close();
});
// Smart Positioning
const rect = this.wrapper.getBoundingClientRect();
const spaceRight = window.innerWidth - rect.left;
// Reset positioning classes
this.menu.classList.remove('right-0', 'origin-top-right', 'left-0', 'origin-top-left');
// Logic: Zone Check - If near right edge (< 300px), Force Right Align.
// Doing this purely based on coordinates prevents "Layout Jumping" caused by measuring content width.
if (spaceRight < 300) {
this.menu.classList.add('right-0', 'origin-top-right');
} else {
this.menu.classList.add('left-0', 'origin-top-left');
}
// Apply visual open states
this.menu.classList.add('open');
this.trigger.classList.add('ring-1', 'ring-foreground');
const icon = this.trigger.querySelector('.custom-select-icon');
if(icon) icon.classList.add('rotate-180');
if (this.searchInput) {
setTimeout(() => this.searchInput.focus(), 50);
}
}
close() {
this.menu.classList.remove('open');
this.trigger.classList.remove('ring-1', 'ring-foreground');
const icon = this.trigger.querySelector('.custom-select-icon');
if(icon) icon.classList.remove('rotate-180');
}
select(index) {
// Update Original Select
this.originalSelect.selectedIndex = index;
// Update UI
this.trigger.querySelector('.custom-select-value').textContent = this.options[index].text;
// Update Active State in List
Array.from(this.listContainer.children).forEach((child) => {
// Safe check
if (!child.dataset.index) return;
if (parseInt(child.dataset.index) === index) {
child.classList.add('bg-accents-1', 'font-medium');
} else {
child.classList.remove('bg-accents-1', 'font-medium');
}
});
this.close();
this.originalSelect.dispatchEvent(new Event('change'));
}
refresh() {
// Clear list items
this.listContainer.innerHTML = '';
// Re-read options
this.options = Array.from(this.originalSelect.options);
this.options.forEach((option, index) => {
const item = document.createElement('div');
item.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center justify-between whitespace-nowrap';
if(option.selected) item.classList.add('bg-accents-1', 'font-medium');
item.textContent = option.text;
item.dataset.value = option.value;
item.dataset.index = index;
item.addEventListener('click', () => {
this.select(index);
});
this.listContainer.appendChild(item);
});
// Update Trigger
if (this.originalSelect.selectedIndex >= 0) {
this.trigger.querySelector('.custom-select-value').textContent = this.originalSelect.options[this.originalSelect.selectedIndex].text;
}
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('select.custom-select').forEach(el => new CustomSelect(el));
});

70
public/assets/js/mivo.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* Mivo JS Core "The Kernel"
* Central management for Modules (Services) and Components (UI).
*/
class MivoCore {
constructor() {
this.modules = {};
this.components = {};
this.events = new EventTarget();
this.isReady = false;
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.init());
} else {
this.init();
}
}
/**
* Register a Global Module (Service)
* @param {string} name
* @param {Object} instance
*/
registerModule(name, instance) {
this.modules[name] = instance;
console.debug(`[Mivo] Module '${name}' registered.`);
}
/**
* Register a UI Component definition
* @param {string} name
* @param {Class} classRef
*/
registerComponent(name, classRef) {
this.components[name] = classRef;
console.debug(`[Mivo] Component '${name}' registered.`);
}
/**
* Listen to global events
* @param {string} eventName
* @param {function} callback
*/
on(eventName, callback) {
this.events.addEventListener(eventName, (e) => callback(e.detail));
}
/**
* Emit global events
* @param {string} eventName
* @param {any} data
*/
emit(eventName, data) {
this.events.dispatchEvent(new CustomEvent(eventName, { detail: data }));
console.debug(`[Mivo] Event emitted: ${eventName}`, data);
}
init() {
if (this.isReady) return;
this.isReady = true;
console.log('[Mivo] Framework initialized.');
// Dispatch ready event for external scripts
this.emit('ready', { timestamp: Date.now() });
}
}
// Global Singleton
window.Mivo = new MivoCore();

View File

@@ -1,16 +1,19 @@
/**
* Global Alert Helper for Mivo
* Provides a standardized way to trigger premium SweetAlert2 dialogs.
* Mivo Module: Alert
* Wraps SweetAlert2 and provides Toast notifications.
*/
const Mivo = {
class AlertModule {
constructor() {
// No specific initialization needed for now
}
/**
* Show a simple alert dialog.
* @param {string} type - 'success', 'error', 'warning', 'info', 'question'
* @param {string} title - The title of the alert
* @param {string} message - The body text/HTML
* @returns {Promise}
* @param {string} title
* @param {string} message
*/
alert: function(type, title, message = '') {
fire(type, title, message = '', options = {}) {
const typeMap = {
'success': { icon: 'check-circle-2', color: 'text-success' },
'error': { icon: 'x-circle', color: 'text-error' },
@@ -21,7 +24,8 @@ const Mivo = {
const config = typeMap[type] || typeMap['info'];
return Swal.fire({
// Default Config
const defaultConfig = {
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`,
title: title,
html: message,
@@ -33,21 +37,32 @@ const Mivo = {
},
buttonsStyling: false,
heightAuto: false,
scrollbarPadding: false,
didOpen: () => {
if (typeof lucide !== 'undefined') lucide.createIcons();
}
});
},
};
// Merge user options with default config
// Special deep merge for customClass if provided to avoid wiping defaults completely?
// simple spread for now, user should know what they are doing if overriding classes.
// Actually, let's smart merge customClass
if (options.customClass) {
options.customClass = {
...defaultConfig.customClass,
...options.customClass
};
}
const finalConfig = { ...defaultConfig, ...options };
return Swal.fire(finalConfig);
}
/**
* Show a confirmation dialog.
* @param {string} title - The title of the confirmation
* @param {string} message - The body text/HTML
* @param {string} confirmText - Text for the confirm button
* @param {string} cancelText - Text for the cancel button
* @returns {Promise} Resolves if confirmed, rejects if cancelled
*/
confirm: function(title, message = '', confirmText = 'Yes, Proceed', cancelText = 'Cancel') {
confirm(title, message = '', confirmText = 'Yes, Proceed', cancelText = 'Cancel') {
return Swal.fire({
iconHtml: `<i data-lucide="help-circle" class="w-12 h-12 text-question"></i>`,
title: title,
@@ -63,20 +78,17 @@ const Mivo = {
buttonsStyling: false,
reverseButtons: true,
heightAuto: false,
scrollbarPadding: false,
didOpen: () => {
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}).then(result => result.isConfirmed);
},
}
/**
* Show a premium stacking toast.
* @param {string} type - 'success', 'error', 'warning', 'info'
* @param {string} title - Title
* @param {string} message - Body text
* @param {number} duration - ms before auto-close
* Show a stacking toast notification.
*/
toast: function(type, title, message = '', duration = 5000) {
toast(type, title, message = '', duration = 5000) {
let container = document.getElementById('mivo-toast-container');
if (!container) {
container = document.createElement('div');
@@ -124,7 +136,7 @@ const Mivo = {
toast.querySelector('.mivo-toast-close').addEventListener('click', closeToast);
// Auto-close with progress bar
// Progress Bar
const progress = toast.querySelector('.mivo-toast-progress');
const start = Date.now();
@@ -142,7 +154,73 @@ const Mivo = {
requestAnimationFrame(updateProgress);
}
};
// Also expose as global shortcuts if needed
window.Mivo = Mivo;
/**
* Modal Form Logic
*/
form(title, html, confirmText = 'Save', preConfirmFn = null, didOpenFn = null, customClass = '') {
return Swal.fire({
title: title,
html: html,
showCancelButton: true,
confirmButtonText: confirmText,
cancelButtonText: window.i18n ? window.i18n.t('common.cancel') : 'Cancel',
customClass: {
popup: `swal2-premium-card ${customClass}`,
title: 'text-xl font-bold text-foreground mb-4',
htmlContainer: 'text-left overflow-visible', // overflow-visible for selects
confirmButton: 'btn btn-primary px-6',
cancelButton: 'btn btn-secondary px-6',
actions: 'gap-3'
},
buttonsStyling: false,
reverseButtons: true,
heightAuto: false,
scrollbarPadding: false,
didOpen: () => {
if (typeof lucide !== 'undefined') lucide.createIcons();
const popup = Swal.getHtmlContainer();
if (didOpenFn && typeof didOpenFn === 'function') {
didOpenFn(popup);
}
// Initialize Custom Selects using Mivo Component if available
if (popup && window.Mivo && window.Mivo.components.Select) {
const selects = popup.querySelectorAll('select');
selects.forEach(el => {
if (!el.classList.contains('custom-select')) {
el.classList.add('custom-select');
}
new window.Mivo.components.Select(el);
});
}
const firstInput = popup.querySelector('input:not([type="hidden"]), textarea');
if (firstInput) firstInput.focus();
},
preConfirm: () => {
return preConfirmFn ? preConfirmFn() : true;
}
});
}
}
// Register Module
if (window.Mivo) {
const alertModule = new AlertModule();
window.Mivo.registerModule('Alert', alertModule);
// Add Aliases to Mivo object for easy access (Mivo.alert(...))
// This maintains backward compatibility with the old object literal style
window.Mivo.alert = (type, title, msg, opts) => alertModule.fire(type, title, msg, opts);
window.Mivo.confirm = (t, m, c, cx) => alertModule.confirm(t, m, c, cx);
window.Mivo.toast = (t, ti, m, d) => alertModule.toast(t, ti, m, d);
// Aliases for Mivo.modal call style
window.Mivo.modal = {
form: (t, h, c, p, o, cc) => alertModule.form(t, h, c, p, o, cc)
};
// Wait, modal was nested. Let's expose the form method carefully or keep it on the module.
// Let's just expose the module mostly.
}

View File

@@ -1,9 +1,16 @@
/**
* Mivo Module: I18n
* Internationalization support.
*/
class I18n {
constructor() {
this.currentLang = localStorage.getItem('mivo_lang') || 'en';
this.translations = {};
this.isLoaded = false;
// The ready promise resolves after the first language load
// Expose global helper for legacy onclicks
window.changeLanguage = (lang) => this.loadLanguage(lang);
this.ready = this.init();
}
@@ -14,7 +21,6 @@ class I18n {
async loadLanguage(lang) {
try {
// Add cache busting to ensure fresh translation files
const cacheBuster = Date.now();
const response = await fetch(`/lang/${lang}.json?v=${cacheBuster}`);
if (!response.ok) throw new Error(`Failed to load language: ${lang}`);
@@ -22,12 +28,17 @@ class I18n {
this.translations = await response.json();
this.currentLang = lang;
localStorage.setItem('mivo_lang', lang);
this.applyTranslations();
// Dispatch event for other components
// Dispatch via Mivo Event Bus
if (window.Mivo) {
window.Mivo.emit('languageChanged', { lang });
}
// Legacy Event for compatibility
window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } }));
// Update html lang attribute
document.documentElement.lang = lang;
} catch (error) {
console.error('I18n Error:', error);
@@ -43,16 +54,9 @@ class I18n {
if (element.tagName === 'INPUT' && element.getAttribute('placeholder')) {
element.placeholder = translation;
} else {
// Check if element has child nodes that are not text (e.g. icons)
// If simple text, just replace
// If complex, try to preserve icon?
// For now, let's assume strictly text replacement or user wraps text in span
// Better approach: Look for a text node?
// Simplest for now: innerText
element.textContent = translation;
}
} else {
// Log missing translation for developers (only if fully loaded)
if (this.isLoaded) {
console.warn(`[i18n] Missing translation for key: "${key}" (lang: ${this.currentLang})`);
}
@@ -68,13 +72,10 @@ class I18n {
let text = this.getNestedValue(this.translations, key);
if (!text) {
if (this.isLoaded) {
console.warn(`[i18n] Missing translation for key: "${key}" (lang: ${this.currentLang})`);
}
text = key; // Fallback to key
if (this.isLoaded) console.warn(`[i18n] Missing translation for key: "${key}"`);
text = key;
}
// Simple interpolation: {key}
if (params) {
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`{${param}}`, 'g'), params[param]);
@@ -84,10 +85,12 @@ class I18n {
}
}
// Initialize
window.i18n = new I18n();
// Global helper
function changeLanguage(lang) {
window.i18n.loadLanguage(lang);
// Register Module
if (window.Mivo) {
window.Mivo.registerModule('I18n', new I18n());
// Alias for global usage if needed
window.i18n = window.Mivo.modules.I18n;
} else {
// Fallback if Mivo not loaded
window.i18n = new I18n();
}

View File

@@ -0,0 +1,112 @@
/**
* Mivo Module: Updater
* Handles version checking and update notifications.
*/
class UpdaterModule {
constructor() {
this.repo = 'dyzulk/mivo';
this.cacheKey = 'mivo_update_data';
this.ttl = 24 * 60 * 60 * 1000; // 24 hours
// Wait for Mivo core to be ready
if (window.Mivo) {
window.Mivo.on('ready', () => this.init());
}
}
async init() {
const updateData = this.getCache();
const now = Date.now();
if (updateData && (now - updateData.timestamp < this.ttl)) {
this.checkUpdate(updateData.version, updateData.url);
} else {
await this.fetchLatest();
}
}
getCache() {
const data = localStorage.getItem(this.cacheKey);
return data ? JSON.parse(data) : null;
}
setCache(version, url) {
const data = {
version: version,
url: url,
timestamp: Date.now()
};
localStorage.setItem(this.cacheKey, JSON.stringify(data));
}
async fetchLatest() {
try {
const response = await fetch(`https://api.github.com/repos/${this.repo}/releases/latest`);
if (!response.ok) throw new Error('Failed to fetch version');
const data = await response.json();
const version = data.tag_name; // e.g., v1.1.0
const url = data.html_url;
this.setCache(version, url);
this.checkUpdate(version, url);
} catch (error) {
console.error('[Mivo] Update check failed:', error);
}
}
checkUpdate(latestVersion, url) {
if (!window.currentVersion) return;
// Simple version comparison (removing 'v' prefix if exists)
const current = window.currentVersion.replace('v', '');
const latest = latestVersion.replace('v', '');
if (this.isNewer(current, latest)) {
this.showNotification(latestVersion, url);
}
}
isNewer(current, latest) {
const cParts = current.split('.').map(Number);
const lParts = latest.split('.').map(Number);
for (let i = 0; i < Math.max(cParts.length, lParts.length); i++) {
const c = cParts[i] || 0;
const l = lParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
showNotification(version, url) {
const badge = document.getElementById('update-badge');
const content = document.getElementById('notification-content');
if (badge) badge.classList.remove('hidden');
if (content) {
content.innerHTML = `
<div class="flex flex-col items-center gap-3">
<div class="p-2 bg-blue-500/10 rounded-full">
<i data-lucide="rocket" class="w-6 h-6 text-blue-500"></i>
</div>
<div class="space-y-1">
<p class="font-bold text-foreground">New Version Available!</p>
<p class="text-xs text-accents-4">Version <span class="font-mono">${version}</span> is now available.</p>
</div>
<a href="${url}" target="_blank" class="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold transition-colors flex items-center justify-center gap-2">
<i data-lucide="download" class="w-3 h-3"></i>
<span>Download Update</span>
</a>
</div>
`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
}
// Register Module
if (window.Mivo) {
window.Mivo.registerModule('Updater', new UpdaterModule());
}

View File

@@ -1,98 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const checkBtn = document.getElementById('check-interface-btn');
const ifaceSelect = document.getElementById('iface');
if (checkBtn && ifaceSelect) {
checkBtn.addEventListener('click', async () => {
const originalText = checkBtn.innerHTML;
checkBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 mr-2 animate-spin"></i> Checking...';
checkBtn.disabled = true;
if (typeof lucide !== 'undefined') lucide.createIcons();
// Collect Data
const ip = document.querySelector('input[name="ipmik"]').value;
const user = document.querySelector('input[name="usermik"]').value;
const pass = document.querySelector('input[name="passmik"]').value;
const idInput = document.querySelector('input[name="id"]');
const id = idInput ? idInput.value : null;
try {
const response = await fetch('/api/router/interfaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip, user, password: pass, id })
});
const data = await response.json();
if (data.success && data.interfaces) {
// Update Select
ifaceSelect.innerHTML = ''; // Clear
data.interfaces.forEach(iface => {
const opt = document.createElement('option');
opt.value = iface;
opt.textContent = iface;
if (iface === 'ether1') opt.selected = true; // Default preferred?
ifaceSelect.appendChild(opt);
});
// Refresh Custom Select
if (typeof CustomSelect !== 'undefined' && CustomSelect.instances) {
const instance = CustomSelect.instances.find(i => i.originalSelect.id === 'iface');
if (instance) instance.refresh();
}
// Show success
checkBtn.innerHTML = '<i data-lucide="check" class="w-4 h-4 mr-2"></i> Interfaces Loaded';
setTimeout(() => {
checkBtn.innerHTML = originalText;
checkBtn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons();
}, 2000);
} else {
alert('Error: ' + (data.error || 'Failed to fetch interfaces'));
checkBtn.innerHTML = originalText;
checkBtn.disabled = false;
}
} catch (err) {
console.error(err);
alert('Connection Error');
checkBtn.innerHTML = originalText;
checkBtn.disabled = false;
}
});
}
// Session Name Auto-Conversion
const sessInput = document.querySelector('input[name="sessname"]');
const sessPreview = document.getElementById('sessname-preview');
if (sessInput) {
// Initial set if editing
if(sessPreview) sessPreview.textContent = sessInput.value;
sessInput.addEventListener('input', (e) => {
let val = e.target.value;
// 1. Lowercase
val = val.toLowerCase();
// 2. Space -> Dash
val = val.replace(/\s+/g, '-');
// 3. Remove non-alphanumeric (except dash)
val = val.replace(/[^a-z0-9-]/g, '');
// 4. No double dashes
val = val.replace(/-+/g, '-');
// Write back to input (Auto Convert)
e.target.value = val;
// Update Preview
if (sessPreview) {
sessPreview.textContent = val || '...';
sessPreview.className = val ? 'font-mono text-primary font-bold' : 'font-mono text-accents-4';
}
});
}
});

View File

@@ -39,7 +39,8 @@
"none": "none",
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"warning": "Warning"
},
"home": {
"subtitle": "A modern, lightweight MikroTik Hotspot Manager built for performance and simplicity.",
@@ -137,7 +138,26 @@
"origin": "Origin",
"methods": "Allowed Methods",
"headers": "Allowed Headers",
"max_age": "Max Age (seconds)"
"max_age": "Max Age (seconds)",
"cpu_warning": "Low values (< 5s) may increase CPU usage on older routers.",
"back": "Back to Settings"
},
"routers": {
"edit_router_title": "Edit Router",
"add_router_title": "Add Router",
"connect_desc": "Connect Mikhmon to your RouterOS device.",
"session_settings": "Session Settings",
"unique_id": "Unique ID. Preview:",
"show_quick_access": "Show in Quick Access (Home Page)",
"connection_details": "Connection Details",
"password_hint": "Leave empty to keep existing password.",
"hotspot_info": "Hotspot Information",
"dns_name": "DNS Name",
"traffic_interface": "Traffic Interface",
"check_btn": "Check",
"currency": "Currency",
"auto_reload": "Auto Reload (Sec)",
"save_connect": "Save & Connect"
},
"login": {
"welcome": "Welcome back, please sign in to continue.",
@@ -244,9 +264,9 @@
"host": "Host Name"
},
"cookies": {
"title": "Hotspot Cookies",
"subtitle": "Active authentication cookies for:",
"user": "User",
"title": "Hotspot Users",
"subtitle": "Manage users and vouchers",
"name": "Name",
"mac": "MAC Address",
"ip": "IP Address",
"expires": "Expires In",
@@ -316,6 +336,16 @@
}
},
"hotspot_users": {
"add_user": "Add User",
"edit_user": "Edit User",
"title": "Hotspot Users",
"subtitle": "Manage users and vouchers",
"name": "Name",
"profile": "Profile",
"uptime_limit": "Uptime / Limit",
"bytes_in_out": "Bytes In/Out",
"comment": "Comment",
"no_users_selected": "No users selected.",
"form": {
"add_title": "Add User",
"edit_title": "Edit User",
@@ -540,5 +570,38 @@
"cors_rule_updated_desc": "Changes to CORS rule for {origin} have been saved.",
"cors_rule_deleted": "CORS Rule Deleted",
"cors_rule_deleted_desc": "The CORS rule has been removed."
},
"status": {
"check_title": "Check Voucher Status",
"check_desc": "Monitor your data usage and voucher validity in real-time without needing to re-login.",
"voucher_code_label": "Voucher Code",
"voucher_code_placeholder": "Ex: QWASZX",
"code_placeholder": "Ex: QWASZX",
"check_now": "Check Now",
"details_title": "Voucher Details",
"code": "Voucher Code",
"data_remaining": "Data Remaining",
"used": "Used",
"package": "Package",
"validity": "Validity",
"uptime": "Uptime",
"expires": "Expires",
"not_found_title": "Voucher Not Found",
"not_found_desc": "The voucher code you entered does not exist.",
"try_again": "Try Again"
},
"errors": {
"404_title": "Page Not Found",
"404_desc": "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.",
"403_title": "Access Denied",
"403_desc": "You do not have permission to access this resource.",
"500_title": "Server Error",
"500_desc": "Something went wrong on our end. Please try again later.",
"503_title": "Service Unavailable",
"503_desc": "The server is currently unable to handle the request due to maintenance or overload.",
"router_not_found_title": "Router Not Found",
"router_not_found_desc": "The router session you are trying to access does not exist or has been removed.",
"return_home": "Return Home",
"go_back": "Go Back"
}
}

View File

@@ -39,7 +39,8 @@
"save_changes": "Simpan Perubahan",
"please_wait": "Mohon tunggu...",
"none": "tidak ada"
}
},
"warning": "Peringatan"
},
"home": {
"subtitle": "Hotspot Manager MikroTik modern dan ringan yang dirancang untuk performa dan kemudahan.",
@@ -137,7 +138,26 @@
"origin": "Origin",
"methods": "Metode Diizinkan",
"headers": "Header Diizinkan",
"max_age": "Max Age (detik)"
"max_age": "Max Age (detik)",
"cpu_warning": "Nilai rendah (< 5s) dapat meningkatkan beban CPU pada router lama.",
"back": "Kembali ke Pengaturan"
},
"routers": {
"edit_router_title": "Edit Router",
"add_router_title": "Tambah Router",
"connect_desc": "Hubungkan Mikhmon ke perangkat RouterOS Anda.",
"session_settings": "Pengaturan Sesi",
"unique_id": "ID Unik. Pratinjau:",
"show_quick_access": "Tampilkan di Akses Cepat (Beranda)",
"connection_details": "Detail Koneksi",
"password_hint": "Biarkan kosong untuk menggunakan kata sandi yang ada.",
"hotspot_info": "Informasi Hotspot",
"dns_name": "Nama DNS",
"traffic_interface": "Antarmuka Trafik",
"check_btn": "Periksa",
"currency": "Mata Uang",
"auto_reload": "Auto Reload (Detik)",
"save_connect": "Simpan & Hubungkan"
},
"login": {
"welcome": "Selamat datang kembali, silakan masuk untuk melanjutkan.",
@@ -244,9 +264,9 @@
"host": "Nama Host"
},
"cookies": {
"title": "Cookie Hotspot",
"subtitle": "Cookie autentikasi aktif untuk:",
"user": "User",
"title": "Pengguna Hotspot",
"subtitle": "Kelola pengguna dan voucher",
"name": "Nama",
"mac": "Alamat MAC",
"ip": "Alamat IP",
"expires": "Kedaluwarsa Dalam",
@@ -326,6 +346,16 @@
}
},
"hotspot_users": {
"add_user": "Tambah Pengguna",
"edit_user": "Edit Pengguna",
"title": "Pengguna Hotspot",
"subtitle": "Kelola pengguna dan voucher",
"name": "Nama",
"profile": "Profil",
"uptime_limit": "Waktu Aktif / Batas",
"bytes_in_out": "Bytes Masuk/Keluar",
"comment": "Komentar",
"no_users_selected": "Tidak ada pengguna yang dipilih.",
"form": {
"add_title": "Tambah User",
"edit_title": "Edit User",
@@ -550,5 +580,38 @@
"cors_rule_updated_desc": "Perubahan pada aturan CORS untuk {origin} berhasil disimpan.",
"cors_rule_deleted": "Aturan CORS Dihapus",
"cors_rule_deleted_desc": "Aturan CORS berhasil dihapus."
},
"status": {
"check_title": "Cek Status Voucher",
"check_desc": "Pantau penggunaan data dan masa aktif voucher Anda secara real-time tanpa perlu login ulang.",
"voucher_code_label": "Kode Voucher",
"voucher_code_placeholder": "Contoh: QWASZX",
"code_placeholder": "Contoh: QWASZX",
"check_now": "Cek Sekarang",
"details_title": "Detail Voucher",
"code": "Kode Voucher",
"data_remaining": "Sisa Kuota",
"used": "Terpakai",
"package": "Paket",
"validity": "Masa Aktif",
"uptime": "Uptime",
"expires": "Kedaluwarsa Pada",
"not_found_title": "Voucher Tidak Ditemukan",
"not_found_desc": "Kode voucher yang Anda masukkan tidak ada.",
"try_again": "Coba Lagi"
},
"errors": {
"404_title": "Halaman Tidak Ditemukan",
"404_desc": "Halaman yang Anda cari mungkin telah dihapus, namanya diganti, atau sedang tidak tersedia sementara.",
"403_title": "Akses Ditolak",
"403_desc": "Anda tidak memiliki izin untuk mengakses sumber daya ini.",
"500_title": "Kesalahan Server",
"500_desc": "Terjadi kesalahan di sisi kami. Silakan coba lagi nanti.",
"503_title": "Layanan Tidak Tersedia",
"503_desc": "Server saat ini tidak dapat menangani permintaan karena pemeliharaan atau kelebihan beban.",
"router_not_found_title": "Router Tidak Ditemukan",
"router_not_found_desc": "Sesi router yang Anda coba akses tidak ada atau telah dihapus.",
"return_home": "Kembali ke Beranda",
"go_back": "Kembali"
}
}