Merge branch 'dev' into staging-env

This commit is contained in:
Evgenii Burmakin 2025-09-26 19:50:25 +02:00 committed by GitHub
commit 8807950180
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 728 additions and 164 deletions

View file

@ -7,7 +7,7 @@ orbs:
jobs:
test:
docker:
- image: cimg/ruby:3.4.1-browsers
- image: cimg/ruby:3.4.6-browsers
environment:
RAILS_ENV: test
CI: true

View file

@ -1,5 +1,5 @@
# Base-Image for Ruby and Node.js
FROM ruby:3.4.1-alpine
FROM ruby:3.4.6-alpine
ENV APP_PATH=/var/app
ENV BUNDLE_VERSION=2.5.21

View file

@ -34,7 +34,7 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4.1'
ruby-version: '3.4.6'
bundler-cache: true
- name: Set up Node.js

View file

@ -1 +1 @@
3.4.1
3.4.6

View file

@ -15,15 +15,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `GET /api/v1/points will now return correct latitude and longitude values. #1502
- Deleting an import will now trigger stats recalculation for affected months. #1789
- Importing process should now schedule visits suggestions job a lot faster.
- Importing GPX files that start with `<gpx` tag will now be detected correctly. #1775
- Buttons on the map now have correct contrast in both light and dark modes.
## Changed
- Onboarding modal window now features a link to the App Store and a QR code to configure the Dawarich iOS app.
- A permanent option was removed from stats sharing options. Now, stats can be shared for 1, 12 or 24 hours only.
- User data archive importing now uploads the file directly to the storage service instead of uploading it to the app first.
- Importing progress bars are now looking nice.
- Ruby version was updated to 3.4.6.
## Added
- Added foundation for upcoming authentication from iOS app.
- [Dawarich Cloud] Based on preferred theme (light or dark), the map page will now load with the corresponding map layer (light or dark).
- [Dawarich Cloud] Added foundation for upcoming authentication from iOS app.
- [Dawarich Cloud] Trial users can now create up to 5 imports. After that, they will be prompted to subscribe to a paid plan.

View file

@ -600,7 +600,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.1p0
ruby 3.4.6p54
BUNDLED WITH
2.5.21

File diff suppressed because one or more lines are too long

View file

@ -54,21 +54,28 @@ class Settings::UsersController < ApplicationController
end
def import
unless params[:archive].present?
if params[:archive].blank?
redirect_to edit_user_registration_path, alert: 'Please select a ZIP archive to import.'
return
end
archive_file = params[:archive]
archive_param = params[:archive]
validate_archive_file(archive_file)
# Handle both direct upload (signed_id) and traditional upload (file)
if archive_param.is_a?(String)
# Direct upload: archive_param is a signed blob ID
import = create_import_from_signed_archive_id(archive_param)
else
# Traditional upload: archive_param is an uploaded file
validate_archive_file(archive_param)
import = current_user.imports.build(
name: archive_file.original_filename,
source: :user_data_archive
)
import = current_user.imports.build(
name: archive_param.original_filename,
source: :user_data_archive
)
import.file.attach(archive_file)
import.file.attach(archive_param)
end
if import.save
redirect_to edit_user_registration_path,
@ -89,6 +96,36 @@ class Settings::UsersController < ApplicationController
params.require(:user).permit(:email, :password)
end
def create_import_from_signed_archive_id(signed_id)
Rails.logger.debug "Creating archive import from signed ID: #{signed_id[0..20]}..."
blob = ActiveStorage::Blob.find_signed(signed_id)
# Validate that it's a ZIP file
validate_blob_file_type(blob)
import_name = generate_unique_import_name(blob.filename.to_s)
import = current_user.imports.build(
name: import_name,
source: :user_data_archive
)
import.file.attach(blob)
import
end
def generate_unique_import_name(original_name)
return original_name unless current_user.imports.exists?(name: original_name)
# Extract filename and extension
basename = File.basename(original_name, File.extname(original_name))
extension = File.extname(original_name)
# Add current datetime
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
"#{basename}_#{timestamp}#{extension}"
end
def validate_archive_file(archive_file)
unless ['application/zip', 'application/x-zip-compressed'].include?(archive_file.content_type) ||
File.extname(archive_file.original_filename).downcase == '.zip'
@ -96,4 +133,12 @@ class Settings::UsersController < ApplicationController
redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.' and return
end
end
def validate_blob_file_type(blob)
unless ['application/zip', 'application/x-zip-compressed'].include?(blob.content_type) ||
File.extname(blob.filename.to_s).downcase == '.zip'
raise StandardError, 'Please upload a valid ZIP file.'
end
end
end

View file

@ -1,11 +1,13 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import { showFlashMessage } from "../maps/helpers";
import { applyThemeToButton } from "../maps/theme_utils";
export default class extends Controller {
static targets = [""];
static values = {
apiKey: String
apiKey: String,
userTheme: String
}
connect() {
@ -17,12 +19,16 @@ export default class extends Controller {
this.currentPopup = null;
this.mapsController = null;
// Listen for theme changes
document.addEventListener('theme:changed', this.handleThemeChange.bind(this));
// Wait for the map to be initialized
this.waitForMap();
}
disconnect() {
this.cleanup();
document.removeEventListener('theme:changed', this.handleThemeChange.bind(this));
console.log("Add visit controller disconnected");
}
@ -76,13 +82,10 @@ export default class extends Controller {
button.innerHTML = '';
button.title = 'Add a visit';
// Style the button to match other map controls
// Style the button with theme-aware styling
applyThemeToButton(button, this.userThemeValue || 'dark');
button.style.width = '48px';
button.style.height = '48px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
button.style.backgroundColor = 'white';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
@ -93,19 +96,6 @@ export default class extends Controller {
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Add hover effects
button.addEventListener('mouseenter', () => {
if (!this.isAddingVisit) {
button.style.backgroundColor = '#f0f0f0';
}
});
button.addEventListener('mouseleave', () => {
if (!this.isAddingVisit) {
button.style.backgroundColor = 'white';
}
});
// Toggle add visit mode on button click
L.DomEvent.on(button, 'click', () => {
this.toggleAddVisitMode(button);
@ -150,9 +140,8 @@ export default class extends Controller {
exitAddVisitMode(button) {
this.isAddingVisit = false;
// Reset button style
button.style.backgroundColor = 'white';
button.style.color = 'black';
// Reset button style with theme-aware styling
applyThemeToButton(button, this.userThemeValue || 'dark');
button.innerHTML = '';
// Reset cursor
@ -446,6 +435,16 @@ export default class extends Controller {
});
}
handleThemeChange(event) {
console.log('Add visit controller: Theme changed to', event.detail.theme);
this.userThemeValue = event.detail.theme;
// Update button theme if it exists
if (this.addVisitButton && !this.isAddingVisit) {
applyThemeToButton(this.addVisitButton, this.userThemeValue);
}
}
cleanup() {
if (this.map) {
this.map.off('click', this.onMapClick, this);

View file

@ -93,31 +93,33 @@ export default class extends Controller {
this.progressTarget.remove()
}
// Create a wrapper div for better positioning and visibility
// Create a wrapper div with better DaisyUI styling
const progressWrapper = document.createElement("div")
progressWrapper.className = "mt-4 mb-6 border p-4 rounded-lg bg-gray-50"
progressWrapper.className = "w-full mt-4 mb-4"
// Add a label
// Add a label with better typography
const progressLabel = document.createElement("div")
progressLabel.className = "font-medium mb-2 text-gray-700"
progressLabel.textContent = "Upload Progress"
progressLabel.className = "text-sm font-medium text-base-content mb-2 flex justify-between items-center"
progressLabel.innerHTML = `
<span>Upload Progress</span>
<span class="text-xs text-base-content/70 progress-percentage">0%</span>
`
progressWrapper.appendChild(progressLabel)
// Create a new progress container
const progressContainer = document.createElement("div")
// Create DaisyUI progress element
const progressContainer = document.createElement("progress")
progressContainer.setAttribute("data-direct-upload-target", "progress")
progressContainer.className = "w-full bg-gray-200 rounded-full h-4"
progressContainer.className = "progress progress-primary w-full h-3"
progressContainer.value = 0
progressContainer.max = 100
// Create the progress bar fill element
// Create a hidden div for the progress bar target (for compatibility)
const progressBarFill = document.createElement("div")
progressBarFill.setAttribute("data-direct-upload-target", "progressBar")
progressBarFill.className = "bg-blue-600 h-4 rounded-full transition-all duration-300"
progressBarFill.style.width = "0%"
progressBarFill.style.display = "none"
// Add the fill element to the container
progressContainer.appendChild(progressBarFill)
progressWrapper.appendChild(progressContainer)
progressBarFill.dataset.percentageDisplay = "true"
progressWrapper.appendChild(progressBarFill)
// Add the progress wrapper AFTER the file input field but BEFORE the submit button
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
@ -169,6 +171,19 @@ export default class extends Controller {
showFlashMessage('error', 'No files were successfully uploaded. Please try again.')
} else {
showFlashMessage('notice', `${successfulUploads} file(s) uploaded successfully. Ready to submit.`)
// Add a completion animation to the progress bar
const percentageDisplay = this.element.querySelector('.progress-percentage')
if (percentageDisplay) {
percentageDisplay.textContent = '100%'
percentageDisplay.classList.add('text-success')
}
if (this.hasProgressTarget) {
this.progressTarget.value = 100
this.progressTarget.classList.add('progress-success')
this.progressTarget.classList.remove('progress-primary')
}
}
this.isUploading = false
console.log("All uploads completed")
@ -180,18 +195,20 @@ export default class extends Controller {
directUploadWillStoreFileWithXHR(request) {
request.upload.addEventListener("progress", event => {
if (!this.hasProgressBarTarget) {
console.warn("Progress bar target not found")
if (!this.hasProgressTarget) {
console.warn("Progress target not found")
return
}
const progress = (event.loaded / event.total) * 100
const progressPercentage = `${progress.toFixed(1)}%`
console.log(`Upload progress: ${progressPercentage}`)
this.progressBarTarget.style.width = progressPercentage
// Update text percentage if exists
const percentageDisplay = this.element.querySelector('[data-percentage-display="true"]')
// Update the DaisyUI progress element
this.progressTarget.value = progress
// Update the percentage display
const percentageDisplay = this.element.querySelector('.progress-percentage')
if (percentageDisplay) {
percentageDisplay.textContent = progressPercentage
}

View file

@ -43,6 +43,8 @@ import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fo
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 { injectThemeStyles } from "../maps/theme_styles";
export default class extends BaseController {
static targets = ["container"];
@ -61,6 +63,10 @@ export default class extends BaseController {
this.apiKey = this.element.dataset.api_key;
this.selfHosted = this.element.dataset.self_hosted;
this.userTheme = this.element.dataset.user_theme || 'dark';
// Inject theme styles for Leaflet controls
injectThemeStyles(this.userTheme);
try {
this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : [];
@ -134,10 +140,11 @@ export default class extends BaseController {
const unit = this.distanceUnit === 'km' ? 'km' : 'mi';
div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`;
div.style.backgroundColor = 'white';
div.style.padding = '0 5px';
div.style.marginRight = '5px';
div.style.display = 'inline-block';
applyThemeToControl(div, this.userTheme, {
padding: '0 5px',
marginRight: '5px',
display: 'inline-block'
});
return div;
}
});
@ -195,8 +202,8 @@ export default class extends BaseController {
}
// Initialize the visits manager
this.visitsManager = new VisitsManager(this.map, this.apiKey);
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme);
// Expose visits manager globally for location search integration
window.visitsManager = this.visitsManager;
@ -257,6 +264,7 @@ export default class extends BaseController {
disconnect() {
super.disconnect();
this.removeEventListeners();
if (this.tracksSubscription) {
this.tracksSubscription.unsubscribe();
}
@ -396,40 +404,28 @@ export default class extends BaseController {
// 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
// Remove any existing base layers first
Object.values(maps).forEach(layer => {
if (this.map.hasLayer(layer)) {
this.map.removeLayer(layer);
}
});
customLayer.addTo(this.map);
}
maps[this.userSettings.maps.name] = customLayer;
} else {
// If no custom map is set, ensure a default layer is added
// First check if maps object has any entries
// If no maps were created (fallback case), add OSM
if (Object.keys(maps).length === 0) {
// Fallback to OSM if no maps are configured
maps["OpenStreetMap"] = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
console.warn('No map layers available, adding OSM fallback');
const osmLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
});
osmLayer.addTo(this.map);
maps["OpenStreetMap"] = osmLayer;
}
// Now try to get the selected layer or fall back to alternatives
const defaultLayer = maps[selectedLayerName] || Object.values(maps)[0];
if (defaultLayer) {
defaultLayer.addTo(this.map);
} else {
console.error("Could not find any default map layer");
// Ultimate fallback - create and add OSM layer directly
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
}).addTo(this.map);
}
// Note: createAllMapLayers already added the user's preferred layer to the map
}
return maps;
@ -731,13 +727,10 @@ export default class extends BaseController {
const button = L.DomUtil.create('button', 'map-settings-button');
button.innerHTML = '⚙️'; // Gear icon
// Style the button
button.style.backgroundColor = 'white';
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '32px';
button.style.height = '32px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
@ -863,11 +856,9 @@ export default class extends BaseController {
</form>
`;
// Style the panel
div.style.backgroundColor = 'white';
// Style the panel with theme-aware styling
applyThemeToPanel(div, this.userTheme);
div.style.padding = '10px';
div.style.border = '1px solid #ccc';
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
// Prevent map interactions when interacting with the form
L.DomEvent.disableClickPropagation(div);
@ -1010,6 +1001,17 @@ export default class extends BaseController {
const mapElement = document.getElementById('map');
if (mapElement) {
mapElement.setAttribute('data-user_settings', JSON.stringify(this.userSettings));
// Update theme if it changed
if (newSettings.theme && newSettings.theme !== this.userTheme) {
this.userTheme = newSettings.theme;
mapElement.setAttribute('data-user_theme', this.userTheme);
injectThemeStyles(this.userTheme);
// Dispatch theme change event for other controllers
document.dispatchEvent(new CustomEvent('theme:changed', {
detail: { theme: this.userTheme }
}));
}
}
// Store current layer states
@ -1091,12 +1093,10 @@ export default class extends BaseController {
const button = L.DomUtil.create('button', 'toggle-panel-button');
button.innerHTML = '📅';
// Style the button with theme-aware styling
applyThemeToButton(button, controller.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
button.style.backgroundColor = 'white';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
@ -1131,12 +1131,12 @@ export default class extends BaseController {
const RouteTracksControl = L.Control.extend({
onAdd: function(map) {
const container = L.DomUtil.create('div', 'routes-tracks-selector leaflet-bar');
container.style.backgroundColor = 'white';
container.style.padding = '8px';
container.style.borderRadius = '4px';
container.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
container.style.fontSize = '12px';
container.style.lineHeight = '1.2';
applyThemeToControl(container, controller.userTheme, {
padding: '8px',
borderRadius: '4px',
fontSize: '12px',
lineHeight: '1.2'
});
// Get saved preference or default to 'routes'
const savedPreference = localStorage.getItem('mapRouteMode') || 'routes';
@ -1395,10 +1395,8 @@ export default class extends BaseController {
this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths);
div.style.backgroundColor = 'white';
applyThemeToPanel(div, this.userTheme);
div.style.padding = '10px';
div.style.border = '1px solid #ccc';
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
div.style.marginRight = '10px';
div.style.marginTop = '10px';
div.style.width = '300px';
@ -1840,7 +1838,7 @@ export default class extends BaseController {
initializeLocationSearch() {
if (this.map && this.apiKey && this.features.reverse_geocoding) {
this.locationSearch = new LocationSearch(this.map, this.apiKey);
this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme);
}
}
}

View file

@ -50,7 +50,7 @@ export default class extends BaseController {
try {
// Use appropriate default layer based on self-hosted mode
const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted, 'dark');
// If no layers were created, fall back to OSM
if (Object.keys(maps).length === 0) {

View file

@ -264,7 +264,7 @@ export default class extends BaseController {
try {
// Use appropriate default layer based on self-hosted mode
const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted, 'dark');
// If no layers were created, fall back to OSM
if (Object.keys(maps).length === 0) {

View file

@ -53,7 +53,7 @@ export default class extends BaseController {
this.userSettingsValue.preferred_map_layer || "OpenStreetMap" :
"OpenStreetMap";
let maps = createAllMapLayers(this.map, selectedLayerName);
let maps = createAllMapLayers(this.map, selectedLayerName, "false", 'dark');
// Add custom map if it exists in settings
if (this.hasUserSettingsValue && this.userSettingsValue.maps && this.userSettingsValue.maps.url) {

View file

@ -168,7 +168,7 @@ export default class extends BaseController {
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
let maps = createAllMapLayers(this.map, selectedLayerName);
let maps = createAllMapLayers(this.map, selectedLayerName, "false", 'dark');
// Add custom map if it exists in settings
if (this.userSettings.maps && this.userSettings.maps.url) {

View file

@ -0,0 +1,217 @@
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "@rails/activestorage"
import { showFlashMessage } from "../maps/helpers"
export default class extends Controller {
static targets = ["input", "progress", "progressBar", "submit", "form"]
static values = {
url: String,
userTrial: Boolean
}
connect() {
this.inputTarget.addEventListener("change", this.upload.bind(this))
// Add form submission handler to disable the file input
if (this.hasFormTarget) {
this.formTarget.addEventListener("submit", this.onSubmit.bind(this))
}
// Initially disable submit button if no files are uploaded
if (this.hasSubmitTarget) {
const hasUploadedFiles = this.element.querySelectorAll('input[name="archive"][type="hidden"]').length > 0
this.submitTarget.disabled = !hasUploadedFiles
}
}
onSubmit(event) {
if (this.isUploading) {
// If still uploading, prevent submission
event.preventDefault()
console.log("Form submission prevented during upload")
return
}
// Disable the file input to prevent it from being submitted with the form
// This ensures only our hidden input with signed ID is submitted
this.inputTarget.disabled = true
// Check if we have a signed ID
const signedId = this.element.querySelector('input[name="archive"][type="hidden"]')
if (!signedId) {
event.preventDefault()
console.log("No file uploaded yet")
alert("Please select and upload a ZIP archive first")
} else {
console.log("Submitting form with uploaded archive")
}
}
upload() {
const files = this.inputTarget.files
if (files.length === 0) return
const file = files[0] // Only handle single file for archives
// Validate file type
if (!this.isValidZipFile(file)) {
showFlashMessage('error', 'Please select a valid ZIP file.')
this.inputTarget.value = ''
return
}
// Check file size limits for trial users
if (this.userTrialValue) {
const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes
if (file.size > MAX_FILE_SIZE) {
const message = `File size limit exceeded. Trial users can only upload files up to 10MB. File size: ${(file.size / 1024 / 1024).toFixed(1)}MB`
showFlashMessage('error', message)
// Clear the file input
this.inputTarget.value = ''
return
}
}
console.log(`Uploading archive: ${file.name}`)
this.isUploading = true
// Disable submit button during upload
this.submitTarget.disabled = true
this.submitTarget.classList.add("opacity-50", "cursor-not-allowed")
// Show uploading message using flash
showFlashMessage('notice', `Uploading ${file.name}, please wait...`)
// Always remove any existing progress bar to ensure we create a fresh one
if (this.hasProgressTarget) {
this.progressTarget.remove()
}
// Create a wrapper div with better DaisyUI styling
const progressWrapper = document.createElement("div")
progressWrapper.className = "w-full mt-4 mb-4"
// Add a label with better typography
const progressLabel = document.createElement("div")
progressLabel.className = "text-sm font-medium text-base-content mb-2 flex justify-between items-center"
progressLabel.innerHTML = `
<span>Upload Progress</span>
<span class="text-xs text-base-content/70 progress-percentage">0%</span>
`
progressWrapper.appendChild(progressLabel)
// Create DaisyUI progress element
const progressContainer = document.createElement("progress")
progressContainer.setAttribute("data-user-data-archive-direct-upload-target", "progress")
progressContainer.className = "progress progress-primary w-full h-3"
progressContainer.value = 0
progressContainer.max = 100
// Create a hidden div for the progress bar target (for compatibility)
const progressBarFill = document.createElement("div")
progressBarFill.setAttribute("data-user-data-archive-direct-upload-target", "progressBar")
progressBarFill.style.display = "none"
progressWrapper.appendChild(progressContainer)
progressWrapper.appendChild(progressBarFill)
// Add the progress wrapper after the form-control div containing the file input
const formControl = this.inputTarget.closest('.form-control')
if (formControl) {
formControl.parentNode.insertBefore(progressWrapper, formControl.nextSibling)
} else {
// Fallback: insert before submit button
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
}
console.log("Progress bar created and inserted after file input")
// Clear any existing hidden field for archive
const existingHiddenField = this.element.querySelector('input[name="archive"][type="hidden"]')
if (existingHiddenField) {
existingHiddenField.remove()
}
const upload = new DirectUpload(file, this.urlValue, this)
upload.create((error, blob) => {
if (error) {
console.error("Error uploading file:", error)
// Show error to user using flash
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
// Re-enable submit button but keep it disabled since no file was uploaded
this.submitTarget.disabled = true
this.submitTarget.classList.add("opacity-50", "cursor-not-allowed")
} else {
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
// Create a hidden field with the correct name
const hiddenField = document.createElement("input")
hiddenField.setAttribute("type", "hidden")
hiddenField.setAttribute("name", "archive")
hiddenField.setAttribute("value", blob.signed_id)
this.element.appendChild(hiddenField)
console.log("Added hidden field with signed ID:", blob.signed_id)
// Enable submit button
this.submitTarget.disabled = false
this.submitTarget.classList.remove("opacity-50", "cursor-not-allowed")
showFlashMessage('notice', `Archive uploaded successfully. Ready to import.`)
// Add a completion animation to the progress bar
const percentageDisplay = this.element.querySelector('.progress-percentage')
if (percentageDisplay) {
percentageDisplay.textContent = '100%'
percentageDisplay.classList.add('text-success')
}
if (this.hasProgressTarget) {
this.progressTarget.value = 100
this.progressTarget.classList.add('progress-success')
this.progressTarget.classList.remove('progress-primary')
}
}
this.isUploading = false
console.log("Upload completed")
})
}
isValidZipFile(file) {
// Check MIME type
const validMimeTypes = ['application/zip', 'application/x-zip-compressed']
if (validMimeTypes.includes(file.type)) {
return true
}
// Check file extension as fallback
const filename = file.name.toLowerCase()
return filename.endsWith('.zip')
}
directUploadWillStoreFileWithXHR(request) {
request.upload.addEventListener("progress", event => {
if (!this.hasProgressTarget) {
console.warn("Progress target not found")
return
}
const progress = (event.loaded / event.total) * 100
const progressPercentage = `${progress.toFixed(1)}%`
console.log(`Upload progress: ${progressPercentage}`)
// Update the DaisyUI progress element
this.progressTarget.value = progress
// Update the percentage display
const percentageDisplay = this.element.querySelector('.progress-percentage')
if (percentageDisplay) {
percentageDisplay.textContent = progressPercentage
}
})
}
}

View file

@ -49,8 +49,11 @@ export function createMapLayer(map, selectedLayerName, layerKey, selfHosted) {
export function createAllMapLayers(map, selectedLayerName, selfHosted) {
const layers = {};
const mapsConfig = selfHosted === "true" ? rasterMapsConfig : vectorMapsConfig;
Object.keys(mapsConfig).forEach(layerKey => {
layers[layerKey] = createMapLayer(map, selectedLayerName, layerKey, selfHosted);
// Create the layer and add it to the map if it's the user's selected layer
const layer = createMapLayer(map, selectedLayerName, layerKey, selfHosted);
layers[layerKey] = layer;
});
return layers;

View file

@ -1,8 +1,11 @@
// Location search functionality for the map
import { applyThemeToButton } from "./theme_utils";
class LocationSearch {
constructor(map, apiKey) {
constructor(map, apiKey, userTheme = 'dark') {
this.map = map;
this.apiKey = apiKey;
this.userTheme = userTheme;
this.searchResults = [];
this.searchMarkersLayer = null;
this.currentSearchQuery = '';
@ -22,12 +25,10 @@ class LocationSearch {
onAdd: function(map) {
const button = L.DomUtil.create('button', 'location-search-toggle');
button.innerHTML = '🔍';
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
button.style.backgroundColor = 'white';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.fontSize = '18px';
@ -1158,6 +1159,7 @@ class LocationSearch {
return new Date(dateString).toLocaleDateString() + ' ' +
new Date(dateString).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
}
export { LocationSearch };

View file

@ -0,0 +1,156 @@
// Dynamic CSS injection for theme-aware Leaflet controls
export function injectThemeStyles(userTheme) {
// Remove existing theme styles if any
const existingStyle = document.getElementById('leaflet-theme-styles');
if (existingStyle) {
existingStyle.remove();
}
const themeColors = getThemeColors(userTheme);
const css = `
/* Leaflet default controls theme override */
.leaflet-control-layers,
.leaflet-control-zoom,
.leaflet-control-attribution,
.leaflet-bar a,
.leaflet-control-layers-toggle,
.leaflet-control-layers-list,
.leaflet-control-draw {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
border-color: ${themeColors.borderColor} !important;
box-shadow: 0 1px 4px ${themeColors.shadowColor} !important;
}
/* Leaflet zoom buttons */
.leaflet-control-zoom a {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
border-bottom: 1px solid ${themeColors.borderColor} !important;
}
.leaflet-control-zoom a:hover {
background-color: ${themeColors.hoverColor} !important;
}
/* Leaflet layer control */
.leaflet-control-layers-toggle {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
}
.leaflet-control-layers-expanded {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
}
.leaflet-control-layers label {
color: ${themeColors.textColor} !important;
}
/* Leaflet Draw controls */
.leaflet-draw-toolbar a {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
border-bottom: 1px solid ${themeColors.borderColor} !important;
}
.leaflet-draw-toolbar a:hover {
background-color: ${themeColors.hoverColor} !important;
}
.leaflet-draw-actions a {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
}
/* Leaflet popups */
.leaflet-popup-content-wrapper {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
}
.leaflet-popup-tip {
background-color: ${themeColors.backgroundColor} !important;
}
/* Attribution control */
.leaflet-control-attribution a {
color: ${userTheme === 'light' ? '#0066cc' : '#66b3ff'} !important;
}
/* Custom control buttons */
.leaflet-control-button,
.add-visit-button,
.leaflet-bar button {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
border: 1px solid ${themeColors.borderColor} !important;
box-shadow: 0 1px 4px ${themeColors.shadowColor} !important;
}
.leaflet-control-button:hover,
.add-visit-button:hover,
.leaflet-bar button:hover {
background-color: ${themeColors.hoverColor} !important;
}
/* Any other custom controls */
.leaflet-top .leaflet-control button,
.leaflet-bottom .leaflet-control button,
.leaflet-left .leaflet-control button,
.leaflet-right .leaflet-control button {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
border: 1px solid ${themeColors.borderColor} !important;
}
/* Location search button */
.location-search-toggle,
#location-search-toggle {
background-color: ${themeColors.backgroundColor} !important;
color: ${themeColors.textColor} !important;
border: 1px solid ${themeColors.borderColor} !important;
box-shadow: 0 1px 4px ${themeColors.shadowColor} !important;
}
.location-search-toggle:hover,
#location-search-toggle:hover {
background-color: ${themeColors.hoverColor} !important;
}
/* Distance scale control - minimal theming to avoid duplication */
.leaflet-control-scale {
background: rgba(${userTheme === 'light' ? '255, 255, 255' : '55, 65, 81'}, 0.9) !important;
border-radius: 3px !important;
padding: 2px !important;
}
`;
// Inject the CSS
const style = document.createElement('style');
style.id = 'leaflet-theme-styles';
style.textContent = css;
document.head.appendChild(style);
}
function getThemeColors(userTheme) {
if (userTheme === 'light') {
return {
backgroundColor: '#ffffff',
textColor: '#000000',
borderColor: '#e5e7eb',
shadowColor: 'rgba(0, 0, 0, 0.1)',
hoverColor: '#f3f4f6'
};
} else {
return {
backgroundColor: '#374151',
textColor: '#ffffff',
borderColor: '#4b5563',
shadowColor: 'rgba(0, 0, 0, 0.3)',
hoverColor: '#4b5563'
};
}
}

View file

@ -0,0 +1,79 @@
// Theme utility functions for map controls and buttons
/**
* Get theme-aware styles for map controls based on user theme
* @param {string} userTheme - 'light' or 'dark'
* @returns {Object} Object containing CSS properties for the theme
*/
export function getThemeStyles(userTheme) {
if (userTheme === 'light') {
return {
backgroundColor: '#ffffff',
color: '#000000',
borderColor: '#e5e7eb',
shadowColor: 'rgba(0, 0, 0, 0.1)'
};
} else {
return {
backgroundColor: '#374151',
color: '#ffffff',
borderColor: '#4b5563',
shadowColor: 'rgba(0, 0, 0, 0.3)'
};
}
}
/**
* Apply theme-aware styles to a control element
* @param {HTMLElement} element - DOM element to style
* @param {string} userTheme - 'light' or 'dark'
* @param {Object} additionalStyles - Optional additional CSS properties
*/
export function applyThemeToControl(element, userTheme, additionalStyles = {}) {
const themeStyles = getThemeStyles(userTheme);
// Apply base theme styles
element.style.backgroundColor = themeStyles.backgroundColor;
element.style.color = themeStyles.color;
element.style.border = `1px solid ${themeStyles.borderColor}`;
element.style.boxShadow = `0 1px 4px ${themeStyles.shadowColor}`;
// Apply any additional styles
Object.assign(element.style, additionalStyles);
}
/**
* Apply theme-aware styles to a button element
* @param {HTMLElement} button - Button element to style
* @param {string} userTheme - 'light' or 'dark'
*/
export function applyThemeToButton(button, userTheme) {
applyThemeToControl(button, userTheme, {
border: 'none',
cursor: 'pointer'
});
// Add hover effects
const themeStyles = getThemeStyles(userTheme);
const hoverBg = userTheme === 'light' ? '#f3f4f6' : '#4b5563';
button.addEventListener('mouseenter', () => {
button.style.backgroundColor = hoverBg;
});
button.addEventListener('mouseleave', () => {
button.style.backgroundColor = themeStyles.backgroundColor;
});
}
/**
* Apply theme-aware styles to a panel/container element
* @param {HTMLElement} panel - Panel element to style
* @param {string} userTheme - 'light' or 'dark'
*/
export function applyThemeToPanel(panel, userTheme) {
applyThemeToControl(panel, userTheme, {
borderRadius: '4px'
});
}

View file

@ -1,13 +1,15 @@
import L from "leaflet";
import { showFlashMessage } from "./helpers";
import { applyThemeToButton } from "./theme_utils";
/**
* Manages visits functionality including displaying, fetching, and interacting with visits
*/
export class VisitsManager {
constructor(map, apiKey) {
constructor(map, apiKey, userTheme = 'dark') {
this.map = map;
this.apiKey = apiKey;
this.userTheme = userTheme;
// Create custom panes for different visit types
if (!map.getPane('confirmedVisitsPane')) {
@ -67,12 +69,10 @@ export class VisitsManager {
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.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
button.style.backgroundColor = 'white';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
@ -104,12 +104,10 @@ export class VisitsManager {
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.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
button.style.backgroundColor = 'white';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';

View file

@ -40,7 +40,7 @@ class Imports::SourceDetector
]
},
geojson: {
required_keys: ['type', 'features'],
required_keys: %w[type features],
required_values: { 'type' => 'FeatureCollection' },
nested_patterns: [
['features', 0, 'type'],
@ -79,9 +79,7 @@ class Imports::SourceDetector
DETECTION_RULES.each do |format, rules|
next if format == :owntracks # Already handled above
if matches_format?(json_data, rules)
return format
end
return format if matches_format?(json_data, rules)
end
nil
@ -105,14 +103,17 @@ class Imports::SourceDetector
return false unless filename.downcase.end_with?('.gpx')
# Check content for GPX structure
content_to_check = if file_path && File.exist?(file_path)
# Read first 1KB for GPX detection
File.open(file_path, 'rb') { |f| f.read(1024) }
else
file_content
end
content_to_check.strip.start_with?('<?xml') && content_to_check.include?('<gpx')
content_to_check =
if file_path && File.exist?(file_path)
# Read first 1KB for GPX detection
File.open(file_path, 'rb') { |f| f.read(1024) }
else
file_content
end
(
content_to_check.strip.start_with?('<?xml') ||
content_to_check.strip.start_with?('<gpx')
) && content_to_check.include?('<gpx')
end
def owntracks_file?
@ -123,11 +124,11 @@ class Imports::SourceDetector
# Check for specific OwnTracks line format in content
content_to_check = if file_path && File.exist?(file_path)
# For OwnTracks, read first few lines only
File.open(file_path, 'r') { |f| f.read(2048) }
else
file_content
end
# For OwnTracks, read first few lines only
File.open(file_path, 'r') { |f| f.read(2048) }
else
file_content
end
content_to_check.lines.any? { |line| line.include?('"_type":"location"') }
end
@ -169,19 +170,13 @@ class Imports::SourceDetector
return false unless structure_matches?(json_data, pattern[:structure])
# Check required keys
if pattern[:required_keys]
return false unless has_required_keys?(json_data, pattern[:required_keys])
end
return false if pattern[:required_keys] && !has_required_keys?(json_data, pattern[:required_keys])
# Check required values
if pattern[:required_values]
return false unless has_required_values?(json_data, pattern[:required_values])
end
return false if pattern[:required_values] && !has_required_values?(json_data, pattern[:required_values])
# Check nested patterns
if pattern[:nested_patterns]
return false unless has_nested_patterns?(json_data, pattern[:nested_patterns])
end
return false if pattern[:nested_patterns] && !has_nested_patterns?(json_data, pattern[:nested_patterns])
true
end
@ -221,9 +216,11 @@ class Imports::SourceDetector
if current.is_a?(Array)
return false if key >= current.length
current = current[key]
elsif current.is_a?(Hash)
return false unless current.key?(key)
current = current[key]
else
return false

View file

@ -82,16 +82,35 @@
<h3 class="font-bold text-lg mb-4">Import your data</h3>
<p class="mb-4 text-sm text-gray-600">Upload a ZIP file containing your exported Dawarich data to restore your points, trips, and settings.</p>
<%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: { turbo: false } do |f| %>
<%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: {
turbo: false,
controller: "user-data-archive-direct-upload",
user_data_archive_direct_upload_url_value: rails_direct_uploads_url,
user_data_archive_direct_upload_user_trial_value: current_user.trial?,
user_data_archive_direct_upload_target: "form"
} do |f| %>
<div class="form-control">
<%= f.label :archive, class: 'label' do %>
<span class="label-text">Select ZIP archive</span>
<% end %>
<%= f.file_field :archive, accept: '.zip', required: true, class: 'file-input file-input-bordered w-full' %>
<%= f.file_field :archive,
accept: '.zip',
required: true,
direct_upload: true,
class: 'file-input file-input-bordered w-full',
data: { user_data_archive_direct_upload_target: "input" } %>
<div class="text-sm text-gray-500 mt-2">
File will be uploaded directly to storage. Please be patient during upload.
</div>
</div>
<div class="modal-action">
<%= f.submit "Import Data", class: 'btn btn-primary', data: { disable_with: 'Importing...' } %>
<%= f.submit "Import Data",
class: 'btn btn-primary',
data: {
disable_with: 'Importing...',
user_data_archive_direct_upload_target: "submit"
} %>
<button type="button" class="btn" onclick="import_modal.close()">Cancel</button>
</div>
<% end %>

View file

@ -68,6 +68,7 @@
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= (current_user.settings || {}).to_json.html_safe %>'
data-user_theme="<%= current_user&.theme || 'dark' %>"
data-coordinates='<%= @coordinates.to_json.html_safe %>'
data-tracks='<%= @tracks.to_json.html_safe %>'
data-distance="<%= @distance %>"

View file

@ -4,7 +4,6 @@ class DawarichSettings
BASIC_PAID_PLAN_LIMIT = 10_000_000 # 10 million points
class << self
def reverse_geocoding_enabled?
@reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? || nominatim_enabled?
end

View file

@ -1,4 +1,4 @@
FROM ruby:3.4.1-slim
FROM ruby:3.4.6-slim
ENV APP_PATH=/var/app
ENV BUNDLE_VERSION=2.5.21
@ -13,6 +13,7 @@ ENV SIDEKIQ_PASSWORD=password
ENV PGSSENCMODE=disable
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl \
wget \
build-essential \
git \
@ -24,10 +25,12 @@ RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no
libgeos-dev libgeos++-dev \
imagemagick \
tzdata \
nodejs \
yarn \
less \
libjemalloc2 libjemalloc-dev \
cmake \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g yarn \
&& mkdir -p $APP_PATH \
&& rm -rf /var/lib/apt/lists/*
@ -42,7 +45,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \
ENV RUBY_YJIT_ENABLE=1
# Update RubyGems and install Bundler
RUN gem update --system 3.6.2 \
RUN gem update --system 3.6.9 \
&& gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
@ -52,7 +55,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
RUN bundle config set --local path 'vendor/bundle' \
&& bundle install --jobs 4 --retry 3 \
&& rm -rf vendor/bundle/ruby/3.4.1/cache/*.gem
&& rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem
COPY ../. ./

View file

@ -1,4 +1,4 @@
FROM ruby:3.4.1-slim
FROM ruby:3.4.6-slim
ENV APP_PATH=/var/app
ENV BUNDLE_VERSION=2.5.21
@ -8,6 +8,7 @@ ENV RAILS_PORT=3000
ENV RAILS_ENV=production
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl \
wget \
build-essential \
git \
@ -19,10 +20,12 @@ RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no
libgeos-dev libgeos++-dev \
imagemagick \
tzdata \
nodejs \
yarn \
less \
libjemalloc2 libjemalloc-dev \
cmake \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g yarn \
&& mkdir -p $APP_PATH \
&& rm -rf /var/lib/apt/lists/*
@ -37,7 +40,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \
ENV RUBY_YJIT_ENABLE=1
# Update gem system and install bundler
RUN gem update --system 3.6.2 \
RUN gem update --system 3.6.9 \
&& gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
@ -49,7 +52,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
RUN bundle config set --local path 'vendor/bundle' \
&& bundle config set --local without 'development test' \
&& bundle install --jobs 4 --retry 3 \
&& rm -rf vendor/bundle/ruby/3.4.1/cache/*.gem
&& rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem
COPY ../. ./

View file

@ -1 +1,19 @@
{"name":"Dawarich","short_name":"Dawarich","icons":[{"src":"/assets/favicon/android-chrome-192x192-f9610e2af28e4e48ff0472572c0cb9e3902d29bccc2b07f8f03aabf684822355.png","sizes":"192x192","type":"image/png"},{"src":"/assets/favicon/android-chrome-512x512-c2ec8132d773ae99f53955360cdd5691bb38e0ed141bddebd39d896b78b5afb6.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "Dawarich",
"short_name": "Dawarich",
"icons": [
{
"src": "/assets/favicon/android-chrome-192x192-f9610e2af28e4e48ff0472572c0cb9e3902d29bccc2b07f8f03aabf684822355.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/favicon/android-chrome-512x512-c2ec8132d773ae99f53955360cdd5691bb38e0ed141bddebd39d896b78b5afb6.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}