mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 05:25:42 +07:00
Chore: Bump version to v1.1.0 and implement automated release system
This commit is contained in:
@@ -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;
|
||||
}
|
||||
252
public/assets/js/components/select.js
Normal file
252
public/assets/js/components/select.js
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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
70
public/assets/js/mivo.js
Normal 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();
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
112
public/assets/js/modules/updater.js
Normal file
112
public/assets/js/modules/updater.js
Normal 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());
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user