web server

This commit is contained in:
2026-05-20 14:10:25 +07:00
parent 5ade939ff9
commit 190d2418da
30 changed files with 8917 additions and 0 deletions

719
web-server/public/js/app.js Normal file
View File

@@ -0,0 +1,719 @@
(function () {
const body = document.body;
const menuButton = document.getElementById('mobileMenuBtn');
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
function initNotiflix() {
if (!window.Notiflix) return;
window.Notiflix.Notify.init({
width: '320px',
position: 'right-top',
distance: '16px',
timeout: 2600,
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
fontSize: '13px',
messageMaxLength: 180,
clickToClose: true,
pauseOnHover: true,
cssAnimationStyle: 'from-right',
useIcon: true,
zindex: 5000,
success: {
background: '#067647',
textColor: '#ffffff'
},
failure: {
background: '#b42318',
textColor: '#ffffff'
},
warning: {
background: '#b54708',
textColor: '#ffffff'
},
info: {
background: '#3755c3',
textColor: '#ffffff'
}
});
window.Notiflix.Confirm.init({
width: '360px',
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
titleColor: '#111827',
titleFontSize: '16px',
messageColor: '#475569',
messageFontSize: '13px',
okButtonBackground: '#3755c3',
okButtonColor: '#ffffff',
cancelButtonBackground: '#e2e8f0',
cancelButtonColor: '#334155',
backOverlayColor: 'rgba(15, 23, 42, 0.42)',
zindex: 5001,
cssAnimationStyle: 'zoom'
});
}
function notify(type, message) {
if (!message) return;
if (!window.Notiflix) {
console.info(message);
return;
}
const Notify = window.Notiflix.Notify;
if (type === 'success') {
Notify.success(message);
return;
}
if (type === 'failure') {
Notify.failure(message);
return;
}
if (type === 'warning') {
Notify.warning(message);
return;
}
Notify.info(message);
}
function confirmAction(message, onConfirm) {
if (!window.Notiflix) {
if (window.confirm(message || 'Xác nhận thao tác?') && typeof onConfirm === 'function') {
onConfirm();
}
return;
}
window.Notiflix.Confirm.show(
'Xác nhận thao tác',
message,
'Xác nhận',
'Hủy',
() => {
if (typeof onConfirm === 'function') {
onConfirm();
return;
}
notify('success', 'Đã xác nhận thao tác');
},
() => {
notify('info', 'Đã hủy thao tác');
}
);
}
function setMobileNav(open) {
body.classList.toggle('mobile-nav-open', open);
if (menuButton) {
menuButton.setAttribute('aria-expanded', open ? 'true' : 'false');
}
}
function openModal(id) {
const modal = document.getElementById(id);
if (!modal) return;
modal.classList.add('open');
const focusTarget = modal.querySelector('input, select, textarea, button');
if (focusTarget) {
window.setTimeout(() => focusTarget.focus(), 60);
}
}
function closeModal(modal) {
if (!modal) return;
modal.classList.remove('open');
}
function applyTableFilters(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const searchInput = document.querySelector(`[data-table-search="${tableId}"]`);
const query = searchInput ? searchInput.value.trim().toLowerCase() : '';
const selects = document.querySelectorAll(`[data-filter-select][data-filter-table="${tableId}"]`);
table.querySelectorAll('tbody tr').forEach((row) => {
const matchesSearch = !query || (row.dataset.search || '').includes(query);
let matchesSelects = true;
selects.forEach((select) => {
const column = select.dataset.filterColumn;
const value = select.value;
if (value && row.dataset[column] !== value) {
matchesSelects = false;
}
});
row.hidden = !(matchesSearch && matchesSelects);
});
}
function formatFileSize(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, index);
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function isAllowedPackageFile(file) {
if (!file) return false;
const name = file.name.toLowerCase();
const allowedExtensions = ['.deb', '.tar', '.tar.gz', '.tgz', '.zip', '.gz'];
return allowedExtensions.some((extension) => name.endsWith(extension));
}
function renderSelectedFile(zone, file) {
const preview = zone.querySelector('[data-file-preview]');
const fileName = zone.querySelector('[data-file-name]');
const fileMeta = zone.querySelector('[data-file-meta]');
if (!preview || !fileName || !fileMeta) return;
if (!file) {
preview.hidden = true;
fileName.textContent = 'Chưa chọn file';
fileMeta.textContent = '';
zone.classList.remove('has-file');
return;
}
fileName.textContent = file.name;
fileMeta.textContent = `${formatFileSize(file.size)}${file.type || 'package file'}`;
preview.hidden = false;
zone.classList.add('has-file');
notify('success', `Đã chọn file: ${file.name}`);
}
function setInputFiles(input, files) {
try {
input.files = files;
} catch (error) {
console.info('Browser does not allow assigning dropped files to input.files.', error);
}
}
function handlePackageFiles(zone, files) {
const input = zone.querySelector('[data-file-input]');
const file = files && files[0];
if (!input || !file) return;
if (!isAllowedPackageFile(file)) {
notify('warning', 'File chưa đúng định dạng. Vui lòng chọn .deb, .tar, .tgz, .zip hoặc .gz.');
return;
}
setInputFiles(input, files);
renderSelectedFile(zone, file);
}
function initFileDropzones() {
document.querySelectorAll('[data-file-dropzone]').forEach((zone) => {
const input = zone.querySelector('[data-file-input]');
const browseButton = zone.querySelector('[data-file-browse]');
const clearButton = zone.querySelector('[data-file-clear]');
if (!input) return;
if (browseButton) {
browseButton.addEventListener('click', () => input.click());
}
if (clearButton) {
clearButton.addEventListener('click', () => {
input.value = '';
renderSelectedFile(zone, null);
notify('info', 'Đã bỏ file đã chọn');
});
}
input.addEventListener('change', () => {
handlePackageFiles(zone, input.files);
});
['dragenter', 'dragover'].forEach((eventName) => {
zone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach((eventName) => {
zone.addEventListener(eventName, (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('dragover');
});
});
zone.addEventListener('drop', (event) => {
handlePackageFiles(zone, event.dataTransfer.files);
});
});
}
function updateRegisterSubmit(form) {
const submitButton = form.querySelector('[data-register-submit]');
if (!submitButton) return;
const uniqueInputs = form.querySelectorAll('[data-unique-check]');
const isBlocked = Array.from(uniqueInputs).some((input) => (
input.dataset.uniqueStatus === 'error' || input.dataset.uniqueStatus === 'checking'
));
submitButton.disabled = isBlocked;
}
function setUniqueState(input, state, message) {
const field = input.closest('.form-field');
const form = input.closest('[data-register-form]');
const feedback = form
? form.querySelector(`[data-unique-feedback="${input.dataset.uniqueCheck}"]`)
: null;
input.dataset.uniqueStatus = state;
if (field) {
field.classList.toggle('has-error', state === 'error');
field.classList.toggle('has-success', state === 'success');
}
if (feedback) {
feedback.textContent = message || '';
feedback.style.display = message ? 'block' : '';
}
input.setCustomValidity(state === 'error' ? message : '');
if (form) {
updateRegisterSubmit(form);
}
}
function checkUniqueInput(input) {
const field = input.dataset.uniqueCheck;
const value = input.value.trim();
if (!field || !value) {
setUniqueState(input, 'idle', '');
return;
}
if (field === 'email' && !input.validity.valid) {
setUniqueState(input, 'idle', '');
return;
}
const requestId = String(Date.now());
input.dataset.uniqueRequestId = requestId;
setUniqueState(input, 'checking', 'Đang kiểm tra...');
fetch(`/register/check?field=${encodeURIComponent(field)}&value=${encodeURIComponent(value)}`, {
headers: {
Accept: 'application/json'
}
})
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Cannot check field')))
.then((data) => {
if (input.dataset.uniqueRequestId !== requestId) return;
setUniqueState(
input,
data.available ? 'success' : 'error',
data.message || (data.available ? 'Có thể sử dụng.' : 'Đã tồn tại.')
);
})
.catch((error) => {
console.info('Cannot check registration field:', error);
if (input.dataset.uniqueRequestId === requestId) {
setUniqueState(input, 'idle', '');
}
});
}
function initRegistrationUniqueChecks() {
document.querySelectorAll('[data-register-form]').forEach((form) => {
const timers = new Map();
form.querySelectorAll('[data-unique-check]').forEach((input) => {
input.addEventListener('input', () => {
window.clearTimeout(timers.get(input));
setUniqueState(input, 'idle', '');
timers.set(input, window.setTimeout(() => checkUniqueInput(input), 450));
});
input.addEventListener('blur', () => {
window.clearTimeout(timers.get(input));
checkUniqueInput(input);
});
if (input.value.trim()) {
checkUniqueInput(input);
}
});
form.addEventListener('submit', (event) => {
const blockedInput = form.querySelector('[data-unique-status="error"], [data-unique-status="checking"]');
if (!blockedInput) return;
event.preventDefault();
notify('warning', blockedInput.dataset.uniqueStatus === 'checking'
? 'Đang kiểm tra username/email, vui lòng chờ một chút.'
: 'Vui lòng đổi username/email đang bị trùng.');
blockedInput.focus();
});
});
}
function getUserDataFromRow(row) {
return {
id: row.dataset.userId || '',
name: row.dataset.userName || '',
username: row.dataset.userUsername || '',
email: row.dataset.userEmail || '',
fullName: row.dataset.userFullName || '',
role: row.dataset.userRole || 'User',
status: row.dataset.userStatus || '',
isActive: row.dataset.userActive === 'true',
createdAt: row.dataset.userCreatedAt || '',
updatedAt: row.dataset.userUpdatedAt || '',
packageCount: row.dataset.userPackageCount || '0',
applicationCount: row.dataset.userApplicationCount || '0'
};
}
function parseJsonAttribute(value, fallback) {
try {
return JSON.parse(value || '');
} catch (error) {
return fallback;
}
}
function getAppDataFromTrigger(trigger) {
return {
id: trigger.dataset.appId || '',
code: trigger.dataset.appCode || '',
name: trigger.dataset.appName || '',
version: trigger.dataset.appVersion || '',
status: trigger.dataset.appStatus || 'Draft',
notes: trigger.dataset.appNotes || '',
packages: parseJsonAttribute(trigger.dataset.appPackages, [])
};
}
function findEditAppVersionSelect(form, packageId) {
return Array.from(form.querySelectorAll('[data-edit-app-version]'))
.find((select) => select.dataset.editAppVersion === packageId);
}
function setEditAppPackageEnabled(form, checkbox) {
const select = findEditAppVersionSelect(form, checkbox.dataset.editAppPackage);
if (select) {
select.disabled = !checkbox.checked;
}
}
function setEditAppField(form, field, value) {
const input = form.querySelector(`[data-edit-app-field="${field}"]`);
if (input) {
input.value = value || '';
}
}
function openAppEdit(trigger) {
const app = getAppDataFromTrigger(trigger);
const form = document.getElementById('editAppForm');
if (!form || !app.id) return;
form.action = `/applications/${encodeURIComponent(app.id)}/edit`;
setEditAppField(form, 'appCode', app.code);
setEditAppField(form, 'appName', app.name);
setEditAppField(form, 'appVersion', app.version);
setEditAppField(form, 'status', app.status);
setEditAppField(form, 'notes', app.notes);
form.querySelectorAll('[data-edit-app-package]').forEach((checkbox) => {
checkbox.checked = false;
setEditAppPackageEnabled(form, checkbox);
});
app.packages.forEach((packageItem) => {
const packageId = packageItem.packageId || '';
const checkbox = Array.from(form.querySelectorAll('[data-edit-app-package]'))
.find((input) => input.dataset.editAppPackage === packageId);
const select = findEditAppVersionSelect(form, packageId);
if (checkbox) {
checkbox.checked = true;
}
if (select) {
select.disabled = false;
select.value = packageItem.selectedVersionId || '';
}
});
openModal('editAppModal');
}
function openPackageUpdate(packageId) {
const modalId = 'updatePackageModal';
const modal = document.getElementById(modalId);
const packageSelect = modal ? modal.querySelector('select[name="packageId"]') : null;
if (packageSelect && packageId) {
packageSelect.value = packageId;
}
openModal(modalId);
}
function setText(selector, value) {
const element = document.querySelector(selector);
if (element) {
element.textContent = value || '';
}
}
function openUserDetail(row) {
const user = getUserDataFromRow(row);
setText('[data-user-detail="name"]', user.name);
setText('[data-user-detail="username"]', user.username);
setText('[data-user-detail="email"]', user.email);
setText('[data-user-detail="role"]', user.role);
setText('[data-user-detail="status"]', user.status);
setText('[data-user-detail="createdAt"]', user.createdAt);
setText('[data-user-detail="updatedAt"]', user.updatedAt || 'Chưa cập nhật');
setText('[data-user-detail="ownedData"]', `${user.packageCount} packages, ${user.applicationCount} apps`);
openModal('userDetailModal');
}
function openUserEdit(row) {
const user = getUserDataFromRow(row);
const form = document.getElementById('editUserForm');
if (!form) return;
form.action = `/users/${encodeURIComponent(user.id)}/edit`;
form.querySelector('[data-edit-user-field="username"]').value = user.username;
form.querySelector('[data-edit-user-field="fullName"]').value = user.fullName;
form.querySelector('[data-edit-user-field="email"]').value = user.email;
form.querySelector('[data-edit-user-field="role"]').value = user.role;
form.querySelector('[data-edit-user-field="isActive"]').checked = user.isActive;
form.querySelector('[data-edit-user-field="newPassword"]').value = '';
form.querySelector('[data-edit-user-field="confirmPassword"]').value = '';
openModal('editUserModal');
}
function validateProfileForm(form, shouldNotify) {
const email = form.querySelector('[data-profile-email]');
const confirmEmail = form.querySelector('[data-profile-confirm-email]');
const newPassword = form.querySelector('[data-profile-new-password]');
const confirmPassword = form.querySelector('[data-profile-confirm-password]');
const emailFeedback = form.querySelector('[data-profile-feedback="email"]');
const passwordFeedback = form.querySelector('[data-profile-feedback="password"]');
const emailMismatch = Boolean(email && confirmEmail
&& email.value.trim().toLowerCase() !== confirmEmail.value.trim().toLowerCase());
const passwordMismatch = Boolean(newPassword && confirmPassword
&& (newPassword.value || confirmPassword.value)
&& newPassword.value !== confirmPassword.value);
if (confirmEmail) {
confirmEmail.setCustomValidity(emailMismatch ? 'Confirm email mới chưa khớp.' : '');
const field = confirmEmail.closest('.form-field');
if (field) {
field.classList.toggle('has-error', emailMismatch);
}
}
if (emailFeedback) {
emailFeedback.textContent = emailMismatch ? 'Confirm email mới chưa khớp.' : '';
emailFeedback.style.display = emailMismatch ? 'block' : '';
}
if (confirmPassword) {
confirmPassword.setCustomValidity(passwordMismatch ? 'Xác nhận mật khẩu mới chưa khớp.' : '');
const field = confirmPassword.closest('.form-field');
if (field) {
field.classList.toggle('has-error', passwordMismatch);
}
}
if (passwordFeedback) {
passwordFeedback.textContent = passwordMismatch ? 'Xác nhận mật khẩu mới chưa khớp.' : '';
passwordFeedback.style.display = passwordMismatch ? 'block' : '';
}
if (shouldNotify && emailMismatch) {
notify('warning', 'Confirm email mới chưa khớp.');
} else if (shouldNotify && passwordMismatch) {
notify('warning', 'Xác nhận mật khẩu mới chưa khớp.');
}
return !emailMismatch && !passwordMismatch;
}
function initProfileForms() {
document.querySelectorAll('[data-profile-form]').forEach((form) => {
form.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', () => validateProfileForm(form, false));
});
form.addEventListener('submit', (event) => {
if (!validateProfileForm(form, true)) {
event.preventDefault();
}
});
});
}
function initEditAppForms() {
document.querySelectorAll('#editAppForm [data-edit-app-package]').forEach((checkbox) => {
const form = checkbox.closest('form');
setEditAppPackageEnabled(form, checkbox);
checkbox.addEventListener('change', () => setEditAppPackageEnabled(form, checkbox));
});
}
initNotiflix();
initFileDropzones();
initRegistrationUniqueChecks();
initProfileForms();
initEditAppForms();
if (body.dataset.notice) {
notify(body.dataset.noticeType || 'info', body.dataset.notice);
const url = new URL(window.location.href);
if (url.searchParams.has('notice') || url.searchParams.has('noticeType')) {
url.searchParams.delete('notice');
url.searchParams.delete('noticeType');
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
}
delete body.dataset.notice;
delete body.dataset.noticeType;
}
if (menuButton) {
menuButton.addEventListener('click', () => {
setMobileNav(!body.classList.contains('mobile-nav-open'));
});
}
if (sidebarBackdrop) {
sidebarBackdrop.addEventListener('click', () => setMobileNav(false));
}
document.querySelectorAll('[data-table-search]').forEach((input) => {
input.addEventListener('input', () => applyTableFilters(input.dataset.tableSearch));
});
document.querySelectorAll('[data-filter-select]').forEach((select) => {
select.addEventListener('change', () => applyTableFilters(select.dataset.filterTable));
});
document.addEventListener('click', (event) => {
const appEditButton = event.target.closest('[data-app-edit]');
if (appEditButton) {
openAppEdit(appEditButton);
return;
}
const packageUpdateButton = event.target.closest('[data-package-update]');
if (packageUpdateButton) {
openPackageUpdate(packageUpdateButton.dataset.packageUpdate);
return;
}
const refreshButton = event.target.closest('[data-refresh-page]');
if (refreshButton) {
window.location.reload();
return;
}
const userViewButton = event.target.closest('[data-user-view]');
if (userViewButton) {
const row = userViewButton.closest('tr');
if (row) {
openUserDetail(row);
}
return;
}
const userEditButton = event.target.closest('[data-user-edit]');
if (userEditButton) {
const row = userEditButton.closest('tr');
if (row) {
openUserEdit(row);
}
return;
}
const modalOpenButton = event.target.closest('[data-modal-open]');
if (modalOpenButton) {
openModal(modalOpenButton.dataset.modalOpen);
return;
}
const modalCloseButton = event.target.closest('[data-modal-close]');
if (modalCloseButton) {
closeModal(modalCloseButton.closest('.modal-backdrop'));
}
const modalBackdrop = event.target.classList.contains('modal-backdrop') ? event.target : null;
if (modalBackdrop) {
closeModal(modalBackdrop);
}
const toastButton = event.target.closest('[data-toast]');
if (toastButton) {
notify(toastButton.dataset.toastType || 'info', toastButton.dataset.toast);
}
const confirmButton = event.target.closest('[data-confirm]');
if (confirmButton) {
confirmAction(confirmButton.dataset.confirm);
}
});
document.addEventListener('submit', (event) => {
const form = event.target.closest('form[data-confirm-submit]');
if (!form || form.dataset.confirmed === 'true') return;
event.preventDefault();
confirmAction(form.dataset.confirmSubmit, () => {
form.dataset.confirmed = 'true';
form.submit();
});
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const openModalNode = document.querySelector('.modal-backdrop.open');
if (openModalNode) {
closeModal(openModalNode);
return;
}
setMobileNav(false);
});
})();