is-1/src/main/webapp/js/app.js
2025-10-20 13:50:49 +03:00

1203 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class MusicBandApp {
constructor() {
this.API_BASE = '/is1/api';
this.currentPage = 0;
this.pageSize = 10;
this.currentSort = 'id';
this.currentAscending = true;
this.currentFilter = '';
this.editingBandId = null;
this.websocket = null;
this.reconnectAttempts = 0;
this.token = localStorage.getItem('token');
this.user = JSON.parse(localStorage.getItem('user') || 'null');
this.toastEl = document.getElementById('toast');
this.initializeApp();
}
initializeApp() {
if (this.token && this.user) {
this.showAppScreen();
} else {
this.showAuthScreen();
}
}
showAuthScreen() {
document.getElementById('authScreen').style.display = 'grid';
document.getElementById('appScreen').style.display = 'none';
this.initializeAuthListeners();
}
showAppScreen() {
document.getElementById('authScreen').style.display = 'none';
document.getElementById('appScreen').style.display = 'block';
document.getElementById('userInfo').textContent =
`${this.user.username} (${this.user.role})`;
this.initializeEventListeners();
this.loadBands();
setTimeout(() => {
this.initializeWebSocket();
}, 500);
}
initializeAuthListeners() {
document.getElementById('showRegisterBtn')?.addEventListener('click', () => {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('registerForm').style.display = 'block';
});
document.getElementById('showLoginBtn')?.addEventListener('click', () => {
document.getElementById('registerForm').style.display = 'none';
document.getElementById('loginForm').style.display = 'block';
});
document.getElementById('loginFormElement')?.addEventListener('submit', (e) => {
e.preventDefault();
this.login();
});
document.getElementById('registerFormElement')?.addEventListener('submit', (e) => {
e.preventDefault();
this.register();
});
}
async login() {
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
if (!username || !password) {
this.showError('Please fill in all fields');
return;
}
try {
const response = await fetch(`${this.API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
this.token = data.token;
this.user = {
userId: data.userId,
username: data.username,
role: data.role
};
localStorage.setItem('token', this.token);
localStorage.setItem('user', JSON.stringify(this.user));
this.showSuccess('Login successful!');
this.showAppScreen();
} catch (error) {
this.showError(error.message);
}
}
async register() {
const username = document.getElementById('registerUsername').value.trim();
const password = document.getElementById('registerPassword').value;
const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
if (!username || !password || !passwordConfirm) {
this.showError('Please fill in all fields');
return;
}
if (password !== passwordConfirm) {
this.showError('Passwords do not match');
return;
}
if (password.length < 6) {
this.showError('Password must be at least 6 characters');
return;
}
try {
const response = await fetch(`${this.API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registration failed');
}
this.token = data.token;
this.user = {
userId: data.userId,
username: data.username,
role: data.role
};
localStorage.setItem('token', this.token);
localStorage.setItem('user', JSON.stringify(this.user));
this.showSuccess('Registration successful!');
this.showAppScreen();
} catch (error) {
this.showError(error.message);
}
}
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
this.showAuthScreen();
this.showInfo('Logged out successfully');
}
initializeEventListeners() {
document.getElementById('logoutBtn').addEventListener('click', () => this.logout());
document.getElementById('showBandsBtn').addEventListener('click', (e) => this.showSection('bandsSection', e.currentTarget));
document.getElementById('showStatisticsBtn').addEventListener('click', (e) => this.showSection('statisticsSection', e.currentTarget));
document.getElementById('showSpecialOpsBtn').addEventListener('click', (e) => this.showSection('specialOpsSection', e.currentTarget));
document.getElementById('showImportBtn').addEventListener('click', (e) => this.showSection('importSection', e.currentTarget));
document.getElementById('showHistoryBtn').addEventListener('click', (e) => {
this.showSection('historySection', e.currentTarget);
this.loadImportHistory();
});
document.getElementById('addBandBtn').addEventListener('click', () => this.showAddBandModal());
document.getElementById('nameFilter').addEventListener('input', (e) => this.filterBands(e.target.value));
document.getElementById('sortBy').addEventListener('change', (e) => this.sortBands(e.target.value));
document.getElementById('sortAscBtn').addEventListener('click', () => this.sortBands(this.currentSort, true));
document.getElementById('sortDescBtn').addEventListener('click', () => this.sortBands(this.currentSort, false));
document.getElementById('prevPageBtn').addEventListener('click', () => this.previousPage());
document.getElementById('nextPageBtn').addEventListener('click', () => this.nextPage());
document.getElementById('calcAvgBtn').addEventListener('click', () => this.calculateAverageAlbums());
document.getElementById('findMaxNameBtn').addEventListener('click', () => this.findBandWithMaxName());
document.getElementById('groupByParticipantsBtn').addEventListener('click', () => this.groupByParticipants());
document.getElementById('addSingleBtn').addEventListener('click', () => this.addSingle());
document.getElementById('removeParticipantBtn').addEventListener('click', () => this.removeParticipant());
document.getElementById('importBtn').addEventListener('click', () => this.importFile());
document.getElementById('refreshHistoryBtn').addEventListener('click', () => this.loadImportHistory());
document.querySelector('.close').addEventListener('click', () => this.closeModal());
document.getElementById('cancelBtn').addEventListener('click', () => this.closeModal());
document.getElementById('bandForm').addEventListener('submit', (e) => this.saveBand(e));
window.addEventListener('click', (e) => {
if (e.target === document.getElementById('bandModal')) this.closeModal();
if (e.target === document.getElementById('viewModal')) this.closeViewModal();
});
}
async makeRequest(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (this.token && !url.includes('/auth/')) {
headers['Authorization'] = `Bearer ${this.token}`;
}
try {
const response = await fetch(url, {
headers,
...options
});
let data;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
try {
data = await response.json();
} catch (e) {
data = {};
}
} else {
data = {};
}
if (!response.ok) {
if (response.status === 401) {
this.showError('Session expired. Please login again.');
this.logout();
throw new Error('Unauthorized');
}
const errorMessage = this.extractErrorMessage(data, response.status);
throw new Error(errorMessage);
}
return data;
} catch (error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('Failed to connect to server. Please check your connection.');
}
throw error;
}
}
extractErrorMessage(data, status) {
if (data.error) return data.error;
if (data.message) return data.message;
switch (status) {
case 400: return 'Invalid request data. Please check your input.';
case 401: return 'Authentication required.';
case 403: return 'Access denied.';
case 404: return 'Resource not found.';
case 409: return 'Conflict with existing data.';
case 422: return 'Validation failed. Please check your input.';
case 500: return 'Internal server error. Please try again later.';
case 503: return 'Service temporarily unavailable.';
default: return `Request failed with status ${status}`;
}
}
showSection(sectionId, btn) {
document.querySelectorAll('.section').forEach((s) => s.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach((b) => b.classList.remove('active'));
document.getElementById(sectionId).classList.add('active');
btn.classList.add('active');
}
async loadBands() {
try {
const params = new URLSearchParams({
page: this.currentPage,
size: this.pageSize,
sortBy: this.currentSort,
ascending: this.currentAscending
});
if (this.currentFilter) {
params.append('nameFilter', this.currentFilter);
}
const data = await this.makeRequest(`${this.API_BASE}/music-bands?${params}`);
const bands = data.bands || [];
const totalCount = data.totalCount || 0;
this.renderBandsTable(bands);
this.updatePagination(totalCount);
if (totalCount === 0 && this.currentFilter) {
this.showInfo('No bands found matching your filter');
}
} catch (error) {
this.showError(error.message);
this.renderBandsTable([]);
this.updatePagination(0);
}
}
renderBandsTable(bands) {
const tbody = document.getElementById('bandsTableBody');
tbody.innerHTML = '';
if (bands.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="8" style="text-align: center; color: var(--muted);">No bands found</td>';
tbody.appendChild(row);
return;
}
bands.forEach((band) => {
const row = document.createElement('tr');
const canEdit = this.user.role === 'ADMIN' || this.user.userId === band.createdBy;
row.innerHTML = `
<td>${band.id}</td>
<td>${this.escapeHtml(band.name || '')}</td>
<td>${band.genre || '—'}</td>
<td>${band.numberOfParticipants || '—'}</td>
<td>${band.singlesCount || '—'}</td>
<td>${band.albumsCount ?? '—'}</td>
<td>${band.establishmentDate ? new Date(band.establishmentDate).toLocaleDateString() : '—'}</td>
<td>
<div class="field-row">
<button class="btn btn-primary btn-small" data-action="view">View</button>
${canEdit ? '<button class="btn btn-ghost btn-small" data-action="edit">Edit</button>' : ''}
${canEdit ? '<button class="btn btn-ghost btn-small" data-action="del">Delete</button>' : ''}
</div>
</td>
`;
row.querySelector('[data-action="view"]').addEventListener('click', () => this.viewBand(band.id));
if (canEdit) {
row.querySelector('[data-action="edit"]').addEventListener('click', () => this.editBand(band.id));
row.querySelector('[data-action="del"]').addEventListener('click', () => this.deleteBand(band.id));
}
tbody.appendChild(row);
});
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
updatePagination(totalCount) {
const totalPages = Math.max(1, Math.ceil(totalCount / this.pageSize));
this.currentPage = Math.min(this.currentPage, totalPages - 1);
document.getElementById('pageInfo').textContent = `Page ${this.currentPage + 1} of ${totalPages}`;
document.getElementById('prevPageBtn').disabled = this.currentPage === 0;
document.getElementById('nextPageBtn').disabled = this.currentPage >= totalPages - 1;
}
filterBands(filter) {
this.currentFilter = filter.trim();
this.currentPage = 0;
this.loadBands();
}
sortBands(sortBy, ascending = this.currentAscending) {
this.currentSort = sortBy;
this.currentAscending = ascending;
this.currentPage = 0;
document.getElementById('sortAscBtn').classList.toggle('active', ascending);
document.getElementById('sortDescBtn').classList.toggle('active', !ascending);
this.loadBands();
}
previousPage() {
if (this.currentPage > 0) {
this.currentPage--;
this.loadBands();
}
}
nextPage() {
this.currentPage++;
this.loadBands();
}
showAddBandModal() {
this.editingBandId = null;
document.getElementById('modalTitle').textContent = 'Add New Band';
document.getElementById('bandForm').reset();
this.setDefaultEstablishmentDate();
this.clearFormErrors();
document.getElementById('bandModal').style.display = 'block';
}
setDefaultEstablishmentDate() {
if (!this.editingBandId) {
const now = new Date();
now.setFullYear(now.getFullYear() - 1);
const formatted = now.toISOString().slice(0, 16);
document.getElementById('establishmentDate').value = formatted;
}
}
async editBand(id) {
try {
const band = await this.makeRequest(`${this.API_BASE}/music-bands/${id}`);
this.editingBandId = id;
document.getElementById('modalTitle').textContent = 'Edit Band';
this.populateForm(band);
this.clearFormErrors();
document.getElementById('bandModal').style.display = 'block';
} catch (error) {
this.showError(error.message);
}
}
async viewBand(id) {
try {
const band = await this.makeRequest(`${this.API_BASE}/music-bands/${id}`);
this.showViewModal(band);
} catch (error) {
this.showError(error.message);
}
}
showViewModal(band) {
const modal = document.getElementById('viewModal') || this.createViewModal();
const content = modal.querySelector('.view-content');
content.innerHTML = `
<div class="view-header">
<h2>${this.escapeHtml(band.name)}</h2>
<span class="badge">${band.genre}</span>
</div>
<div class="view-grid">
<div class="view-item">
<label>ID</label>
<div class="value">${band.id}</div>
</div>
<div class="view-item">
<label>Participants</label>
<div class="value">${band.numberOfParticipants}</div>
</div>
<div class="view-item">
<label>Singles</label>
<div class="value">${band.singlesCount}</div>
</div>
<div class="view-item">
<label>Albums</label>
<div class="value">${band.albumsCount ?? '—'}</div>
</div>
<div class="view-item">
<label>Coordinates</label>
<div class="value">(${band.coordinates?.x ?? '—'}, ${band.coordinates?.y ?? '—'})</div>
</div>
<div class="view-item">
<label>Establishment Date</label>
<div class="value">${band.establishmentDate ? new Date(band.establishmentDate).toLocaleString() : '—'}</div>
</div>
<div class="view-item">
<label>Creation Date</label>
<div class="value">${band.creationDate ? new Date(band.creationDate).toLocaleString() : '—'}</div>
</div>
</div>
${band.description ? `
<div class="view-section">
<h3>Description</h3>
<p>${this.escapeHtml(band.description)}</p>
</div>
` : ''}
${band.bestAlbum ? `
<div class="view-section">
<h3>Best Album</h3>
<div class="view-grid">
<div class="view-item">
<label>Name</label>
<div class="value">${this.escapeHtml(band.bestAlbum.name)}</div>
</div>
<div class="view-item">
<label>Tracks</label>
<div class="value">${band.bestAlbum.tracks}</div>
</div>
</div>
</div>
` : ''}
${band.frontMan ? `
<div class="view-section">
<h3>Front Man</h3>
<div class="view-grid">
<div class="view-item">
<label>Name</label>
<div class="value">${this.escapeHtml(band.frontMan.name)}</div>
</div>
<div class="view-item">
<label>Nationality</label>
<div class="value">${band.frontMan.nationality.replace(/_/g, ' ')}</div>
</div>
<div class="view-item">
<label>Eye Color</label>
<div class="value">${band.frontMan.eyeColor}</div>
</div>
${band.frontMan.hairColor ? `
<div class="view-item">
<label>Hair Color</label>
<div class="value">${band.frontMan.hairColor}</div>
</div>
` : ''}
<div class="view-item">
<label>Height</label>
<div class="value">${band.frontMan.height} cm</div>
</div>
${band.frontMan.location ? `
<div class="view-item">
<label>Location</label>
<div class="value">(${band.frontMan.location.x}, ${band.frontMan.location.y}, ${band.frontMan.location.z})</div>
</div>
` : ''}
</div>
</div>
` : ''}
`;
modal.style.display = 'block';
}
createViewModal() {
const modal = document.createElement('div');
modal.id = 'viewModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content card view-modal">
<button class="close icon-btn" aria-label="Close">&times;</button>
<div class="view-content"></div>
<div class="view-actions">
<button class="btn btn-ghost" onclick="app.closeViewModal()">Close</button>
</div>
</div>
`;
modal.querySelector('.close').addEventListener('click', () => this.closeViewModal());
document.body.appendChild(modal);
return modal;
}
closeViewModal() {
const modal = document.getElementById('viewModal');
if (modal) {
modal.style.display = 'none';
}
}
populateForm(band) {
document.getElementById('bandName').value = band.name || '';
document.getElementById('bandGenre').value = band.genre || '';
document.getElementById('coordinatesX').value = band.coordinates?.x || '';
document.getElementById('coordinatesY').value = band.coordinates?.y || '';
document.getElementById('numberOfParticipants').value = band.numberOfParticipants || '';
document.getElementById('singlesCount').value = band.singlesCount || '';
document.getElementById('albumsCount').value = band.albumsCount || '';
document.getElementById('establishmentDate').value = this.convertFromZonedDateTime(band.establishmentDate);
document.getElementById('description').value = band.description || '';
if (band.bestAlbum) {
document.getElementById('albumName').value = band.bestAlbum.name || '';
document.getElementById('albumTracks').value = band.bestAlbum.tracks || '';
}
if (band.frontMan) {
document.getElementById('frontManName').value = band.frontMan.name || '';
document.getElementById('eyeColor').value = band.frontMan.eyeColor || '';
document.getElementById('hairColor').value = band.frontMan.hairColor || '';
document.getElementById('height').value = band.frontMan.height || '';
document.getElementById('nationality').value = band.frontMan.nationality || '';
if (band.frontMan.location) {
document.getElementById('locationX').value = band.frontMan.location.x || '';
document.getElementById('locationY').value = band.frontMan.location.y || '';
document.getElementById('locationZ').value = band.frontMan.location.z || '';
}
}
}
convertFromZonedDateTime(zonedDateTime) {
if (!zonedDateTime) return '';
try {
const date = new Date(zonedDateTime);
if (isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
} catch (e) {
return '';
}
}
convertToZonedDateTime(datetimeLocalValue) {
if (!datetimeLocalValue) return null;
try {
const date = new Date(datetimeLocalValue);
if (isNaN(date.getTime())) throw new Error('Invalid date format');
const offsetMinutes = date.getTimezoneOffset();
const offsetHours = Math.abs(Math.floor(offsetMinutes / 60));
const offsetMins = Math.abs(offsetMinutes % 60);
const offsetSign = offsetMinutes <= 0 ? '+' : '-';
const offset = `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(offsetMins).padStart(2, '0')}`;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offset}`;
} catch (e) {
throw new Error('Invalid date format');
}
}
async saveBand(event) {
event.preventDefault();
this.clearFormErrors();
const bandData = this.collectFormData();
if (!this.validateFormData(bandData)) {
return;
}
const submitButton = event.target.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Saving...';
submitButton.disabled = true;
try {
const url = this.editingBandId
? `${this.API_BASE}/music-bands/${this.editingBandId}`
: `${this.API_BASE}/music-bands`;
const method = this.editingBandId ? 'PUT' : 'POST';
const savedBand = await this.makeRequest(url, {
method,
body: JSON.stringify(bandData)
});
this.closeModal();
await this.loadBands();
this.showSuccess(this.editingBandId ? 'Band updated successfully!' : 'Band created successfully!');
} catch (error) {
this.showError(error.message);
this.highlightErrorFields(error.message);
} finally {
submitButton.textContent = originalText;
submitButton.disabled = false;
}
}
validateFormData(data) {
const errors = [];
if (!data.name?.trim()) {
errors.push('Band name is required');
}
if (!data.genre) {
errors.push('Genre is required');
}
if (data.coordinates?.x === null || data.coordinates?.x === undefined) {
errors.push('Coordinates X is required');
}
if (data.coordinates?.y === null || data.coordinates?.y === undefined) {
errors.push('Coordinates Y is required');
}
if (!data.numberOfParticipants || data.numberOfParticipants <= 0) {
errors.push('Number of participants must be greater than 0');
}
if (!data.singlesCount || data.singlesCount <= 0) {
errors.push('Singles count must be greater than 0');
}
if (data.albumsCount !== null && data.albumsCount !== undefined && data.albumsCount <= 0) {
errors.push('Albums count must be greater than 0');
}
if (!data.establishmentDate) {
errors.push('Establishment date is required');
} else {
try {
const estDate = new Date(data.establishmentDate);
const currentDate = new Date();
const minDate = new Date('1900-01-01');
if (isNaN(estDate.getTime())) {
errors.push('Invalid establishment date format');
} else if (estDate > currentDate) {
errors.push('Establishment date cannot be in the future');
} else if (estDate < minDate) {
errors.push('Establishment date must be after 1900');
}
} catch (e) {
errors.push('Invalid establishment date');
}
}
if (data.bestAlbum) {
if (!data.bestAlbum.name?.trim()) {
errors.push('Album name cannot be empty if album is specified');
}
if (!data.bestAlbum.tracks || data.bestAlbum.tracks <= 0) {
errors.push('Album tracks must be greater than 0 if album is specified');
}
}
if (data.frontMan) {
if (!data.frontMan.name?.trim()) {
errors.push('Front man name cannot be empty if front man is specified');
}
if (!data.frontMan.eyeColor) {
errors.push('Front man eye color is required if front man is specified');
}
if (!data.frontMan.height || data.frontMan.height <= 0) {
errors.push('Front man height must be greater than 0 if front man is specified');
}
if (!data.frontMan.nationality) {
errors.push('Front man nationality is required if front man is specified');
}
if (data.frontMan.location) {
const loc = data.frontMan.location;
if (loc.x === null || loc.x === undefined) {
errors.push('Front man location X is required if location is specified');
}
if (loc.y === null || loc.y === undefined) {
errors.push('Front man location Y is required if location is specified');
}
if (loc.z === null || loc.z === undefined) {
errors.push('Front man location Z is required if location is specified');
}
}
}
if (errors.length > 0) {
this.showError('Validation failed:\n• ' + errors.join('\n• '));
return false;
}
return true;
}
highlightErrorFields(errorMessage) {
const fieldMap = {
'name': 'bandName',
'genre': 'bandGenre',
'coordinates': ['coordinatesX', 'coordinatesY'],
'numberOfParticipants': 'numberOfParticipants',
'singlesCount': 'singlesCount',
'establishmentDate': 'establishmentDate'
};
Object.entries(fieldMap).forEach(([keyword, fieldIds]) => {
if (errorMessage.toLowerCase().includes(keyword.toLowerCase())) {
const ids = Array.isArray(fieldIds) ? fieldIds : [fieldIds];
ids.forEach(id => {
const field = document.getElementById(id);
if (field) {
field.style.borderColor = 'var(--error)';
field.style.boxShadow = '0 0 0 2px rgba(239,68,68,0.2)';
}
});
}
});
}
clearFormErrors() {
document.querySelectorAll('#bandForm input, #bandForm select, #bandForm textarea').forEach(field => {
field.style.borderColor = '';
field.style.boxShadow = '';
});
}
collectFormData() {
const data = {
name: document.getElementById('bandName').value.trim(),
genre: document.getElementById('bandGenre').value,
coordinates: {
x: this.parseInteger(document.getElementById('coordinatesX').value),
y: this.parseInteger(document.getElementById('coordinatesY').value)
},
numberOfParticipants: this.parseInteger(document.getElementById('numberOfParticipants').value),
singlesCount: this.parseInteger(document.getElementById('singlesCount').value),
establishmentDate: this.convertToZonedDateTime(document.getElementById('establishmentDate').value)
};
const albumsCount = this.parseInteger(document.getElementById('albumsCount').value);
if (albumsCount !== null && albumsCount > 0) {
data.albumsCount = albumsCount;
}
const description = document.getElementById('description').value.trim();
if (description) {
data.description = description;
}
const albumName = document.getElementById('albumName').value.trim();
const albumTracks = this.parseInteger(document.getElementById('albumTracks').value);
if (albumName && albumTracks && albumTracks > 0) {
data.bestAlbum = { name: albumName, tracks: albumTracks };
} else if (albumName || (albumTracks && albumTracks > 0)) {
data.bestAlbum = {
name: albumName || '',
tracks: albumTracks || 0
};
}
const frontManName = document.getElementById('frontManName').value.trim();
const eyeColor = document.getElementById('eyeColor').value;
const height = this.parseFloat(document.getElementById('height').value);
const nationality = document.getElementById('nationality').value;
if (frontManName || eyeColor || height || nationality) {
data.frontMan = {
name: frontManName,
eyeColor,
height,
nationality
};
const hairColor = document.getElementById('hairColor').value;
if (hairColor) {
data.frontMan.hairColor = hairColor;
}
const lx = this.parseInteger(document.getElementById('locationX').value);
const ly = this.parseFloat(document.getElementById('locationY').value);
const lz = this.parseInteger(document.getElementById('locationZ').value);
if (lx !== null || ly !== null || lz !== null) {
if (lx !== null && ly !== null && lz !== null) {
data.frontMan.location = { x: lx, y: ly, z: lz };
} else {
data.frontMan.location = {
x: lx ?? 0,
y: ly ?? 0.0,
z: lz ?? 0
};
}
}
}
return data;
}
parseInteger(value) {
if (!value || value.trim() === '') return null;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? null : parsed;
}
parseFloat(value) {
if (!value || value.trim() === '') return null;
const parsed = parseFloat(value);
return isNaN(parsed) ? null : parsed;
}
async deleteBand(id) {
const confirmed = await this.showConfirm(
'Delete Band',
'Are you sure you want to delete this band? This action cannot be undone and will also delete all related data.'
);
if (!confirmed) return;
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${id}`, { method: 'DELETE' });
if (this.currentPage > 0) {
const bands = document.querySelectorAll('#bandsTableBody tr');
if (bands.length <= 1) {
this.currentPage = Math.max(0, this.currentPage - 1);
}
}
await this.loadBands();
this.showSuccess('Band deleted successfully!');
} catch (error) {
this.showError(error.message);
}
}
closeModal() {
document.getElementById('bandModal').style.display = 'none';
this.clearFormErrors();
}
async calculateAverageAlbums() {
try {
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/average-albums`);
const avg = (data.averageAlbumsCount ?? 0).toFixed(2);
document.getElementById('avgAlbumsCount').textContent = avg;
this.showSuccess(`Average albums count: ${avg}`);
} catch (error) {
this.showError(error.message);
}
}
async findBandWithMaxName() {
try {
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/max-name`);
const name = data.name ?? 'No bands found';
document.getElementById('maxNameBand').textContent = name;
this.showSuccess(`Band with max name: ${name}`);
} catch (error) {
this.showError(error.message);
}
}
async groupByParticipants() {
try {
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/group-by-participants`);
const lines = Object.entries(data).map(([participants, count]) =>
`${participants} participants: ${count} band${count !== 1 ? 's' : ''}`
);
document.getElementById('participantsGroups').innerHTML = lines.length > 0 ?
lines.join('<br>') : 'No data available';
this.showSuccess('Bands grouped by participants');
} catch (error) {
this.showError(error.message);
}
}
async addSingle() {
const bandId = this.parseInteger(document.getElementById('addSingleBandId').value);
if (!bandId || bandId <= 0) {
this.showError('Please enter a valid band ID');
return;
}
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/add-single`, { method: 'POST' });
document.getElementById('addSingleBandId').value = '';
await this.loadBands();
this.showSuccess(`Single added to band #${bandId}!`);
} catch (error) {
this.showError(error.message);
}
}
async removeParticipant() {
const bandId = this.parseInteger(document.getElementById('removeParticipantBandId').value);
if (!bandId || bandId <= 0) {
this.showError('Please enter a valid band ID');
return;
}
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/remove-participant`, { method: 'POST' });
document.getElementById('removeParticipantBandId').value = '';
await this.loadBands();
this.showSuccess(`Participant removed from band #${bandId}!`);
} catch (error) {
this.showError(error.message);
}
}
async importFile() {
const fileInput = document.getElementById('importFile');
const formatSelect = document.getElementById('importFormat');
if (!fileInput.files || fileInput.files.length === 0) {
this.showError('Please select a file to import');
return;
}
const file = fileInput.files[0];
let format = formatSelect.value;
if (!format) {
const ext = file.name.split('.').pop().toLowerCase();
if (['json', 'xml', 'yaml', 'yml'].includes(ext)) {
format = ext === 'yml' ? 'yaml' : ext;
} else {
this.showError('Could not detect file format. Please select format manually.');
return;
}
}
try {
const fileContent = await this.readFileAsText(file);
const response = await fetch(`${this.API_BASE}/import?format=${format}`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'Authorization': `Bearer ${this.token}`
},
body: fileContent
});
const data = await response.json();
if (response.ok) {
fileInput.value = '';
this.showSuccess(`Import completed successfully! ${data.objectsCount} bands imported.`);
await this.loadBands();
} else {
const errorMsg = data.error || 'Import failed';
const opId = data.operationId ? ` (Operation ID: ${data.operationId})` : '';
this.showError(`Import failed: ${errorMsg}${opId}`);
}
} catch (error) {
this.showError(`Import error: ${error.message}`);
}
}
readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
async loadImportHistory() {
try {
const history = await this.makeRequest(`${this.API_BASE}/import/history`);
this.renderImportHistory(history);
} catch (error) {
this.showError(error.message);
}
}
renderImportHistory(history) {
const tbody = document.getElementById('historyTableBody');
tbody.innerHTML = '';
if (history.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="6" style="text-align: center; color: var(--muted);">No import history</td>';
tbody.appendChild(row);
return;
}
history.forEach((item) => {
const row = document.createElement('tr');
const statusClass = item.status === 'SUCCESS' ? 'text-success' : 'text-error';
row.innerHTML = `
<td>${item.id}</td>
<td>${this.escapeHtml(item.username)}</td>
<td class="${statusClass}">${item.status}</td>
<td>${item.objectsCount ?? '—'}</td>
<td>${new Date(item.createdAt).toLocaleString()}</td>
<td>${item.errorMessage ? this.escapeHtml(item.errorMessage) : '—'}</td>
`;
tbody.appendChild(row);
});
}
initializeWebSocket() {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
return;
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/is1/websocket/bands`;
try {
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
this.reconnectAttempts = 0;
};
this.websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'band_update') {
this.handleBandUpdate(data.action);
}
} catch (e) {}
};
this.websocket.onclose = (event) => {
this.websocket = null;
this.reconnectAttempts = (this.reconnectAttempts || 0) + 1;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
setTimeout(() => this.initializeWebSocket(), delay);
};
this.websocket.onerror = (error) => {
if (this.websocket) {
this.websocket.close();
}
};
} catch (e) {
setTimeout(() => this.initializeWebSocket(), 5000);
}
}
handleBandUpdate(action) {
if (['create', 'update', 'delete'].includes(action)) {
this.loadBands();
if (action === 'create') {
this.showInfo('New band added by another user');
} else if (action === 'update') {
this.showInfo('Band updated by another user');
} else if (action === 'delete') {
this.showInfo('Band deleted by another user');
}
}
}
showError(message) {
this.showToast(message, 'error');
}
showSuccess(message) {
this.showToast(message, 'success');
}
showInfo(message) {
this.showToast(message, 'info');
}
showToast(message, type = 'info') {
const el = this.toastEl;
el.className = `toast ${type} show`;
const icon = type === 'success' ? '✓' : type === 'error' ? '✗' : '';
el.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-message">${this.escapeHtml(message)}</span>`;
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => {
el.classList.remove('show');
}, type === 'error' ? 6000 : 4000);
}
async showConfirm(title, message) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal';
modal.style.display = 'block';
modal.innerHTML = `
<div class="modal-content card confirm-modal">
<h2>${this.escapeHtml(title)}</h2>
<p>${this.escapeHtml(message)}</p>
<div class="form-actions">
<button class="btn btn-ghost cancel-btn">Cancel</button>
<button class="btn btn-primary confirm-btn">Confirm</button>
</div>
</div>
`;
const handleResponse = (result) => {
document.body.removeChild(modal);
resolve(result);
};
modal.querySelector('.cancel-btn').addEventListener('click', () => handleResponse(false));
modal.querySelector('.confirm-btn').addEventListener('click', () => handleResponse(true));
modal.addEventListener('click', (e) => {
if (e.target === modal) handleResponse(false);
});
document.body.appendChild(modal);
});
}
}
const app = new MusicBandApp();