1203 lines
39 KiB
JavaScript
1203 lines
39 KiB
JavaScript
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">×</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(); |