Files
dawarich/app/javascript/controllers/location_sharing_toggle_controller.js
Evgenii Burmakin b1393ee674 0.36.0 (#1952)
* Implement OmniAuth GitHub authentication

* Fix omniauth GitHub scope to include user email access

* Remove margin-bottom

* Implement Google OAuth2 authentication

* Implement OIDC authentication for Dawarich using omniauth_openid_connect gem.

* Add patreon account linking and patron checking service

* Update docker-compose.yml to use boolean values instead of strings

* Add support for KML files

* Add tests

* Update changelog

* Remove patreon OAuth integration

* Move omniauthable to a concern

* Update an icon in integrations

* Update changelog

* Update app version

* Fix family location sharing toggle

* Move family location sharing to its own controller

* Update changelog

* Implement basic tagging functionality for places, allowing users to categorize and label places with custom tags.

* Add places management API and tags feature

* Add some changes related to places management feature

* Fix some tests

* Fix sometests

* Add places layer

* Update places layer to use Leaflet.Control.Layers.Tree for hierarchical layer control

* Rework tag form

* Add hashtag

* Add privacy zones to tags

* Add notes to places and manage place tags

* Update changelog

* Update e2e tests

* Extract tag serializer to its own file

* Fix some tests

* Fix tags request specs

* Fix some tests

* Fix rest of the tests

* Revert some changes

* Add missing specs

* Revert changes in place export/import code

* Fix some specs

* Fix PlaceFinder to only consider global places when finding existing places

* Fix few more specs

* Fix visits creator spec

* Fix last tests

* Update place creating modal

* Add home location based on "Home" tagged place

* Save enabled tag layers

* Some fixes

* Fix bug where enabling place tag layers would trigger saving enabled layers, overwriting with incomplete data

* Update migration to use disable_ddl_transaction! and add up/down methods

* Fix tag layers restoration and filtering logic

* Update OIDC auto-registration and email/password registration settings

* Fix potential xss
2025-11-24 19:45:09 +01:00

277 lines
8.6 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["checkbox", "durationContainer", "durationSelect", "expirationInfo"];
static values = {
memberId: Number,
enabled: Boolean,
familyId: Number,
duration: String,
expiresAt: String
};
connect() {
console.log("Location sharing toggle controller connected");
this.updateToggleState();
this.setupExpirationTimer();
}
disconnect() {
this.clearExpirationTimer();
}
toggle() {
const newState = !this.enabledValue;
const duration = this.hasDurationSelectTarget ? this.durationSelectTarget.value : 'permanent';
// Optimistically update UI
this.enabledValue = newState;
this.updateToggleState();
// Send the update to server
this.updateLocationSharing(newState, duration);
}
changeDuration() {
if (!this.enabledValue) return; // Only allow duration changes when sharing is enabled
const duration = this.durationSelectTarget.value;
this.durationValue = duration;
// Update sharing with new duration
this.updateLocationSharing(true, duration);
}
updateToggleState() {
const isEnabled = this.enabledValue;
// Update checkbox (DaisyUI toggle)
this.checkboxTarget.checked = isEnabled;
// Show/hide duration container
if (this.hasDurationContainerTarget) {
if (isEnabled) {
this.durationContainerTarget.classList.remove('hidden');
} else {
this.durationContainerTarget.classList.add('hidden');
}
}
}
async updateLocationSharing(enabled, duration = 'permanent') {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const response = await fetch(`/family/location_sharing`, {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
enabled: enabled,
duration: duration
})
});
const data = await response.json();
if (data.success) {
// Update local values from server response
this.durationValue = data.duration;
this.expiresAtValue = data.expires_at;
// Update duration select if it exists
if (this.hasDurationSelectTarget) {
this.durationSelectTarget.value = data.duration;
}
// Update expiration info
this.updateExpirationInfo(data.expires_at_formatted);
// Show success message
this.showFlashMessage('success', data.message);
// Setup/clear expiration timer
this.setupExpirationTimer();
// Trigger custom event for other controllers to listen to
document.dispatchEvent(new CustomEvent('location-sharing:updated', {
detail: {
userId: this.memberIdValue,
enabled: enabled,
duration: data.duration,
expiresAt: data.expires_at
}
}));
} else {
// Revert the UI change if server update failed
this.enabledValue = !enabled;
this.updateToggleState();
this.showFlashMessage('error', data.message || 'Failed to update location sharing');
}
} catch (error) {
console.error('Error updating location sharing:', error);
// Revert the UI change if request failed
this.enabledValue = !enabled;
this.updateToggleState();
this.showFlashMessage('error', 'Network error occurred while updating location sharing');
}
}
setupExpirationTimer() {
this.clearExpirationTimer();
if (this.enabledValue && this.expiresAtValue) {
const expiresAt = new Date(this.expiresAtValue);
const now = new Date();
const msUntilExpiration = expiresAt.getTime() - now.getTime();
if (msUntilExpiration > 0) {
// Set timer to automatically disable sharing when it expires
this.expirationTimer = setTimeout(() => {
this.enabledValue = false;
this.updateToggleState();
this.showFlashMessage('info', 'Location sharing has expired');
// Trigger update event
document.dispatchEvent(new CustomEvent('location-sharing:expired', {
detail: { userId: this.memberIdValue }
}));
}, msUntilExpiration);
// Also set up periodic updates to show countdown
this.updateExpirationCountdown();
this.countdownInterval = setInterval(() => {
this.updateExpirationCountdown();
}, 60000); // Update every minute
}
}
}
clearExpirationTimer() {
if (this.expirationTimer) {
clearTimeout(this.expirationTimer);
this.expirationTimer = null;
}
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
updateExpirationInfo(formattedTime) {
if (this.hasExpirationInfoTarget && formattedTime) {
this.expirationInfoTarget.textContent = `Expires ${formattedTime}`;
this.expirationInfoTarget.style.display = 'block';
} else if (this.hasExpirationInfoTarget) {
this.expirationInfoTarget.style.display = 'none';
}
}
updateExpirationCountdown() {
if (!this.hasExpirationInfoTarget || !this.expiresAtValue) return;
const expiresAt = new Date(this.expiresAtValue);
const now = new Date();
const msUntilExpiration = expiresAt.getTime() - now.getTime();
if (msUntilExpiration <= 0) {
this.expirationInfoTarget.textContent = 'Expired';
this.expirationInfoTarget.style.display = 'block';
return;
}
const hoursLeft = Math.floor(msUntilExpiration / (1000 * 60 * 60));
const minutesLeft = Math.floor((msUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
let timeText;
if (hoursLeft > 0) {
timeText = `${hoursLeft}h ${minutesLeft}m remaining`;
} else {
timeText = `${minutesLeft}m remaining`;
}
this.expirationInfoTarget.textContent = `Expires in ${timeText}`;
}
showFlashMessage(type, message) {
// Create a flash message element matching the project style (_flash.html.erb)
const flashContainer = document.getElementById('flash-messages') ||
this.createFlashContainer();
const bgClass = this.getFlashClasses(type);
const flashElement = document.createElement('div');
flashElement.className = `flex items-center ${bgClass} py-3 px-5 rounded-lg z-[6000]`;
flashElement.innerHTML = `
<div class="mr-4">${message}</div>
<button type="button">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
`;
// Add click handler to dismiss button
const dismissButton = flashElement.querySelector('button');
dismissButton.addEventListener('click', () => {
flashElement.classList.add('fade-out');
setTimeout(() => {
flashElement.remove();
// Remove the container if it's empty
if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
}, 150);
});
flashContainer.appendChild(flashElement);
// Auto-remove after 5 seconds
setTimeout(() => {
if (flashElement.parentNode) {
flashElement.classList.add('fade-out');
setTimeout(() => {
flashElement.remove();
// Remove the container if it's empty
if (flashContainer && !flashContainer.hasChildNodes()) {
flashContainer.remove();
}
}, 150);
}
}, 5000);
}
createFlashContainer() {
const container = document.createElement('div');
container.id = 'flash-messages';
container.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50';
document.body.appendChild(container);
return container;
}
getFlashClasses(type) {
switch (type) {
case 'error':
case 'alert':
return 'bg-red-100 text-red-700 border-red-300';
default:
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
// Helper method to check if user's own location sharing is enabled
// This can be used by other controllers
static getUserLocationSharingStatus() {
const toggleController = document.querySelector('[data-controller*="location-sharing-toggle"]');
if (toggleController) {
const controller = this.application.getControllerForElementAndIdentifier(toggleController, 'location-sharing-toggle');
return controller?.enabledValue || false;
}
return false;
}
}