mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 17:51:39 -05:00
Merge branch 'dev' into staging-env
This commit is contained in:
commit
8807950180
28 changed files with 728 additions and 164 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.4.1
|
||||
3.4.6
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "© <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: "© <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
156
app/javascript/maps/theme_styles.js
Normal file
156
app/javascript/maps/theme_styles.js
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
79
app/javascript/maps/theme_utils.js
Normal file
79
app/javascript/maps/theme_utils.js
Normal 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'
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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 %>"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ../. ./
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ../. ./
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue