feat: Enhance calendar display and event management

- Improve event display: Show only the first two events for each day
  in the calendar, with a "+X more" link to show the rest.
- Add event details modal:  Allows viewing and deleting events.
- Enhance event creation modal: Improve user experience and add color
  selection for events.
- Improve year view: Show the number of events for each month.
- Improve day view: Display all day events separately.
- Improve styling and layout: Enhance the visual appeal and
  responsiveness of the calendar.
This commit is contained in:
Mahmoud-Emad 2025-05-28 16:59:24 +03:00
parent 58d1cde1ce
commit 45c4f4985e

View File

@ -92,8 +92,9 @@
<tr> <tr>
{% for day_idx in range(start=0, end=7) %} {% for day_idx in range(start=0, end=7) %}
{% set idx = week * 7 + day_idx %} {% set idx = week * 7 + day_idx %}
<td class="calendar-day" onclick="openEventModalForDate(this)" {% if idx < <td class="calendar-day {% if idx < calendar_days|length and calendar_days[idx].events|length > 2 %}has-more-events{% endif %}"
calendar_days|length %} data-day="{{ calendar_days[idx].day }}" onclick="openEventModalForDate(this)" {% if idx < calendar_days|length %}
data-day="{{ calendar_days[idx].day }}"
data-is-current-month="{{ calendar_days[idx].is_current_month }}" data-is-current-month="{{ calendar_days[idx].is_current_month }}"
data-date="{{ current_year }}-{{ current_month|format_hour }}-{{ calendar_days[idx].day|format_hour }}" data-date="{{ current_year }}-{{ current_month|format_hour }}-{{ calendar_days[idx].day|format_hour }}"
{% else %} data-day="0" data-is-current-month="false" data-date="" {% endif %}> {% else %} data-day="0" data-is-current-month="false" data-date="" {% endif %}>
@ -111,14 +112,24 @@
{% if day.events|length > 0 %} {% if day.events|length > 0 %}
<div class="calendar-events"> <div class="calendar-events">
{% for event in day.events %} {% for event in day.events %}
{% if loop.index <= 2 %} <div class="event-preview text-truncate mb-1" <div class="event-preview text-truncate mb-1 {% if loop.index > 2 %}d-none{% endif %}"
style="background-color: {{ event.color }}; color: white;"> style="background-color: {{ event.color }}; color: white;"
onclick="openEventDetails(event, '{{ event.id }}', '{{ event.title|escape }}', '{{ event.description|escape }}', '{{ event.color }}', {{ event.all_day }}, '{{ event.start_time }}', '{{ event.end_time }}')"
data-event-id="{{ event.id }}" data-event-title="{{ event.title|escape }}"
data-event-description="{{ event.description|escape }}"
data-event-color="{{ event.color }}"
data-event-all-day="{{ event.all_day }}"
data-event-start="{{ event.start_time }}"
data-event-end="{{ event.end_time }}" data-event-index="{{ loop.index }}"
title="{{ event.title|escape }}">
{{ event.title }} {{ event.title }}
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
{% if day.events|length > 2 %} {% if day.events|length > 2 %}
<div class="small text-muted text-center">+{{ day.events|length - 2 }} more</div> <div class="small text-muted text-center more-events-link"
onclick="showMoreEventsForDay(event, '{{ current_year }}-{{ current_month|format_hour }}-{{ day.day|format_hour }}')">
+{{ day.events|length - 2 }} more
</div>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@ -132,10 +143,10 @@
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{% elif view_mode == "year" %} {% elif view_mode == "year" %}
<!-- Year View --> <!-- Year View -->
<div class="card"> <div class="card">
<div class="card-header bg-primary text-white"> <div class="card-header bg-primary text-white">
<h4 class="mb-0"> <h4 class="mb-0">
<i class="bi bi-calendar4-range"></i> Year {{ current_year }} <i class="bi bi-calendar4-range"></i> Year {{ current_year }}
@ -178,11 +189,11 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
{% elif view_mode == "day" %} {% elif view_mode == "day" %}
<h2>Day View: {{ current_date }}</h2> <h2>Day View: {{ current_date }}</h2>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header bg-primary text-white"> <div class="card-header bg-primary text-white">
All Day Events All Day Events
</div> </div>
@ -190,7 +201,8 @@
{% if events is defined and events|length > 0 %} {% if events is defined and events|length > 0 %}
{% for event in events %} {% for event in events %}
{% if event.all_day %} {% if event.all_day %}
<div class="alert" style="background-color: {{ event.color }}; color: white;"> <div class="alert" style="background-color: {{ event.color }}; color: white; cursor: pointer;"
onclick="openEventDetails(event, '{{ event.id }}', '{{ event.title|escape }}', '{{ event.description|escape }}', '{{ event.color }}', {{ event.all_day }}, '{{ event.start_time }}', '{{ event.end_time }}')">
<h5>{{ event.title }}</h5> <h5>{{ event.title }}</h5>
<p>{{ event.description }}</p> <p>{{ event.description }}</p>
</div> </div>
@ -200,9 +212,9 @@
<p class="text-muted">No all-day events</p> <p class="text-muted">No all-day events</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="list-group"> <div class="list-group">
{% for hour in range(start=0, end=24) %} {% for hour in range(start=0, end=24) %}
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex"> <div class="d-flex">
@ -215,7 +227,8 @@
{% if not event.all_day %} {% if not event.all_day %}
{% set start_hour = event.start_time|extract_hour %} {% set start_hour = event.start_time|extract_hour %}
{% if start_hour == hour|string %} {% if start_hour == hour|string %}
<div class="alert mb-2" style="background-color: {{ event.color }}; color: white;"> <div class="alert mb-2" style="background-color: {{ event.color }}; color: white; cursor: pointer;"
onclick="openEventDetails(event, '{{ event.id }}', '{{ event.title|escape }}', '{{ event.description|escape }}', '{{ event.color }}', {{ event.all_day }}, '{{ event.start_time }}', '{{ event.end_time }}')">
<h5>{{ event.title }}</h5> <h5>{{ event.title }}</h5>
<p>{{ event.start_time|format_time }} - {{ event.end_time|format_time }}</p> <p>{{ event.start_time|format_time }} - {{ event.end_time|format_time }}</p>
<p>{{ event.description }}</p> <p>{{ event.description }}</p>
@ -228,11 +241,11 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- New Event Modal --> <!-- New Event Modal -->
<div class="modal fade" id="newEventModal" tabindex="-1" aria-labelledby="newEventModalLabel" aria-hidden="true"> <div class="modal fade" id="newEventModal" tabindex="-1" aria-labelledby="newEventModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header bg-primary text-white"> <div class="modal-header bg-primary text-white">
@ -297,7 +310,8 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="endTime" class="form-label">End Time *</label> <label for="endTime" class="form-label">End Time *</label>
<input type="datetime-local" class="form-control" id="endTime" name="end_time" required> <input type="datetime-local" class="form-control" id="endTime" name="end_time"
required>
</div> </div>
</div> </div>
</div> </div>
@ -311,14 +325,97 @@
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Floating Action Button (FAB) for mobile --> <!-- Event Details Modal (Read-only) -->
<button type="button" class="d-md-none position-fixed bottom-0 end-0 m-4 btn btn-primary rounded-circle shadow" <div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title" id="eventDetailsModalLabel">
<i class="bi bi-calendar-event"></i> Event Details
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label class="form-label fw-bold">Event Title</label>
<div class="form-control-plaintext border rounded p-2 bg-light" id="detailsEventTitle">
</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label fw-bold">Color</label>
<div class="d-flex align-items-center">
<div id="detailsEventColorBox" class="me-2"
style="width: 20px; height: 20px; border-radius: 3px;"></div>
<span id="detailsEventColor" class="form-control-plaintext"></span>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Description</label>
<div class="form-control-plaintext border rounded p-2 bg-light" id="detailsEventDescription"
style="min-height: 80px;"></div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="detailsAllDayEvent" disabled>
<label class="form-check-label fw-bold" for="detailsAllDayEvent">All Day Event</label>
</div>
</div>
<div class="row" id="detailsTimeInputs">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label fw-bold">Start Time</label>
<div class="form-control-plaintext border rounded p-2 bg-light" id="detailsStartTime">
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label fw-bold">End Time</label>
<div class="form-control-plaintext border rounded p-2 bg-light" id="detailsEndTime">
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-danger" id="deleteEventBtn" onclick="deleteCurrentEvent()">
<i class="bi bi-trash"></i> Delete Event
</button>
</div>
</div>
</div>
</div>
<!-- Events Popup for "+X more" -->
<div id="eventsPopup" class="position-absolute bg-white border rounded shadow-lg p-3"
style="display: none; z-index: 1050; min-width: 250px; max-width: 300px;">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 fw-bold">Events</h6>
<button type="button" class="btn-close btn-sm" onclick="closeEventsPopup()"></button>
</div>
<div id="eventsPopupContent"></div>
</div>
<!-- Floating Action Button (FAB) for mobile -->
<button type="button" class="d-md-none position-fixed bottom-0 end-0 m-4 btn btn-primary rounded-circle shadow"
onclick="openEventModal()" onclick="openEventModal()"
style="width: 60px; height: 60px; display: flex; align-items: center; justify-content: center; font-size: 24px;"> style="width: 60px; height: 60px; display: flex; align-items: center; justify-content: center; font-size: 24px;">
<i class="bi bi-plus"></i> <i class="bi bi-plus"></i>
</button> </button>
</div> </div>
{% block extra_css %} {% block extra_css %}
@ -326,6 +423,10 @@
<style> <style>
.calendar-table { .calendar-table {
border: 2px solid #dee2e6; border: 2px solid #dee2e6;
width: 100%;
table-layout: fixed;
/* Fixed table layout for consistent column widths */
border-collapse: collapse;
} }
.calendar-table td { .calendar-table td {
@ -343,9 +444,20 @@
} }
.calendar-day { .calendar-day {
height: 120px;
min-height: 120px; min-height: 120px;
max-height: 120px;
width: 14.28%;
/* Fixed width for 7 columns */
position: relative; position: relative;
padding: 30px !important; padding: 8px !important;
overflow: hidden;
/* Prevent content from stretching the cell */
box-sizing: border-box;
vertical-align: top;
border: 1px solid #dee2e6;
cursor: pointer;
transition: background-color 0.2s;
} }
.calendar-day-header { .calendar-day-header {
@ -360,6 +472,16 @@
text-align: center; text-align: center;
} }
.calendar-events {
max-height: 70px;
/* Fixed height for events container */
overflow-y: auto;
/* Scroll if too many events */
overflow-x: hidden;
margin-top: 4px;
text-align: left;
}
.calendar-day[data-is-current-month="true"]:hover::after { .calendar-day[data-is-current-month="true"]:hover::after {
content: "Click to add event"; content: "Click to add event";
position: absolute; position: absolute;
@ -373,6 +495,12 @@
font-size: 0.7rem; font-size: 0.7rem;
white-space: nowrap; white-space: nowrap;
z-index: 10; z-index: 10;
transition: opacity 0.2s;
}
/* Hide hint when cell has "+X more" link */
.calendar-day.has-more-events:hover::after {
display: none;
} }
.calendar-day[data-is-current-month="false"] { .calendar-day[data-is-current-month="false"] {
@ -391,9 +519,7 @@
font-weight: 900; font-weight: 900;
} }
.calendar-events {
text-align: left;
}
.event-preview { .event-preview {
font-size: 0.7rem; font-size: 0.7rem;
@ -403,6 +529,14 @@
cursor: pointer; cursor: pointer;
transition: opacity 0.2s; transition: opacity 0.2s;
font-weight: 500; font-weight: 500;
/* Limit text length and prevent calendar stretching */
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
word-break: break-all;
/* Ensure consistent width */
box-sizing: border-box;
} }
.event-preview:hover { .event-preview:hover {
@ -421,6 +555,48 @@
opacity: 0.7; opacity: 0.7;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* Events popup styles */
#eventsPopup {
border: 1px solid #dee2e6;
background: white;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.popup-event-item {
font-size: 0.8rem;
padding: 6px 8px;
margin: 2px 0;
border-radius: 3px;
cursor: pointer;
transition: opacity 0.2s;
font-weight: 500;
color: white;
}
.popup-event-item:hover {
opacity: 0.8;
transform: translateX(2px);
}
.more-events-link {
cursor: pointer;
transition: color 0.2s;
}
.more-events-link:hover {
color: #0056b3 !important;
text-decoration: underline;
}
/* Dark theme support for popup */
@media (prefers-color-scheme: dark) {
#eventsPopup {
background: #2d3748;
border-color: #4a5568;
color: white;
}
}
</style> </style>
{% endblock %} {% endblock %}
@ -454,9 +630,9 @@
}); });
// Handle form submission (ensure only one listener) // Handle form submission (ensure only one listener)
if (!window.calendarFormInitialized) {
window.calendarFormInitialized = true;
const eventForm = document.getElementById('newEventForm'); const eventForm = document.getElementById('newEventForm');
if (!eventForm.hasAttribute('data-listener-added')) {
eventForm.setAttribute('data-listener-added', 'true');
eventForm.addEventListener('submit', function (e) { eventForm.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
@ -554,9 +730,162 @@
alert('Network error creating event. Please try again.'); alert('Network error creating event. Please try again.');
}); });
}); });
// Global variable to store current event ID for deletion
window.currentEventId = null;
} }
// Delete event function // Open event details modal
function openEventDetails(clickEvent, eventId, title, description, color, allDay, startTime, endTime) {
// Prevent event bubbling to calendar day click
clickEvent.stopPropagation();
// Store event ID for potential deletion
window.currentEventId = eventId;
// Populate modal fields
document.getElementById('detailsEventTitle').textContent = title;
document.getElementById('detailsEventDescription').textContent = description || 'No description';
document.getElementById('detailsEventColorBox').style.backgroundColor = color;
document.getElementById('detailsEventColor').textContent = getColorName(color);
document.getElementById('detailsAllDayEvent').checked = allDay;
// Format and display times
if (allDay) {
document.getElementById('detailsTimeInputs').style.display = 'none';
} else {
document.getElementById('detailsTimeInputs').style.display = 'block';
document.getElementById('detailsStartTime').textContent = formatDateTime(startTime);
document.getElementById('detailsEndTime').textContent = formatDateTime(endTime);
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('eventDetailsModal'));
modal.show();
}
// Show more events popup for a specific day
function showMoreEventsForDay(clickEvent, dateStr) {
// Prevent event bubbling to calendar day click
clickEvent.stopPropagation();
const popup = document.getElementById('eventsPopup');
const content = document.getElementById('eventsPopupContent');
// Clear previous content
content.innerHTML = '';
// Find the calendar day cell for this date
const dayCell = document.querySelector(`[data-date="${dateStr}"]`);
if (!dayCell) return;
// Get all event elements from this day (including hidden ones)
const eventElements = dayCell.querySelectorAll('.event-preview');
const allEvents = Array.from(eventElements).map(el => ({
id: el.getAttribute('data-event-id'),
title: el.getAttribute('data-event-title'),
description: el.getAttribute('data-event-description'),
color: el.getAttribute('data-event-color'),
all_day: el.getAttribute('data-event-all-day') === 'true',
start_time: el.getAttribute('data-event-start'),
end_time: el.getAttribute('data-event-end'),
index: parseInt(el.getAttribute('data-event-index'))
}));
// Show only the hidden events (index > 2)
const hiddenEvents = allEvents.filter(event => event.index > 2);
hiddenEvents.forEach(event => {
const eventDiv = document.createElement('div');
eventDiv.className = 'popup-event-item text-truncate';
eventDiv.style.backgroundColor = event.color;
// Truncate title to 30 characters
const truncatedTitle = event.title.length > 30 ? event.title.substring(0, 27) + '...' : event.title;
eventDiv.textContent = truncatedTitle;
eventDiv.title = event.title; // Show full title on hover
eventDiv.onclick = (e) => {
e.stopPropagation();
closeEventsPopup();
openEventDetails(e, event.id, event.title, event.description, event.color, event.all_day, event.start_time, event.end_time);
};
content.appendChild(eventDiv);
});
// Position popup near the clicked element
const rect = clickEvent.target.getBoundingClientRect();
popup.style.left = (rect.left + window.scrollX) + 'px';
popup.style.top = (rect.bottom + window.scrollY + 5) + 'px';
popup.style.display = 'block';
// Close popup when clicking outside
setTimeout(() => {
document.addEventListener('click', closeEventsPopupOnOutsideClick);
}, 100);
}
// Close events popup
function closeEventsPopup() {
document.getElementById('eventsPopup').style.display = 'none';
document.removeEventListener('click', closeEventsPopupOnOutsideClick);
}
// Close popup when clicking outside
function closeEventsPopupOnOutsideClick(event) {
const popup = document.getElementById('eventsPopup');
if (!popup.contains(event.target)) {
closeEventsPopup();
}
}
// Delete current event
function deleteCurrentEvent() {
if (!window.currentEventId) return;
if (confirm('Are you sure you want to delete this event?')) {
fetch(`/calendar/events/${window.currentEventId}/delete`, {
method: 'POST'
}).then(response => {
if (response.ok) {
window.location.reload();
} else {
alert('Error deleting event. Please try again.');
}
}).catch(error => {
console.error('Error:', error);
alert('Error deleting event. Please try again.');
});
}
}
// Helper function to get color name
function getColorName(colorCode) {
const colorMap = {
'#4285F4': 'Blue',
'#EA4335': 'Red',
'#34A853': 'Green',
'#FBBC05': 'Yellow',
'#A142F4': 'Purple',
'#24C1E0': 'Cyan'
};
return colorMap[colorCode] || 'Custom';
}
// Helper function to format date/time
function formatDateTime(dateTimeStr) {
const date = new Date(dateTimeStr);
return date.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Delete event function (legacy - keeping for compatibility)
function deleteEvent(eventId) { function deleteEvent(eventId) {
if (confirm('Are you sure you want to delete this event?')) { if (confirm('Are you sure you want to delete this event?')) {
fetch(`/calendar/events/${eventId}/delete`, { fetch(`/calendar/events/${eventId}/delete`, {
@ -719,8 +1048,22 @@
bootstrapModal.show(); bootstrapModal.show();
} }
// Truncate event titles to prevent calendar stretching
function truncateEventTitles() {
const eventElements = document.querySelectorAll('.event-preview');
eventElements.forEach(element => {
const originalTitle = element.textContent.trim();
if (originalTitle.length > 30) {
element.textContent = originalTitle.substring(0, 27) + '...';
}
});
}
// Initialize calendar features // Initialize calendar features
function initializeCalendar() { function initializeCalendar() {
// Truncate long event titles
truncateEventTitles();
// Highlight today's date // Highlight today's date
const today = new Date(); const today = new Date();
const todayStr = today.toISOString().split('T')[0]; const todayStr = today.toISOString().split('T')[0];