Initial Release v1.0.0: Full feature set with Docker automation, Nginx/Alpine stack

This commit is contained in:
dyzulk
2026-01-16 11:21:32 +07:00
commit 45623973a8
139 changed files with 24302 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
/**
* Global Alert Helper for Mivo
* Provides a standardized way to trigger premium SweetAlert2 dialogs.
*/
const Mivo = {
/**
* 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}
*/
alert: function(type, title, message = '') {
const typeMap = {
'success': { icon: 'check-circle-2', color: 'text-success' },
'error': { icon: 'x-circle', color: 'text-error' },
'warning': { icon: 'alert-triangle', color: 'text-warning' },
'info': { icon: 'info', color: 'text-info' },
'question':{ icon: 'help-circle', color: 'text-question' }
};
const config = typeMap[type] || typeMap['info'];
return Swal.fire({
iconHtml: `<i data-lucide="${config.icon}" class="w-12 h-12 ${config.color}"></i>`,
title: title,
html: message,
confirmButtonText: 'OK',
customClass: {
popup: 'swal2-premium-card',
confirmButton: 'btn btn-primary',
cancelButton: 'btn btn-secondary',
},
buttonsStyling: false,
heightAuto: false,
didOpen: () => {
if (typeof lucide !== 'undefined') lucide.createIcons();
}
});
},
/**
* 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') {
return Swal.fire({
iconHtml: `<i data-lucide="help-circle" class="w-12 h-12 text-question"></i>`,
title: title,
html: message,
showCancelButton: true,
confirmButtonText: confirmText,
cancelButtonText: cancelText,
customClass: {
popup: 'swal2-premium-card',
confirmButton: 'btn btn-primary',
cancelButton: 'btn btn-secondary',
},
buttonsStyling: false,
reverseButtons: true,
heightAuto: 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
*/
toast: function(type, title, message = '', duration = 5000) {
let container = document.getElementById('mivo-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'mivo-toast-container';
document.body.appendChild(container);
}
const typeMap = {
'success': { icon: 'check-circle-2', color: 'text-success' },
'error': { icon: 'x-circle', color: 'text-error' },
'warning': { icon: 'alert-triangle', color: 'text-warning' },
'info': { icon: 'info', color: 'text-info' }
};
const config = typeMap[type] || typeMap['info'];
const toast = document.createElement('div');
toast.className = `mivo-toast ${config.color}`;
toast.innerHTML = `
<div class="mivo-toast-icon">
<i data-lucide="${config.icon}" class="w-5 h-5"></i>
</div>
<div class="mivo-toast-content">
<div class="mivo-toast-title">${title}</div>
${message ? `<div class="mivo-toast-message">${message}</div>` : ''}
</div>
<button class="mivo-toast-close">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<div class="mivo-toast-progress"></div>
`;
container.appendChild(toast);
if (typeof lucide !== 'undefined') lucide.createIcons();
// Close logic
const closeToast = () => {
toast.classList.add('mivo-toast-fade-out');
setTimeout(() => {
toast.remove();
if (container.children.length === 0) container.remove();
}, 300);
};
toast.querySelector('.mivo-toast-close').addEventListener('click', closeToast);
// Auto-close with progress bar
const progress = toast.querySelector('.mivo-toast-progress');
const start = Date.now();
const updateProgress = () => {
const elapsed = Date.now() - start;
const percentage = Math.min((elapsed / duration) * 100, 100);
progress.style.width = percentage + '%';
if (percentage < 100) {
requestAnimationFrame(updateProgress);
} else {
closeToast();
}
};
requestAnimationFrame(updateProgress);
}
};
// Also expose as global shortcuts if needed
window.Mivo = Mivo;

14
public/assets/js/chart.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,261 @@
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));
});

View File

