Merge pull request #1848 from Freika/fix/family-stuff

Fix/family stuff
This commit is contained in:
Evgenii Burmakin 2025-10-20 20:41:27 +02:00 committed by GitHub
commit 07216e00dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 830 additions and 381 deletions

View file

@ -17,6 +17,7 @@ In this release we're introducing family features that allow users to create fam
## Fixed
- Sign out button works again. #1844
- Fixed user deletion bug where user could not be deleted due to counter cache on points.
- Users always have default distance unit set to kilometers. #1832
## Changed

File diff suppressed because one or more lines are too long

View file

@ -34,6 +34,7 @@
color: var(--leaflet-text-color) !important;
border-color: var(--leaflet-border-color) !important;
box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important;
}
/* Leaflet zoom buttons */
@ -51,6 +52,32 @@
.leaflet-control-layers-toggle {
background-color: var(--leaflet-bg-color) !important;
color: var(--leaflet-text-color) !important;
/* Replace default icon with custom SVG */
background-image: none !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.leaflet-control-layers-toggle::before {
content: '' !important;
display: block !important;
width: 24px !important;
height: 24px !important;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>') !important;
background-size: contain !important;
background-repeat: no-repeat !important;
background-position: center !important;
}
/* Dark theme - use white stroke for the icon */
[data-theme="dark"] .leaflet-control-layers-toggle::before {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>') !important;
}
/* Light theme - use black stroke for the icon */
[data-theme="light"] .leaflet-control-layers-toggle::before {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>') !important;
}
.leaflet-control-layers-expanded {
@ -187,3 +214,23 @@
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7);
border-radius: 50%;
}
/* Fix bottom controls being cut off */
.leaflet-bottom {
padding-bottom: 10px !important;
transition: padding-bottom 0.3s ease;
}
.leaflet-bottom.leaflet-left {
padding-left: 10px !important;
}
.leaflet-bottom.leaflet-right {
padding-right: 10px !important;
}
/* DaisyUI tooltips on map buttons - ensure they appear above date navigation (z-index: 9999) */
.tooltip:before,
.tooltip:after {
z-index: 10000 !important;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up-icon lucide-chevron-up"><path d="m18 15-6-6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dashed-mouse-pointer-icon lucide-square-dashed-mouse-pointer"><path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h2"/><path d="M14 3h1"/><path d="M3 9v1"/><path d="M21 9v2"/><path d="M3 14v1"/></svg>

After

Width:  |  Height:  |  Size: 623 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View file

@ -1,7 +1,10 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import { showFlashMessage } from "../maps/helpers";
import { applyThemeToButton } from "../maps/theme_utils";
import {
setAddVisitButtonActive,
setAddVisitButtonInactive
} from "../maps/map_controls";
export default class extends Controller {
static targets = [""];
@ -71,39 +74,26 @@ export default class extends Controller {
setupAddVisitButton() {
if (!this.map || this.addVisitButton) return;
// Create the Add Visit control
const AddVisitControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button');
button.innerHTML = '';
button.title = 'Add a visit';
// The Add Visit button is now created centrally by maps_controller.js
// via addTopRightButtons(). We just need to find it and attach our handler.
setTimeout(() => {
this.addVisitButton = document.querySelector('.add-visit-button');
// Style the button with theme-aware styling
applyThemeToButton(button, this.userThemeValue || 'dark');
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
button.style.transition = 'all 0.2s ease';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Toggle add visit mode on button click
L.DomEvent.on(button, 'click', () => {
this.toggleAddVisitMode(button);
});
this.addVisitButton = button;
return button;
if (this.addVisitButton) {
// Attach our click handler to the existing button
// Use event capturing and stopPropagation to prevent map click
this.addVisitButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleAddVisitMode(this.addVisitButton);
}, true); // Use capture phase
} else {
console.warn('Add visit button not found, retrying...');
// Retry if button hasn't been created yet
this.addVisitButton = null;
setTimeout(() => this.setupAddVisitButton(), 200);
}
});
// Add the control to the map (top right, below existing buttons)
this.map.addControl(new AddVisitControl({ position: 'topright' }));
}, 100);
}
toggleAddVisitMode(button) {
@ -120,15 +110,18 @@ export default class extends Controller {
this.isAddingVisit = true;
// Update button style to show active state
button.style.backgroundColor = '#dc3545';
button.style.color = 'white';
button.innerHTML = '✕';
setAddVisitButtonActive(button);
// Change cursor to crosshair
this.map.getContainer().style.cursor = 'crosshair';
// Add map click listener
// Add map click listener with a small delay to prevent immediate trigger
// This ensures the button click doesn't propagate to the map
setTimeout(() => {
if (this.isAddingVisit) {
this.map.on('click', this.onMapClick, this);
}
}, 100);
showFlashMessage('notice', 'Click on the map to place a visit');
}
@ -136,9 +129,8 @@ export default class extends Controller {
exitAddVisitMode(button) {
this.isAddingVisit = false;
// Reset button style with theme-aware styling
applyThemeToButton(button, this.userThemeValue || 'dark');
button.innerHTML = '';
// Reset button style to inactive state
setAddVisitButtonInactive(button, this.userThemeValue || 'dark');
// Reset cursor
this.map.getContainer().style.cursor = '';
@ -185,6 +177,12 @@ export default class extends Controller {
}
showVisitForm(lat, lng) {
// Close any existing popup first to ensure only one popup is open
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
}
// Get current date/time for default values
const now = new Date();
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000));
@ -290,7 +288,8 @@ export default class extends Controller {
started_at: formData.get('started_at'),
ended_at: formData.get('ended_at'),
latitude: formData.get('latitude'),
longitude: formData.get('longitude')
longitude: formData.get('longitude'),
status: 'confirmed' // Manually created visits should be confirmed
}
};
@ -324,15 +323,14 @@ export default class extends Controller {
if (response.ok) {
showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`);
// Store the created visit data
const createdVisit = data;
this.exitAddVisitMode(this.addVisitButton);
// Refresh visits layer - this will clear and refetch data
this.refreshVisitsLayer();
// Ensure confirmed visits layer is enabled (with a small delay for the API call to complete)
setTimeout(() => {
this.ensureVisitsLayersEnabled();
}, 300);
// Add the newly created visit marker immediately to the map
this.addCreatedVisitToMap(createdVisit, visitData.visit.latitude, visitData.visit.longitude);
} else {
const errorMessage = data.error || data.message || 'Failed to create visit';
showFlashMessage('error', errorMessage);
@ -347,95 +345,91 @@ export default class extends Controller {
}
}
refreshVisitsLayer() {
console.log('Attempting to refresh visits layer...');
addCreatedVisitToMap(visitData, latitude, longitude) {
console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData });
// Try multiple approaches to refresh the visits layer
const mapsController = document.querySelector('[data-controller*="maps"]');
if (mapsController) {
// Try to get the Stimulus controller instance
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
if (stimulusController && stimulusController.visitsManager) {
console.log('Found maps controller with visits manager');
// Clear existing visits and fetch fresh data
if (stimulusController.visitsManager.visitCircles) {
stimulusController.visitsManager.visitCircles.clearLayers();
}
if (stimulusController.visitsManager.confirmedVisitCircles) {
stimulusController.visitsManager.confirmedVisitCircles.clearLayers();
}
// Refresh the visits data
if (typeof stimulusController.visitsManager.fetchAndDisplayVisits === 'function') {
console.log('Refreshing visits data...');
stimulusController.visitsManager.fetchAndDisplayVisits();
}
} else {
console.log('Could not find maps controller or visits manager');
// Fallback: Try to dispatch a custom event
const refreshEvent = new CustomEvent('visits:refresh', { bubbles: true });
mapsController.dispatchEvent(refreshEvent);
}
} else {
if (!mapsController) {
console.log('Could not find maps controller element');
}
return;
}
ensureVisitsLayersEnabled() {
console.log('Ensuring visits layers are enabled...');
const mapsController = document.querySelector('[data-controller*="maps"]');
if (mapsController) {
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
if (!stimulusController || !stimulusController.visitsManager) {
console.log('Could not find maps controller or visits manager');
return;
}
if (stimulusController && stimulusController.map && stimulusController.visitsManager) {
const map = stimulusController.map;
const visitsManager = stimulusController.visitsManager;
// Get the confirmed visits layer (newly created visits are always confirmed)
const confirmedVisitsLayer = visitsManager.getConfirmedVisitCirclesLayer();
// Create a circle for the newly created visit (always confirmed)
const circle = L.circle([latitude, longitude], {
color: '#4A90E2', // Border color for confirmed visits
fillColor: '#4A90E2', // Fill color for confirmed visits
fillOpacity: 0.5,
radius: 110, // Confirmed visit size
weight: 2,
interactive: true,
bubblingMouseEvents: false,
pane: 'confirmedVisitsPane'
});
// Ensure confirmed visits layer is added to map since we create confirmed visits
if (confirmedVisitsLayer && !map.hasLayer(confirmedVisitsLayer)) {
console.log('Adding confirmed visits layer to map');
map.addLayer(confirmedVisitsLayer);
// Add the circle to the confirmed visits layer
visitsManager.confirmedVisitCircles.addLayer(circle);
console.log('✅ Added newly created confirmed visit circle to layer');
console.log('Confirmed visits layer info:', {
layerCount: visitsManager.confirmedVisitCircles.getLayers().length,
isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles)
});
// Update the layer control checkbox to reflect the layer is now active
this.updateLayerControlCheckbox('Confirmed Visits', true);
// Make sure the layer is visible on the map
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
this.map.addLayer(visitsManager.confirmedVisitCircles);
console.log('✅ Added confirmed visits layer to map');
}
// Refresh visits data to include the new visit
if (typeof visitsManager.fetchAndDisplayVisits === 'function') {
console.log('Final refresh of visits to show new visit...');
visitsManager.fetchAndDisplayVisits();
}
}
}
// Check if the layer control has the confirmed visits layer enabled
this.ensureConfirmedVisitsLayerEnabled();
}
updateLayerControlCheckbox(layerName, isEnabled) {
// Find the layer control input for the specified layer
ensureConfirmedVisitsLayerEnabled() {
// Find the layer control and check/enable the "Confirmed Visits" checkbox
const layerControlContainer = document.querySelector('.leaflet-control-layers');
if (!layerControlContainer) {
console.log('Layer control container not found');
return;
}
// Expand the layer control if it's collapsed
const layerControlExpand = layerControlContainer.querySelector('.leaflet-control-layers-toggle');
if (layerControlExpand) {
layerControlExpand.click();
}
setTimeout(() => {
const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]');
inputs.forEach(input => {
const label = input.nextElementSibling;
if (label && label.textContent.trim() === layerName) {
console.log(`Updating ${layerName} checkbox to ${isEnabled}`);
input.checked = isEnabled;
// Trigger change event to ensure proper state management
if (label && label.textContent.trim().includes('Confirmed Visits')) {
console.log('Found Confirmed Visits checkbox, current state:', input.checked);
if (!input.checked) {
console.log('Enabling Confirmed Visits layer via checkbox');
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
});
}, 100);
}
refreshVisitsLayer() {
// Don't auto-refresh after creating a visit
// The visit is already visible on the map from addCreatedVisitToMap()
// Auto-refresh would clear it because fetchAndDisplayVisits uses URL date params
// which might not include the newly created visit
console.log('Skipping auto-refresh - visit already added to map');
}
cleanup() {
if (this.map) {

View file

@ -0,0 +1,43 @@
import { Controller } from "@hotwired/stimulus"
import { showFlashMessage } from "../maps/helpers"
export default class extends Controller {
static values = {
text: String
}
static targets = ["icon", "text"]
copy() {
navigator.clipboard.writeText(this.textValue).then(() => {
this.showButtonFeedback()
showFlashMessage('notice', 'Link copied to clipboard!')
}).catch(err => {
console.error('Failed to copy text: ', err)
showFlashMessage('error', 'Failed to copy link')
})
}
showButtonFeedback() {
const button = this.element
const originalClasses = button.className
const originalHTML = button.innerHTML
// Change button appearance
button.className = 'btn btn-success btn-xs'
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Copied!
`
button.disabled = true
// Reset after 2 seconds
setTimeout(() => {
button.className = originalClasses
button.innerHTML = originalHTML
button.disabled = false
}, 2000)
}
}

