diff --git a/CHANGELOG.md b/CHANGELOG.md index 372d12b5..30ba22d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -# 0.24.0 - 2025-02-09 +# 0.24.1 - 2025-02-10 + +## Custom map tiles + +In the user settings, you can now set a custom tile URL for the map. This is useful if you want to use a custom map tile provider or if you want to use a map tile provider that is not listed in the dropdown. + +To set a custom tile URL, go to the user settings and set the `Maps` section to your liking. Be mindful that currently, only raster tiles are supported. The URL should be a valid tile URL, like `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`. You, as the user, are responsible for any extra costs that may occur due to using a custom tile URL. + +### Added + +- Safe settings for user with default values. +- In the user settings, you can now set a custom tile URL for the map. #429 #715 + +# 0.24.0 - 2025-02-10 ## Points speed units diff --git a/app/controllers/settings/maps_controller.rb b/app/controllers/settings/maps_controller.rb new file mode 100644 index 00000000..59beb04d --- /dev/null +++ b/app/controllers/settings/maps_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Settings::MapsController < ApplicationController + before_action :authenticate_user! + + def index + @maps = current_user.safe_settings.maps + end + + def update + current_user.settings['maps'] = settings_params + current_user.save! + + redirect_to settings_maps_path, notice: 'Settings updated' + end + + private + + def settings_params + params.require(:maps).permit(:name, :url) + end +end diff --git a/app/javascript/controllers/map_preview_controller.js b/app/javascript/controllers/map_preview_controller.js new file mode 100644 index 00000000..3b610a33 --- /dev/null +++ b/app/javascript/controllers/map_preview_controller.js @@ -0,0 +1,67 @@ +import { Controller } from "@hotwired/stimulus" +import L from "leaflet" +import { showFlashMessage } from "../maps/helpers" + +export default class extends Controller { + static targets = ["urlInput", "mapContainer", "saveButton"] + + DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + + connect() { + console.log("Controller connected!") + // Wait for the next frame to ensure the DOM is ready + requestAnimationFrame(() => { + // Force container height + this.mapContainerTarget.style.height = '500px' + this.initializeMap() + }) + } + + initializeMap() { + console.log("Initializing map...") + if (!this.map) { + this.map = L.map(this.mapContainerTarget).setView([51.505, -0.09], 13) + // Invalidate size after initialization + setTimeout(() => { + this.map.invalidateSize() + }, 0) + this.updatePreview() + } + } + + updatePreview() { + console.log("Updating preview...") + const url = this.urlInputTarget.value || this.DEFAULT_TILE_URL + + // Only animate if save button target exists + if (this.hasSaveButtonTarget) { + this.saveButtonTarget.classList.add('btn-animate') + setTimeout(() => { + this.saveButtonTarget.classList.remove('btn-animate') + }, 1000) + } + + if (this.currentLayer) { + this.map.removeLayer(this.currentLayer) + } + + try { + this.currentLayer = L.tileLayer(url, { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(this.map) + } catch (e) { + console.error('Invalid tile URL:', e) + showFlashMessage('error', 'Invalid tile URL. Reverting to OpenStreetMap.') + + // Reset input to default OSM URL + this.urlInputTarget.value = this.DEFAULT_TILE_URL + + // Create default layer + this.currentLayer = L.tileLayer(this.DEFAULT_TILE_URL, { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(this.map) + } + } +} diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 2e921c86..f08af801 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -385,8 +385,7 @@ export default class extends Controller { baseMaps() { let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; - - return { + let maps = { OpenStreetMap: osmMapLayer(this.map, selectedLayerName), "OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName), OPNV: OPNVMapLayer(this.map, selectedLayerName), @@ -397,6 +396,33 @@ export default class extends Controller { esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName), esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName) }; + + // Add custom map if it exists in settings + if (this.userSettings.maps && this.userSettings.maps.url) { + const customLayer = L.tileLayer(this.userSettings.maps.url, { + maxZoom: 19, + attribution: "© OpenStreetMap contributors" + }); + + // If this is the preferred layer, add it to the map immediately + if (selectedLayerName === this.userSettings.maps.name) { + customLayer.addTo(this.map); + // Remove any other base layers that might be active + Object.values(maps).forEach(layer => { + if (this.map.hasLayer(layer)) { + this.map.removeLayer(layer); + } + }); + } + + maps[this.userSettings.maps.name] = customLayer; + } else { + // If no custom map is set, ensure a default layer is added + const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"]; + defaultLayer.addTo(this.map); + } + + return maps; } removeEventListeners() { diff --git a/app/models/user.rb b/app/models/user.rb index b8d27f17..b2ffd980 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,13 +16,18 @@ class User < ApplicationRecord has_many :trips, dependent: :destroy after_create :create_api_key - before_save :strip_trailing_slashes + before_save :sanitize_input validates :email, presence: true + validates :reset_password_token, uniqueness: true, allow_nil: true attribute :admin, :boolean, default: false + def safe_settings + Users::SafeSettings.new(settings) + end + def countries_visited stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact end @@ -99,8 +104,9 @@ class User < ApplicationRecord save end - def strip_trailing_slashes + def sanitize_input settings['immich_url']&.gsub!(%r{/+\z}, '') settings['photoprism_url']&.gsub!(%r{/+\z}, '') + settings['maps']['url']&.strip! end end diff --git a/app/services/areas/visits/create.rb b/app/services/areas/visits/create.rb index f58bf4b7..768f5f9f 100644 --- a/app/services/areas/visits/create.rb +++ b/app/services/areas/visits/create.rb @@ -6,8 +6,8 @@ class Areas::Visits::Create def initialize(user, areas) @user = user @areas = areas - @time_threshold_minutes = 30 || user.settings['time_threshold_minutes'] - @merge_threshold_minutes = 15 || user.settings['merge_threshold_minutes'] + @time_threshold_minutes = 30 || user.safe_settings.time_threshold_minutes + @merge_threshold_minutes = 15 || user.safe_settings.merge_threshold_minutes end def call diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 0d3f6e1f..0dfcbcd5 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -5,15 +5,15 @@ class Immich::RequestPhotos def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata") - @immich_api_key = user.settings['immich_api_key'] + @immich_api_base_url = URI.parse("#{user.safe_settings.immich_url}/api/search/metadata") + @immich_api_key = user.safe_settings.immich_api_key @start_date = start_date @end_date = end_date end def call raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank? - raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank? + raise ArgumentError, 'Immich URL is missing' if user.safe_settings.immich_url.blank? data = retrieve_immich_data diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 276e7e5c..0f7fd93b 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -9,14 +9,14 @@ class Photoprism::RequestPhotos def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos") - @photoprism_api_key = user.settings['photoprism_api_key'] + @photoprism_api_base_url = URI.parse("#{user.safe_settings.photoprism_url}/api/v1/photos") + @photoprism_api_key = user.safe_settings.photoprism_api_key @start_date = start_date @end_date = end_date end def call - raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank? + raise ArgumentError, 'Photoprism URL is missing' if user.safe_settings.photoprism_url.blank? raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank? data = retrieve_photoprism_data diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb index 6bdb7fd5..4f927aad 100644 --- a/app/services/photos/thumbnail.rb +++ b/app/services/photos/thumbnail.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Photos::Thumbnail + SUPPORTED_SOURCES = %w[immich photoprism].freeze + def initialize(user, source, id) @user = user @source = source @@ -8,6 +10,8 @@ class Photos::Thumbnail end def call + raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source) + HTTParty.get(request_url, headers: headers) end @@ -16,11 +20,11 @@ class Photos::Thumbnail attr_reader :user, :source, :id def source_url - user.settings["#{source}_url"] + user.safe_settings.public_send("#{source}_url") end def source_api_key - user.settings["#{source}_api_key"] + user.safe_settings.public_send("#{source}_api_key") end def source_path @@ -30,8 +34,6 @@ class Photos::Thumbnail when 'photoprism' preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}") "/api/v1/t/#{id}/#{preview_token}/tile_500" - else - raise "Unsupported source: #{source}" end end @@ -48,4 +50,8 @@ class Photos::Thumbnail request_headers end + + def unsupported_source_error + raise ArgumentError, "Unsupported source: #{source}" + end end diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb new file mode 100644 index 00000000..64d41b12 --- /dev/null +++ b/app/services/users/safe_settings.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class Users::SafeSettings + attr_reader :settings + + def initialize(settings) + @settings = settings + end + + # rubocop:disable Metrics/MethodLength + def config + { + fog_of_war_meters: fog_of_war_meters, + meters_between_routes: meters_between_routes, + preferred_map_layer: preferred_map_layer, + speed_colored_routes: speed_colored_routes, + points_rendering_mode: points_rendering_mode, + minutes_between_routes: minutes_between_routes, + time_threshold_minutes: time_threshold_minutes, + merge_threshold_minutes: merge_threshold_minutes, + live_map_enabled: live_map_enabled, + route_opacity: route_opacity, + immich_url: immich_url, + immich_api_key: immich_api_key, + photoprism_url: photoprism_url, + photoprism_api_key: photoprism_api_key, + maps: maps + } + end + # rubocop:enable Metrics/MethodLength + + def fog_of_war_meters + settings['fog_of_war_meters'] || 50 + end + + def meters_between_routes + settings['meters_between_routes'] || 500 + end + + def preferred_map_layer + settings['preferred_map_layer'] || 'OpenStreetMap' + end + + def speed_colored_routes + settings['speed_colored_routes'] || false + end + + def points_rendering_mode + settings['points_rendering_mode'] || 'raw' + end + + def minutes_between_routes + settings['minutes_between_routes'] || 30 + end + + def time_threshold_minutes + settings['time_threshold_minutes'] || 30 + end + + def merge_threshold_minutes + settings['merge_threshold_minutes'] || 15 + end + + def live_map_enabled + settings['live_map_enabled'] || true + end + + def route_opacity + settings['route_opacity'] || 0.6 + end + + def immich_url + settings['immich_url'] + end + + def immich_api_key + settings['immich_api_key'] + end + + def photoprism_url + settings['photoprism_url'] + end + + def photoprism_api_key + settings['photoprism_api_key'] + end + + def maps + settings['maps'] || {} + end +end diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index d2ee8d30..97f82a38 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -5,12 +5,12 @@