Files
dawarich/app/javascript/controllers/place_creation_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

292 lines
9.6 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", "noteInput",
"nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton",
"modalTitle", "submitButton", "placeIdInput"]
static values = {
apiKey: String
}
connect() {
this.setupEventListeners()
this.currentRadius = 0.5 // Start with 500m (0.5km)
this.maxRadius = 1.5 // Max 1500m (1.5km)
this.setupTagListeners()
this.editingPlaceId = null
}
setupEventListeners() {
document.addEventListener('place:create', (e) => {
this.open(e.detail.latitude, e.detail.longitude)
})
document.addEventListener('place:edit', (e) => {
this.openForEdit(e.detail.place)
})
}
setupTagListeners() {
// Listen for checkbox changes to update badge styling
if (this.hasTagCheckboxesTarget) {
this.tagCheckboxesTarget.addEventListener('change', (e) => {
if (e.target.type === 'checkbox' && e.target.name === 'tag_ids[]') {
const badge = e.target.nextElementSibling
const color = badge.dataset.color
if (e.target.checked) {
// Filled style
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.borderColor = color
badge.style.color = 'white'
} else {
// Outline style
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.borderColor = color
badge.style.color = color
}
}
})
}
}
async open(latitude, longitude) {
this.editingPlaceId = null
this.latitudeInputTarget.value = latitude
this.longitudeInputTarget.value = longitude
this.currentRadius = 0.5 // Reset radius when opening modal
// Update modal for creation mode
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Create New Place'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Create Place'
}
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
await this.loadNearbyPlaces(latitude, longitude)
}
async openForEdit(place) {
this.editingPlaceId = place.id
this.currentRadius = 0.5
// Fill in form with place data
this.nameInputTarget.value = place.name
this.latitudeInputTarget.value = place.latitude
this.longitudeInputTarget.value = place.longitude
if (this.hasNoteInputTarget && place.note) {
this.noteInputTarget.value = place.note
}
// Update modal for edit mode
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Edit Place'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Update Place'
}
// Check the appropriate tag checkboxes
const tagCheckboxes = this.formTarget.querySelectorAll('input[name="tag_ids[]"]')
tagCheckboxes.forEach(checkbox => {
const isSelected = place.tags.some(tag => tag.id === parseInt(checkbox.value))
checkbox.checked = isSelected
// Trigger change event to update badge styling
const event = new Event('change', { bubbles: true })
checkbox.dispatchEvent(event)
})
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
// Load nearby places for suggestions
await this.loadNearbyPlaces(place.latitude, place.longitude)
}
close() {
this.modalTarget.classList.remove('modal-open')
this.formTarget.reset()
this.nearbyListTarget.innerHTML = ''
this.loadMoreContainerTarget.classList.add('hidden')
this.currentRadius = 0.5
this.editingPlaceId = null
const event = new CustomEvent('place:create:cancelled')
document.dispatchEvent(event)
}
async loadNearbyPlaces(latitude, longitude, radius = null) {
this.loadingSpinnerTarget.classList.remove('hidden')
// Use provided radius or current radius
const searchRadius = radius || this.currentRadius
const isLoadingMore = radius !== null && radius > this.currentRadius - 0.5
// Only clear the list on initial load, not when loading more
if (!isLoadingMore) {
this.nearbyListTarget.innerHTML = ''
}
try {
const response = await fetch(
`/api/v1/places/nearby?latitude=${latitude}&longitude=${longitude}&radius=${searchRadius}&limit=5`,
{ headers: { 'Authorization': `Bearer ${this.apiKeyValue}` } }
)
if (!response.ok) throw new Error('Failed to load nearby places')
const data = await response.json()
this.renderNearbyPlaces(data.places, isLoadingMore)
// Show load more button if we can expand radius further
if (searchRadius < this.maxRadius) {
this.loadMoreContainerTarget.classList.remove('hidden')
this.updateLoadMoreButton(searchRadius)
} else {
this.loadMoreContainerTarget.classList.add('hidden')
}
} catch (error) {
console.error('Error loading nearby places:', error)
this.nearbyListTarget.innerHTML = '<p class="text-error">Failed to load suggestions</p>'
} finally {
this.loadingSpinnerTarget.classList.add('hidden')
}
}
renderNearbyPlaces(places, append = false) {
if (!places || places.length === 0) {
if (!append) {
this.nearbyListTarget.innerHTML = '<p class="text-sm text-gray-500">No nearby places found</p>'
}
return
}
// Calculate starting index based on existing items
const currentCount = append ? this.nearbyListTarget.querySelectorAll('.card').length : 0
const html = places.map((place, index) => `
<div class="card card-compact bg-base-200 cursor-pointer hover:bg-base-300 transition"
data-action="click->place-creation#selectNearby"
data-place-name="${this.escapeHtml(place.name)}"
data-place-latitude="${place.latitude}"
data-place-longitude="${place.longitude}">
<div class="card-body">
<div class="flex gap-2">
<span class="badge badge-primary badge-sm">#${currentCount + index + 1}</span>
<div class="flex-1">
<h4 class="font-semibold">${this.escapeHtml(place.name)}</h4>
${place.street ? `<p class="text-sm">${this.escapeHtml(place.street)}</p>` : ''}
${place.city ? `<p class="text-xs text-gray-500">${this.escapeHtml(place.city)}, ${this.escapeHtml(place.country || '')}</p>` : ''}
</div>
</div>
</div>
</div>
`).join('')
if (append) {
this.nearbyListTarget.insertAdjacentHTML('beforeend', html)
} else {
this.nearbyListTarget.innerHTML = html
}
}
async loadMore() {
// Increase radius by 500m (0.5km) up to max of 1500m (1.5km)
if (this.currentRadius >= this.maxRadius) return
this.currentRadius = Math.min(this.currentRadius + 0.5, this.maxRadius)
const latitude = parseFloat(this.latitudeInputTarget.value)
const longitude = parseFloat(this.longitudeInputTarget.value)
await this.loadNearbyPlaces(latitude, longitude, this.currentRadius)
}
updateLoadMoreButton(currentRadius) {
const nextRadius = Math.min(currentRadius + 0.5, this.maxRadius)
const radiusInMeters = Math.round(nextRadius * 1000)
this.loadMoreButtonTarget.textContent = `Load More (search up to ${radiusInMeters}m)`
}
selectNearby(event) {
const element = event.currentTarget
this.nameInputTarget.value = element.dataset.placeName
this.latitudeInputTarget.value = element.dataset.placeLatitude
this.longitudeInputTarget.value = element.dataset.placeLongitude
}
async submit(event) {
event.preventDefault()
const formData = new FormData(this.formTarget)
const tagIds = Array.from(this.formTarget.querySelectorAll('input[name="tag_ids[]"]:checked'))
.map(cb => cb.value)
const payload = {
place: {
name: formData.get('name'),
latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')),
note: formData.get('note') || null,
source: 'manual',
tag_ids: tagIds
}
}
try {
const isEdit = this.editingPlaceId !== null
const url = isEdit ? `/api/v1/places/${this.editingPlaceId}` : '/api/v1/places'
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`
},
body: JSON.stringify(payload)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.errors?.join(', ') || `Failed to ${isEdit ? 'update' : 'create'} place`)
}
const place = await response.json()
this.close()
this.showNotification(`Place ${isEdit ? 'updated' : 'created'} successfully!`, 'success')
const eventName = isEdit ? 'place:updated' : 'place:created'
const customEvent = new CustomEvent(eventName, { detail: { place } })
document.dispatchEvent(customEvent)
} catch (error) {
console.error(`Error ${this.editingPlaceId ? 'updating' : 'creating'} place:`, error)
this.showNotification(error.message, 'error')
}
}
showNotification(message, type = 'info') {
const event = new CustomEvent('notification:show', {
detail: { message, type },
bubbles: true
})
document.dispatchEvent(event)
}
escapeHtml(text) {
if (!text) return ''
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}