View file

@ -109,7 +109,7 @@ export default class extends Controller {
const lastSeen = new Date(location.updated_at).toLocaleString();
// Create small tooltip that shows automatically
const tooltipContent = this.createTooltipContent(lastSeen);
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
const tooltip = familyMarker.bindTooltip(tooltipContent, {
permanent: true,
direction: 'top',
@ -177,7 +177,7 @@ export default class extends Controller {
// Update tooltip content
const lastSeen = new Date(locationData.updated_at).toLocaleString();
const tooltipContent = this.createTooltipContent(lastSeen);
const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery);
existingMarker.setTooltipContent(tooltipContent);
// Update popup content
@ -216,7 +216,7 @@ export default class extends Controller {
const lastSeen = new Date(location.updated_at).toLocaleString();
const tooltipContent = this.createTooltipContent(lastSeen);
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
familyMarker.bindTooltip(tooltipContent, {
permanent: true,
direction: 'top',
@ -238,8 +238,9 @@ export default class extends Controller {
this.familyMarkers[location.user_id] = familyMarker;
}
createTooltipContent(lastSeen) {
return `Last seen: ${lastSeen}`;
createTooltipContent(lastSeen, battery) {
const batteryInfo = battery !== null && battery !== undefined ? ` | Battery: ${battery}%` : '';
return `Last seen: ${lastSeen}${batteryInfo}`;
}
createPopupContent(location, lastSeen) {
@ -250,6 +251,59 @@ export default class extends Controller {
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
// Battery display with icon
const battery = location.battery;
const batteryStatus = location.battery_status;
let batteryDisplay = '';
if (battery !== null && battery !== undefined) {
// Determine battery color based on level and status
let batteryColor = '#10B981'; // green
if (batteryStatus === 'charging') {
batteryColor = battery <= 50 ? '#F59E0B' : '#10B981'; // orange if low, green if high
} else if (battery <= 20) {
batteryColor = '#EF4444'; // red
} else if (battery <= 50) {
batteryColor = '#F59E0B'; // orange
}
// Helper function to get appropriate Lucide battery icon
const getBatteryIcon = (battery, batteryStatus, batteryColor) => {
const baseAttrs = `width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${batteryColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"`;
// Charging icon
if (batteryStatus === 'charging') {
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="m11 7-3 5h4l-3 5"/><path d="M14.856 6H16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.935"/><path d="M22 14v-4"/><path d="M5.14 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.936"/></svg>`;
}
// Full battery
if (battery === 100 || batteryStatus === 'full') {
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 10v4"/><path d="M14 10v4"/><path d="M22 14v-4"/><path d="M6 10v4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
}
// Low battery (≤20%)
if (battery <= 20) {
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M22 14v-4"/><path d="M6 14v-4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
}
// Medium battery (21-50%)
if (battery <= 50) {
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 14v-4"/><path d="M22 14v-4"/><path d="M6 14v-4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
}
// High battery (>50%, default to full)
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 10v4"/><path d="M14 10v4"/><path d="M22 14v-4"/><path d="M6 10v4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
};
const batteryIcon = getBatteryIcon(battery, batteryStatus, batteryColor);
batteryDisplay = `
<p style="margin: 0 0 8px 0; font-size: 13px;">
${batteryIcon}<strong>Battery:</strong> ${battery}%${batteryStatus ? ` (${batteryStatus})` : ''}
</p>
`;
}
return `
<div class="family-member-popup" style="background-color: ${bgColor}; color: ${textColor}; padding: 12px; border-radius: 8px; min-width: 220px;">
<h3 style="margin: 0 0 12px 0; color: #10B981; font-size: 15px; font-weight: bold; display: flex; align-items: center; gap: 8px;">
@ -263,6 +317,7 @@ export default class extends Controller {
<strong>Coordinates:</strong><br/>
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
</p>
${batteryDisplay}
<p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};">
<strong>Last seen:</strong> ${lastSeen}
</p>

View file

@ -0,0 +1,45 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["panel", "toggleIcon"]
connect() {
// Restore panel state from sessionStorage on page load
const panelState = sessionStorage.getItem('mapControlsPanelState')
if (panelState === 'visible') {
this.showPanel()
}
}
toggle() {
const isHidden = this.panelTarget.classList.contains("hidden")
if (isHidden) {
this.showPanel()
sessionStorage.setItem('mapControlsPanelState', 'visible')
} else {
this.hidePanel()
sessionStorage.setItem('mapControlsPanelState', 'hidden')
}
}
showPanel() {
this.panelTarget.classList.remove("hidden")
// Update icon to chevron-up
const currentIcon = this.toggleIconTarget.querySelector('svg')
currentIcon.classList.remove('lucide-chevron-down')
currentIcon.classList.add('lucide-chevron-up')
currentIcon.innerHTML = '<path d="m18 15-6-6-6 6"/>'
}
hidePanel() {
this.panelTarget.classList.add("hidden")
// Update icon to chevron-down
const currentIcon = this.toggleIconTarget.querySelector('svg')
currentIcon.classList.remove('lucide-chevron-up')
currentIcon.classList.add('lucide-chevron-down')
currentIcon.innerHTML = '<path d="m6 9 6 6 6-6"/>'
}
}

View file

@ -44,6 +44,7 @@ import { TileMonitor } from "../maps/tile_monitor";
import BaseController from "./base_controller";
import { createAllMapLayers } from "../maps/layers";
import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils";
import { addTopRightButtons } from "../maps/map_controls";
export default class extends BaseController {
static targets = ["container"];
@ -112,7 +113,7 @@ export default class extends BaseController {
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
// Add scale control
L.control.scale({
this.scaleControl = L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km',
@ -145,7 +146,7 @@ export default class extends BaseController {
}
});
new StatsControl().addTo(this.map);
this.statsControl = new StatsControl().addTo(this.map);
// Set the maximum bounds to prevent infinite scroll
var southWest = L.latLng(-120, -210);
@ -200,6 +201,9 @@ export default class extends BaseController {
this.addSettingsButton();
}
// Add info toggle button
this.addInfoToggleButton();
// Initialize the visits manager
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme);
@ -209,22 +213,6 @@ export default class extends BaseController {
// Expose maps controller globally for family integration
window.mapsController = this;
// Initialize layers for the layer control
const controlsLayer = {
Points: this.markersLayer,
Routes: this.polylinesLayer,
Tracks: this.tracksLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer,
Photos: this.photoMarkers,
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer()
};
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Initialize tile monitor
this.tileMonitor = new TileMonitor(this.map, this.apiKey);
@ -250,11 +238,25 @@ export default class extends BaseController {
// Preload areas
fetchAndDrawAreas(this.areasLayer, this.apiKey);
// Add right panel toggle
this.addTogglePanelButton();
// Add all top-right buttons in the correct order
this.initializeTopRightButtons();
// Initialize layers for the layer control
const controlsLayer = {
Points: this.markersLayer,
Routes: this.polylinesLayer,
Tracks: this.tracksLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer,
Photos: this.photoMarkers,
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer()
};
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Add visits buttons after calendar button to position them below
this.visitsManager.addDrawerButton();
// Initialize Live Map Handler
this.initializeLiveMapHandler();
@ -800,13 +802,19 @@ export default class extends BaseController {
// Define the custom control
const SettingsControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'map-settings-button');
button.innerHTML = '⚙️'; // Gear icon
const button = L.DomUtil.create('button', 'map-settings-button tooltip tooltip-right');
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog-icon lucide-cog"><path d="M11 10.27 7 3.34"/><path d="m11 13.73-4 6.93"/><path d="M12 22v-2"/><path d="M12 2v2"/><path d="M14 12h8"/><path d="m17 20.66-1-1.73"/><path d="m17 3.34-1 1.73"/><path d="M2 12h2"/><path d="m20.66 17-1.73-1"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m3.34 7 1.73 1"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="12" r="8"/></svg>'; // Gear icon
button.setAttribute('data-tip', 'Settings');
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '32px';
button.style.height = '32px';
button.style.width = '30px';
button.style.height = '30px';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';
button.style.padding = '0';
button.style.borderRadius = '4px';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
@ -825,6 +833,104 @@ export default class extends BaseController {
this.settingsButtonAdded = true;
}
addInfoToggleButton() {
// Store reference to the controller instance for use in the control
const controller = this;
const InfoToggleControl = L.Control.extend({
options: {
position: 'bottomleft'
},
onAdd: function(map) {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
const button = L.DomUtil.create('button', 'map-info-toggle-button tooltip tooltip-right', container);
button.setAttribute('data-tip', 'Toggle footer visibility');
// Lucide info icon
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
`;
// Style the button with theme-aware styling
applyThemeToButton(button, controller.userTheme);
button.style.width = '34px';
button.style.height = '34px';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';
button.style.cursor = 'pointer';
button.style.border = 'none';
button.style.borderRadius = '4px';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(container);
// Toggle footer visibility on button click
L.DomEvent.on(button, 'click', () => {
controller.toggleFooterVisibility();
});
return container;
}
});
// Add the control to the map
this.map.addControl(new InfoToggleControl());
}
toggleFooterVisibility() {
// Toggle the page footer
const footer = document.getElementById('map-footer');
if (!footer) return;
const isCurrentlyHidden = footer.classList.contains('hidden');
// Toggle Tailwind's hidden class
footer.classList.toggle('hidden');
// Adjust bottom controls position based on footer visibility
if (isCurrentlyHidden) {
// Footer is being shown - move controls up
setTimeout(() => {
const footerHeight = footer.offsetHeight;
// Add extra 20px margin above footer
this.adjustBottomControls(footerHeight + 20);
}, 10); // Small delay to ensure footer is rendered
} else {
// Footer is being hidden - reset controls position
this.adjustBottomControls(10); // Back to default padding
}
// Add click event to close footer when clicking on it (only add once)
if (!footer.dataset.clickHandlerAdded) {
footer.addEventListener('click', (e) => {
// Only close if clicking the footer itself, not its contents
if (e.target === footer) {
footer.classList.add('hidden');
this.adjustBottomControls(10); // Reset controls position
}
});
footer.dataset.clickHandlerAdded = 'true';
}
}
adjustBottomControls(paddingBottom) {
// Adjust all bottom Leaflet controls
const bottomLeftControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-left');
const bottomRightControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-right');
if (bottomLeftControls) {
bottomLeftControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important');
}
if (bottomRightControls) {
bottomRightControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important');
}
}
toggleSettingsMenu() {
// If the settings panel already exists, just show/hide it
if (this.settingsPanel) {
@ -1161,48 +1267,35 @@ export default class extends BaseController {
}
}
initializeTopRightButtons() {
// Add all top-right buttons in the correct order:
// 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer
// Note: Layer control is added separately and appears at the top
addTogglePanelButton() {
// Store reference to the controller instance for use in the control
const controller = this;
this.topRightControls = addTopRightButtons(
this.map,
{
onSelectArea: () => this.visitsManager.toggleSelectionMode(),
// onAddVisit is intentionally null - the add_visit_controller will attach its handler
onAddVisit: null,
onToggleCalendar: () => this.toggleRightPanel(),
onToggleDrawer: () => this.visitsManager.toggleDrawer()
},
this.userTheme
);
const TogglePanelControl = L.Control.extend({
onAdd: function(map) {
const button = L.DomUtil.create('button', 'toggle-panel-button');
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4" />
<path d="M16 2v4" />
<path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8" />
<path d="M3 10h18" />
<path d="m16 20 2 2 4-4" />
</svg>
`;
// Style the button with theme-aware styling
applyThemeToButton(button, controller.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Toggle panel on button click
L.DomEvent.on(button, 'click', () => {
controller.toggleRightPanel();
});
return button;
// Add CSS for selection button active state (needed by visits manager)
if (!document.getElementById('selection-tool-active-style')) {
const style = document.createElement('style');
style.id = 'selection-tool-active-style';
style.textContent = `
#selection-tool-button.active {
border: 2px dashed #3388ff !important;
box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important;
}
`;
document.head.appendChild(style);
}
});
// Add the control to the map
this.map.addControl(new TogglePanelControl({ position: 'topright' }));
}
shouldShowTracksSelector() {

View file

@ -99,13 +99,6 @@ export default class extends BaseController {
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
}
console.log('🎯 Public sharing: using manual hexagon loading');
console.log('🔍 Debug values:');
console.log(' dataBounds:', dataBounds);
console.log(' point_count:', dataBounds?.point_count);
console.log(' hexagonsAvailableValue:', this.hexagonsAvailableValue);
console.log(' hexagonsAvailableValue type:', typeof this.hexagonsAvailableValue);
// Load hexagons only if they are pre-calculated and data exists
if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) {
await this.loadStaticHexagons();
@ -140,7 +133,7 @@ export default class extends BaseController {
// Ensure loading overlay is visible and disable map interaction
const loadingElement = document.getElementById('map-loading');
console.log('🔍 Loading element found:', !!loadingElement);
if (loadingElement) {
loadingElement.style.display = 'flex';
loadingElement.style.visibility = 'visible';

View file

@ -94,8 +94,8 @@ export default class extends Controller {
// Show temporary success feedback
const button = this.sharingLinkTarget.nextElementSibling
const originalText = button.innerHTML
button.innerHTML = "✅ Copied!"
button.classList.add("btn-success")
button.innerHTML = "✅ Link Copied!"
button.classList.add("btn-outline btn-success")
setTimeout(() => {
button.innerHTML = originalText

View file

@ -125,14 +125,16 @@ export function showFlashMessage(type, message) {
if (!flashContainer) {
flashContainer = document.createElement('div');
flashContainer.id = 'flash-messages';
flashContainer.className = 'fixed top-5 right-5 flex flex-col-reverse gap-2 z-50';
// Use z-[9999] to ensure flash messages appear above navbar (z-50)
flashContainer.className = 'fixed top-20 right-5 flex flex-col-reverse gap-2';
flashContainer.style.zIndex = '9999';
document.body.appendChild(flashContainer);
}
// Create the flash message div
const flashDiv = document.createElement('div');
flashDiv.setAttribute('data-controller', 'removals');
flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg z-50`;
flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg shadow-lg`;
// Create the message div
const messageDiv = document.createElement('div');

View file

@ -24,7 +24,7 @@ class LocationSearch {
const SearchToggleControl = L.Control.extend({
onAdd: function(map) {
const button = L.DomUtil.create('button', 'location-search-toggle');
button.innerHTML = '🔍';
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>';
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '48px';
@ -33,6 +33,9 @@ class LocationSearch {
button.style.padding = '0';
button.style.fontSize = '18px';
button.style.marginTop = '10px'; // Space below settings button
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';
button.title = 'Search locations';
button.id = 'location-search-toggle';
return button;
@ -174,8 +177,6 @@ class LocationSearch {
container.addEventListener('DOMMouseScroll', (e) => {
e.stopPropagation();
}, { passive: false });
console.log('LocationSearch: Added scroll prevention to container', container.id || 'search-bar');
}
});
}

View file

@ -0,0 +1,193 @@
// Map control buttons and utilities
// This file contains all button controls that are positioned on the top-right corner of the map
import L from "leaflet";
import { applyThemeToButton } from "./theme_utils";
/**
* Creates a standardized button element for map controls
* @param {String} className - CSS class name for the button
* @param {String} svgIcon - SVG icon HTML
* @param {String} title - Button title/tooltip
* @param {String} userTheme - User's theme preference ('dark' or 'light')
* @param {Function} onClickCallback - Callback function to execute when button is clicked
* @returns {HTMLElement} Button element with tooltip
*/
function createStandardButton(className, svgIcon, title, userTheme, onClickCallback) {
const button = L.DomUtil.create('button', `${className} tooltip tooltip-left`);
button.innerHTML = svgIcon;
button.setAttribute('data-tip', title);
// Apply standard button styling
applyThemeToButton(button, userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';
button.style.fontSize = '18px';
button.style.transition = 'all 0.2s ease';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Attach click handler if provided
// Note: Some buttons (like Add Visit) have their handlers attached separately
if (onClickCallback && typeof onClickCallback === 'function') {
L.DomEvent.on(button, 'click', () => {
onClickCallback(button);
});
}
return button;
}
/**
* Creates a "Toggle Panel" button control for the map
* @param {Function} onClickCallback - Callback function to execute when button is clicked
* @param {String} userTheme - User's theme preference ('dark' or 'light')
* @returns {L.Control} Leaflet control instance
*/
export function createTogglePanelControl(onClickCallback, userTheme = 'dark') {
const TogglePanelControl = L.Control.extend({
onAdd: function(map) {
const svgIcon = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v4" />
<path d="M16 2v4" />
<path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8" />
<path d="M3 10h18" />
<path d="m16 20 2 2 4-4" />
</svg>
`;
return createStandardButton('toggle-panel-button', svgIcon, 'Toggle Panel', userTheme, onClickCallback);
}
});
return TogglePanelControl;
}
/**
* Creates a "Visits Drawer" button control for the map
* @param {Function} onClickCallback - Callback function to execute when button is clicked
* @param {String} userTheme - User's theme preference ('dark' or 'light')
* @returns {L.Control} Leaflet control instance
*/
export function createVisitsDrawerControl(onClickCallback, userTheme = 'dark') {
const DrawerControl = L.Control.extend({
onAdd: function(map) {
const svgIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
return createStandardButton('leaflet-control-button drawer-button', svgIcon, 'Toggle Visits Drawer', userTheme, onClickCallback);
}
});
return DrawerControl;
}
/**
* Creates an "Area Selection" button control for the map
* @param {Function} onClickCallback - Callback function to execute when button is clicked
* @param {String} userTheme - User's theme preference ('dark' or 'light')
* @returns {L.Control} Leaflet control instance
*/
export function createAreaSelectionControl(onClickCallback, userTheme = 'dark') {
const SelectionControl = L.Control.extend({
onAdd: function(map) {
const svgIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dashed-mouse-pointer-icon lucide-square-dashed-mouse-pointer"><path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h2"/><path d="M14 3h1"/><path d="M3 9v1"/><path d="M21 9v2"/><path d="M3 14v1"/></svg>';
const button = createStandardButton('leaflet-bar leaflet-control leaflet-control-custom', svgIcon, 'Select Area', userTheme, onClickCallback);
button.id = 'selection-tool-button';
return button;
}
});
return SelectionControl;
}
/**
* Creates an "Add Visit" button control for the map
* @param {Function} onClickCallback - Callback function to execute when button is clicked
* @param {String} userTheme - User's theme preference ('dark' or 'light')
* @returns {L.Control} Leaflet control instance
*/
export function createAddVisitControl(onClickCallback, userTheme = 'dark') {
const AddVisitControl = L.Control.extend({
onAdd: function(map) {
const svgIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-check-icon lucide-map-pin-check"><path d="M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728"/><circle cx="12" cy="10" r="3"/><path d="m16 18 2 2 4-4"/></svg>';
return createStandardButton('leaflet-control-button add-visit-button', svgIcon, 'Add a visit', userTheme, onClickCallback);
}
});
return AddVisitControl;
}
/**
* Adds all top-right corner buttons to the map in the correct order
* Order: 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer
* Note: Layer control is added separately by Leaflet and appears at the top
*
* @param {Object} map - Leaflet map instance
* @param {Object} callbacks - Object containing callback functions for each button
* @param {Function} callbacks.onSelectArea - Callback for select area button
* @param {Function} callbacks.onAddVisit - Callback for add visit button
* @param {Function} callbacks.onToggleCalendar - Callback for toggle calendar/panel button
* @param {Function} callbacks.onToggleDrawer - Callback for toggle drawer button
* @param {String} userTheme - User's theme preference ('dark' or 'light')
* @returns {Object} Object containing references to all created controls
*/
export function addTopRightButtons(map, callbacks, userTheme = 'dark') {
const controls = {};
// 1. Select Area button
if (callbacks.onSelectArea) {
const SelectionControl = createAreaSelectionControl(callbacks.onSelectArea, userTheme);
controls.selectionControl = new SelectionControl({ position: 'topright' });
map.addControl(controls.selectionControl);
}
// 2. Add Visit button
// Note: Button is always created, callback is optional (add_visit_controller attaches its own handler)
const AddVisitControl = createAddVisitControl(callbacks.onAddVisit, userTheme);
controls.addVisitControl = new AddVisitControl({ position: 'topright' });
map.addControl(controls.addVisitControl);
// 3. Open Calendar (Toggle Panel) button
if (callbacks.onToggleCalendar) {
const TogglePanelControl = createTogglePanelControl(callbacks.onToggleCalendar, userTheme);
controls.togglePanelControl = new TogglePanelControl({ position: 'topright' });
map.addControl(controls.togglePanelControl);
}
// 4. Open Drawer button
if (callbacks.onToggleDrawer) {
const DrawerControl = createVisitsDrawerControl(callbacks.onToggleDrawer, userTheme);
controls.drawerControl = new DrawerControl({ position: 'topright' });
map.addControl(controls.drawerControl);
}
return controls;
}
/**
* Updates the Add Visit button to show active state
* @param {HTMLElement} button - The button element to update
*/
export function setAddVisitButtonActive(button) {
if (!button) return;
button.style.backgroundColor = '#dc3545';
button.style.color = 'white';
button.innerHTML = '✕';
}
/**
* Updates the Add Visit button to show inactive/default state
* @param {HTMLElement} button - The button element to update
* @param {String} userTheme - User's theme preference ('dark' or 'light')
*/
export function setAddVisitButtonInactive(button, userTheme = 'dark') {
if (!button) return;
applyThemeToButton(button, userTheme);
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-check-icon lucide-map-pin-check"><path d="M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728"/><circle cx="12" cy="10" r="3"/><path d="m16 18 2 2 4-4"/></svg>';
}

View file

@ -1,6 +1,5 @@
import L from "leaflet";
import { showFlashMessage } from "./helpers";
import { applyThemeToButton } from "./theme_utils";
/**
* Manages visits functionality including displaying, fetching, and interacting with visits
@ -65,74 +64,14 @@ export class VisitsManager {
}
/**
* Adds a button to toggle the visits drawer
* Note: Drawer and selection buttons are now added centrally via addTopRightButtons()
* in maps_controller.js to ensure correct button ordering.
*
* The methods below are kept for backwards compatibility but are no longer called
* during initialization. Button callbacks are wired directly in maps_controller.js:
* - onSelectArea -> this.toggleSelectionMode()
* - onToggleDrawer -> this.toggleDrawer()
*/
addDrawerButton() {
const DrawerControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button');
button.innerHTML = '⬅️'; // Left arrow icon
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
L.DomEvent.disableClickPropagation(button);
L.DomEvent.on(button, 'click', () => {
this.toggleDrawer();
});
return button;
}
});
this.map.addControl(new DrawerControl({ position: 'topright' }));
// Add the selection tool button
this.addSelectionButton();
}
/**
* Adds a button to enable/disable the area selection tool
*/
addSelectionButton() {
const SelectionControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom');
button.innerHTML = '⚓️';
button.title = 'Select Area';
button.id = 'selection-tool-button';
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
button.onclick = () => this.toggleSelectionMode();
return button;
}
});
new SelectionControl({ position: 'topright' }).addTo(this.map);
// Add CSS for selection button active state
const style = document.createElement('style');
style.textContent = `
#selection-tool-button.active {
border: 2px dashed #3388ff !important;
box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important;
}
`;
document.head.appendChild(style);
}
/**
* Toggles the area selection mode
@ -482,7 +421,7 @@ export class VisitsManager {
const drawerButton = document.querySelector('.drawer-button');
if (drawerButton) {
drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️';
drawerButton.innerHTML = this.drawerOpen ? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-close-icon lucide-panel-right-close"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
}
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button');

View file

@ -5,7 +5,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable
has_many :points, dependent: :destroy, counter_cache: true
has_many :points, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :stats, dependent: :destroy
has_many :exports, dependent: :destroy

View file

@ -41,7 +41,9 @@ class Families::Locations
latitude: point.lat,
longitude: point.lon,
timestamp: point.timestamp.to_i,
updated_at: Time.zone.at(point.timestamp.to_i)
updated_at: Time.zone.at(point.timestamp.to_i),
battery: point.battery,
battery_status: point.battery_status
}
end
end

View file

@ -101,7 +101,7 @@
data-location-sharing-toggle-expires-at-value="<%= member.family_sharing_expires_at&.iso8601 %>"
class="flex items-center gap-3">
<span class="text-sm opacity-60">Location:</span>
<span class="text-sm opacity-60">Location sharing:</span>
<!-- Toggle Switch -->
<input type="checkbox"
@ -136,7 +136,7 @@
<% else %>
<!-- Other member's status - read-only indicator -->
<div class="flex items-center gap-2">
<span class="text-sm opacity-60">Location:</span>
<span class="text-sm opacity-60">Location sharing:</span>
<% if member.family_sharing_enabled? %>
<div class="w-3 h-3 bg-success rounded-full animate-pulse"></div>
<span class="text-xs text-success font-medium">
@ -174,7 +174,7 @@
<div class="space-y-3 mb-4">
<% @pending_invitations.each do |invitation| %>
<div class="flex items-center justify-between p-3 bg-base-100 rounded-lg">
<div>
<div class="flex-grow">
<div class="font-medium text-base-content"><%= invitation.email %></div>
<div class="text-sm text-base-content opacity-60">
<%= t('families.show.invited_on', default: 'Invited') %>
@ -184,14 +184,26 @@
<%= t('families.show.expires_on', default: 'Expires') %>
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
</div>
<div class="mt-2">
<button data-controller="clipboard"
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
data-action="click->clipboard#copy"
class="btn btn-outline btn-info btn-xs"
title="Copy invitation link">
<%= icon 'copy', class: "inline-block w-3" %>
Copy Invitation Link
</button>
</div>
</div>
<% if policy(@family).manage_invitations? %>
<div class="ml-3">
<%= link_to family_invitation_path(invitation.token),
method: :delete,
data: { confirm: 'Are you sure you want to cancel this invitation?', turbo_confirm: 'Are you sure you want to cancel this invitation?' },
class: "btn btn-outline btn-warning btn-sm opacity-70" do %>
class: "btn btn-outline btn-warning btn-sm" do %>
Cancel
<% end %>
</div>
<% end %>
</div>
<% end %>
@ -227,9 +239,7 @@
<!-- Family at capacity message -->
<div class="border-t pt-4">
<div class="alert alert-warning">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<%= icon 'triangle-alert', class: "inline-block w-6 mr-2 flex-shrink-0" %>
<div>
<h3 class="text-sm font-medium">
Family at Capacity

View file

@ -28,6 +28,17 @@
</div>
<div class="flex space-x-2">
<button type="button"
data-controller="clipboard"
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
data-action="click->clipboard#copy"
class="btn btn-ghost btn-sm text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<%= t('family_invitations.index.copy_link', default: 'Copy Link') %>
</button>
<%= link_to public_invitation_path(invitation.token),
class: "btn btn-ghost btn-sm text-info" do %>
<%= t('family_invitations.index.view_invitation', default: 'View') %>

View file

@ -24,26 +24,29 @@
<body class='h-screen overflow-hidden relative'>
<!-- Fixed Navbar -->
<div class='fixed w-full z-50 bg-base-100 shadow-md h-16'>
<div class='fixed w-full z-40 bg-base-100 shadow-md h-16'>
<div class='container mx-auto h-full w-full flex items-center'>
<%= render 'shared/navbar' %>
</div>
</div>
<!-- Flash Messages - Fixed below navbar -->
<div class='fixed top-16 w-full z-40 h-8'>
<div class='fixed top-16 w-full z-50'>
<div class='container mx-auto px-5'>
<%= render 'shared/flash' %>
</div>
</div>
<!-- Date Navigation - Fixed below flash messages -->
<!-- Full Screen Map Container -->
<div class='absolute top-40 left-0 right-0 bottom-0 w-full z-10 overflow-auto'>
<div class='absolute top-16 left-0 right-0 w-full z-20' style='height: calc(100vh - 4rem);'>
<%= yield %>
</div>
<!-- Fixed Footer (hidden by default) -->
<div id='map-footer' class='fixed bottom-0 left-0 right-0 z-30 hidden'>
<%= render 'shared/legal_footer' %>
</div>
<%= render 'map/onboarding_modal' %>
</body>
</html>

View file

@ -1,11 +1,28 @@
<% content_for :title, 'Map' %>
<div class="flex flex-col lg:flex-row lg:space-x-4 w-full">
<div class='w-full'>
<div class="flex flex-col space-y-4 mb-4 w-full" style="margin-top: 5rem;">
<!-- Floating Date Navigation Controls -->
<div class="fixed top-20 left-0 right-0 flex justify-center" style="z-index: 9999; margin-left: 80px; margin-right: 80px;">
<div style="width: 1500px; max-width: 100%;" data-controller="map-controls">
<!-- Mobile: Compact Toggle Button -->
<div class="lg:hidden justify-center flex">
<button
type="button"
data-action="click->map-controls#toggle"
class="btn btn-primary w-96 shadow-lg">
<span data-map-controls-target="toggleIcon">
<%= icon 'chevron-down' %>
</span>
<span class="ml-2"><%= human_date(@start_at) %></span>
</button>
</div>
<!-- Expandable Panel (hidden on mobile by default, always visible on desktop) -->
<div
data-map-controls-target="panel"
class="hidden lg:!block bg-base-100 bg-opacity-95 rounded-lg shadow-lg p-4 mt-2 lg:mt-0 scale-80">
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
@ -14,19 +31,13 @@
</span>
</div>
</div>
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="flex flex-col space-y-2">
<%= f.label :start_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time">
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary w-full", value: @start_at %>
</div>
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time">
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary w-full", value: @end_at %>
</div>
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="flex flex-col space-y-2">
<%= f.label :end_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
</div>
</div>
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
@ -35,39 +46,43 @@
</span>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2">
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
<%= f.submit "Search", class: "btn btn-primary hover:btn-info w-full" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
<div class="w-full lg:w-1/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Today",
map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
class: "btn border border-base-300 hover:btn-ghost" %>
class: "btn border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
<div class="w-full lg:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" %>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Full Screen Map -->
<div
id='map'
class="w-full z-0"
class="absolute inset-0 w-full h-full z-0"
data-controller="maps points add-visit family-members"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= current_user.safe_settings.settings.to_json.html_safe %>'
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
data-user_theme="<%= current_user&.theme || 'dark' %>"
data-coordinates='<%= @coordinates.to_json.html_safe %>'
data-tracks='<%= @tracks.to_json.html_safe %>'
@ -77,12 +92,9 @@
data-features='<%= @features.to_json.html_safe %>'
data-family-members-features-value='<%= @features.to_json.html_safe %>'
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen z-0">
<div data-maps-target="container" class="w-full h-full">
<div id="fog" class="fog"></div>
</div>
</div>
</div>
</div>
</div>
<%= render 'map/settings_modals' %>

View file

@ -1,4 +1,4 @@
<div class="navbar bg-base-100">
<div class="navbar bg-base-100 h-16">
<div class="navbar-start">
<div class="dropdown">
<label tabindex="0" class="btn btn-ghost lg:hidden">

View file

@ -525,7 +525,7 @@ test.describe('Map Functionality', () => {
// Verify it's actually a clickable button with gear icon
const buttonText = await settingsButton.textContent();
expect(buttonText).toBe('⚙️');
expect(buttonText).toBe('<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog-icon lucide-cog"><path d="M11 10.27 7 3.34"/><path d="m11 13.73-4 6.93"/><path d="M12 22v-2"/><path d="M12 2v2"/><path d="M14 12h8"/><path d="m17 20.66-1-1.73"/><path d="m17 3.34-1 1.73"/><path d="M2 12h2"/><path d="m20.66 17-1.73-1"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m3.34 7 1.73 1"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="12" r="8"/></svg>');
// Test opening settings panel
await settingsButton.click();