commit
9ae6d88c21
24 changed files with 7589 additions and 0 deletions
463
static/app.js
Normal file
463
static/app.js
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
const eventList = document.getElementById('event-list');
|
||||
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 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 eventToDelete = null;
|
||||
|
||||
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 all events
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}?upcoming=true`);
|
||||
if (response.ok) {
|
||||
const events = await response.json();
|
||||
renderEvents(events);
|
||||
} else {
|
||||
eventList.innerHTML = '<p class="text-muted">Please login to see your events.</p>';
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Render events in the grid
|
||||
function renderEvents(events) {
|
||||
eventList.innerHTML = '';
|
||||
events.forEach(event => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'event-card';
|
||||
card.innerHTML = `
|
||||
<h3>${event.name}</h3>
|
||||
<div class="event-time">
|
||||
<p>From: ${formatDateEuropean(event.from)}</p>
|
||||
<p>To: ${formatDateEuropean(event.to)}</p>
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<button class="btn-secondary btn-sm" onclick="editEvent(${event.id})">Edit</button>
|
||||
<button class="btn-danger btn-sm" onclick="deleteEvent(${event.id})">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
eventList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Open modal
|
||||
function openModal(title = 'Add Event') {
|
||||
modalTitle.textContent = title;
|
||||
eventModal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal() {
|
||||
eventModal.style.display = 'none';
|
||||
eventForm.reset();
|
||||
document.getElementById('event-id').value = '';
|
||||
}
|
||||
|
||||
// Handle form submit
|
||||
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) {
|
||||
// Update
|
||||
response = await authenticatedFetch(`${API_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
response = await authenticatedFetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
closeModal();
|
||||
fetchEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Edit event
|
||||
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;
|
||||
|
||||
// Split datetime strings for input date/time
|
||||
if (event.from) {
|
||||
const [fromDate, fromTime] = event.from.includes('T') ? event.from.split('T') : event.from.split(' ');
|
||||
setDateValues('event-from', fromDate);
|
||||
setSpinnerTime('event-from', fromTime);
|
||||
}
|
||||
if (event.to) {
|
||||
const [toDate, toTime] = event.to.includes('T') ? event.to.split('T') : event.to.split(' ');
|
||||
setDateValues('event-to', toDate);
|
||||
setSpinnerTime('event-to', toTime);
|
||||
}
|
||||
|
||||
openModal('Edit Event');
|
||||
} catch (error) {
|
||||
console.error('Error fetching event for edit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete event
|
||||
async function deleteEvent(id) {
|
||||
eventToDelete = id;
|
||||
confirmModal.style.display = 'flex';
|
||||
}
|
||||
|
||||
confirmCancelBtn.onclick = () => {
|
||||
confirmModal.style.display = 'none';
|
||||
eventToDelete = null;
|
||||
};
|
||||
|
||||
confirmDeleteBtn.onclick = async () => {
|
||||
if (!eventToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`${API_URL}/${eventToDelete}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
confirmModal.style.display = 'none';
|
||||
eventToDelete = null;
|
||||
fetchEvents();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
fetchEvents();
|
||||
} else {
|
||||
loginGate.style.display = 'flex';
|
||||
appContent.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function login() {
|
||||
window.location.href = `${AUTH_URL}/login`;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('jwt_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
addEventBtn.addEventListener('click', () => openModal());
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuth();
|
||||
initTimeSelectors();
|
||||
initDateInputs();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue