init
All checks were successful
/ upload (release) Successful in 1m54s

This commit is contained in:
pavel 2026-02-15 23:16:26 +01:00
commit 9ae6d88c21
24 changed files with 7589 additions and 0 deletions

463
static/app.js Normal file
View 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();
});

149
static/calendar.html Normal file
View file

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Calendar - Month View</title>
<link rel="stylesheet" href="style.css?v=3">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<!-- Login Gate -->
<div id="login-gate" class="calendar-container">
<h1>Welcome to Event Calendar</h1>
<p>Keep track of your schedule with ease. Sign in to start managing your events.</p>
<button onclick="login()" class="btn-primary">Login with Authentik</button>
</div>
<!-- App Content (Hidden until auth) -->
<div id="app-content" class="calendar-container">
<header>
<div class="header-left">
<h1>Calendar View</h1>
</div>
<nav class="view-switch">
<a href="/">List View</a>
<a href="/calendar.html" class="active">Calendar View</a>
</nav>
<div id="auth-status">
<!-- Filled by JS -->
</div>
</header>
<header class="page-header">
<div class="calendar-controls">
<div class="btn-group">
<button id="prev-month" class="btn-secondary">&lt;</button>
<button id="next-month" class="btn-secondary">&gt;</button>
</div>
<h1 id="current-month-year">Month Year</h1>
</div>
<button id="add-event-btn" class="btn-primary">Add Event</button>
</header>
<main class="calendar-view-container">
<div class="month-calendar-grid" id="calendar-grid">
<!-- Grid will be generated here -->
</div>
</main>
</div>
<!-- Modal for adding/editing events -->
<div id="event-modal" class="modal">
<div class="modal-content">
<h2 id="modal-title">Add Event</h2>
<form id="event-form">
<input type="hidden" id="event-id">
<div class="form-group">
<label for="event-name">Name</label>
<input type="text" id="event-name" required placeholder="Enter event name">
</div>
<div class="form-group">
<label>From</label>
<div class="datetime-input-group">
<div class="date-input-container">
<input type="number" id="event-from-day" placeholder="DD" min="1" max="31">
<span>.</span>
<input type="number" id="event-from-month" placeholder="MM" min="1" max="12">
<span>.</span>
<input type="number" id="event-from-year" placeholder="YYYY" min="2000" max="2100">
<input type="date" id="event-from-picker" class="hidden-picker">
<button type="button" class="calendar-btn"
onclick="document.getElementById('event-from-picker').showPicker()">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</button>
</div>
<div class="time-spinner" id="event-from-spinner">
<div class="spinner-highlight"></div>
<div class="spinner-column hours" id="event-from-hours"></div>
<div class="spinner-separator">:</div>
<div class="spinner-column minutes" id="event-from-minutes"></div>
</div>
</div>
</div>
<div class="form-group">
<label>To</label>
<div class="datetime-input-group">
<div class="date-input-container">
<input type="number" id="event-to-day" placeholder="DD" min="1" max="31">
<span>.</span>
<input type="number" id="event-to-month" placeholder="MM" min="1" max="12">
<span>.</span>
<input type="number" id="event-to-year" placeholder="YYYY" min="2000" max="2100">
<input type="date" id="event-to-picker" class="hidden-picker">
<button type="button" class="calendar-btn"
onclick="document.getElementById('event-to-picker').showPicker()">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</button>
</div>
<div class="time-spinner" id="event-to-spinner">
<div class="spinner-highlight"></div>
<div class="spinner-column hours" id="event-to-hours"></div>
<div class="spinner-separator">:</div>
<div class="spinner-column minutes" id="event-to-minutes"></div>
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" id="delete-btn" class="btn-danger"
style="display: none; margin-right: auto;">Delete</button>
<button type="button" id="cancel-btn" class="btn-secondary">Cancel</button>
<button type="submit" class="btn-primary">Save Event</button>
</div>
</form>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="modal">
<div class="modal-content confirm-modal-content">
<h3>Are you sure?</h3>
<p>Do you really want to delete this event? This action cannot be undone.</p>
<div class="modal-actions">
<button id="confirm-cancel-btn" class="btn-secondary">Cancel</button>
<button id="confirm-delete-btn" class="btn-primary"
style="background-color: var(--danger);">Delete</button>
</div>
</div>
</div>
<script src="calendar.js?v=3"></script>
</body>
</html>

532
static/calendar.js Normal file
View file

@ -0,0 +1,532 @@
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);
}
};

139
static/index.html Normal file
View file

