Implement custom map tiles and user settings with default values

This commit is contained in:
Eugene Burmakin 2025-02-10 20:37:20 +01:00
parent b8c69c2a76
commit 0b362168c9
18 changed files with 347 additions and 25 deletions

View file

@ -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

View file

@ -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

View file

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.map)
}
}
}

View file

@ -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: "&copy; 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() {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -5,12 +5,12 @@
<h1 class="font-bold text-4xl">Imports</h1>
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
<% if current_user.settings['immich_url'] && current_user.settings['immich_api_key'] %>
<% if current_user.safe_settings.immich_url && current_user.safe_settings.immich_api_key %>
<%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a>
<% end %>
<% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %>
<% if current_user.safe_settings.photoprism_url && current_user.safe_settings.photoprism_api_key %>
<%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Photoprism instance data in the Settings">Import Photoprism data</a>

View file

@ -49,7 +49,7 @@
data-points-target="map"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings=<%= current_user.settings.to_json %>
data-user_settings='<%= current_user.settings.to_json.html_safe %>'
data-coordinates="<%= @coordinates %>"
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"

View file

@ -4,4 +4,5 @@
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %>
<% end %>
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab #{active_tab?(settings_maps_path)}" %>
</div>

View file

@ -9,20 +9,20 @@
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2">
<%= f.label :immich_url %>
<%= f.text_field :immich_url, value: current_user.settings['immich_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
<%= f.text_field :immich_url, value: current_user.safe_settings.immich_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
</div>
<div class="form-control my-2">
<%= f.label :immich_api_key %>
<%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
<%= f.text_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<div class="divider"></div>
<div class="form-control my-2">
<%= f.label :photoprism_url %>
<%= f.text_field :photoprism_url, value: current_user.settings['photoprism_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
<%= f.text_field :photoprism_url, value: current_user.safe_settings.photoprism_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
</div>
<div class="form-control my-2">
<%= f.label :photoprism_api_key %>
<%= f.text_field :photoprism_api_key, value: current_user.settings['photoprism_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
<%= f.text_field :photoprism_api_key, value: current_user.safe_settings.photoprism_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<div class="form-control my-2">

View file

@ -0,0 +1,45 @@
<% content_for :title, "Background jobs" %>
<div class="min-h-content w-full my-5">
<%= render 'settings/navigation' %>
<div class="flex justify-between items-center mt-5">
<h1 class="font-bold text-4xl">Maps settings</h1>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-5" data-controller="map-preview">
<%= form_for :maps,
url: settings_maps_path,
method: :patch,
autocomplete: "off",
data: { turbo_method: :patch, turbo: false },
class: "lg:col-span-1" do |f| %>
<div class="form-control my-2">
<%= f.label :name %>
<%= f.text_field :name, value: @maps['name'], placeholder: 'Example: OpenStreetMap', class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :url, 'URL' %>
<%= f.text_field :url,
value: @maps['url'],
autocomplete: "off",
placeholder: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
class: "input input-bordered",
data: {
map_preview_target: "urlInput",
action: "input->map-preview#updatePreview"
} %>
</div>
<%= f.submit 'Save', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %>
<% end %>
<div class="lg:col-span-2" style="height: 500px;">
<div
data-map-preview-target="mapContainer"
class="w-full h-full rounded-lg border"
></div>
</div>
</div>
</div>

View file

@ -20,6 +20,8 @@ Rails.application.routes.draw do
namespace :settings do
resources :background_jobs, only: %i[index create destroy]
resources :users, only: %i[index create destroy edit update]
resources :maps, only: %i[index]
patch 'maps', to: 'maps#update'
end
patch 'settings', to: 'settings#update'

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'settings/maps', type: :request do
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
context 'when user is authenticated' do
let!(:user) { create(:user) }
before do
sign_in user
end
describe 'GET /index' do
it 'returns a success response' do
get settings_maps_url
expect(response).to be_successful
end
end
describe 'PATCH /update' do
it 'returns a success response' do
patch settings_maps_path, params: { maps: { name: 'Test', url: 'https://test.com' } }
expect(response).to redirect_to(settings_maps_path)
expect(user.settings['maps']).to eq({ 'name' => 'Test', 'url' => 'https://test.com' })
end
end
end
context 'when user is not authenticated' do
it 'redirects to the sign in page' do
get settings_maps_path
expect(response).to redirect_to(new_user_session_path)
end
end
end

View file

@ -70,7 +70,7 @@ RSpec.describe Photos::Thumbnail do
let(:source) { 'unsupported' }
it 'raises an error' do
expect { subject }.to raise_error(RuntimeError, 'Unsupported source: unsupported')
expect { subject }.to raise_error(ArgumentError, 'Unsupported source: unsupported')
end
end
end