mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-10 09:57:57 -05:00
143 add sse endpoint (#147)
This commit is contained in:
@@ -47,7 +47,7 @@ public class UserSseEmitterService implements SmartLifecycle {
|
||||
for (SseEmitter emitter : new CopyOnWriteArraySet<>(emitters)) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(eventData));
|
||||
log.info("Sent event to user: {}", userId);
|
||||
log.debug("Sent event to user: {}", userId);
|
||||
} catch (IOException e) {
|
||||
log.error("Error sending event to user {}: {}", userId, e.getMessage());
|
||||
emitter.completeWithError(e);
|
||||
@@ -55,7 +55,7 @@ public class UserSseEmitterService implements SmartLifecycle {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println("No active SSE emitters for user: " + userId);
|
||||
log.debug("No active SSE emitters for user: {}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,28 +81,13 @@ public class UserSseEmitterService implements SmartLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
public void sendEventToAllUsers(Object eventData) {
|
||||
userEmitters.forEach((userId, emitters) -> {
|
||||
for (SseEmitter emitter : new CopyOnWriteArraySet<>(emitters)) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(eventData));
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
removeEmitter(userId, emitter);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
userEmitters.values().forEach(sseEmitters -> sseEmitters.forEach(SseEmitter::complete));
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -393,3 +393,5 @@ month.12=December
|
||||
|
||||
# SSE Events
|
||||
sse.error.connection-lost=Connection to server lost! Try reconnecting ...
|
||||
# Map
|
||||
map.auto-update.latest-location=Latest location
|
||||
|
||||
@@ -327,6 +327,12 @@ login.password=Passwort
|
||||
login.remember.me=Angemeldet bleiben
|
||||
login.button=Anmelden
|
||||
|
||||
# SSE Events
|
||||
sse.error.connection-lost=Verbindung zum Server verloren! Versuchen erneut zu verbinden ...
|
||||
|
||||
# Map
|
||||
map.auto-update.latest-location=Neuster Standort
|
||||
|
||||
# Additional messages
|
||||
message.success.geocode.created=Geokodierungsdienst erfolgreich erstellt
|
||||
message.error.geocode.creation=Fehler beim Erstellen des Geokodierungsdienstes: {0}
|
||||
|
||||
@@ -385,3 +385,11 @@ data.remove.all.button=Poista kaikki tiedot
|
||||
data.remove.all.confirm=Oletko varma, ett\u00E4 haluat poistaa KAIKKI tiedot paitsi merkitt\u00E4v\u00E4t paikat? T\u00E4t\u00E4 toimintoa ei voi peruuttaa.
|
||||
data.remove.all.success=Kaikki tiedot paitsi merkitt\u00E4v\u00E4t paikat on poistettu onnistuneesti
|
||||
data.remove.all.error=Virhe poistettaessa tietoja: {0}
|
||||
|
||||
|
||||
|
||||
# SSE Events
|
||||
sse.error.connection-lost=Yhteys palvelimeen katkesi! Yrit\u00E4 muodostaa yhteys uudelleen...
|
||||
|
||||
# Map
|
||||
map.auto-update.latest-location=Viimeisin sijainti
|
||||
|
||||
@@ -394,3 +394,9 @@ data.remove.all.button=Supprimer toutes les donn\u00E9es
|
||||
data.remove.all.confirm=\u00CAtes-vous s\u00FBr de vouloir supprimer TOUTES les donn\u00E9es sauf les lieux significatifs ? Cette action ne peut pas \u00EAtre annul\u00E9e.
|
||||
data.remove.all.success=Toutes les donn\u00E9es sauf les lieux significatifs ont \u00E9t\u00E9 supprim\u00E9es avec succ\u00E8s
|
||||
data.remove.all.error=Erreur lors de la suppression des donn\u00E9es : {0}
|
||||
|
||||
# SSE Events
|
||||
sse.error.connection-lost=Connexion au serveur perdue ! Reconnexion en cours...
|
||||
|
||||
# Map
|
||||
map.auto-update.latest-location=Derni\u00E8re position
|
||||
|
||||
@@ -1071,6 +1071,44 @@ button:disabled {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pulsating-marker {
|
||||
animation: pulse 1s infinite;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.css-icon {
|
||||
|
||||
}
|
||||
|
||||
.gps_ring {
|
||||
border: 3px solid #ff0000;
|
||||
-webkit-border-radius: 30px;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
-webkit-animation: pulsate 1.5s ease-out;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@-webkit-keyframes pulsate {
|
||||
0% {-webkit-transform: scale(0.1, 0.1); opacity: 0.0;}
|
||||
50% {opacity: 1.0;}
|
||||
100% {-webkit-transform: scale(1.2, 1.2); opacity: 0.0;}
|
||||
}
|
||||
.sr-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class HorizontalDatePicker {
|
||||
this._isManualSelection = true;
|
||||
|
||||
// Force selection of the clicked date
|
||||
this.selectDate(dateItem, true);
|
||||
this.selectDateItem(dateItem, true);
|
||||
|
||||
// Reset the manual selection flag after a delay
|
||||
setTimeout(() => {
|
||||
@@ -232,7 +232,7 @@ class HorizontalDatePicker {
|
||||
this._isManualSelection = true;
|
||||
|
||||
// Force selection of the tapped date
|
||||
this.selectDate(dateItem, true);
|
||||
this.selectDateItem(dateItem, true);
|
||||
|
||||
// Reset the manual selection flag after a delay
|
||||
setTimeout(() => {
|
||||
@@ -424,8 +424,8 @@ class HorizontalDatePicker {
|
||||
|
||||
return dateItem;
|
||||
}
|
||||
|
||||
selectDate(dateItem, isManualSelection = false) {
|
||||
|
||||
selectDateItem(dateItem, isManualSelection = false) {
|
||||
// Check if date is within min/max range, but only if they are set
|
||||
const dateToSelect = this.parseDate(dateItem.dataset.date);
|
||||
|
||||
@@ -447,6 +447,14 @@ class HorizontalDatePicker {
|
||||
if (this.selectedElement === dateItem && !isManualSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is a manual selection and auto-update mode is active, disable it
|
||||
if (isManualSelection && window.autoUpdateMode) {
|
||||
console.log('Manual date selection detected, disabling auto-update mode');
|
||||
if (typeof window.disableAutoUpdate === 'function') {
|
||||
window.disableAutoUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any existing selection
|
||||
if (this.selectedElement) {
|
||||
@@ -498,7 +506,7 @@ class HorizontalDatePicker {
|
||||
// For manual selections, call the callback immediately
|
||||
if (isManualSelection) {
|
||||
if (typeof this.options.onDateSelect === 'function') {
|
||||
this.options.onDateSelect(dateToSelect, dateItem.dataset.date);
|
||||
this.options.onDateSelect(dateToSelect, dateItem.dataset.date, true);
|
||||
}
|
||||
|
||||
// Dispatch custom event
|
||||
@@ -824,6 +832,14 @@ class HorizontalDatePicker {
|
||||
|
||||
// Select a month
|
||||
selectMonth(year, month) {
|
||||
// If auto-update mode is active, disable it for manual month selection
|
||||
if (window.autoUpdateMode) {
|
||||
console.log('Manual month selection detected, disabling auto-update mode');
|
||||
if (typeof window.disableAutoUpdate === 'function') {
|
||||
window.disableAutoUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current day from the selected date
|
||||
const currentDay = this.options.selectedDate.getDate();
|
||||
|
||||
@@ -862,7 +878,7 @@ class HorizontalDatePicker {
|
||||
|
||||
for (const item of dateItems) {
|
||||
if (item.dataset.date === formattedExactDate) {
|
||||
this.selectDate(item, true);
|
||||
this.selectDateItem(item, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -874,7 +890,7 @@ class HorizontalDatePicker {
|
||||
// Call onDateSelect callback if provided
|
||||
const formattedDate = this.formatDate(exactSelectedDate);
|
||||
if (typeof this.options.onDateSelect === 'function') {
|
||||
this.options.onDateSelect(exactSelectedDate, formattedDate);
|
||||
this.options.onDateSelect(exactSelectedDate, formattedDate, false);
|
||||
}
|
||||
|
||||
// Dispatch custom event
|
||||
@@ -889,6 +905,14 @@ class HorizontalDatePicker {
|
||||
|
||||
// Select a year
|
||||
selectYear(year) {
|
||||
// If auto-update mode is active, disable it for manual year selection
|
||||
if (window.autoUpdateMode) {
|
||||
console.log('Manual year selection detected, disabling auto-update mode');
|
||||
if (typeof window.disableAutoUpdate === 'function') {
|
||||
window.disableAutoUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current month and day from the selected date
|
||||
const currentDate = new Date(this.options.selectedDate);
|
||||
|
||||
@@ -930,7 +954,7 @@ class HorizontalDatePicker {
|
||||
|
||||
for (const item of dateItems) {
|
||||
if (item.dataset.date === formattedExactDate) {
|
||||
this.selectDate(item, true);
|
||||
this.selectDateItem(item, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -942,7 +966,7 @@ class HorizontalDatePicker {
|
||||
// Call onDateSelect callback if provided
|
||||
const formattedDate = this.formatDate(exactSelectedDate);
|
||||
if (typeof this.options.onDateSelect === 'function') {
|
||||
this.options.onDateSelect(exactSelectedDate, formattedDate);
|
||||
this.options.onDateSelect(exactSelectedDate, formattedDate, false);
|
||||
}
|
||||
|
||||
// Dispatch custom event
|
||||
@@ -1044,7 +1068,8 @@ class HorizontalDatePicker {
|
||||
if (this.options.showMonthRow) {
|
||||
this.highlightSelectedMonth();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Find and mark the selected date element
|
||||
setTimeout(() => {
|
||||
const dateItems = this.dateContainer.querySelectorAll('.date-item');
|
||||
@@ -1060,9 +1085,20 @@ class HorizontalDatePicker {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (typeof this.options.onDateSelect === 'function') {
|
||||
this.options.onDateSelect(today, formattedDate, false);
|
||||
}
|
||||
// Center the selected date
|
||||
this.scrollToSelectedDate(false);
|
||||
const event = new CustomEvent('dateSelected', {
|
||||
detail: {
|
||||
date: newDate,
|
||||
formattedDate: formattedDate
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(event);
|
||||
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1084,7 +1120,7 @@ class HorizontalDatePicker {
|
||||
// Call onDateSelect callback
|
||||
const formattedDate = this.formatDate(today);
|
||||
if (typeof this.options.onDateSelect === 'function') {
|
||||
this.options.onDateSelect(today, formattedDate);
|
||||
this.options.onDateSelect(today, formattedDate, true);
|
||||
}
|
||||
|
||||
// Dispatch custom event
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<span><img class="logo" th:src="@{/img/logo.png}" alt="reitti logo" title="reitti" src="/img/logo.png"></span>
|
||||
<a href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
|
||||
<a href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
|
||||
<a type="button" class="nav-link" id="auto-update-btn" onclick="toggleAutoUpdate()" title="Auto Update"><i class="lni lni-play"></i></a>
|
||||
<a type="button" class="nav-link" onclick="toggleFullscreen()" title="Toggle Fullscreen"><i class="lni lni-arrow-all-direction"></i></a>
|
||||
<form th:action="@{/logout}" method="post" >
|
||||
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i
|
||||
@@ -79,11 +80,23 @@
|
||||
],
|
||||
sse: {
|
||||
error: /*[[#{sse.error.connection-lost}]]*/ 'Connection to server lost! Will reconnect ...',
|
||||
},
|
||||
autoupdate: {
|
||||
latestLocation: /*[[#{map.auto-update.latest-location}]]*/ 'Latest location',
|
||||
}
|
||||
};
|
||||
|
||||
window.userSettings = /*[[${userSettings}]]*/ {}
|
||||
|
||||
const messagesDiv = document.getElementById('sse-message'); let autoUpdateMode = false;
|
||||
let autoUpdateTimer = null;
|
||||
let eventSource = null;
|
||||
let reloadTimeoutId = null;
|
||||
let pendingEvents = [];
|
||||
|
||||
// Initialize the map
|
||||
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12);
|
||||
|
||||
function getSelectedDate() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('date')) {
|
||||
@@ -129,6 +142,8 @@
|
||||
zoomSnap: 0.1
|
||||
};
|
||||
|
||||
let pulsatingMarkers = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Check if date is in URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -158,9 +173,6 @@
|
||||
}
|
||||
|
||||
|
||||
// Initialize the map
|
||||
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12);
|
||||
|
||||
const tilesUrl = window.userSettings.tiles.service;
|
||||
const tilesAttribution = window.userSettings.tiles.attribution;
|
||||
|
||||
@@ -183,6 +195,8 @@
|
||||
function loadTimelineData(date) {
|
||||
// Load photos for the selected date
|
||||
photoClient.updatePhotosForDate(date, getUserTimezone());
|
||||
// Remove pulsating markers when loading new data
|
||||
removePulsatingMarkers();
|
||||
// Get raw location points URL from timeline container
|
||||
const timelineContainer = document.querySelectorAll('.user-timeline-section');
|
||||
for (const path of rawPointPaths) {
|
||||
@@ -230,6 +244,12 @@
|
||||
rawPointsPath.setLatLngs(rawPointsCoords);
|
||||
rawPointsPath.addTo(map);
|
||||
rawPointPaths.push(rawPointsPath)
|
||||
|
||||
// Add pulsating marker for the latest point if in auto-update mode and today is selected
|
||||
if (autoUpdateMode && isSelectedDateToday() && rawPointsData.points.length > 0) {
|
||||
const latestPoint = rawPointsData.points[rawPointsData.points.length - 1];
|
||||
addPulsatingMarker(latestPoint.latitude, latestPoint.longitude, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +465,7 @@
|
||||
}
|
||||
|
||||
// Initialize horizontal date picker
|
||||
new HorizontalDatePicker({
|
||||
window.horizontalDatePicker = new HorizontalDatePicker({
|
||||
container: document.getElementById('horizontal-date-picker-container'),
|
||||
selectedDate: dateToUse,
|
||||
showNavButtons: false, // Show navigation buttons
|
||||
@@ -456,20 +476,196 @@
|
||||
allowFutureDates: false, // Disable selection of future dates
|
||||
showTodayButton: true, // Show the Today button
|
||||
// No min/max date for infinite scrolling
|
||||
onDateSelect: (date, formattedDate) => {
|
||||
onDateSelect: (date, formattedDate, isManualSelected) => {
|
||||
// Update URL
|
||||
updateUrlWithDate(formattedDate);
|
||||
// Trigger HTMX reload of timeline
|
||||
document.body.dispatchEvent(new CustomEvent('dateChanged'));
|
||||
},
|
||||
onDateDeselect: () => {
|
||||
// Clear photos when no date is selected
|
||||
photoClient.clearPhotos();
|
||||
if (isManualSelected) {
|
||||
disableAutoUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fullscreen functionality
|
||||
|
||||
function toggleAutoUpdate() {
|
||||
const btn = document.getElementById('auto-update-btn');
|
||||
const icon = btn.querySelector('i');
|
||||
|
||||
if (!autoUpdateMode) {
|
||||
// Enable auto-update mode
|
||||
autoUpdateMode = true;
|
||||
icon.className = 'lni lni-pause';
|
||||
btn.title = 'Pause Auto Update';
|
||||
|
||||
// Update the date picker to today
|
||||
if (window.horizontalDatePicker) {
|
||||
window.horizontalDatePicker.setDate(new Date());
|
||||
}
|
||||
|
||||
// Start the timer to check for date changes every 30 seconds
|
||||
startAutoUpdateTimer();
|
||||
|
||||
eventSource = new EventSource('/events'); // Connect to your SSE endpoint
|
||||
|
||||
eventSource.onopen = function() {
|
||||
console.log('SSE connection opened.');
|
||||
messagesDiv.classList.remove('active')
|
||||
};
|
||||
|
||||
// Listen for events with the name "message"
|
||||
eventSource.addEventListener('message', function(event) {
|
||||
console.log('Received message event:', event.data);
|
||||
});
|
||||
|
||||
|
||||
eventSource.onerror = function(error) {
|
||||
console.error('EventSource failed:', error);
|
||||
messagesDiv.innerHTML = `<p><strong>${window.locale.sse.error}</strong></p>`;
|
||||
messagesDiv.classList.add('active')
|
||||
};
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
console.log('Received generic event:', event.data);
|
||||
|
||||
// Parse the event data
|
||||
try {
|
||||
const eventData = JSON.parse(event.data);
|
||||
|
||||
// Check if the event has a date field and it matches today
|
||||
if (eventData.date) {
|
||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
const eventDate = eventData.date;
|
||||
|
||||
// If event date matches today and we're in auto-update mode, schedule reload
|
||||
if (eventDate === today && autoUpdateMode) {
|
||||
console.log('Auto-update: Scheduling timeline reload due to SSE event for today');
|
||||
scheduleTimelineReload(eventData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not parse SSE event data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
} else {
|
||||
// Disable auto-update mode
|
||||
disableAutoUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function disableAutoUpdate() {
|
||||
autoUpdateMode = false;
|
||||
|
||||
// Clear the timer
|
||||
if (autoUpdateTimer) {
|
||||
clearInterval(autoUpdateTimer);
|
||||
autoUpdateTimer = null;
|
||||
}
|
||||
|
||||
// Clear any pending reload timeout
|
||||
if (reloadTimeoutId) {
|
||||
clearTimeout(reloadTimeoutId);
|
||||
reloadTimeoutId = null;
|
||||
}
|
||||
|
||||
// Clear pending events
|
||||
pendingEvents = [];
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
// Remove pulsating markers when auto-update is disabled
|
||||
removePulsatingMarkers();
|
||||
|
||||
const btn = document.getElementById('auto-update-btn');
|
||||
const icon = btn.querySelector('i');
|
||||
icon.className = 'lni lni-play';
|
||||
btn.title = 'Auto Update';
|
||||
}
|
||||
|
||||
function startAutoUpdateTimer() {
|
||||
// Check every 30 seconds if we need to switch to today's date
|
||||
autoUpdateTimer = setInterval(() => {
|
||||
if (!autoUpdateMode) {
|
||||
return; // Exit if auto-update mode was disabled
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const todayString = today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
const currentSelectedDate = getSelectedDate();
|
||||
|
||||
// If the selected date is not today, switch to today
|
||||
if (currentSelectedDate !== todayString) {
|
||||
console.log('Auto-update: Switching to today\'s date');
|
||||
if (window.horizontalDatePicker) {
|
||||
window.horizontalDatePicker.setDate(today);
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
}
|
||||
|
||||
function isSelectedDateToday() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const selectedDate = getSelectedDate();
|
||||
return selectedDate === today;
|
||||
}
|
||||
|
||||
function addPulsatingMarker(lat, lng, color) {
|
||||
// Create new pulsating marker
|
||||
const cssIcon = L.divIcon({
|
||||
// Specify a class name we can refer to in CSS.
|
||||
className: 'css-icon',
|
||||
html: `<div style="border-color: ${color || '#606060'}" class="gps_ring"></div>`
|
||||
,iconSize: [22,22]
|
||||
});
|
||||
|
||||
const pulsatingMarker = L.marker([lat, lng], {icon: cssIcon}).addTo(map);
|
||||
|
||||
// Add tooltip
|
||||
pulsatingMarker.bindTooltip(window.locale.autoupdate.latestLocation, {
|
||||
permanent: false,
|
||||
direction: 'top'
|
||||
});
|
||||
|
||||
// Store the marker for cleanup later
|
||||
pulsatingMarkers.push(pulsatingMarker);
|
||||
}
|
||||
|
||||
function removePulsatingMarkers() {
|
||||
pulsatingMarkers.forEach(marker => {
|
||||
if (marker) {
|
||||
map.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
pulsatingMarkers = [];
|
||||
}
|
||||
|
||||
function scheduleTimelineReload(eventData) {
|
||||
// Add event to pending events
|
||||
pendingEvents.push(eventData);
|
||||
|
||||
// Clear existing timeout if any
|
||||
if (reloadTimeoutId) {
|
||||
clearTimeout(reloadTimeoutId);
|
||||
}
|
||||
|
||||
// Schedule reload after 5 seconds of idle time
|
||||
reloadTimeoutId = setTimeout(() => {
|
||||
debugger
|
||||
if (autoUpdateMode && pendingEvents.length > 0) {
|
||||
console.log(`Auto-update: Reloading timeline data after ${pendingEvents.length} accumulated events`);
|
||||
document.body.dispatchEvent(new CustomEvent('dateChanged'));
|
||||
// Clear pending events
|
||||
pendingEvents = [];
|
||||
}
|
||||
reloadTimeoutId = null;
|
||||
}, 5000); // 5 seconds
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(err => {
|
||||
|
||||
Reference in New Issue
Block a user