mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
* fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements --------- Co-authored-by: Robin Tuszik <mail@robin.gg>
729 lines
22 KiB
JavaScript
729 lines
22 KiB
JavaScript
/**
|
|
* Search Manager
|
|
* Manages location search functionality for Maps V2
|
|
*/
|
|
|
|
import { LocationSearchService } from '../services/location_search_service.js'
|
|
|
|
export class SearchManager {
|
|
constructor(map, apiKey) {
|
|
this.map = map
|
|
this.service = new LocationSearchService(apiKey)
|
|
this.searchInput = null
|
|
this.resultsContainer = null
|
|
this.debounceTimer = null
|
|
this.debounceDelay = 300 // ms
|
|
this.currentMarker = null
|
|
this.currentVisitsData = null // Store visits data for click handling
|
|
}
|
|
|
|
/**
|
|
* Initialize search manager with DOM elements
|
|
* @param {HTMLInputElement} searchInput - Search input element
|
|
* @param {HTMLElement} resultsContainer - Container for search results
|
|
*/
|
|
initialize(searchInput, resultsContainer) {
|
|
this.searchInput = searchInput
|
|
this.resultsContainer = resultsContainer
|
|
|
|
if (!this.searchInput || !this.resultsContainer) {
|
|
console.warn('SearchManager: Missing required DOM elements')
|
|
return
|
|
}
|
|
|
|
this.attachEventListeners()
|
|
}
|
|
|
|
/**
|
|
* Attach event listeners to search input
|
|
*/
|
|
attachEventListeners() {
|
|
// Input event with debouncing
|
|
this.searchInput.addEventListener('input', (e) => {
|
|
this.handleSearchInput(e.target.value)
|
|
})
|
|
|
|
// Prevent results from hiding when clicking inside results container
|
|
this.resultsContainer.addEventListener('mousedown', (e) => {
|
|
e.preventDefault() // Prevent blur event on search input
|
|
})
|
|
|
|
// Clear results when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!this.searchInput.contains(e.target) && !this.resultsContainer.contains(e.target)) {
|
|
// Delay to allow animations to complete
|
|
setTimeout(() => {
|
|
this.clearResults()
|
|
}, 100)
|
|
}
|
|
})
|
|
|
|
// Handle Enter key
|
|
this.searchInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
const firstResult = this.resultsContainer.querySelector('.search-result-item')
|
|
if (firstResult) {
|
|
firstResult.click()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Handle search input with debouncing
|
|
* @param {string} query - Search query
|
|
*/
|
|
handleSearchInput(query) {
|
|
clearTimeout(this.debounceTimer)
|
|
|
|
if (!query || query.length < 2) {
|
|
this.clearResults()
|
|
return
|
|
}
|
|
|
|
this.debounceTimer = setTimeout(async () => {
|
|
try {
|
|
this.showLoading()
|
|
const suggestions = await this.service.fetchSuggestions(query)
|
|
this.displayResults(suggestions)
|
|
} catch (error) {
|
|
this.showError('Failed to fetch suggestions')
|
|
console.error('SearchManager: Search error:', error)
|
|
}
|
|
}, this.debounceDelay)
|
|
}
|
|
|
|
/**
|
|
* Display search results
|
|
* @param {Array} suggestions - Array of location suggestions
|
|
*/
|
|
displayResults(suggestions) {
|
|
this.clearResults()
|
|
|
|
if (!suggestions || suggestions.length === 0) {
|
|
this.showNoResults()
|
|
return
|
|
}
|
|
|
|
suggestions.forEach(suggestion => {
|
|
const resultItem = this.createResultItem(suggestion)
|
|
this.resultsContainer.appendChild(resultItem)
|
|
})
|
|
|
|
this.resultsContainer.classList.remove('hidden')
|
|
}
|
|
|
|
/**
|
|
* Create a result item element
|
|
* @param {Object} suggestion - Location suggestion
|
|
* @returns {HTMLElement} Result item element
|
|
*/
|
|
createResultItem(suggestion) {
|
|
const item = document.createElement('div')
|
|
item.className = 'search-result-item p-3 hover:bg-base-200 cursor-pointer rounded-lg transition-colors'
|
|
item.setAttribute('data-lat', suggestion.lat)
|
|
item.setAttribute('data-lon', suggestion.lon)
|
|
|
|
const name = document.createElement('div')
|
|
name.className = 'font-medium text-sm'
|
|
name.textContent = suggestion.name || 'Unknown location'
|
|
|
|
if (suggestion.address) {
|
|
const address = document.createElement('div')
|
|
address.className = 'text-xs text-base-content/60 mt-1'
|
|
address.textContent = suggestion.address
|
|
item.appendChild(name)
|
|
item.appendChild(address)
|
|
} else {
|
|
item.appendChild(name)
|
|
}
|
|
|
|
item.addEventListener('click', () => {
|
|
this.handleResultClick(suggestion)
|
|
})
|
|
|
|
return item
|
|
}
|
|
|
|
/**
|
|
* Handle click on search result
|
|
* @param {Object} location - Selected location
|
|
*/
|
|
async handleResultClick(location) {
|
|
// Fly to location on map
|
|
this.map.flyTo({
|
|
center: [location.lon, location.lat],
|
|
zoom: 15,
|
|
duration: 1000
|
|
})
|
|
|
|
// Add temporary marker
|
|
this.addSearchMarker(location.lon, location.lat)
|
|
|
|
// Update search input
|
|
if (this.searchInput) {
|
|
this.searchInput.value = location.name || ''
|
|
}
|
|
|
|
// Show loading state in results
|
|
this.showVisitsLoading(location.name)
|
|
|
|
// Search for visits at this location
|
|
try {
|
|
const visitsData = await this.service.searchVisits({
|
|
lat: location.lat,
|
|
lon: location.lon,
|
|
name: location.name,
|
|
address: location.address || ''
|
|
})
|
|
|
|
// Display visits results
|
|
this.displayVisitsResults(visitsData, location)
|
|
} catch (error) {
|
|
console.error('SearchManager: Failed to fetch visits:', error)
|
|
this.showError('Failed to load visits for this location')
|
|
}
|
|
|
|
// Dispatch custom event for other components
|
|
this.dispatchSearchEvent(location)
|
|
}
|
|
|
|
/**
|
|
* Add a temporary marker at search location
|
|
* @param {number} lon - Longitude
|
|
* @param {number} lat - Latitude
|
|
*/
|
|
addSearchMarker(lon, lat) {
|
|
// Remove existing marker
|
|
if (this.currentMarker) {
|
|
this.currentMarker.remove()
|
|
}
|
|
|
|
// Create marker element
|
|
const el = document.createElement('div')
|
|
el.className = 'search-marker'
|
|
el.style.cssText = `
|
|
width: 30px;
|
|
height: 30px;
|
|
background-color: #3b82f6;
|
|
border: 3px solid white;
|
|
border-radius: 50%;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
cursor: pointer;
|
|
`
|
|
|
|
// Add marker to map (MapLibre GL style)
|
|
if (this.map.getSource) {
|
|
// Use MapLibre marker
|
|
const maplibregl = window.maplibregl
|
|
if (maplibregl) {
|
|
this.currentMarker = new maplibregl.Marker({ element: el })
|
|
.setLngLat([lon, lat])
|
|
.addTo(this.map)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dispatch custom search event
|
|
* @param {Object} location - Selected location
|
|
*/
|
|
dispatchSearchEvent(location) {
|
|
const event = new CustomEvent('location-search:selected', {
|
|
detail: { location },
|
|
bubbles: true
|
|
})
|
|
document.dispatchEvent(event)
|
|
}
|
|
|
|
/**
|
|
* Show loading indicator
|
|
*/
|
|
showLoading() {
|
|
this.clearResults()
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="p-3 text-sm text-base-content/60 flex items-center gap-2">
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
Searching...
|
|
</div>
|
|
`
|
|
this.resultsContainer.classList.remove('hidden')
|
|
}
|
|
|
|
/**
|
|
* Show no results message
|
|
*/
|
|
showNoResults() {
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="p-3 text-sm text-base-content/60">
|
|
No locations found
|
|
</div>
|
|
`
|
|
this.resultsContainer.classList.remove('hidden')
|
|
}
|
|
|
|
/**
|
|
* Show error message
|
|
* @param {string} message - Error message
|
|
*/
|
|
showError(message) {
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="p-3 text-sm text-error">
|
|
${message}
|
|
</div>
|
|
`
|
|
this.resultsContainer.classList.remove('hidden')
|
|
}
|
|
|
|
/**
|
|
* Show loading state while fetching visits
|
|
* @param {string} locationName - Name of the location being searched
|
|
*/
|
|
showVisitsLoading(locationName) {
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="p-4 text-sm text-base-content/60">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
<span class="font-medium">Searching for visits...</span>
|
|
</div>
|
|
<div class="text-xs">${this.escapeHtml(locationName)}</div>
|
|
</div>
|
|
`
|
|
this.resultsContainer.classList.remove('hidden')
|
|
}
|
|
|
|
/**
|
|
* Display visits results
|
|
* @param {Object} visitsData - Visits data from API
|
|
* @param {Object} location - Selected location
|
|
*/
|
|
displayVisitsResults(visitsData, location) {
|
|
// Store visits data for click handling
|
|
this.currentVisitsData = visitsData
|
|
|
|
if (!visitsData.locations || visitsData.locations.length === 0) {
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="p-6 text-center text-base-content/60">
|
|
<div class="text-3xl mb-3">📍</div>
|
|
<div class="text-sm font-medium">No visits found</div>
|
|
<div class="text-xs mt-1">No visits found for "${this.escapeHtml(location.name)}"</div>
|
|
</div>
|
|
`
|
|
this.resultsContainer.classList.remove('hidden')
|
|
return
|
|
}
|
|
|
|
// Display visits grouped by location
|
|
let html = `
|
|
<div class="p-4 border-b bg-base-200">
|
|
<div class="text-sm font-medium">Found ${visitsData.total_locations} location(s)</div>
|
|
<div class="text-xs text-base-content/60 mt-1">for "${this.escapeHtml(location.name)}"</div>
|
|
</div>
|
|
`
|
|
|
|
visitsData.locations.forEach((loc, index) => {
|
|
html += this.buildLocationVisitsHtml(loc, index)
|
|
})
|
|
|
|
this.resultsContainer.innerHTML = html
|
|
this.resultsContainer.classList.remove('hidden')
|
|
|
|
// Attach event listeners to year toggles and visit items
|
|
this.attachYearToggleListeners()
|
|
}
|
|
|
|
/**
|
|
* Build HTML for a location with its visits
|
|
* @param {Object} location - Location with visits
|
|
* @param {number} index - Location index
|
|
* @returns {string} HTML string
|
|
*/
|
|
buildLocationVisitsHtml(location, index) {
|
|
const visits = location.visits || []
|
|
if (visits.length === 0) return ''
|
|
|
|
// Handle case where visits are sorted newest first
|
|
const sortedVisits = [...visits].sort((a, b) => new Date(a.date) - new Date(b.date))
|
|
const firstVisit = sortedVisits[0]
|
|
const lastVisit = sortedVisits[sortedVisits.length - 1]
|
|
const visitsByYear = this.groupVisitsByYear(visits)
|
|
|
|
// Use place_name, address, or coordinates as fallback
|
|
const displayName = location.place_name || location.address ||
|
|
`Location (${location.coordinates?.[0]?.toFixed(4)}, ${location.coordinates?.[1]?.toFixed(4)})`
|
|
|
|
return `
|
|
<div class="location-result border-b" data-location-index="${index}">
|
|
<div class="p-4">
|
|
<div class="font-medium text-sm">${this.escapeHtml(displayName)}</div>
|
|
${location.address && location.place_name !== location.address ?
|
|
`<div class="text-xs text-base-content/60 mt-1">${this.escapeHtml(location.address)}</div>` : ''}
|
|
<div class="flex justify-between items-center mt-3">
|
|
<div class="text-xs text-primary">${location.total_visits} visit(s)</div>
|
|
<div class="text-xs text-base-content/60">
|
|
first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Years Section -->
|
|
<div class="border-t bg-base-200">
|
|
${Object.entries(visitsByYear).map(([year, yearVisits]) => `
|
|
<div class="year-section">
|
|
<div class="year-toggle p-3 hover:bg-base-300 cursor-pointer border-b flex justify-between items-center"
|
|
data-location-index="${index}" data-year="${year}">
|
|
<span class="text-sm font-medium">${year}</span>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs text-primary">${yearVisits.length} visits</span>
|
|
<span class="year-arrow text-base-content/40 transition-transform">▶</span>
|
|
</div>
|
|
</div>
|
|
<div class="year-visits hidden" id="year-${index}-${year}">
|
|
${yearVisits.map((visit) => `
|
|
<div class="visit-item text-xs py-2 px-4 border-b hover:bg-base-300 cursor-pointer"
|
|
data-location-index="${index}" data-visit-index="${visits.indexOf(visit)}">
|
|
<div class="flex justify-between items-start">
|
|
<div>📍 ${this.formatDateTime(visit.date)}</div>
|
|
<div class="text-xs text-base-content/60">${visit.duration_estimate || 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Group visits by year
|
|
* @param {Array} visits - Array of visits
|
|
* @returns {Object} Visits grouped by year
|
|
*/
|
|
groupVisitsByYear(visits) {
|
|
const groups = {}
|
|
visits.forEach(visit => {
|
|
const year = new Date(visit.date).getFullYear().toString()
|
|
if (!groups[year]) {
|
|
groups[year] = []
|
|
}
|
|
groups[year].push(visit)
|
|
})
|
|
return groups
|
|
}
|
|
|
|
/**
|
|
* Attach event listeners to year toggle elements
|
|
*/
|
|
attachYearToggleListeners() {
|
|
const toggles = this.resultsContainer.querySelectorAll('.year-toggle')
|
|
toggles.forEach(toggle => {
|
|
toggle.addEventListener('click', (e) => {
|
|
const locationIndex = e.currentTarget.dataset.locationIndex
|
|
const year = e.currentTarget.dataset.year
|
|
const visitsContainer = document.getElementById(`year-${locationIndex}-${year}`)
|
|
const arrow = e.currentTarget.querySelector('.year-arrow')
|
|
|
|
if (visitsContainer) {
|
|
visitsContainer.classList.toggle('hidden')
|
|
arrow.style.transform = visitsContainer.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)'
|
|
}
|
|
})
|
|
})
|
|
|
|
// Attach event listeners to individual visit items
|
|
const visitItems = this.resultsContainer.querySelectorAll('.visit-item')
|
|
visitItems.forEach(item => {
|
|
item.addEventListener('click', (e) => {
|
|
e.stopPropagation()
|
|
const locationIndex = parseInt(item.dataset.locationIndex)
|
|
const visitIndex = parseInt(item.dataset.visitIndex)
|
|
this.handleVisitClick(locationIndex, visitIndex)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Handle click on individual visit item
|
|
* @param {number} locationIndex - Index of location in results
|
|
* @param {number} visitIndex - Index of visit within location
|
|
*/
|
|
handleVisitClick(locationIndex, visitIndex) {
|
|
if (!this.currentVisitsData || !this.currentVisitsData.locations) return
|
|
|
|
const location = this.currentVisitsData.locations[locationIndex]
|
|
if (!location || !location.visits) return
|
|
|
|
const visit = location.visits[visitIndex]
|
|
if (!visit) return
|
|
|
|
// Fly to visit coordinates (more precise than location coordinates)
|
|
const [lat, lon] = visit.coordinates || location.coordinates
|
|
this.map.flyTo({
|
|
center: [lon, lat],
|
|
zoom: 18,
|
|
duration: 1000
|
|
})
|
|
|
|
// Extract visit details
|
|
const visitDetails = visit.visit_details || {}
|
|
const startTime = visitDetails.start_time || visit.date
|
|
const endTime = visitDetails.end_time || visit.date
|
|
const placeName = location.place_name || location.address || 'Unnamed Location'
|
|
|
|
// Open create visit modal
|
|
this.openCreateVisitModal({
|
|
name: placeName,
|
|
latitude: lat,
|
|
longitude: lon,
|
|
started_at: startTime,
|
|
ended_at: endTime
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Open modal to create a visit with prefilled data
|
|
* @param {Object} visitData - Visit data to prefill
|
|
*/
|
|
openCreateVisitModal(visitData) {
|
|
// Create modal HTML
|
|
const modalId = 'create-visit-modal'
|
|
|
|
// Remove existing modal if present
|
|
const existingModal = document.getElementById(modalId)
|
|
if (existingModal) {
|
|
existingModal.remove()
|
|
}
|
|
|
|
const modal = document.createElement('div')
|
|
modal.id = modalId
|
|
modal.innerHTML = `
|
|
<input type="checkbox" id="${modalId}-toggle" class="modal-toggle" checked />
|
|
<div class="modal" role="dialog">
|
|
<div class="modal-box">
|
|
<h3 class="text-lg font-bold mb-4">Create Visit</h3>
|
|
|
|
<form id="${modalId}-form">
|
|
<div class="form-control mb-4">
|
|
<label class="label">
|
|
<span class="label-text">Name</span>
|
|
</label>
|
|
<input type="text" name="name" class="input input-bordered w-full"
|
|
value="${this.escapeHtml(visitData.name)}" required />
|
|
</div>
|
|
|
|
<div class="form-control mb-4">
|
|
<label class="label">
|
|
<span class="label-text">Start Time</span>
|
|
</label>
|
|
<input type="datetime-local" name="started_at" class="input input-bordered w-full"
|
|
value="${this.formatDateTimeForInput(visitData.started_at)}" required />
|
|
</div>
|
|
|
|
<div class="form-control mb-4">
|
|
<label class="label">
|
|
<span class="label-text">End Time</span>
|
|
</label>
|
|
<input type="datetime-local" name="ended_at" class="input input-bordered w-full"
|
|
value="${this.formatDateTimeForInput(visitData.ended_at)}" required />
|
|
</div>
|
|
|
|
<input type="hidden" name="latitude" value="${visitData.latitude}" />
|
|
<input type="hidden" name="longitude" value="${visitData.longitude}" />
|
|
|
|
<div class="modal-action">
|
|
<button type="button" class="btn" data-action="close">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
<span class="submit-text">Create Visit</span>
|
|
<span class="loading loading-spinner loading-sm hidden"></span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<label class="modal-backdrop" for="${modalId}-toggle"></label>
|
|
</div>
|
|
`
|
|
|
|
document.body.appendChild(modal)
|
|
|
|
// Attach event listeners
|
|
const form = modal.querySelector('form')
|
|
const closeBtn = modal.querySelector('[data-action="close"]')
|
|
const modalToggle = modal.querySelector(`#${modalId}-toggle`)
|
|
const backdrop = modal.querySelector('.modal-backdrop')
|
|
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault()
|
|
this.submitCreateVisit(form, modal)
|
|
})
|
|
|
|
closeBtn.addEventListener('click', () => {
|
|
modalToggle.checked = false
|
|
setTimeout(() => modal.remove(), 300)
|
|
})
|
|
|
|
backdrop.addEventListener('click', () => {
|
|
modalToggle.checked = false
|
|
setTimeout(() => modal.remove(), 300)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Submit create visit form
|
|
* @param {HTMLFormElement} form - Form element
|
|
* @param {HTMLElement} modal - Modal element
|
|
*/
|
|
async submitCreateVisit(form, modal) {
|
|
const submitBtn = form.querySelector('button[type="submit"]')
|
|
const submitText = submitBtn.querySelector('.submit-text')
|
|
const spinner = submitBtn.querySelector('.loading')
|
|
|
|
// Disable submit button and show loading
|
|
submitBtn.disabled = true
|
|
submitText.classList.add('hidden')
|
|
spinner.classList.remove('hidden')
|
|
|
|
try {
|
|
const formData = new FormData(form)
|
|
const visitData = {
|
|
name: formData.get('name'),
|
|
latitude: parseFloat(formData.get('latitude')),
|
|
longitude: parseFloat(formData.get('longitude')),
|
|
started_at: formData.get('started_at'),
|
|
ended_at: formData.get('ended_at'),
|
|
status: 'confirmed'
|
|
}
|
|
|
|
const response = await this.service.createVisit(visitData)
|
|
|
|
if (response.error) {
|
|
throw new Error(response.error)
|
|
}
|
|
|
|
// Success - close modal and show success message
|
|
const modalToggle = modal.querySelector('.modal-toggle')
|
|
modalToggle.checked = false
|
|
setTimeout(() => modal.remove(), 300)
|
|
|
|
// Show success notification
|
|
this.showSuccessNotification('Visit created successfully!')
|
|
|
|
// Dispatch custom event for other components to react
|
|
document.dispatchEvent(new CustomEvent('visit:created', {
|
|
detail: { visit: response, coordinates: [visitData.longitude, visitData.latitude] }
|
|
}))
|
|
|
|
} catch (error) {
|
|
console.error('Failed to create visit:', error)
|
|
alert(`Failed to create visit: ${error.message}`)
|
|
|
|
// Re-enable submit button
|
|
submitBtn.disabled = false
|
|
submitText.classList.remove('hidden')
|
|
spinner.classList.add('hidden')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show success notification
|
|
* @param {string} message - Success message
|
|
*/
|
|
showSuccessNotification(message) {
|
|
const notification = document.createElement('div')
|
|
notification.className = 'toast toast-top toast-end z-[9999]'
|
|
notification.innerHTML = `
|
|
<div class="alert alert-success">
|
|
<span>✓ ${this.escapeHtml(message)}</span>
|
|
</div>
|
|
`
|
|
document.body.appendChild(notification)
|
|
|
|
setTimeout(() => {
|
|
notification.remove()
|
|
}, 3000)
|
|
}
|
|
|
|
/**
|
|
* Format datetime for input field (YYYY-MM-DDTHH:MM)
|
|
* @param {string} dateString - Date string
|
|
* @returns {string} Formatted datetime
|
|
*/
|
|
formatDateTimeForInput(dateString) {
|
|
const date = new Date(dateString)
|
|
const year = date.getFullYear()
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
const hours = String(date.getHours()).padStart(2, '0')
|
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
|
}
|
|
|
|
/**
|
|
* Format date in short format
|
|
* @param {string} dateString - Date string
|
|
* @returns {string} Formatted date
|
|
*/
|
|
formatDateShort(dateString) {
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
}
|
|
|
|
/**
|
|
* Format date and time
|
|
* @param {string} dateString - Date string
|
|
* @returns {string} Formatted date and time
|
|
*/
|
|
formatDateTime(dateString) {
|
|
const date = new Date(dateString)
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
* @param {string} str - String to escape
|
|
* @returns {string} Escaped string
|
|
*/
|
|
escapeHtml(str) {
|
|
if (!str) return ''
|
|
const div = document.createElement('div')
|
|
div.textContent = str
|
|
return div.innerHTML
|
|
}
|
|
|
|
/**
|
|
* Clear search results
|
|
*/
|
|
clearResults() {
|
|
if (this.resultsContainer) {
|
|
this.resultsContainer.innerHTML = ''
|
|
this.resultsContainer.classList.add('hidden')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear search marker
|
|
*/
|
|
clearMarker() {
|
|
if (this.currentMarker) {
|
|
this.currentMarker.remove()
|
|
this.currentMarker = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup
|
|
*/
|
|
destroy() {
|
|
clearTimeout(this.debounceTimer)
|
|
this.clearMarker()
|
|
this.clearResults()
|
|
}
|
|
}
|