@@ -0,0 +1,336 @@
class SimpleDataTable {
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.options = {
itemsPerPage: 10,
searchable: true,
pagination: true,
filters: [], // Array of { index: number, label: string }
...options
};
this.currentPage = 1;
this.searchQuery = '';
this.activeFilters = {}; // { columnIndex: value }
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 change
window.addEventListener('languageChanged', () => {
this.reTranslate();
this.render();
});
}
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';
}
});
// 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...';
}
// Update All option
const perPageSelect = this.wrapper.querySelector('select');
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';
}
}
}
init() {
// Create 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
const header = document.createElement('div');
header.className = 'flex flex-col sm:flex-row justify-between items-center gap-4 mb-4';
// Show Entries Wrapper
const controlsLeft = document.createElement('div');
controlsLeft.className = 'flex items-center gap-3 w-full sm:w-auto flex-wrap';
const perPageSelect = document.createElement('select');
perPageSelect.className = 'form-filter w-20';
[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);
});
// All option
const allOption = document.createElement('option');
allOption.value = -1;
allOption.text = window.i18n ? window.i18n.t('common.table.all') : 'All';
perPageSelect.appendChild(allOption);
perPageSelect.addEventListener('change', (e) => {
const val = parseInt(e.target.value);
this.options.itemsPerPage = val === -1 ? this.originalRows.length : val;
this.currentPage = 1;
this.render();
});
// 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';
controlsLeft.appendChild(perPageSelect);
controlsLeft.appendChild(label);
// Initialize Filters if provided
if (this.options.filters && this.options.filters.length > 0) {
this.options.filters.forEach(filterConfig => {
this.initFilter(filterConfig, controlsLeft); // Append to Left Controls
});
}
header.appendChild(controlsLeft);
// Initialize CustomSelect if available (for perPage)
if (typeof CustomSelect !== 'undefined') {
new CustomSelect(perPageSelect);
}
// Search Input
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...';
searchWrapper.innerHTML = `
<div class="input-icon">
<i data-lucide="search" class="w-4 h-4"></i>
</div>
<input type="text" class="form-input-search w-full" placeholder="${placeholder}">
`;
const input = searchWrapper.querySelector('input');
input.addEventListener('input', (e) => this.handleSearch(e.target.value));
header.appendChild(searchWrapper);
}
this.wrapper.appendChild(header);
// Move Table into Wrapper
// Move Table into Wrapper
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.appendChild(this.table);
this.wrapper.appendChild(this.tableWrapper);
// Render Icons for Header Controls
if (typeof lucide !== 'undefined') {
lucide.createIcons({
root: header
});
}
// Pagination Controls
if (this.options.pagination) {
this.paginationContainer = document.createElement('div');
this.paginationContainer.className = 'flex items-center justify-between px-2';
this.wrapper.appendChild(this.paginationContainer);
}
this.render();
}
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
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
// Default Option
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.text = config.label;
select.appendChild(defaultOption);
Array.from(values).sort().forEach(val => {
const opt = document.createElement('option');
opt.value = val;
opt.text = val;
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;
}
this.currentPage = 1;
this.filterRows();
this.render();
});
container.appendChild(select);
if (typeof CustomSelect !== 'undefined') {
new CustomSelect(select);
}
}
handleSearch(query) {
this.searchQuery = query.toLowerCase();
this.currentPage = 1;
this.filterRows();
this.render();
}
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);
}
// 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) {
matchesFilters = false;
break;
}
}
return matchesSearch && matchesFilters;
});
}
render() {
// Calculate pagination
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;
const start = (this.currentPage - 1) * this.options.itemsPerPage;
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.';
emptyRow.innerHTML = `
<td colspan="100%" class="px-6 py-12 text-center text-accents-5">
<span class="text-sm">${noMatchText}</span>
</td>
`;
this.tbody.appendChild(emptyRow);
}
// Render Pagination
if (this.options.pagination) {
this.renderPagination(totalItems, totalPages, start + 1, Math.min(end, totalItems));
}
// Re-initialize icons if Lucide is available
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
renderPagination(totalItems, totalPages, start, end) {
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}`;
this.paginationContainer.innerHTML = `
<div class="text-sm text-accents-5">
${showingText}
</div>
<div class="flex items-center gap-2">
<button class="btn-prev btn btn-secondary py-1 px-3 text-xs disabled:opacity-50 disabled:cursor-not-allowed" ${this.currentPage === 1 ? 'disabled' : ''}>
${previousText}
</button>
<div class="text-sm font-medium">${pageText}</div>
<button class="btn-next btn btn-secondary py-1 px-3 text-xs disabled:opacity-50 disabled:cursor-not-allowed" ${this.currentPage === totalPages ? 'disabled' : ''}>
${nextText}
</button>
</div>
`;
this.paginationContainer.querySelector('.btn-prev').addEventListener('click', () => {
if (this.currentPage > 1) {
this.currentPage--;
this.render();
}
});
this.paginationContainer.querySelector('.btn-next').addEventListener('click', () => {
if (this.currentPage < totalPages) {
this.currentPage++;
this.render();
}
});
}
}
// Export if using modules, otherwise it's global
if (typeof module !== 'undefined' && module.exports) {
module.exports = SimpleDataTable;
}

93
public/assets/js/i18n.js Normal file
View File

@@ -0,0 +1,93 @@
class I18n {
constructor() {
this.currentLang = localStorage.getItem('mivo_lang') || 'en';
this.translations = {};
this.isLoaded = false;
// The ready promise resolves after the first language load
this.ready = this.init();
}
async init() {
await this.loadLanguage(this.currentLang);
this.isLoaded = true;
}
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}`);
this.translations = await response.json();
this.currentLang = lang;
localStorage.setItem('mivo_lang', lang);
this.applyTranslations();
// Dispatch event for other components
window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } }));
// Update html lang attribute
document.documentElement.lang = lang;
} catch (error) {
console.error('I18n Error:', error);
}
}
applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
const translation = this.getNestedValue(this.translations, key);
if (translation) {
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})`);
}
}
});
}
getNestedValue(obj, path) {
return path.split('.').reduce((acc, part) => acc && acc[part], obj);
}
t(key, params = {}) {
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
}
// Simple interpolation: {key}
if (params) {
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`{${param}}`, 'g'), params[param]);
});
}
return text;
}
}
// Initialize
window.i18n = new I18n();
// Global helper
function changeLanguage(lang) {
window.i18n.loadLanguage(lang);
}

2
public/assets/js/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

12
public/assets/js/lucide.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
public/assets/js/qrious.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,98 @@
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';
}
});
}
});

File diff suppressed because one or more lines are too long