@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Calendar</title>
<link rel="stylesheet" href="style.css?v=3">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<!-- Login Gate -->
<div id="login-gate" class="container">
<h1>Welcome to Event Calendar</h1>
<p>Keep track of your schedule with ease. Sign in to start managing your events.</p>
<button onclick="login()" class="btn-primary">Login with Authentik</button>
</div>
<!-- App Content (Hidden until auth) -->
<div id="app-content" class="container">
<header>
<div class="header-left">
<h1>Calendar Events</h1>
</div>
<nav class="view-switch">
<a href="/" class="active">List View</a>
<a href="/calendar.html">Calendar View</a>
</nav>
<div id="auth-status">
<!-- Filled by JS -->
</div>
</header>
<header class="page-header">
<h1>My Events</h1>
<button id="add-event-btn" class="btn-primary">Add Event</button>
</header>
<main id="event-list" class="event-grid">
<!-- Events will be loaded here -->
</main>
</div>
<!-- Modal for adding/editing events -->
<div id="event-modal" class="modal">
<div class="modal-content">
<h2 id="modal-title">Add Event</h2>
<form id="event-form">
<input type="hidden" id="event-id">
<div class="form-group">
<label for="event-name">Name</label>
<input type="text" id="event-name" required placeholder="Enter event name">
</div>
<div class="form-group">
<label>From</label>
<div class="datetime-input-group">
<div class="date-input-container">
<input type="number" id="event-from-day" placeholder="DD" min="1" max="31">
<span>.</span>
<input type="number" id="event-from-month" placeholder="MM" min="1" max="12">
<span>.</span>
<input type="number" id="event-from-year" placeholder="YYYY" min="2000" max="2100">
<input type="date" id="event-from-picker" class="hidden-picker">
<button type="button" class="calendar-btn"
onclick="document.getElementById('event-from-picker').showPicker()">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</button>
</div>
<div class="time-spinner" id="event-from-spinner">
<div class="spinner-highlight"></div>
<div class="spinner-column hours" id="event-from-hours"></div>
<div class="spinner-separator">:</div>
<div class="spinner-column minutes" id="event-from-minutes"></div>
</div>
</div>
</div>
<div class="form-group">
<label>To</label>
<div class="datetime-input-group">
<div class="date-input-container">
<input type="number" id="event-to-day" placeholder="DD" min="1" max="31">
<span>.</span>
<input type="number" id="event-to-month" placeholder="MM" min="1" max="12">
<span>.</span>
<input type="number" id="event-to-year" placeholder="YYYY" min="2000" max="2100">
<input type="date" id="event-to-picker" class="hidden-picker">
<button type="button" class="calendar-btn"
onclick="document.getElementById('event-to-picker').showPicker()">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</button>
</div>
<div class="time-spinner" id="event-to-spinner">
<div class="spinner-highlight"></div>
<div class="spinner-column hours" id="event-to-hours"></div>
<div class="spinner-separator">:</div>
<div class="spinner-column minutes" id="event-to-minutes"></div>
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" id="cancel-btn" class="btn-secondary">Cancel</button>
<button type="submit" class="btn-primary">Save Event</button>
</div>
</form>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="modal">
<div class="modal-content confirm-modal-content">
<h3>Are you sure?</h3>
<p>Do you really want to delete this event? This action cannot be undone.</p>
<div class="modal-actions">
<button id="confirm-cancel-btn" class="btn-secondary">Cancel</button>
<button id="confirm-delete-btn" class="btn-primary"
style="background-color: var(--danger);">Delete</button>
</div>
</div>
</div>
<script src="app.js?v=3"></script>
</body>
</html>

634
static/style.css Normal file
View file

