532 lines
17 KiB
JavaScript
532 lines
17 KiB
JavaScript
const calendarGrid = document.getElementById('calendar-grid');
|
|
const currentMonthYearHeader = document.getElementById('current-month-year');
|
|
const prevMonthBtn = document.getElementById('prev-month');
|
|
const nextMonthBtn = document.getElementById('next-month');
|
|
|
|
const eventModal = document.getElementById('event-modal');
|
|
const eventForm = document.getElementById('event-form');
|
|
const addEventBtn = document.getElementById('add-event-btn');
|
|
const cancelBtn = document.getElementById('cancel-btn');
|
|
const deleteBtn = document.getElementById('delete-btn');
|
|
const modalTitle = document.getElementById('modal-title');
|
|
const confirmModal = document.getElementById('confirm-modal');
|
|
const confirmCancelBtn = document.getElementById('confirm-cancel-btn');
|
|
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
|
|
|
|
let currentDate = new Date();
|
|
let events = [];
|
|
|
|
const API_URL = '/events';
|
|
const AUTH_URL = '/auth';
|
|
let currentUser = null;
|
|
|
|
// Helper for authenticated requests
|
|
async function authenticatedFetch(url, options = {}) {
|
|
const token = localStorage.getItem('jwt_token');
|
|
const headers = {
|
|
...options.headers,
|
|
'Authorization': `Bearer ${token}`
|
|
};
|
|
|
|
let response = await fetch(url, { ...options, headers });
|
|
|
|
if (response.status === 401) {
|
|
const refreshToken = localStorage.getItem('refresh_token');
|
|
if (refreshToken) {
|
|
const refreshRes = await fetch('/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refresh_token: refreshToken })
|
|
});
|
|
|
|
if (refreshRes.ok) {
|
|
const tokens = await refreshRes.json();
|
|
localStorage.setItem('jwt_token', tokens.id_token);
|
|
if (tokens.refresh_token) {
|
|
localStorage.setItem('refresh_token', tokens.refresh_token);
|
|
}
|
|
|
|
// Retry original request
|
|
return authenticatedFetch(url, options);
|
|
}
|
|
}
|
|
// If refresh fails or no refresh token, logout
|
|
logout();
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// Helper to format date in European format (DD.MM.YYYY)
|
|
function formatDateEuropean(dateStr) {
|
|
if (!dateStr) return '';
|
|
const date = new Date(dateStr);
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const year = date.getFullYear();
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
return `${day}.${month}.${year} ${hours}:${minutes}`;
|
|
}
|
|
|
|
// Helpers for custom date inputs
|
|
function getDateValues(idPrefix) {
|
|
const d = document.getElementById(`${idPrefix}-day`).value.padStart(2, '0');
|
|
const m = document.getElementById(`${idPrefix}-month`).value.padStart(2, '0');
|
|
const y = document.getElementById(`${idPrefix}-year`).value;
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
function setDateValues(idPrefix, isoDate) {
|
|
if (!isoDate) return;
|
|
const [y, m, d] = isoDate.split('-');
|
|
document.getElementById(`${idPrefix}-day`).value = d;
|
|
document.getElementById(`${idPrefix}-month`).value = m;
|
|
document.getElementById(`${idPrefix}-year`).value = y;
|
|
document.getElementById(`${idPrefix}-picker`).value = isoDate;
|
|
}
|
|
|
|
function initDateInputs() {
|
|
['event-from', 'event-to'].forEach(prefix => {
|
|
const day = document.getElementById(`${prefix}-day`);
|
|
const month = document.getElementById(`${prefix}-month`);
|
|
const year = document.getElementById(`${prefix}-year`);
|
|
const picker = document.getElementById(`${prefix}-picker`);
|
|
|
|
if (!day || !month || !year || !picker) return;
|
|
|
|
// Sync triple inputs to picker on change
|
|
const syncToPicker = () => {
|
|
const d = day.value.padStart(2, '0');
|
|
const m = month.value.padStart(2, '0');
|
|
const y = year.value;
|
|
if (d && m && y.length === 4) {
|
|
picker.value = `${y}-${m}-${d}`;
|
|
}
|
|
};
|
|
|
|
day.addEventListener('input', () => {
|
|
if (day.value.length >= 2) month.focus();
|
|
syncToPicker();
|
|
});
|
|
month.addEventListener('input', () => {
|
|
if (month.value.length >= 2) year.focus();
|
|
syncToPicker();
|
|
});
|
|
year.addEventListener('input', syncToPicker);
|
|
|
|
// Sync picker to triple inputs on change
|
|
picker.addEventListener('change', () => {
|
|
if (picker.value) {
|
|
const [y, m, d] = picker.value.split('-');
|
|
day.value = d;
|
|
month.value = m;
|
|
year.value = y;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Fetch events and render calendar
|
|
async function initCalendar() {
|
|
await fetchEvents();
|
|
renderCalendar();
|
|
}
|
|
|
|
async function fetchEvents() {
|
|
try {
|
|
const response = await authenticatedFetch(API_URL);
|
|
if (response.ok) {
|
|
events = await response.json();
|
|
} else {
|
|
events = [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching events:', error);
|
|
}
|
|
}
|
|
|
|
// Initialize Scrolling Time Spinner
|
|
function initTimeSelectors() {
|
|
const fromHours = document.getElementById('event-from-hours');
|
|
const fromMinutes = document.getElementById('event-from-minutes');
|
|
const toHours = document.getElementById('event-to-hours');
|
|
const toMinutes = document.getElementById('event-to-minutes');
|
|
|
|
if (!fromHours || !fromMinutes || !toHours || !toMinutes) return;
|
|
|
|
populateSpinner(fromHours, 24);
|
|
populateSpinner(toHours, 24);
|
|
populateSpinner(fromMinutes, 60, 5);
|
|
populateSpinner(toMinutes, 60, 5);
|
|
|
|
// Initial styles and scroll syncing
|
|
[fromHours, fromMinutes, toHours, toMinutes].forEach(col => {
|
|
col.addEventListener('scroll', () => updateSpinnerStyles(col));
|
|
// Small delay to ensure initial scroll-snap works
|
|
setTimeout(() => updateSpinnerStyles(col), 100);
|
|
});
|
|
}
|
|
|
|
function populateSpinner(container, total, step = 1) {
|
|
container.innerHTML = '';
|
|
// Add spacer items at top and bottom for 5-item view (2 spacers each)
|
|
for (let i = 0; i < 2; i++) {
|
|
const spacer = document.createElement('div');
|
|
spacer.className = 'spinner-item spacer';
|
|
container.appendChild(spacer);
|
|
}
|
|
|
|
for (let i = 0; i < total; i += step) {
|
|
const item = document.createElement('div');
|
|
item.className = 'spinner-item';
|
|
const val = String(i).padStart(2, '0');
|
|
item.textContent = val;
|
|
item.dataset.value = val;
|
|
item.onclick = () => {
|
|
container.scrollTo({
|
|
top: item.offsetTop - container.offsetTop - (container.clientHeight / 2 - item.clientHeight / 2),
|
|
behavior: 'smooth'
|
|
});
|
|
};
|
|
container.appendChild(item);
|
|
}
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
const spacer = document.createElement('div');
|
|
spacer.className = 'spinner-item spacer';
|
|
container.appendChild(spacer);
|
|
}
|
|
}
|
|
|
|
function updateSpinnerStyles(container) {
|
|
const items = container.querySelectorAll('.spinner-item:not(.spacer)');
|
|
const containerCenter = container.scrollTop + container.clientHeight / 2;
|
|
|
|
let closestItem = null;
|
|
let minDistance = Infinity;
|
|
|
|
items.forEach(item => {
|
|
const itemCenter = item.offsetTop - container.offsetTop + item.clientHeight / 2;
|
|
const distance = Math.abs(containerCenter - itemCenter);
|
|
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
closestItem = item;
|
|
}
|
|
item.classList.remove('active');
|
|
});
|
|
|
|
if (closestItem) {
|
|
closestItem.classList.add('active');
|
|
}
|
|
}
|
|
|
|
function getSpinnerTime(idPrefix) {
|
|
const hourCol = document.getElementById(`${idPrefix}-hours`);
|
|
const minCol = document.getElementById(`${idPrefix}-minutes`);
|
|
|
|
const hour = hourCol.querySelector('.spinner-item.active')?.dataset.value || '00';
|
|
const minute = minCol.querySelector('.spinner-item.active')?.dataset.value || '00';
|
|
|
|
return `${hour}:${minute}`;
|
|
}
|
|
|
|
function setSpinnerTime(idPrefix, timeStr) {
|
|
if (!timeStr) return;
|
|
const [h, m] = timeStr.split(':');
|
|
const hourCol = document.getElementById(`${idPrefix}-hours`);
|
|
const minCol = document.getElementById(`${idPrefix}-minutes`);
|
|
|
|
scrollSpinnerToValue(hourCol, h);
|
|
scrollSpinnerToValue(minCol, m.slice(0, 2));
|
|
}
|
|
|
|
function scrollSpinnerToValue(container, value) {
|
|
const items = container.querySelectorAll('.spinner-item:not(.spacer)');
|
|
const targetItem = Array.from(items).find(item => item.dataset.value === value);
|
|
if (targetItem) {
|
|
// We scroll so the target item is centered
|
|
container.scrollTop = targetItem.offsetTop - container.offsetTop - (container.clientHeight / 2 - targetItem.clientHeight / 2);
|
|
}
|
|
}
|
|
|
|
function renderCalendar() {
|
|
calendarGrid.innerHTML = '';
|
|
|
|
// Day names header
|
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
days.forEach(day => {
|
|
const dayHeader = document.createElement('div');
|
|
dayHeader.className = 'calendar-day-header';
|
|
dayHeader.textContent = day;
|
|
calendarGrid.appendChild(dayHeader);
|
|
});
|
|
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
|
|
currentMonthYearHeader.textContent = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' }).format(currentDate);
|
|
|
|
// Get first day of month and adjust for Monday start (0=Sun, 1=Mon, ..., 6=Sat)
|
|
// We want Mon=0, Tue=1, ..., Sun=6
|
|
let firstDayOfMonth = new Date(year, month, 1).getDay();
|
|
let startingDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
|
|
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
|
|
// Previous month days to fill the gap
|
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
|
for (let i = startingDay - 1; i >= 0; i--) {
|
|
const daySquare = document.createElement('div');
|
|
daySquare.className = 'calendar-day empty';
|
|
daySquare.textContent = prevMonthLastDay - i;
|
|
calendarGrid.appendChild(daySquare);
|
|
}
|
|
|
|
// Current month days
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|
const daySquare = document.createElement('div');
|
|
daySquare.className = 'calendar-day';
|
|
daySquare.innerHTML = `<span class="day-number">${i}</span>`;
|
|
|
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
|
|
const dayEvents = events.filter(e => e.from.startsWith(dateStr));
|
|
|
|
dayEvents.forEach(event => {
|
|
const eventEl = document.createElement('div');
|
|
eventEl.className = 'calendar-event';
|
|
const startTime = event.from.split('T')[1].substring(0, 5);
|
|
eventEl.textContent = `${startTime} - ${event.name}`;
|
|
eventEl.onclick = (e) => {
|
|
e.stopPropagation();
|
|
editEvent(event.id);
|
|
};
|
|
daySquare.appendChild(eventEl);
|
|
});
|
|
|
|
daySquare.onclick = () => {
|
|
openModal('Add Event', `${dateStr}T12:00`, `${dateStr}T13:00`);
|
|
};
|
|
|
|
calendarGrid.appendChild(daySquare);
|
|
}
|
|
}
|
|
|
|
function openModal(title = 'Add Event', from = null, to = null, id = null) {
|
|
modalTitle.textContent = title;
|
|
eventModal.style.display = 'flex';
|
|
|
|
if (from && to) {
|
|
const [fromDate, fromTime] = from.includes('T') ? from.split('T') : from.split(' ');
|
|
const [toDate, toTime] = to.includes('T') ? to.split('T') : to.split(' ');
|
|
|
|
setDateValues('event-from', fromDate);
|
|
setDateValues('event-to', toDate);
|
|
|
|
// Round time to nearest 5 minutes for spinner
|
|
const roundTime = (time) => {
|
|
const [h, m] = time.split(':');
|
|
const roundedM = Math.round(parseInt(m) / 5) * 5;
|
|
if (roundedM === 60) return `${String(parseInt(h) + 1).padStart(2, '0')}:00`;
|
|
return `${h}:${String(roundedM).padStart(2, '0')}`;
|
|
};
|
|
|
|
setSpinnerTime('event-from', roundTime(fromTime));
|
|
setSpinnerTime('event-to', roundTime(toTime));
|
|
}
|
|
|
|
if (id) {
|
|
document.getElementById('event-id').value = id;
|
|
deleteBtn.style.display = 'block';
|
|
} else {
|
|
deleteBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
eventModal.style.display = 'none';
|
|
eventForm.reset();
|
|
document.getElementById('event-id').value = '';
|
|
deleteBtn.style.display = 'none';
|
|
}
|
|
|
|
async function checkAuth() {
|
|
const hash = window.location.hash;
|
|
if (hash) {
|
|
const params = new URLSearchParams(hash.substring(1));
|
|
const accessToken = params.get('access_token') || params.get('token');
|
|
const refreshToken = params.get('refresh_token');
|
|
|
|
if (accessToken) {
|
|
localStorage.setItem('jwt_token', accessToken);
|
|
}
|
|
if (refreshToken) {
|
|
localStorage.setItem('refresh_token', refreshToken);
|
|
}
|
|
window.location.hash = '';
|
|
}
|
|
|
|
const token = localStorage.getItem('jwt_token');
|
|
if (!token) {
|
|
updateAuthUI();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await authenticatedFetch(`${AUTH_URL}/me`);
|
|
if (response.ok) {
|
|
currentUser = await response.json();
|
|
} else {
|
|
localStorage.removeItem('jwt_token');
|
|
currentUser = null;
|
|
}
|
|
} catch (error) {
|
|
console.error('Auth check failed:', error);
|
|
}
|
|
updateAuthUI();
|
|
}
|
|
|
|
function updateAuthUI() {
|
|
const authStatus = document.getElementById('auth-status');
|
|
const loginGate = document.getElementById('login-gate');
|
|
const appContent = document.getElementById('app-content');
|
|
|
|
if (currentUser) {
|
|
authStatus.innerHTML = `
|
|
<div class="user-info">
|
|
<div class="user-details">
|
|
<div class="user-name">${currentUser.name}</div>
|
|
</div>
|
|
<button onclick="logout()" class="btn-secondary btn-sm">Logout</button>
|
|
</div>
|
|
`;
|
|
loginGate.style.display = 'none';
|
|
appContent.style.display = 'block';
|
|
initCalendar();
|
|
} else {
|
|
loginGate.style.display = 'flex';
|
|
appContent.style.display = 'none';
|
|
events = [];
|
|
}
|
|
}
|
|
|
|
function login() {
|
|
window.location.href = `${AUTH_URL}/login`;
|
|
}
|
|
|
|
function logout() {
|
|
localStorage.removeItem('jwt_token');
|
|
localStorage.removeItem('refresh_token');
|
|
window.location.reload();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
checkAuth();
|
|
initTimeSelectors();
|
|
initDateInputs();
|
|
});
|
|
|
|
eventForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const id = document.getElementById('event-id').value;
|
|
const fromDate = getDateValues('event-from');
|
|
const fromTime = getSpinnerTime('event-from');
|
|
const toDate = getDateValues('event-to');
|
|
const toTime = getSpinnerTime('event-to');
|
|
|
|
const from = `${fromDate}T${fromTime}`;
|
|
const to = `${toDate}T${toTime}`;
|
|
|
|
if (new Date(from) >= new Date(to)) {
|
|
alert('Start time must be before end time');
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
name: document.getElementById('event-name').value,
|
|
from: from,
|
|
to: to
|
|
};
|
|
|
|
try {
|
|
let response;
|
|
if (id) {
|
|
response = await authenticatedFetch(`${API_URL}/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
} else {
|
|
response = await authenticatedFetch(API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
if (response.ok) {
|
|
closeModal();
|
|
initCalendar();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving event:', error);
|
|
}
|
|
});
|
|
|
|
async function editEvent(id) {
|
|
try {
|
|
const response = await authenticatedFetch(`${API_URL}/${id}`);
|
|
if (!response.ok) {
|
|
console.error('Failed to fetch event for edit');
|
|
return;
|
|
}
|
|
const event = await response.json();
|
|
|
|
document.getElementById('event-id').value = event.id;
|
|
document.getElementById('event-name').value = event.name;
|
|
|
|
openModal('Edit Event', event.from, event.to, id);
|
|
} catch (error) {
|
|
console.error('Error fetching event for edit:', error);
|
|
}
|
|
}
|
|
|
|
prevMonthBtn.onclick = () => {
|
|
currentDate.setMonth(currentDate.getMonth() - 1);
|
|
renderCalendar();
|
|
};
|
|
|
|
nextMonthBtn.onclick = () => {
|
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
|
renderCalendar();
|
|
};
|
|
|
|
addEventBtn.addEventListener('click', () => openModal());
|
|
cancelBtn.addEventListener('click', closeModal);
|
|
|
|
deleteBtn.onclick = () => {
|
|
confirmModal.style.display = 'flex';
|
|
};
|
|
|
|
confirmCancelBtn.onclick = () => {
|
|
confirmModal.style.display = 'none';
|
|
};
|
|
|
|
confirmDeleteBtn.onclick = async () => {
|
|
const id = document.getElementById('event-id').value;
|
|
if (!id) return;
|
|
|
|
try {
|
|
const response = await authenticatedFetch(`${API_URL}/${id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (response.ok) {
|
|
confirmModal.style.display = 'none';
|
|
closeModal();
|
|
initCalendar();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting event:', error);
|
|
}
|
|
};
|