Feature: Calendars are now exportable shareable so Third Party Calendar services can read them, also added recoccurence to calendar, and all day along with sperating the time fields and click in a box to add calendar event is possible

This commit is contained in:
johnnyq
2026-07-30 02:18:30 -04:00
parent 3c8f812a16
commit f2d5ac4a29
15 changed files with 1233 additions and 62 deletions

View File

@@ -43,9 +43,13 @@ if (isset($_GET['calendar_id'])) {
$calendar_id = intval($row['calendar_id']);
$calendar_name = escapeHtml($row['calendar_name']);
$calendar_color = escapeHtml($row['calendar_color']);
$calendar_feed_key = escapeHtml($row['calendar_feed_key'] ?? null);
?>
<div class="form-group d-flex align-items-center">
<i class="fas fa-fw fa-circle mr-2" style="color:<?= $calendar_color ?>;"></i><?= $calendar_name ?>
<?php if (!empty($calendar_feed_key)) { ?>
<i class="fas fa-fw fa-share-alt text-info ml-2" title="Published as a subscription link"></i>
<?php } ?>
<div class="dropdown dropright ml-auto">
<button class="btn btn-tool" type="button" data-toggle="dropdown">
@@ -56,6 +60,12 @@ if (isset($_GET['calendar_id'])) {
data-modal-url="modals/calendar/calendar_edit.php?id=<?= $calendar_id ?>">
<i class="fas fa-fw fa-pencil-alt mr-2"></i>Rename
</a>
<?php if ($session_is_admin) { ?>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/calendar/calendar_share.php?id=<?= $calendar_id ?>">
<i class="fas fa-fw fa-share-alt mr-2"></i><?= empty($calendar_feed_key) ? 'Share' : 'Manage sharing' ?>
</a>
<?php } ?>
<?php if ($session_user_role == 3) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_calendar=<?= $calendar_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
@@ -153,6 +163,19 @@ while ($row = mysqli_fetch_assoc($sql)) {
<script src='/libs/fullcalendar/themes/classic/global.js'></script>
<script>
// Local-time formatters for the date and datetime-local inputs.
// Date.toISOString() would convert to UTC and silently shift the event by the
// browser's offset.
function formatLocalDate(date) {
const pad = (n) => String(n).padStart(2, "0");
return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate());
}
function formatLocalTime(date) {
const pad = (n) => String(n).padStart(2, "0");
return pad(date.getHours()) + ":" + pad(date.getMinutes());
}
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
@@ -163,6 +186,13 @@ while ($row = mysqli_fetch_assoc($sql)) {
text: 'New Event',
iconClass: 'fas fa-plus',
click: function() {
// Reset to the all-day default; without this the modal keeps
// whatever state the last calendar selection left it in
const allDayToggle = document.getElementById("event_add_all_day");
if (allDayToggle) {
allDayToggle.checked = true;
$(allDayToggle).trigger("change");
}
$("#addCalendarEventModal").modal();
}
}
@@ -185,6 +215,65 @@ while ($row = mysqli_fetch_assoc($sql)) {
eventDidMount: function(info) {
// Always show full title when hovering
info.el.setAttribute('title', info.event.title);
// Mark occurrences of a repeating event, so a series is recognisable
// without opening it. Every occurrence carries the parent event id, so
// this is the only cue that a click will edit the whole series.
const repeat = info.event.extendedProps.repeat;
if (repeat) {
info.el.setAttribute('title', info.event.title + ' (repeats every ' + repeat.toLowerCase() + ')');
const titleEl = info.el.querySelector('.fc-event-title') || info.el.querySelector('.fc-list-event-title');
if (titleEl) {
const icon = document.createElement('i');
icon.className = 'fas fa-redo fa-xs mr-1';
titleEl.prepend(icon);
}
}
},
// Clicking - or dragging across - empty calendar space opens the New Event
// modal prefilled with whatever was selected. Month view yields an all-day
// range; the time grid views yield a slot range.
select: function(selectionInfo) {
const allDayToggle = document.getElementById("event_add_all_day");
const startDate = document.getElementById("event_add_start_date");
const endDate = document.getElementById("event_add_end_date");
const startTime = document.getElementById("event_add_start_time");
const endTime = document.getElementById("event_add_end_time");
if (!startDate || !endDate) {
return;
}
// FullCalendar reports an exclusive end for an all-day range, but the form
// asks for the last day the event covers - step back a day so a single-day
// click does not come back as a two-day event. A time-grid selection is not
// exclusive in the same way, so its end date is used as-is (a slot running
// to midnight legitimately ends on the next day).
const lastDay = new Date(selectionInfo.end.getTime());
if (selectionInfo.allDay) {
lastDay.setDate(lastDay.getDate() - 1);
}
startDate.value = formatLocalDate(selectionInfo.start);
endDate.value = formatLocalDate(lastDay);
// A time-grid selection carries a real time; a month-view click does not,
// so its time fields are left for the user to fill in if they uncheck
if (!selectionInfo.allDay && startTime && endTime) {
startTime.value = formatLocalTime(selectionInfo.start);
endTime.value = formatLocalTime(selectionInfo.end);
}
// Last, so the handler in app.js shows or hides the time row to match
if (allDayToggle) {
allDayToggle.checked = selectionInfo.allDay;
$(allDayToggle).trigger("change");
}
calendar.unselect();
$("#addCalendarEventModal").modal();
},
eventClick: function(editEvent) {
var eventId = editEvent.event.id;
@@ -215,16 +304,40 @@ while ($row = mysqli_fetch_assoc($sql)) {
events: [
<?php
$sql = mysqli_query($mysqli, "SELECT * FROM calendar_events LEFT JOIN calendars ON event_calendar_id = calendar_id $client_event_query");
// Repeating events are stored as a single row, so the occurrences have to
// be materialised here - the bundled FullCalendar build has no rrule
// plugin. Every occurrence keeps the parent event_id, so clicking any of
// them opens the series in the edit modal.
$recur_window_start = date('Y-m-d H:i:s', strtotime('-6 months'));
$recur_window_end = date('Y-m-d H:i:s', strtotime('+18 months'));
while ($row = mysqli_fetch_assoc($sql)) {
$event_id = intval($row['event_id']);
$event_title = json_encode($row['event_title']);
$event_start = json_encode($row['event_start']);
$event_end = json_encode($row['event_end']);
$calendar_id = intval($row['calendar_id']);
$calendar_name = json_encode($row['calendar_name']);
$calendar_color = json_encode($row['calendar_color']);
$event_is_all_day = !empty($row['event_all_day'] ?? 0);
$event_all_day = $event_is_all_day ? 'true' : 'false';
$event_repeat = json_encode($row['event_repeat'] ?? '');
echo "{ id: $event_id, title: $event_title, start: $event_start, end: $event_end, color: $calendar_color },";
foreach (expandRecurringEvent($row, $recur_window_start, $recur_window_end) as $occurrence) {
$occurrence_end = $occurrence['end'];
// event_end holds the last day an all-day event covers, but
// FullCalendar's all-day end is exclusive - without this the final
// day of a multi-day event is not drawn
if ($event_is_all_day && !empty($occurrence_end)) {
$occurrence_end = date('Y-m-d H:i:s', strtotime($occurrence_end . ' +1 day'));
}
$event_start = json_encode($occurrence['start']);
$event_end = json_encode($occurrence_end);
echo "{ id: $event_id, title: $event_title, start: $event_start, end: $event_end, allDay: $event_all_day, color: $calendar_color, extendedProps: { repeat: $event_repeat } },";
}
}
// Invoices Created
@@ -384,31 +497,3 @@ while ($row = mysqli_fetch_assoc($sql)) {
calendar.render();
});
</script>
<!-- Automatically set new event end date to 1 hr after start date -->
<script>
// Function - called when user leaves field (onblur)
function updateIncrementEndTime() {
// Get the start date
let start = document.getElementById("event_add_start").value;
// Create a date object
let new_end = new Date(start);
// Get the time zone offset in minutes, convert it to milliseconds
let offsetInMilliseconds = new_end.getTimezoneOffset() * 60 * 1000;
// Adjust the date by the time zone offset before adding an hour
new_end = new Date(new_end.getTime() - offsetInMilliseconds);
// Set the end date to 1 hr in the future
new_end.setHours(new_end.getHours() + 1);
// Get the date back as a string, with the milliseconds trimmed off
new_end = new_end.toISOString().replace(/.\d+Z$/g, "");
// Update the end date field
document.getElementById("event_add_end").value = new_end;
}
</script>