@ -0,0 +1,634 @@
:root {
--bg-color: #0f172a;
--card-bg: rgba(30, 41, 59, 0.7);
--primary: #3b82f6;
--primary-hover: #2563eb;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--border: rgba(255, 255, 255, 0.1);
--danger: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.5;
min-height: 100vh;
padding: 2rem;
background-image: radial-gradient(circle at top left, #1e293b 0%, #0f172a 100%);
}
.container,
.calendar-container {
max-width: 1000px;
margin: 0 auto;
}
header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-bottom: 2rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
}
.header-left {
display: flex;
align-items: center;
justify-content: flex-start;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding: 0;
border: none;
}
.view-switch {
display: flex;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem;
border-radius: 0.5rem;
}
.view-switch a {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
text-decoration: none;
color: var(--text-muted);
font-weight: 500;
transition: all 0.2s;
}
.view-switch a.active {
background: var(--primary);
color: white;
}
#auth-status {
justify-self: end;
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
justify-content: flex-end;
}
.user-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
}
.user-email {
color: var(--text-muted);
font-size: 0.8rem;
}
h1 {
font-size: 2rem;
font-weight: 700;
}
.btn-primary {
background-color: var(--primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.btn-secondary {
background-color: transparent;
color: var(--text-main);
border: 1px solid var(--border);
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.btn-danger {
background-color: transparent;
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.event-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.event-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
padding: 1.5rem;
border-radius: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: transform 0.2s;
}
.event-card:hover {
transform: scale(1.02);
border-color: rgba(59, 130, 246, 0.5);
}
.event-card h3 {
font-size: 1.25rem;
font-weight: 600;
}
.event-time {
font-size: 0.875rem;
color: var(--text-muted);
}
.event-actions {
display: flex;
gap: 0.5rem;
margin-top: auto;
}
.calendar-event {
background-color: var(--primary);
color: white;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
margin-bottom: 2px;
}
.calendar-event:hover {
background-color: var(--primary-hover);
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: #1e293b;
border: 1px solid var(--border);
padding: 2.5rem;
border-radius: 1rem;
width: 100%;
max-width: 500px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.confirm-modal-content {
max-width: 400px;
text-align: center;
}
.confirm-modal-content h3 {
margin-bottom: 1rem;
}
.confirm-modal-content p {
margin-bottom: 2rem;
color: var(--text-muted);
}
.modal h2 {
margin-bottom: 1.5rem;
}
.datetime-input-group input[type="time"],
.datetime-input-group select {
flex: 1;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-main);
padding: 0.75rem;
font-size: 1rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.datetime-input-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='rgba(148, 163, 184, 0.5)'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1rem;
padding-right: 2.5rem;
}
.datetime-input-group select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.form-group input {
width: 100%;
background-color: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border);
padding: 0.75rem;
border-radius: 0.5rem;
color: white;
font-family: inherit;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
}
/* Time Spinner Styles */
.time-spinner-group {
display: flex;
gap: 1rem;
align-items: center;
background-color: rgba(255, 255, 255, 0.03);
padding: 1rem;
border-radius: 0.75rem;
border: 1px solid var(--border);
}
.time-spinner {
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 150px;
/* 30px * 5 items */
width: 100%;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
}
.spinner-column {
flex: 1;
height: 100%;
overflow-y: scroll;
scroll-snap-type: y mandatory;
scrollbar-width: none;
/* Hide scrollbar for Firefox */
-ms-overflow-style: none;
/* Hide scrollbar for IE/Edge */
}
.spinner-column::-webkit-scrollbar {
display: none;
/* Hide scrollbar for Chrome/Safari */
}
.spinner-item {
height: 30px;
display: flex;
align-items: center;
justify-content: center;
scroll-snap-align: center;
font-size: 0.9rem;
color: var(--text-muted);
transition: color 0.2s, font-size 0.2s, font-weight 0.2s;
cursor: pointer;
}
.spinner-item.active {
color: var(--text-main);
font-size: 1.1rem;
font-weight: 700;
}
.spinner-separator {
font-weight: 700;
font-size: 1.25rem;
color: var(--text-muted);
padding: 0 0.25rem;
}
.spinner-highlight {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 30px;
transform: translateY(-50%);
background: rgba(59, 130, 246, 0.1);
border-top: 1px solid rgba(59, 130, 246, 0.3);
border-bottom: 1px solid rgba(59, 130, 246, 0.3);
pointer-events: none;
z-index: 1;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* Login Gate */
#login-gate {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 70vh;
text-align: center;
gap: 2rem;
}
#login-gate h1 {
font-size: 3.5rem;
background: linear-gradient(to bottom right, #fff, #94a3b8);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 1rem;
}
#login-gate p {
font-size: 1.25rem;
color: var(--text-muted);
max-width: 500px;
margin-bottom: 1rem;
}
#app-content {
display: none;
}
/* Month Calendar Styles */
.calendar-view-container {
padding-top: 1rem;
}
.calendar-controls {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 2rem;
}
.btn-group {
display: flex;
gap: 0.5rem;
}
.month-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: var(--border);
border: 1px solid var(--border);
border-radius: 1rem;
overflow: hidden;
}
.weekday-header {
background-color: rgba(30, 41, 59, 0.9);
padding: 0.75rem;
text-align: center;
font-weight: 600;
color: var(--text-muted);
font-size: 0.875rem;
text-transform: uppercase;
}
.calendar-day {
background-color: var(--bg-color);
min-height: 120px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.calendar-day:hover:not(.other-month) {
background-color: rgba(255, 255, 255, 0.05);
}
.calendar-day.other-month {
opacity: 0.3;
}
.calendar-day.empty {
background-color: var(--bg-secondary);
color: var(--text-muted);
opacity: 0.5;
}
/* Custom Date Input Styling */
.date-input-container {
display: flex;
align-items: center;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
gap: 0.25rem;
flex: 1;
}
.date-input-container input {
background: transparent;
border: none;
color: var(--text-primary);
font-family: inherit;
font-size: 1rem;
text-align: center;
padding: 0;
outline: none;
}
.date-input-container input::placeholder {
color: var(--text-muted);
opacity: 0.5;
}
#event-from-day,
#event-from-month,
#event-to-day,
#event-to-month {
width: 2ch;
}
#event-from-year,
#event-to-year {
width: 4ch;
}
.date-input-container span {
color: var(--text-muted);
font-weight: bold;
user-select: none;
}
.date-input-container input::-webkit-outer-spin-button,
.date-input-container input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.date-input-container input[type=number] {
-moz-appearance: textfield;
}
.date-input-container:focus-within {
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.hidden-picker {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.calendar-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
margin-left: auto;
}
.calendar-btn:hover {
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.1);
}
.day-number {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
}
.day-events {
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
max-height: 80px;
}
.calendar-event-tag {
background-color: var(--primary);
color: white;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.calendar-event-tag:hover {
background-color: var(--primary-hover);
}
nav {
margin-bottom: 2rem;
display: flex;
gap: 1rem;
}
nav a {
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
nav a.active {
color: var(--text-main);
background-color: rgba(255, 255, 255, 0.05);
}
nav a:hover:not(.active) {
color: var(--text-main);
}