Compare commits
172 commits
0.34.0-rc.
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f81738df | ||
|
|
6ed6a4fd89 | ||
|
|
8d2ade1bdc | ||
|
|
3f0aaa09f5 | ||
|
|
2a1584e0b8 | ||
|
|
c8242ce902 | ||
|
|
0ed20df08b | ||
|
|
d50f2cc416 | ||
|
|
8934c29fce | ||
|
|
d5dbf002e0 | ||
|
|
52eb80503d | ||
|
|
f8be3ecdca | ||
|
|
573ed510a5 | ||
|
|
b1393ee674 | ||
|
|
fa4e368003 | ||
|
|
4aa6edc3cc | ||
|
|
f447039bbe | ||
|
|
289a2a5373 | ||
|
|
6dfc0099e1 | ||
|
|
78ac365c00 | ||
|
|
5266436396 | ||
|
|
e965838f14 | ||
|
|
a33373ae7c | ||
|
|
e1013b1ae1 | ||
|
|
64d299b363 | ||
|
|
e4fa282eb8 | ||
|
|
ba6314231a | ||
|
|
b18fc392cc | ||
|
|
02cbf65781 | ||
|
|
1e2c709047 | ||
|
|
50bfece971 | ||
|
|
c99f6597f0 | ||
|
|
491767b114 | ||
|
|
ebd0f8d6bc | ||
|
|
01df22d080 | ||
|
|
e02b397b87 | ||
|
|
1d07eb652d | ||
|
|
449884796f | ||
|
|
4f5903e220 | ||
|
|
2ab24201c1 | ||
|
|
c711bed383 | ||
|
|
bce1052608 | ||
|
|
807672170f | ||
|
|
e8e7bcc91b | ||
|
|
602975eeaa | ||
|
|
8a36a69987 | ||
|
|
c462d34efa | ||
|
|
e1f16c98a2 | ||
|
|
78851c5f98 | ||
|
|
69c8779164 | ||
|
|
284f763be4 | ||
|
|
541dba1bc6 | ||
|
|
3b5f775a4e | ||
|
|
3a2dc1da5a | ||
|
|
d1ffc15fea | ||
|
|
88134a0a2b | ||
|
|
bb574f5aa3 | ||
|
|
8ecd75429b | ||
|
|
bedac89821 | ||
|
|
a4dcd0387f | ||
|
|
fde478e2a4 | ||
|
|
cfe5a77a47 | ||
|
|
943b551f4c | ||
|
|
3bd59c20c1 | ||
|
|
8c4d4d5cbe | ||
|
|
59508ceeff | ||
|
|
8c48c173fb | ||
|
|
c64dc8b789 | ||
|
|
32667590fd | ||
|
|
b6d1f1d46d | ||
|
|
eaf66c8bbd | ||
|
|
d707bace78 | ||
|
|
6d905a4466 | ||
|
|
749d1d0031 | ||
|
|
dbe5997495 | ||
|
|
58ae4cf2ae | ||
|
|
b0585b2a97 | ||
|
|
6e1c9d7600 | ||
|
|
b0bd2bf93c | ||
|
|
3061f3e86f | ||
|
|
28bc68ffe2 | ||
|
|
23aa533279 | ||
|
|
18de91e562 | ||
|
|
486974b993 | ||
|
|
73f93b6a57 | ||
|
|
4a022d9695 | ||
|
|
2a8522272d | ||
|
|
a6687eca18 | ||
|
|
c05402b6f3 | ||
|
|
98a157bd0b | ||
|
|
e4f80dbf2b | ||
|
|
f48a512b10 | ||
|
|
b272c7407f | ||
|
|
691ff63b87 | ||
|
|
f9d5762533 | ||
|
|
cb9fb9911c | ||
|
|
b469836d19 | ||
|
|
fa8cbed15b | ||
|
|
8444ee461d | ||
|
|
f9f013c628 | ||
|
|
6a42a170e7 | ||
|
|
d40514c5f8 | ||
|
|
100e645c52 | ||
|
|
a8e9df6f1a | ||
|
|
7b9008445a | ||
|
|
313354bf7c | ||
|
|
eed9480a9e | ||
|
|
a6f2bd3662 | ||
|
|
adcf6aceca | ||
|
|
19f6715218 | ||
|
|
9378e330e4 | ||
|
|
d2ee93f51a | ||
|
|
3626ccd830 | ||
|
|
71943a46e1 | ||
|
|
1043c105ed | ||
|
|
bbd0fe1e4d | ||
|
|
2f160b8d97 | ||
|
|
888e48ccf2 | ||
|
|
ef46c84ce1 | ||
|
|
57149cfc17 | ||
|
|
accf8ffbc9 | ||
|
|
07224723ed | ||
|
|
bf96acf92e | ||
|
|
282441db0b | ||
|
|
18836975ca | ||
|
|
5a9bdfea5f | ||
|
|
6c62edb593 | ||
|
|
6a3f7aebac | ||
|
|
3d6f953063 | ||
|
|
9b50bc0a48 | ||
|
|
60f80ec2da | ||
|
|
55e1f4a161 | ||
|
|
2ffac60dbb | ||
|
|
8e35b8e09f | ||
|
|
5a40f9fe90 | ||
|
|
6787273713 | ||
|
|
67d3c9c9f5 | ||
|
|
7bc579e563 | ||
|
|
e70d7781e4 | ||
|
|
e6c8bd30df | ||
|
|
48e50c2ee8 | ||
|
|
af71661e2b | ||
|
|
44bbbd09b7 | ||
|
|
f5dc7a10a3 | ||
|
|
8c9fc5a5e0 | ||
|
|
cd9c02324b | ||
|
|
4a226638c3 | ||
|
|
0f14e32fb9 | ||
|
|
8ba5fae588 | ||
|
|
e4bc701581 | ||
|
|
64bf9f7cb3 | ||
|
|
39dd1b41e0 | ||
|
|
17c88ede25 | ||
|
|
a7ba4187f6 | ||
|
|
37cf712111 | ||
|
|
832325896c | ||
|
|
8c24764aa5 | ||
|
|
e1ee39ec52 | ||
|
|
d23e118645 | ||
|
|
4f4ac08caf | ||
|
|
7ee2cb22ba | ||
|
|
4677bcc698 | ||
|
|
05237995cf | ||
|
|
2a1792c2d3 | ||
|
|
2accbeef3d | ||
|
|
b413c51c4f | ||
|
|
d46cd2dc74 | ||
|
|
62725a55e7 | ||
|
|
dd2e6a49bc | ||
|
|
240d90cea1 | ||
|
|
480142b494 | ||
|
|
4a704ed608 |
|
|
@ -1 +1 @@
|
||||||
0.34.0
|
0.37.2
|
||||||
|
|
|
||||||
2
.github/workflows/build_and_push.yml
vendored
|
|
@ -96,7 +96,7 @@ jobs:
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile.dev
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
|
||||||
1
.gitignore
vendored
|
|
@ -84,3 +84,4 @@ node_modules/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
/e2e/temp/
|
||||||
|
|
|
||||||
32
AGENTS.md
|
|
@ -1,32 +0,0 @@
|
||||||
# Repository Guidelines
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
|
||||||
- `app/` holds the Rails application: controllers and views under feature-oriented folders, `services/` for importers and background workflows, and `policies/` for Pundit authorization.
|
|
||||||
- `app/javascript/` contains Stimulus controllers (`controllers/`), map widgets (`maps/`), and Tailwind/Turbo setup in `application.js`.
|
|
||||||
- `lib/` stores reusable support code and rake tasks, while `config/` tracks environment settings, credentials, and initializers.
|
|
||||||
- `db/` carries schema migrations and data migrations; `spec/` provides RSpec coverage; `e2e/` hosts Playwright scenarios; `docker/` bundles deployment compose files.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
|
||||||
- `bundle exec rails db:prepare` initializes or migrates the PostgreSQL database.
|
|
||||||
- `bundle exec bin/dev` starts the Rails app plus JS bundler via Foreman using `Procfile.dev` (set `PROMETHEUS_EXPORTER_ENABLED=true` to use the Prometheus profile).
|
|
||||||
- `bundle exec sidekiq` runs background jobs locally alongside the web server.
|
|
||||||
- `docker compose -f docker/docker-compose.yml up` brings up the containerized stack for end-to-end smoke checks.
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
|
||||||
- Follow default Ruby style with two-space indentation and snake_case filenames; run `bin/rubocop` before pushing.
|
|
||||||
- JavaScript modules in `app/javascript/` use ES modules and Stimulus naming (`*_controller.js`); keep exports camelCase and limit files to a single controller.
|
|
||||||
- Tailwind classes power the UI; co-locate shared styles under `app/javascript/styles/` rather than inline overrides.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
- Use `bundle exec rspec` for unit and feature specs; mirror production behavior by tagging jobs or services with factories in `spec/support`.
|
|
||||||
- End-to-end flows live in `e2e/`; execute `npx playwright test` (set `BASE_URL` if the server runs on a non-default port).
|
|
||||||
- Commit failing scenarios together with the fix, and prefer descriptive `it "..."` strings that capture user intent.
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
|
||||||
- Write concise, imperative commit titles (e.g., `Add family sharing policy`); group related changes rather than omnibus commits.
|
|
||||||
- Target pull requests at the `dev` branch, describe the motivation, reference GitHub issues when applicable, and attach screenshots for UI-facing changes.
|
|
||||||
- Confirm CI, lint, and test status before requesting review; call out migrations or data tasks in the PR checklist.
|
|
||||||
|
|
||||||
## Environment & Configuration Tips
|
|
||||||
- Copy `.env.example` to `.env` or rely on Docker secrets to supply API keys, map tokens, and mail credentials.
|
|
||||||
- Regenerate credentials with `bin/rails credentials:edit` when altering secrets, and avoid committing any generated `.env` or `credentials.yml.enc` changes.
|
|
||||||
197
CHANGELOG.md
|
|
@ -4,6 +4,201 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
# [0.37.2] - 2026-01-04
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Months are now correctly ordered (Jan-Dec) in the year-end digest chart instead of being sorted alphabetically.
|
||||||
|
- Time spent in a country and city is now calculated correctly for the year-end digest email. #2104
|
||||||
|
- Updated Trix to fix a XSS vulnerability. #2102
|
||||||
|
- Map v2 UI no longer blocks when Immich/Photoprism integration has a bad URL or is unreachable. Added 10-second timeout to photo API requests and improved error handling to prevent UI freezing during initial load. #2085
|
||||||
|
- In Map v2 settings, you can now enable map to be rendered as a globe.
|
||||||
|
|
||||||
|
# [0.37.1] - 2025-12-30
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- The db migration preventing the app from starting.
|
||||||
|
- Raw data archive verifier now allows having points deleted from the db after archiving.
|
||||||
|
|
||||||
|
# [0.37.0] - 2025-12-30
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- In the beginning of the year users will receive a year-end digest email with stats about their tracking activity during the past year. Users can opt out of receiving these emails in User Settings -> Notifications. Emails won't be sent if no email is configured in the SMTP settings or if user has no points tracked during the year.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Added and removed some indexes to improve the app performance based on the production usage data.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Deleting an import will now be processed in the background to prevent request timeouts for large imports.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Deleting an import will no longer result in negative points count for the user.
|
||||||
|
- Updating stats. #2022
|
||||||
|
- Validate trip start date to be earlier than end date. #2057
|
||||||
|
- Fog of war radius slider in map v2 settings is now being respected correctly. #2041
|
||||||
|
- Applying changes in map v2 settings now works correctly. #2041
|
||||||
|
- Invalidate stats cache on recalculation and other operations that change stats data.
|
||||||
|
|
||||||
|
|
||||||
|
# [0.36.4] - 2025-12-26
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Fixed a bug preventing the app to start if a composite index on stats table already exists. #2034 #2051 #2046
|
||||||
|
- New compiled assets will override old ones on app start to prevent serving stale assets.
|
||||||
|
- Number of points in stats should no longer go negative when points are deleted. #2054
|
||||||
|
- Disable Family::Invitations::CleanupJob no invitations are in the database. #2043
|
||||||
|
- User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036
|
||||||
|
|
||||||
|
|
||||||
|
# [0.36.3] - 2025-12-14
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Setting `ARCHIVE_RAW_DATA` env var to true will enable monthly raw data archiving for all users. It will look for points older than 2 months with `raw_data` column not empty and create a zip archive containing raw data files for each month. After successful archiving, raw data will be removed from the database to save space. Monthly archiving job is being run every day at 2:00 AM. Default env var value is false.
|
||||||
|
- In map v2, user can now move points when Points layer is enabled. #2024
|
||||||
|
- In map v2, routes are now being rendered using same logic as in map v1, route-length-wise. #2026
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Cities visited during a trip are now being calculated correctly. #547 #641 #1686 #1976
|
||||||
|
- Points on the map are now show time in user's timezone. #580 #1035 #1682
|
||||||
|
- Date range inputs now handle pre-epoch dates gracefully by clamping to valid PostgreSQL integer range. #685
|
||||||
|
- Redis client now also being configured so that it could connect via unix socket. #1970
|
||||||
|
- Importing KML files now creates points with correct timestamps. #1988
|
||||||
|
- Importing KMZ files now works correctly.
|
||||||
|
- Map settings are now being respected in map v2. #2012
|
||||||
|
|
||||||
|
|
||||||
|
# [0.36.2] - 2025-12-06
|
||||||
|
|
||||||
|
## The Map v2 release
|
||||||
|
|
||||||
|
In this release we're introducing Map v2 based on MapLibre GL JS. It brings better performance, smoother interactions and more features in the future. User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the Settings -> Map Settings. New map features will be added to Map v2 only.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the User Settings.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Heatmap and Fog of War now are moving correctly during map interactions on v2 map. #1798
|
||||||
|
- Polyline crossing international date line now are rendered correctly on v2 map. #1162
|
||||||
|
- Place popup tags parsing (MapLibre GL JS compatibility)
|
||||||
|
- Stats calculation should be faster now.
|
||||||
|
|
||||||
|
|
||||||
|
# [0.36.1] - 2025-11-29
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Exporting user data now works a lot faster and consumes less memory.
|
||||||
|
- Fix the restart loop. #1937 #1975
|
||||||
|
|
||||||
|
# [0.36.0] - 2025-11-24
|
||||||
|
|
||||||
|
## OIDC and KML support release
|
||||||
|
|
||||||
|
So, you want to configure your OIDC provider. If not — skip to the actual changelog. You're going to need to provide at least 4 environment variables: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER`, and `OIDC_REDIRECT_URI`. Then, if you want to rename the provider from "OpenID Connect" to something else (e.g. "Authentik"), set `OIDC_PROVIDER_NAME` variable as well. If you want to disable email/password registration and allow only OIDC login, set `ALLOW_EMAIL_PASSWORD_REGISTRATION` to `false`. After just 7 brand new environment variables, you'll never have to deal with passwords in Dawarich again!
|
||||||
|
|
||||||
|
Jokes aside, even though I'm not a fan of bloating the environment with too many variables, this is a nice addition and it will be reused in the cloud version of Dawarich as well. Thanks for waiting more than a year for this feature!
|
||||||
|
|
||||||
|
To configure your OIDC provider, set the following environment variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
OIDC_CLIENT_ID=client_id_example
|
||||||
|
OIDC_CLIENT_SECRET=client_secret_example
|
||||||
|
OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/
|
||||||
|
OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback
|
||||||
|
OIDC_AUTO_REGISTER=true # optional, default is false
|
||||||
|
OIDC_PROVIDER_NAME=YourProviderName # optional, default is OpenID Connect
|
||||||
|
ALLOW_EMAIL_PASSWORD_REGISTRATION=false # optional, default is true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Support for KML file uploads. #350
|
||||||
|
- Added a commented line in the `docker-compose.yml` file to use an alternative PostGIS image for ARM architecture.
|
||||||
|
- User can now create a place directly from the map and add tags and notes to it. If reverse geocoding is enabled, list of nearby places will be shown as suggestions.
|
||||||
|
- User can create and manage tags for places.
|
||||||
|
- Visits for manually created places are being suggested automatically, just like for areas.
|
||||||
|
- User can enable or disable places layers on the map to show/hide all or just some of their visited places based on tags.
|
||||||
|
- User can define privacy zones around places with specific tags to hide map data within a certain radius.
|
||||||
|
- If user has a place tagged with a tag named "Home" (case insensitive), and this place doesn't have a privacy zone defined, this place will be used as home location for days with no tracked data. #1659 #1575
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- The map settings panel is now scrollable
|
||||||
|
- Fixed a bug where family location sharing settings were not being updated correctly. #1940
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Internal redis settings updated to implement support for connecting to Redis via unix socket. #1706
|
||||||
|
- Implemented authentication via GitHub and Google for Dawarich Cloud.
|
||||||
|
- Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66
|
||||||
|
|
||||||
|
|
||||||
|
# [0.35.1] - 2025-11-09
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- StrongMigration issue #1931
|
||||||
|
|
||||||
|
|
||||||
|
# [0.35.0] - 2025-11-09
|
||||||
|
|
||||||
|
⚠️ Important ⚠️
|
||||||
|
|
||||||
|
The default `docker-compose.yml` file has been updated to provide sensible defaults for self-hosted production environments. This should not break existing setups, but it's recommended to review your `docker-compose.yml` file and update it accordingly.
|
||||||
|
|
||||||
|
You can now set `RAILS_ENV` environment variable to `production` to run Dawarich in production mode.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Selection tool on the map now can select points that user can delete in bulk. #433
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Taiwan flag is now shown on its own instead of in combination with China flag.
|
||||||
|
- On the registration page and other user forms, if something goes wrong, error messages are now shown to the user.
|
||||||
|
- Leaving family, deleting family and cancelling invitations now prompt confirmation dialog to prevent accidental actions.
|
||||||
|
- Each pending family invitation now also contains a link to share with the invitee.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Removed useless system tests and cover map functionality with Playwright e2e tests instead.
|
||||||
|
- S3 storage now can be used in self-hosted instances as well. Set STORAGE_BACKEND environment variable to `s3` and provide `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_BUCKET` and `AWS_ENDPOINT_URL` environment variables to configure it.
|
||||||
|
- Number of family members on self-hosted instances is no longer limited. #1918
|
||||||
|
- Export to GPX now adds speed and course to each point if they are available.
|
||||||
|
- `docker-compose.yml` file updated to provide sensible defaults for self-hosted production environment.
|
||||||
|
- `.env.example` file added with default environment variables.
|
||||||
|
- Single Dockerfile introduced so Dawarich could be run in self-hosted mode in production environment.
|
||||||
|
|
||||||
|
# [0.34.2] - 2025-10-31
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Fixed a bug in UTM trackable concern. #1909
|
||||||
|
|
||||||
|
# [0.34.1] - 2025-10-30
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Broken Stats page for users with no reverse geocoding enabled. #1877
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Date navigation on the map page is no longer shown as floating panel. It is now part of the top navigation bar to prevent overlapping with other map controls. #1894 #1881
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- [Dawarich Cloud] Added support for UTM parameters during user registration. UTM parameters will be stored with the user record for marketing analytics purposes.
|
||||||
|
|
||||||
# [0.34.0] - 2025-10-10
|
# [0.34.0] - 2025-10-10
|
||||||
|
|
||||||
## The Family release
|
## The Family release
|
||||||
|
|
@ -19,11 +214,13 @@ In this release we're introducing family features that allow users to create fam
|
||||||
- Sign out button works again. #1844
|
- Sign out button works again. #1844
|
||||||
- Fixed user deletion bug where user could not be deleted due to counter cache on points.
|
- Fixed user deletion bug where user could not be deleted due to counter cache on points.
|
||||||
- Users always have default distance unit set to kilometers. #1832
|
- Users always have default distance unit set to kilometers. #1832
|
||||||
|
- All confirmation dialogs are now showing only once.
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840
|
- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840
|
||||||
- Importing process for Google Maps Timeline exports, GeoJSON and geodata from photos is now significantly faster.
|
- Importing process for Google Maps Timeline exports, GeoJSON and geodata from photos is now significantly faster.
|
||||||
|
- The Map page now features a full-screen map.
|
||||||
|
|
||||||
|
|
||||||
# [0.33.1] - 2025-10-07
|
# [0.33.1] - 2025-10-07
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
## How to contribute to Dawarich
|
## How to contribute to Dawarich
|
||||||
|
|
||||||
Refer to [Repository Guidelines](AGENTS.md) for structure, tooling, and workflow expectations before submitting changes.
|
|
||||||
|
|
||||||
#### **Did you find a bug?**
|
#### **Did you find a bug?**
|
||||||
|
|
||||||
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/Freika/dawarich/issues).
|
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/Freika/dawarich/issues).
|
||||||
|
|
|
||||||
35
Gemfile
|
|
@ -5,15 +5,17 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
||||||
|
|
||||||
ruby File.read('.ruby-version').strip
|
ruby File.read('.ruby-version').strip
|
||||||
|
|
||||||
gem 'activerecord-postgis-adapter'
|
gem 'activerecord-postgis-adapter', '11.0'
|
||||||
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
|
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
|
||||||
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
||||||
gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
||||||
gem 'aws-sdk-s3', '~> 1.177.0', require: false
|
gem 'aws-sdk-s3', '~> 1.177.0', require: false
|
||||||
gem 'bootsnap', require: false
|
gem 'bootsnap', require: false
|
||||||
gem 'chartkick'
|
gem 'chartkick'
|
||||||
|
gem 'connection_pool', '< 3' # Pin to 2.x - version 3.0+ has breaking API changes with Rails RedisCacheStore
|
||||||
gem 'data_migrate'
|
gem 'data_migrate'
|
||||||
gem 'devise'
|
gem 'devise'
|
||||||
|
gem 'foreman'
|
||||||
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
|
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
|
||||||
gem 'gpx'
|
gem 'gpx'
|
||||||
gem 'groupdate'
|
gem 'groupdate'
|
||||||
|
|
@ -24,34 +26,39 @@ gem 'jwt', '~> 2.8'
|
||||||
gem 'kaminari'
|
gem 'kaminari'
|
||||||
gem 'lograge'
|
gem 'lograge'
|
||||||
gem 'oj'
|
gem 'oj'
|
||||||
|
gem 'omniauth-github', '~> 2.0.0'
|
||||||
|
gem 'omniauth-google-oauth2'
|
||||||
|
gem 'omniauth_openid_connect'
|
||||||
|
gem 'omniauth-rails_csrf_protection'
|
||||||
gem 'parallel'
|
gem 'parallel'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
gem 'prometheus_exporter'
|
gem 'prometheus_exporter'
|
||||||
gem 'puma'
|
gem 'puma'
|
||||||
gem 'pundit'
|
gem 'pundit', '>= 2.5.1'
|
||||||
gem 'rails', '~> 8.0'
|
gem 'rails', '~> 8.0'
|
||||||
gem 'rails_icons'
|
gem 'rails_icons'
|
||||||
|
gem 'rails_pulse'
|
||||||
gem 'redis'
|
gem 'redis'
|
||||||
gem 'rexml'
|
gem 'rexml'
|
||||||
gem 'rgeo'
|
gem 'rgeo'
|
||||||
gem 'rgeo-activerecord'
|
gem 'rgeo-activerecord', '~> 8.0.0'
|
||||||
gem 'rgeo-geojson'
|
gem 'rgeo-geojson'
|
||||||
gem 'rqrcode', '~> 3.0'
|
gem 'rqrcode', '~> 3.0'
|
||||||
gem 'rswag-api'
|
gem 'rswag-api'
|
||||||
gem 'rswag-ui'
|
gem 'rswag-ui'
|
||||||
gem 'rubyzip', '~> 3.1'
|
gem 'rubyzip', '~> 3.2'
|
||||||
gem 'sentry-rails'
|
gem 'sentry-rails', '>= 5.27.0'
|
||||||
gem 'sentry-ruby'
|
gem 'sentry-ruby'
|
||||||
gem 'sidekiq'
|
gem 'sidekiq', '8.0.10' # Pin to 8.0.x - sidekiq 8.1+ requires connection_pool 3.0+ which has breaking changes with Rails
|
||||||
gem 'sidekiq-cron'
|
gem 'sidekiq-cron', '>= 2.3.1'
|
||||||
gem 'sidekiq-limit_fetch'
|
gem 'sidekiq-limit_fetch'
|
||||||
gem 'sprockets-rails'
|
gem 'sprockets-rails'
|
||||||
gem 'stackprof'
|
gem 'stackprof'
|
||||||
gem 'stimulus-rails'
|
gem 'stimulus-rails'
|
||||||
gem 'strong_migrations'
|
gem 'tailwindcss-rails', '= 3.3.2'
|
||||||
gem 'tailwindcss-rails'
|
gem 'turbo-rails', '>= 2.0.17'
|
||||||
gem 'turbo-rails'
|
|
||||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||||
|
gem 'with_advisory_lock'
|
||||||
|
|
||||||
group :development, :test, :staging do
|
group :development, :test, :staging do
|
||||||
gem 'brakeman', require: false
|
gem 'brakeman', require: false
|
||||||
|
|
@ -62,7 +69,7 @@ group :development, :test, :staging do
|
||||||
gem 'ffaker'
|
gem 'ffaker'
|
||||||
gem 'pry-byebug'
|
gem 'pry-byebug'
|
||||||
gem 'pry-rails'
|
gem 'pry-rails'
|
||||||
gem 'rspec-rails'
|
gem 'rspec-rails', '>= 8.0.1'
|
||||||
gem 'rswag-specs'
|
gem 'rswag-specs'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -77,7 +84,7 @@ group :test do
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'database_consistency', require: false
|
gem 'database_consistency', '>= 2.0.5', require: false
|
||||||
gem 'foreman'
|
gem 'rubocop-rails', '>= 2.33.4', require: false
|
||||||
gem 'rubocop-rails', require: false
|
gem 'strong_migrations', '>= 2.4.0'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
415
Gemfile.lock
|
|
@ -10,29 +10,29 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.0.2.1)
|
actioncable (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.0.2.1)
|
actionmailbox (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activestorage (= 8.0.2.1)
|
activestorage (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.0.2.1)
|
actionmailer (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
actionview (= 8.0.2.1)
|
actionview (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.0.2.1)
|
actionpack (8.0.3)
|
||||||
actionview (= 8.0.2.1)
|
actionview (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
|
|
@ -40,38 +40,38 @@ GEM
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.0.2.1)
|
actiontext (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activestorage (= 8.0.2.1)
|
activestorage (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.0.2.1)
|
actionview (8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.0.2.1)
|
activejob (8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.0.2.1)
|
activemodel (8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
activerecord (8.0.2.1)
|
activerecord (8.0.3)
|
||||||
activemodel (= 8.0.2.1)
|
activemodel (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activerecord-postgis-adapter (11.0.0)
|
activerecord-postgis-adapter (11.0.0)
|
||||||
activerecord (~> 8.0.0)
|
activerecord (~> 8.0.0)
|
||||||
rgeo-activerecord (~> 8.0.0)
|
rgeo-activerecord (~> 8.0.0)
|
||||||
activestorage (8.0.2.1)
|
activestorage (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.0.2.1)
|
activesupport (8.0.3)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
|
|
@ -86,8 +86,10 @@ GEM
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
|
aes_key_wrap (1.1.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.3.2)
|
||||||
aws-partitions (1.1072.0)
|
aws-partitions (1.1072.0)
|
||||||
aws-sdk-core (3.215.1)
|
aws-sdk-core (3.215.1)
|
||||||
|
|
@ -106,11 +108,12 @@ GEM
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.1)
|
benchmark (0.5.0)
|
||||||
bigdecimal (3.2.3)
|
bigdecimal (4.0.1)
|
||||||
|
bindata (2.5.1)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.0.2)
|
brakeman (7.1.1)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.2)
|
bundler-audit (0.9.2)
|
||||||
|
|
@ -126,25 +129,26 @@ GEM
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
chartkick (5.2.0)
|
chartkick (5.2.1)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.6)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
cronex (0.15.0)
|
cronex (0.15.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
unicode (>= 0.4.4.5)
|
unicode (>= 0.4.4.5)
|
||||||
|
css-zero (1.1.15)
|
||||||
csv (3.3.4)
|
csv (3.3.4)
|
||||||
data_migrate (11.3.0)
|
data_migrate (11.3.1)
|
||||||
activerecord (>= 6.1)
|
activerecord (>= 6.1)
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
database_consistency (2.0.4)
|
database_consistency (2.0.6)
|
||||||
activerecord (>= 3.2)
|
activerecord (>= 3.2)
|
||||||
date (3.4.1)
|
date (3.5.0)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
|
|
@ -161,9 +165,11 @@ GEM
|
||||||
dotenv (= 3.1.8)
|
dotenv (= 3.1.8)
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
erb (5.0.2)
|
email_validator (2.2.4)
|
||||||
|
activemodel
|
||||||
|
erb (6.0.0)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
factory_bot (6.5.5)
|
factory_bot (6.5.5)
|
||||||
activesupport (>= 6.1.0)
|
activesupport (>= 6.1.0)
|
||||||
|
|
@ -171,6 +177,14 @@ GEM
|
||||||
factory_bot (~> 6.5)
|
factory_bot (~> 6.5)
|
||||||
railties (>= 6.1.0)
|
railties (>= 6.1.0)
|
||||||
fakeredis (0.1.4)
|
fakeredis (0.1.4)
|
||||||
|
faraday (2.14.0)
|
||||||
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
|
json
|
||||||
|
logger
|
||||||
|
faraday-follow_redirects (0.4.0)
|
||||||
|
faraday (>= 1, < 3)
|
||||||
|
faraday-net_http (3.4.1)
|
||||||
|
net-http (>= 0.5.0)
|
||||||
ffaker (2.25.0)
|
ffaker (2.25.0)
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-arm-linux-gnu)
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
|
|
@ -180,10 +194,10 @@ GEM
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
foreman (0.90.0)
|
foreman (0.90.0)
|
||||||
thor (~> 1.4)
|
thor (~> 1.4)
|
||||||
fugit (1.11.1)
|
fugit (1.12.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
gpx (1.2.1)
|
gpx (1.2.1)
|
||||||
csv
|
csv
|
||||||
|
|
@ -195,24 +209,32 @@ GEM
|
||||||
ffi (~> 1.9)
|
ffi (~> 1.9)
|
||||||
rgeo-geojson (~> 2.1)
|
rgeo-geojson (~> 2.1)
|
||||||
zeitwerk (~> 2.5)
|
zeitwerk (~> 2.5)
|
||||||
hashdiff (1.1.2)
|
hashdiff (1.2.1)
|
||||||
|
hashie (5.0.0)
|
||||||
httparty (0.23.1)
|
httparty (0.23.1)
|
||||||
csv
|
csv
|
||||||
mini_mime (>= 1.0.0)
|
mini_mime (>= 1.0.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
i18n (1.14.7)
|
i18n (1.14.8)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
importmap-rails (2.2.2)
|
importmap-rails (2.2.2)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.1)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.3)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.13.2)
|
json (2.18.0)
|
||||||
|
json-jwt (1.17.0)
|
||||||
|
activesupport (>= 4.2)
|
||||||
|
aes_key_wrap
|
||||||
|
base64
|
||||||
|
bindata
|
||||||
|
faraday (~> 2.0)
|
||||||
|
faraday-follow_redirects
|
||||||
json-schema (5.0.1)
|
json-schema (5.0.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
jwt (2.10.1)
|
jwt (2.10.1)
|
||||||
|
|
@ -240,22 +262,26 @@ GEM
|
||||||
loofah (2.24.1)
|
loofah (2.24.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.8.1)
|
mail (2.9.0)
|
||||||
|
logger
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.1.0)
|
||||||
matrix (0.4.2)
|
matrix (0.4.2)
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (6.0.1)
|
||||||
|
prism (~> 1.5)
|
||||||
msgpack (1.7.3)
|
msgpack (1.7.3)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.7.1)
|
multi_xml (0.8.0)
|
||||||
bigdecimal (~> 3.1)
|
bigdecimal (>= 3.1, < 5)
|
||||||
net-imap (0.5.9)
|
net-http (0.6.0)
|
||||||
|
uri
|
||||||
|
net-imap (0.5.12)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
|
|
@ -265,27 +291,73 @@ GEM
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.18.9)
|
nokogiri (1.18.10)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-aarch64-linux-gnu)
|
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-arm-linux-gnu)
|
nokogiri (1.18.10-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-arm64-darwin)
|
nokogiri (1.18.10-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-x86_64-darwin)
|
nokogiri (1.18.10-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
|
oauth2 (2.0.17)
|
||||||
|
faraday (>= 0.17.3, < 4.0)
|
||||||
|
jwt (>= 1.0, < 4.0)
|
||||||
|
logger (~> 1.2)
|
||||||
|
multi_xml (~> 0.5)
|
||||||
|
rack (>= 1.2, < 4)
|
||||||
|
snaky_hash (~> 2.0, >= 2.0.3)
|
||||||
|
version_gem (~> 1.1, >= 1.1.9)
|
||||||
oj (3.16.11)
|
oj (3.16.11)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
|
omniauth (2.1.4)
|
||||||
|
hashie (>= 3.4.6)
|
||||||
|
logger
|
||||||
|
rack (>= 2.2.3)
|
||||||
|
rack-protection
|
||||||
|
omniauth-github (2.0.1)
|
||||||
|
omniauth (~> 2.0)
|
||||||
|
omniauth-oauth2 (~> 1.8)
|
||||||
|
omniauth-google-oauth2 (1.2.1)
|
||||||
|
jwt (>= 2.9.2)
|
||||||
|
oauth2 (~> 2.0)
|
||||||
|
omniauth (~> 2.0)
|
||||||
|
omniauth-oauth2 (~> 1.8)
|
||||||
|
omniauth-oauth2 (1.8.0)
|
||||||
|
oauth2 (>= 1.4, < 3)
|
||||||
|
omniauth (~> 2.0)
|
||||||
|
omniauth-rails_csrf_protection (1.0.2)
|
||||||
|
actionpack (>= 4.2)
|
||||||
|
omniauth (~> 2.0)
|
||||||
|
omniauth_openid_connect (0.8.0)
|
||||||
|
omniauth (>= 1.9, < 3)
|
||||||
|
openid_connect (~> 2.2)
|
||||||
|
openid_connect (2.3.1)
|
||||||
|
activemodel
|
||||||
|
attr_required (>= 1.0.0)
|
||||||
|
email_validator
|
||||||
|
faraday (~> 2.0)
|
||||||
|
faraday-follow_redirects
|
||||||
|
json-jwt (>= 1.16)
|
||||||
|
mail
|
||||||
|
rack-oauth2 (~> 2.2)
|
||||||
|
swd (~> 2.0)
|
||||||
|
tzinfo
|
||||||
|
validate_url
|
||||||
|
webfinger (~> 2.0)
|
||||||
optimist (3.2.1)
|
optimist (3.2.1)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
|
pagy (43.2.2)
|
||||||
|
json
|
||||||
|
yaml
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.10.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
patience_diff (1.2.0)
|
patience_diff (1.2.0)
|
||||||
|
|
@ -295,10 +367,10 @@ GEM
|
||||||
pg (1.6.2-arm64-darwin)
|
pg (1.6.2-arm64-darwin)
|
||||||
pg (1.6.2-x86_64-darwin)
|
pg (1.6.2-x86_64-darwin)
|
||||||
pg (1.6.2-x86_64-linux)
|
pg (1.6.2-x86_64-linux)
|
||||||
pp (0.6.2)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.5.1)
|
prism (1.7.0)
|
||||||
prometheus_exporter (2.2.0)
|
prometheus_exporter (2.2.0)
|
||||||
webrick
|
webrick
|
||||||
pry (0.15.2)
|
pry (0.15.2)
|
||||||
|
|
@ -312,14 +384,25 @@ GEM
|
||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.2)
|
||||||
puma (6.6.1)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.0)
|
pundit (2.5.2)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.1)
|
rack (3.2.4)
|
||||||
|
rack-oauth2 (2.3.0)
|
||||||
|
activesupport
|
||||||
|
attr_required
|
||||||
|
faraday (~> 2.0)
|
||||||
|
faraday-follow_redirects
|
||||||
|
json-jwt (>= 1.11.0)
|
||||||
|
rack (>= 2.1.0)
|
||||||
|
rack-protection (4.2.1)
|
||||||
|
base64 (>= 0.1.0)
|
||||||
|
logger (>= 1.6.0)
|
||||||
|
rack (>= 3.0.0, < 4)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
|
|
@ -327,20 +410,20 @@ GEM
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.0.2.1)
|
rails (8.0.3)
|
||||||
actioncable (= 8.0.2.1)
|
actioncable (= 8.0.3)
|
||||||
actionmailbox (= 8.0.2.1)
|
actionmailbox (= 8.0.3)
|
||||||
actionmailer (= 8.0.2.1)
|
actionmailer (= 8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
actiontext (= 8.0.2.1)
|
actiontext (= 8.0.3)
|
||||||
actionview (= 8.0.2.1)
|
actionview (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activemodel (= 8.0.2.1)
|
activemodel (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activestorage (= 8.0.2.1)
|
activestorage (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2.1)
|
railties (= 8.0.3)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -351,25 +434,39 @@ GEM
|
||||||
rails_icons (1.4.0)
|
rails_icons (1.4.0)
|
||||||
nokogiri (~> 1.16, >= 1.16.4)
|
nokogiri (~> 1.16, >= 1.16.4)
|
||||||
rails (> 6.1)
|
rails (> 6.1)
|
||||||
railties (8.0.2.1)
|
rails_pulse (0.2.4)
|
||||||
actionpack (= 8.0.2.1)
|
css-zero (~> 1.1, >= 1.1.4)
|
||||||
activesupport (= 8.0.2.1)
|
groupdate (~> 6.0)
|
||||||
|
pagy (>= 8, < 44)
|
||||||
|
rails (>= 7.1.0, < 9.0.0)
|
||||||
|
ransack (~> 4.0)
|
||||||
|
request_store (~> 1.5)
|
||||||
|
turbo-rails (~> 2.0.11)
|
||||||
|
railties (8.0.3)
|
||||||
|
actionpack (= 8.0.3)
|
||||||
|
activesupport (= 8.0.3)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0, >= 1.2.2)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.1)
|
||||||
rdoc (6.14.2)
|
ransack (4.4.1)
|
||||||
|
activerecord (>= 7.2)
|
||||||
|
activesupport (>= 7.2)
|
||||||
|
i18n
|
||||||
|
rdoc (6.16.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
redis (5.4.0)
|
tsort
|
||||||
|
redis (5.4.1)
|
||||||
redis-client (>= 0.22.0)
|
redis-client (>= 0.22.0)
|
||||||
redis-client (0.24.0)
|
redis-client (0.26.2)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.2)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.2)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
|
|
@ -390,13 +487,13 @@ GEM
|
||||||
rqrcode_core (2.0.0)
|
rqrcode_core (2.0.0)
|
||||||
rspec-core (3.13.3)
|
rspec-core (3.13.3)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.4)
|
rspec-expectations (3.13.5)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-mocks (3.13.4)
|
rspec-mocks (3.13.6)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (8.0.0)
|
rspec-rails (8.0.2)
|
||||||
actionpack (>= 7.2)
|
actionpack (>= 7.2)
|
||||||
activesupport (>= 7.2)
|
activesupport (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
|
|
@ -405,18 +502,18 @@ GEM
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-support (3.13.3)
|
rspec-support (3.13.3)
|
||||||
rswag-api (2.16.0)
|
rswag-api (2.17.0)
|
||||||
activesupport (>= 5.2, < 8.1)
|
activesupport (>= 5.2, < 8.2)
|
||||||
railties (>= 5.2, < 8.1)
|
railties (>= 5.2, < 8.2)
|
||||||
rswag-specs (2.16.0)
|
rswag-specs (2.17.0)
|
||||||
activesupport (>= 5.2, < 8.1)
|
activesupport (>= 5.2, < 8.2)
|
||||||
json-schema (>= 2.2, < 6.0)
|
json-schema (>= 2.2, < 7.0)
|
||||||
railties (>= 5.2, < 8.1)
|
railties (>= 5.2, < 8.2)
|
||||||
rspec-core (>= 2.14)
|
rspec-core (>= 2.14)
|
||||||
rswag-ui (2.16.0)
|
rswag-ui (2.17.0)
|
||||||
actionpack (>= 5.2, < 8.1)
|
actionpack (>= 5.2, < 8.2)
|
||||||
railties (>= 5.2, < 8.1)
|
railties (>= 5.2, < 8.2)
|
||||||
rubocop (1.80.2)
|
rubocop (1.82.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
|
@ -424,20 +521,20 @@ GEM
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.46.0, < 2.0)
|
rubocop-ast (>= 1.48.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.46.0)
|
rubocop-ast (1.49.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.7)
|
||||||
rubocop-rails (2.33.3)
|
rubocop-rails (2.34.2)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.44.0, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
rubyzip (3.1.0)
|
rubyzip (3.2.2)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.35.0)
|
selenium-webdriver (4.35.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
|
|
@ -445,21 +542,21 @@ GEM
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (5.26.0)
|
sentry-rails (6.2.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 5.26.0)
|
sentry-ruby (~> 6.2.0)
|
||||||
sentry-ruby (5.26.0)
|
sentry-ruby (6.2.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
shoulda-matchers (6.5.0)
|
shoulda-matchers (6.5.0)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
sidekiq (8.0.4)
|
sidekiq (8.0.10)
|
||||||
connection_pool (>= 2.5.0)
|
connection_pool (>= 2.5.0)
|
||||||
json (>= 2.9.0)
|
json (>= 2.9.0)
|
||||||
logger (>= 1.6.2)
|
logger (>= 1.6.2)
|
||||||
rack (>= 3.1.0)
|
rack (>= 3.1.0)
|
||||||
redis-client (>= 0.23.2)
|
redis-client (>= 0.23.2)
|
||||||
sidekiq-cron (2.3.0)
|
sidekiq-cron (2.3.1)
|
||||||
cronex (>= 0.13.0)
|
cronex (>= 0.13.0)
|
||||||
fugit (~> 1.8, >= 1.11.1)
|
fugit (~> 1.8, >= 1.11.1)
|
||||||
globalid (>= 1.0.1)
|
globalid (>= 1.0.1)
|
||||||
|
|
@ -472,6 +569,9 @@ GEM
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.1)
|
simplecov-html (0.13.1)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
|
snaky_hash (2.0.3)
|
||||||
|
hashie (>= 0.1.0, < 6)
|
||||||
|
version_gem (>= 1.1.8, < 3)
|
||||||
sprockets (4.2.1)
|
sprockets (4.2.1)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
rack (>= 2.2.4, < 4)
|
rack (>= 2.2.4, < 4)
|
||||||
|
|
@ -482,14 +582,19 @@ GEM
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.8)
|
||||||
strong_migrations (2.3.0)
|
strong_migrations (2.5.1)
|
||||||
activerecord (>= 7)
|
activerecord (>= 7.1)
|
||||||
super_diff (0.16.0)
|
super_diff (0.17.0)
|
||||||
attr_extras (>= 6.2.4)
|
attr_extras (>= 6.2.4)
|
||||||
diff-lcs
|
diff-lcs
|
||||||
patience_diff
|
patience_diff
|
||||||
tailwindcss-rails (3.3.1)
|
swd (2.0.3)
|
||||||
|
activesupport (>= 3)
|
||||||
|
attr_required (>= 0.0.5)
|
||||||
|
faraday (~> 2.0)
|
||||||
|
faraday-follow_redirects
|
||||||
|
tailwindcss-rails (3.3.2)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 3.0)
|
tailwindcss-ruby (~> 3.0)
|
||||||
tailwindcss-ruby (3.4.17)
|
tailwindcss-ruby (3.4.17)
|
||||||
|
|
@ -499,8 +604,9 @@ GEM
|
||||||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
timeout (0.4.3)
|
timeout (0.4.4)
|
||||||
turbo-rails (2.0.16)
|
tsort (0.2.0)
|
||||||
|
turbo-rails (2.0.20)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
|
|
@ -508,12 +614,20 @@ GEM
|
||||||
unicode (0.4.4.5)
|
unicode (0.4.4.5)
|
||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.0.3)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
|
validate_url (1.0.15)
|
||||||
|
activemodel (>= 3.0.0)
|
||||||
|
public_suffix
|
||||||
|
version_gem (1.1.9)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
webmock (3.25.1)
|
webfinger (2.1.3)
|
||||||
|
activesupport
|
||||||
|
faraday (~> 2.0)
|
||||||
|
faraday-follow_redirects
|
||||||
|
webmock (3.26.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
|
|
@ -523,8 +637,12 @@ GEM
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
with_advisory_lock (7.0.2)
|
||||||
|
activerecord (>= 7.2)
|
||||||
|
zeitwerk (>= 2.7)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
|
yaml (0.4.0)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.7.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
|
|
@ -536,7 +654,7 @@ PLATFORMS
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
activerecord-postgis-adapter
|
activerecord-postgis-adapter (= 11.0)
|
||||||
aws-sdk-core (~> 3.215.1)
|
aws-sdk-core (~> 3.215.1)
|
||||||
aws-sdk-kms (~> 1.96.0)
|
aws-sdk-kms (~> 1.96.0)
|
||||||
aws-sdk-s3 (~> 1.177.0)
|
aws-sdk-s3 (~> 1.177.0)
|
||||||
|
|
@ -545,8 +663,9 @@ DEPENDENCIES
|
||||||
bundler-audit
|
bundler-audit
|
||||||
capybara
|
capybara
|
||||||
chartkick
|
chartkick
|
||||||
|
connection_pool (< 3)
|
||||||
data_migrate
|
data_migrate
|
||||||
database_consistency
|
database_consistency (>= 2.0.5)
|
||||||
debug
|
debug
|
||||||
devise
|
devise
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
|
|
@ -564,44 +683,50 @@ DEPENDENCIES
|
||||||
kaminari
|
kaminari
|
||||||
lograge
|
lograge
|
||||||
oj
|
oj
|
||||||
|
omniauth-github (~> 2.0.0)
|
||||||
|
omniauth-google-oauth2
|
||||||
|
omniauth-rails_csrf_protection
|
||||||
|
omniauth_openid_connect
|
||||||
parallel
|
parallel
|
||||||
pg
|
pg
|
||||||
prometheus_exporter
|
prometheus_exporter
|
||||||
pry-byebug
|
pry-byebug
|
||||||
pry-rails
|
pry-rails
|
||||||
puma
|
puma
|
||||||
pundit
|
pundit (>= 2.5.1)
|
||||||
rails (~> 8.0)
|
rails (~> 8.0)
|
||||||
rails_icons
|
rails_icons
|
||||||
|
rails_pulse
|
||||||
redis
|
redis
|
||||||
rexml
|
rexml
|
||||||
rgeo
|
rgeo
|
||||||
rgeo-activerecord
|
rgeo-activerecord (~> 8.0.0)
|
||||||
rgeo-geojson
|
rgeo-geojson
|
||||||
rqrcode (~> 3.0)
|
rqrcode (~> 3.0)
|
||||||
rspec-rails
|
rspec-rails (>= 8.0.1)
|
||||||
rswag-api
|
rswag-api
|
||||||
rswag-specs
|
rswag-specs
|
||||||
rswag-ui
|
rswag-ui
|
||||||
rubocop-rails
|
rubocop-rails (>= 2.33.4)
|
||||||
rubyzip (~> 3.1)
|
rubyzip (~> 3.2)
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails
|
sentry-rails (>= 5.27.0)
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq
|
sidekiq (= 8.0.10)
|
||||||
sidekiq-cron
|
sidekiq-cron (>= 2.3.1)
|
||||||
sidekiq-limit_fetch
|
sidekiq-limit_fetch
|
||||||
simplecov
|
simplecov
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
stackprof
|
stackprof
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
strong_migrations
|
strong_migrations (>= 2.4.0)
|
||||||
super_diff
|
super_diff
|
||||||
tailwindcss-rails
|
tailwindcss-rails (= 3.3.2)
|
||||||
turbo-rails
|
turbo-rails (>= 2.0.17)
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
webmock
|
webmock
|
||||||
|
with_advisory_lock
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.4.6p54
|
ruby 3.4.6p54
|
||||||
|
|
|
||||||
26
README.md
|
|
@ -2,20 +2,21 @@
|
||||||
|
|
||||||
[](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](https://www.patreon.com/freika)
|
[](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](https://www.patreon.com/freika)
|
||||||
|
|
||||||
[](https://app.circleci.com/pipelines/github/Freika/dawarich)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||

|

|
||||||
*Map View*
|
*Map View*
|
||||||
|
|
||||||

|

|
||||||
|
*Family Page*
|
||||||
|
|
||||||
|

|
||||||
*Statistics Overview*
|
*Statistics Overview*
|
||||||
|
|
||||||

|

|
||||||
*Imports page*
|
*Trips page*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -28,6 +29,9 @@ It enables you to:
|
||||||
|
|
||||||
- Track your location history.
|
- Track your location history.
|
||||||
- Visualize your data on an interactive map.
|
- Visualize your data on an interactive map.
|
||||||
|
- Create trips and analyze your travel history.
|
||||||
|
- Share your location with family members.
|
||||||
|
- Integrate with photo management apps like Immich and Photoprism to visualize geotagged photos.
|
||||||
- Import your location history from Google Maps Timeline, OwnTracks, GPX, GeoJSON and some other sources
|
- Import your location history from Google Maps Timeline, OwnTracks, GPX, GeoJSON and some other sources
|
||||||
- Explore statistics like the number of countries and cities visited, total distance traveled, and more!
|
- Explore statistics like the number of countries and cities visited, total distance traveled, and more!
|
||||||
|
|
||||||
|
|
@ -67,12 +71,14 @@ Simply install one of the supported apps on your device and configure it to send
|
||||||
1. Clone the repository.
|
1. Clone the repository.
|
||||||
2. Run the following command to start the app:
|
2. Run the following command to start the app:
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.yml up
|
docker compose -f docker/docker-compose.yml up
|
||||||
```
|
```
|
||||||
3. Access the app at `http://localhost:3000`.
|
3. Access the app at `http://localhost:3000`.
|
||||||
|
|
||||||
⏹️ **To stop the app**, press `Ctrl+C`.
|
⏹️ **To stop the app**, press `Ctrl+C`.
|
||||||
|
|
||||||
|
You can use default values or create a `.env` file based on `.env.example` to customize your setup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 How to Install Dawarich
|
## 🔧 How to Install Dawarich
|
||||||
|
|
@ -99,6 +105,11 @@ Feel free to change them in the account settings.
|
||||||
- Lines between points
|
- Lines between points
|
||||||
- Fog of War
|
- Fog of War
|
||||||
|
|
||||||
|
### 👪 Family Sharing
|
||||||
|
- Share your location with family members.
|
||||||
|
- View locations of family members on the map (with their consent).
|
||||||
|
- Each family member can enable or disable location sharing individually.
|
||||||
|
|
||||||
### 🔵 Areas
|
### 🔵 Areas
|
||||||
- Draw areas on the map so Dawarich could suggest your visits there.
|
- Draw areas on the map so Dawarich could suggest your visits there.
|
||||||
|
|
||||||
|
|
@ -109,7 +120,6 @@ Feel free to change them in the account settings.
|
||||||
- Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month.
|
- Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month.
|
||||||
|
|
||||||
### ✈️ Trips
|
### ✈️ Trips
|
||||||
|
|
||||||
- Create a trip to visualize your travels between two points in time. You'll be able to see the route, distance, and time spent, and also add notes to your trip. If you have Immich or Photoprism integration, you'll also be able to see photos from your trips!
|
- Create a trip to visualize your travels between two points in time. You'll be able to see the route, distance, and time spent, and also add notes to your trip. If you have Immich or Photoprism integration, you'll also be able to see photos from your trips!
|
||||||
|
|
||||||
### 📸 Integrations
|
### 📸 Integrations
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,13 @@
|
||||||
/* Style for the settings panel */
|
/* Style for the settings panel */
|
||||||
.leaflet-settings-panel {
|
.leaflet-settings-panel {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 10px;
|
border-radius: 4px;
|
||||||
border: 1px solid #ccc;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
position: absolute !important;
|
||||||
|
top: 10px !important;
|
||||||
|
left: 60px !important;
|
||||||
|
transform: none;
|
||||||
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-settings-panel label {
|
.leaflet-settings-panel label {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@
|
||||||
|
|
||||||
/* Leaflet Panel Styles */
|
/* Leaflet Panel Styles */
|
||||||
.leaflet-right-panel {
|
.leaflet-right-panel {
|
||||||
margin-top: 80px; /* Give space for controls above */
|
margin-top: 80px;
|
||||||
|
/* Give space for controls above */
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
transform: none;
|
transform: none;
|
||||||
transition: right 0.3s ease-in-out;
|
transition: right 0.3s ease-in-out;
|
||||||
|
|
@ -52,10 +53,12 @@
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5);
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||||
|
|
@ -76,33 +79,51 @@
|
||||||
/* Drawer Panel Styles */
|
/* Drawer Panel Styles */
|
||||||
.leaflet-drawer {
|
.leaflet-drawer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 10px;
|
||||||
right: 0;
|
right: 70px;
|
||||||
width: 338px;
|
/* Position to the left of the control buttons with margin */
|
||||||
height: 100%;
|
width: 24rem;
|
||||||
|
max-height: calc(100% - 20px);
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
transform: translateX(100%);
|
border-radius: 8px;
|
||||||
transition: transform 0.3s ease-in-out;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out, visibility 0.2s;
|
||||||
z-index: 450;
|
z-index: 450;
|
||||||
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
height: auto;
|
||||||
|
/* Make height fit content */
|
||||||
|
cursor: default;
|
||||||
|
/* Override map cursor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-drawer * {
|
||||||
|
cursor: default;
|
||||||
|
/* Ensure all children have default cursor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-drawer a,
|
||||||
|
.leaflet-drawer button,
|
||||||
|
.leaflet-drawer .btn,
|
||||||
|
.leaflet-drawer input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
/* Interactive elements get pointer cursor */
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-drawer.open {
|
.leaflet-drawer.open {
|
||||||
transform: translateX(0);
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls transition */
|
/* Controls remain in place - no transition needed */
|
||||||
.leaflet-control-layers,
|
.leaflet-control-layers,
|
||||||
.leaflet-control-button,
|
.leaflet-control-button,
|
||||||
.toggle-panel-button {
|
.toggle-panel-button {
|
||||||
transition: right 0.3s ease-in-out;
|
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-shifted {
|
|
||||||
right: 338px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection Tool Styles */
|
/* Selection Tool Styles */
|
||||||
.leaflet-control-custom {
|
.leaflet-control-custom {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|
@ -127,6 +148,61 @@
|
||||||
|
|
||||||
/* Cancel Selection Button */
|
/* Cancel Selection Button */
|
||||||
#cancel-selection-button {
|
#cancel-selection-button {
|
||||||
margin-bottom: 1rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Emoji Picker Styles */
|
||||||
|
em-emoji-picker {
|
||||||
|
--color-border-over: rgba(0, 0, 0, 0.1);
|
||||||
|
--color-border: rgba(0, 0, 0, 0.05);
|
||||||
|
--font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--rgb-accent: 96, 165, 250;
|
||||||
|
/* Blue accent to match application */
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 400px;
|
||||||
|
min-width: 318px;
|
||||||
|
resize: horizontal;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support for emoji picker */
|
||||||
|
[data-theme="dark"] em-emoji-picker,
|
||||||
|
html.dark em-emoji-picker {
|
||||||
|
--color-border-over: rgba(255, 255, 255, 0.1);
|
||||||
|
--color-border: rgba(255, 255, 255, 0.05);
|
||||||
|
--rgb-accent: 96, 165, 250;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive emoji picker on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
em-emoji-picker {
|
||||||
|
max-width: 90vw;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color Picker Styles */
|
||||||
|
.color-input {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input::-moz-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
36
app/assets/stylesheets/leaflet.control.layers.tree.css
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
.leaflet-control-layers-toggle.leaflet-layerstree-named-toggle {
|
||||||
|
margin: 2px 5px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-layerstree-header input {
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-layerstree-header label {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-layerstree-header-pointer,
|
||||||
|
.leaflet-layerstree-expand-collapse {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-layerstree-children {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-layerstree-children-nopad {
|
||||||
|
padding-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-layerstree-hide,
|
||||||
|
.leaflet-layerstree-nevershow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
line-height: 1.5rem!important;
|
||||||
|
}
|
||||||
|
|
@ -49,14 +49,41 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaflet layer control */
|
/* Leaflet layer control */
|
||||||
.leaflet-control-layers-toggle {
|
.leaflet-control-layers {
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
|
||||||
background-color: var(--leaflet-bg-color) !important;
|
background-color: var(--leaflet-bg-color) !important;
|
||||||
color: var(--leaflet-text-color) !important;
|
color: var(--leaflet-text-color) !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 1rem !important;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the toggle icon when expanded */
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
width: 44px !important;
|
||||||
|
height: 44px !important;
|
||||||
|
background-color: var(--leaflet-bg-color) !important;
|
||||||
|
color: var(--leaflet-text-color) !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
/* Replace default icon with custom SVG */
|
/* Replace default icon with custom SVG */
|
||||||
background-image: none !important;
|
background-image: none !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers-toggle:hover {
|
||||||
|
background-color: var(--leaflet-hover-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-control-layers-toggle::before {
|
.leaflet-control-layers-toggle::before {
|
||||||
|
|
@ -80,13 +107,95 @@
|
||||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>') !important;
|
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>') !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-control-layers-expanded {
|
/* Layer list styling */
|
||||||
background-color: var(--leaflet-bg-color) !important;
|
.leaflet-control-layers-list {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers-base,
|
||||||
|
.leaflet-control-layers-overlays {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
background-color: var(--leaflet-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label styling */
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
color: var(--leaflet-text-color) !important;
|
color: var(--leaflet-text-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-control-layers label {
|
.leaflet-control-layers label:hover {
|
||||||
color: var(--leaflet-text-color) !important;
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers label span {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Checkbox/Radio styling using DaisyUI/Tailwind logic */
|
||||||
|
.leaflet-control-layers input[type="checkbox"],
|
||||||
|
.leaflet-control-layers input[type="radio"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 1px solid var(--leaflet-border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
/* Rounded for checkbox */
|
||||||
|
background-color: var(--leaflet-bg-color);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers input[type="radio"] {
|
||||||
|
border-radius: 9999px;
|
||||||
|
/* Circle for radio */
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-layers input[type="checkbox"]:checked,
|
||||||
|
.leaflet-control-layers input[type="radio"]:checked {
|
||||||
|
background-color: var(--leaflet-link-color);
|
||||||
|
border-color: var(--leaflet-link-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox checkmark */
|
||||||
|
.leaflet-control-layers input[type="checkbox"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio dot */
|
||||||
|
.leaflet-control-layers input[type="radio"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaflet Draw controls */
|
/* Leaflet Draw controls */
|
||||||
|
|
@ -188,7 +297,7 @@
|
||||||
color: #f9fafb !important;
|
color: #f9fafb !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip {
|
.leaflet-popup-content-wrapper:has(.family-member-popup)+.leaflet-popup-tip {
|
||||||
background-color: #1f2937 !important;
|
background-color: #1f2937 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,9 +306,11 @@
|
||||||
0% {
|
0% {
|
||||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +321,7 @@
|
||||||
border-radius: 50% !important;
|
border-radius: 50% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.family-member-marker-recent .leaflet-marker-icon > div {
|
.family-member-marker-recent .leaflet-marker-icon>div {
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
app/assets/stylesheets/maplibre-gl.css
Normal file
187
app/assets/stylesheets/maps_maplibre.css
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
/* Maps V2 Styles */
|
||||||
|
|
||||||
|
/* Loading Overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup Styles */
|
||||||
|
.point-popup {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-row .label {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-row .value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MapLibre Popup Theme Support */
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger close button */
|
||||||
|
.maplibregl-popup-close-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 32px;
|
||||||
|
right: 4px;
|
||||||
|
top: 4px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup-close-button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme (default) */
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup-close-button {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup-close-button:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup-tip {
|
||||||
|
border-top-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme */
|
||||||
|
html[data-theme="dark"] .maplibregl-popup-content,
|
||||||
|
html.dark .maplibregl-popup-content {
|
||||||
|
background-color: #1f2937;
|
||||||
|
color: #f9fafb;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .maplibregl-popup-close-button,
|
||||||
|
html.dark .maplibregl-popup-close-button {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .maplibregl-popup-close-button:hover,
|
||||||
|
html.dark .maplibregl-popup-close-button:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .maplibregl-popup-tip,
|
||||||
|
html.dark .maplibregl-popup-tip {
|
||||||
|
border-top-color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection Indicator */
|
||||||
|
.connection-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
display: none; /* Hidden by default, shown when family sharing is active */
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 20;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show connection indicator when family sharing is active */
|
||||||
|
.connection-indicator.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ef4444;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator.connected .indicator-dot {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator.connected .indicator-text::before {
|
||||||
|
content: 'Connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-indicator.disconnected .indicator-text::before {
|
||||||
|
content: 'Connecting...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
286
app/assets/stylesheets/maps_maplibre_panel.css
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
/* Maps V2 Control Panel Styles */
|
||||||
|
|
||||||
|
.map-control-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -480px; /* Hidden by default */
|
||||||
|
width: 480px;
|
||||||
|
height: 100%;
|
||||||
|
background: oklch(var(--b1));
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 9999;
|
||||||
|
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-control-panel.open {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical Tab Bar */
|
||||||
|
.panel-tabs {
|
||||||
|
width: 64px;
|
||||||
|
background: oklch(var(--b2));
|
||||||
|
border-right: 1px solid oklch(var(--bc) / 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
color: oklch(var(--bc) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background: oklch(var(--b3));
|
||||||
|
color: oklch(var(--bc));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: oklch(var(--p));
|
||||||
|
color: oklch(var(--pc));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 24px;
|
||||||
|
background: oklch(var(--p));
|
||||||
|
border-radius: 2px 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel Content */
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid oklch(var(--bc) / 0.1);
|
||||||
|
background: oklch(var(--b1));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: oklch(var(--bc));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Content */
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
.panel-body::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(var(--bc) / 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(var(--bc) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Focus State - Remove all focus indicators */
|
||||||
|
.toggle:focus,
|
||||||
|
.toggle:focus-visible,
|
||||||
|
.toggle:focus-within {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override DaisyUI toggle focus styles */
|
||||||
|
.toggle:focus-visible:checked,
|
||||||
|
.toggle:checked:focus,
|
||||||
|
.toggle:checked:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure no outline on the toggle container */
|
||||||
|
.form-control .toggle:focus {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent indeterminate visual state on toggles */
|
||||||
|
.toggle:indeterminate {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure smooth toggle transitions without intermediate states */
|
||||||
|
.toggle {
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:checked {
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove any active/pressed state that might cause intermediate appearance */
|
||||||
|
.toggle:active,
|
||||||
|
.toggle:active:focus {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Breakpoints */
|
||||||
|
|
||||||
|
/* Large tablets and smaller desktops (1024px - 1280px) */
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.map-control-panel {
|
||||||
|
width: 420px;
|
||||||
|
right: -420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablets (768px - 1024px) */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.map-control-panel {
|
||||||
|
width: 380px;
|
||||||
|
right: -380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small tablets and large phones (640px - 768px) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.map-control-panel {
|
||||||
|
width: 95%;
|
||||||
|
right: -95%;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile phones (< 640px) */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.map-control-panel {
|
||||||
|
width: 100%;
|
||||||
|
right: -100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs {
|
||||||
|
width: 56px;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce spacing on mobile */
|
||||||
|
.space-y-4 > * + * {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-6 > * + * {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very small phones (< 375px) */
|
||||||
|
@media (max-width: 375px) {
|
||||||
|
.panel-tabs {
|
||||||
|
width: 52px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
app/assets/svg/icons/lucide/outline/arrow-big-down.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-big-down-icon lucide-arrow-big-down"><path d="M15 11a1 1 0 0 0 1 1h2.939a1 1 0 0 1 .75 1.811l-6.835 6.836a1.207 1.207 0 0 1-1.707 0L4.31 13.81a1 1 0 0 1 .75-1.811H8a1 1 0 0 0 1-1V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
1
app/assets/svg/icons/lucide/outline/calendar-plus-2.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-plus2-icon lucide-calendar-plus-2"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M10 16h4"/><path d="M12 14v4"/></svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
1
app/assets/svg/icons/lucide/outline/circle-plus.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
1
app/assets/svg/icons/lucide/outline/grid2x2.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-grid2x2-icon lucide-grid-2x2"><path d="M12 3v18"/><path d="M3 12h18"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||||
|
After Width: | Height: | Size: 328 B |
1
app/assets/svg/icons/lucide/outline/layer.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layers-icon lucide-layers"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>
|
||||||
|
After Width: | Height: | Size: 526 B |
1
app/assets/svg/icons/lucide/outline/lock-open.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-open-icon lucide-lock-open"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
1
app/assets/svg/icons/lucide/outline/mail.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-icon lucide-mail"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
1
app/assets/svg/icons/lucide/outline/map-pin-check.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-check-icon lucide-map-pin-check"><path d="M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728"/><circle cx="12" cy="10" r="3"/><path d="m16 18 2 2 4-4"/></svg>
|
||||||
|
After Width: | Height: | Size: 457 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle-question-mark-icon lucide-message-circle-question-mark"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
1
app/assets/svg/icons/lucide/outline/pocket-knife.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pocket-knife-icon lucide-pocket-knife"><path d="M3 2v1c0 1 2 1 2 2S3 6 3 7s2 1 2 2-2 1-2 2 2 1 2 2"/><path d="M18 6h.01"/><path d="M6 18h.01"/><path d="M20.83 8.83a4 4 0 0 0-5.66-5.66l-12 12a4 4 0 1 0 5.66 5.66Z"/><path d="M18 11.66V22a4 4 0 0 0 4-4V6"/></svg>
|
||||||
|
After Width: | Height: | Size: 463 B |
1
app/assets/svg/icons/lucide/outline/rotate-ccw.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
1
app/assets/svg/icons/lucide/outline/route.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-route-icon lucide-route"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg>
|
||||||
|
After Width: | Height: | Size: 358 B |
1
app/assets/svg/icons/lucide/outline/save.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save-icon lucide-save"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
1
app/assets/svg/icons/lucide/outline/settings.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
After Width: | Height: | Size: 610 B |
1
app/assets/svg/icons/lucide/outline/x.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
|
After Width: | Height: | Size: 270 B |
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class FamilyLocationsChannel < ApplicationCable::Channel
|
class FamilyLocationsChannel < ApplicationCable::Channel
|
||||||
def subscribed
|
def subscribed
|
||||||
return reject unless family_feature_enabled?
|
return reject unless DawarichSettings.family_feature_enabled?
|
||||||
return reject unless current_user.in_family?
|
return reject unless current_user.in_family?
|
||||||
|
|
||||||
stream_for current_user.family
|
stream_for current_user.family
|
||||||
|
|
@ -11,10 +11,4 @@ class FamilyLocationsChannel < ApplicationCable::Channel
|
||||||
def unsubscribed
|
def unsubscribed
|
||||||
# Any cleanup needed when channel is unsubscribed
|
# Any cleanup needed when channel is unsubscribed
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def family_feature_enabled?
|
|
||||||
DawarichSettings.family_feature_enabled?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::AreasController < ApiController
|
class Api::V1::AreasController < ApiController
|
||||||
before_action :set_area, only: %i[update destroy]
|
before_action :set_area, only: %i[show update destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@areas = current_api_user.areas
|
@areas = current_api_user.areas
|
||||||
|
|
@ -9,6 +9,10 @@ class Api::V1::AreasController < ApiController
|
||||||
render json: @areas, status: :ok
|
render json: @areas, status: :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @area, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@area = current_api_user.areas.build(area_params)
|
@area = current_api_user.areas.build(area_params)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Countries::VisitedCitiesController < ApiController
|
class Api::V1::Countries::VisitedCitiesController < ApiController
|
||||||
|
include SafeTimestampParser
|
||||||
|
|
||||||
before_action :validate_params
|
before_action :validate_params
|
||||||
|
|
||||||
def index
|
def index
|
||||||
start_at = DateTime.parse(params[:start_at]).to_i
|
start_at = safe_timestamp(params[:start_at])
|
||||||
end_at = DateTime.parse(params[:end_at]).to_i
|
end_at = safe_timestamp(params[:end_at])
|
||||||
|
|
||||||
points = current_api_user
|
points = current_api_user
|
||||||
.points
|
.points
|
||||||
|
.without_raw_data
|
||||||
.where(timestamp: start_at..end_at)
|
.where(timestamp: start_at..end_at)
|
||||||
|
|
||||||
render json: { data: CountriesAndCities.new(points).call }
|
render json: { data: CountriesAndCities.new(points).call }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::FamiliesController < ApiController
|
class Api::V1::Families::LocationsController < ApiController
|
||||||
before_action :ensure_family_feature_enabled!
|
before_action :ensure_family_feature_enabled!
|
||||||
before_action :ensure_user_in_family!
|
before_action :ensure_user_in_family!
|
||||||
|
|
||||||
def locations
|
def index
|
||||||
family_locations = Families::Locations.new(current_api_user).call
|
family_locations = Families::Locations.new(current_api_user).call
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
|
|
@ -17,7 +17,7 @@ class Api::V1::FamiliesController < ApiController
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_user_in_family!
|
def ensure_user_in_family!
|
||||||
return if current_api_user.in_family?
|
return if current_api_user&.in_family?
|
||||||
|
|
||||||
render json: { error: 'User is not part of a family' }, status: :forbidden
|
render json: { error: 'User is not part of a family' }, status: :forbidden
|
||||||
end
|
end
|
||||||
138
app/controllers/api/v1/places_controller.rb
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PlacesController < ApiController
|
||||||
|
before_action :set_place, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@places = current_api_user.places.includes(:tags, :visits)
|
||||||
|
|
||||||
|
if params[:tag_ids].present?
|
||||||
|
tag_ids = Array(params[:tag_ids])
|
||||||
|
|
||||||
|
# Separate numeric tag IDs from "untagged"
|
||||||
|
numeric_tag_ids = tag_ids.reject { |id| id == 'untagged' }.map(&:to_i)
|
||||||
|
include_untagged = tag_ids.include?('untagged')
|
||||||
|
|
||||||
|
if numeric_tag_ids.any? && include_untagged
|
||||||
|
# Both tagged and untagged: return union (OR logic)
|
||||||
|
tagged = current_api_user.places.includes(:tags, :visits).with_tags(numeric_tag_ids)
|
||||||
|
untagged = current_api_user.places.includes(:tags, :visits).without_tags
|
||||||
|
@places = Place.from("(#{tagged.to_sql} UNION #{untagged.to_sql}) AS places")
|
||||||
|
.includes(:tags, :visits)
|
||||||
|
elsif numeric_tag_ids.any?
|
||||||
|
# Only tagged places with ANY of the selected tags (OR logic)
|
||||||
|
@places = @places.with_tags(numeric_tag_ids)
|
||||||
|
elsif include_untagged
|
||||||
|
# Only untagged places
|
||||||
|
@places = @places.without_tags
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: @places.map { |place| serialize_place(place) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: serialize_place(@place)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@place = current_api_user.places.build(place_params.except(:tag_ids))
|
||||||
|
|
||||||
|
if @place.save
|
||||||
|
add_tags if tag_ids.present?
|
||||||
|
@place = current_api_user.places.includes(:tags, :visits).find(@place.id)
|
||||||
|
|
||||||
|
render json: serialize_place(@place), status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @place.update(place_params)
|
||||||
|
set_tags if params[:place][:tag_ids]
|
||||||
|
@place = current_api_user.places.includes(:tags, :visits).find(@place.id)
|
||||||
|
|
||||||
|
render json: serialize_place(@place)
|
||||||
|
else
|
||||||
|
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@place.destroy!
|
||||||
|
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
def nearby
|
||||||
|
unless params[:latitude].present? && params[:longitude].present?
|
||||||
|
return render json: { error: 'latitude and longitude are required' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
results = Places::NearbySearch.new(
|
||||||
|
latitude: params[:latitude].to_f,
|
||||||
|
longitude: params[:longitude].to_f,
|
||||||
|
radius: params[:radius]&.to_f || 0.5,
|
||||||
|
limit: params[:limit]&.to_i || 10
|
||||||
|
).call
|
||||||
|
|
||||||
|
render json: { places: results }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_place
|
||||||
|
@place = current_api_user.places.includes(:tags, :visits).find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def place_params
|
||||||
|
params.require(:place).permit(:name, :latitude, :longitude, :source, :note, tag_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_ids
|
||||||
|
ids = params.dig(:place, :tag_ids)
|
||||||
|
Array(ids).compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_tags
|
||||||
|
return if tag_ids.empty?
|
||||||
|
|
||||||
|
tags = current_api_user.tags.where(id: tag_ids)
|
||||||
|
@place.tags << tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_tags
|
||||||
|
tag_ids_param = Array(params.dig(:place, :tag_ids)).compact
|
||||||
|
tags = current_api_user.tags.where(id: tag_ids_param)
|
||||||
|
@place.tags = tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_place(place)
|
||||||
|
{
|
||||||
|
id: place.id,
|
||||||
|
name: place.name,
|
||||||
|
latitude: place.lat,
|
||||||
|
longitude: place.lon,
|
||||||
|
source: place.source,
|
||||||
|
note: place.note,
|
||||||
|
icon: place.tags.first&.icon,
|
||||||
|
color: place.tags.first&.color,
|
||||||
|
visits_count: place.visits.count,
|
||||||
|
created_at: place.created_at,
|
||||||
|
tags: place.tags.map do |tag|
|
||||||
|
{
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
icon: tag.icon,
|
||||||
|
color: tag.color,
|
||||||
|
privacy_radius_meters: tag.privacy_radius_meters
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,17 +1,37 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::PointsController < ApiController
|
class Api::V1::PointsController < ApiController
|
||||||
before_action :authenticate_active_api_user!, only: %i[create update destroy]
|
include SafeTimestampParser
|
||||||
|
|
||||||
|
before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy]
|
||||||
before_action :validate_points_limit, only: %i[create]
|
before_action :validate_points_limit, only: %i[create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
start_at = params[:start_at]&.to_datetime&.to_i
|
start_at = params[:start_at].present? ? safe_timestamp(params[:start_at]) : nil
|
||||||
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i
|
end_at = params[:end_at].present? ? safe_timestamp(params[:end_at]) : Time.zone.now.to_i
|
||||||
order = params[:order] || 'desc'
|
order = params[:order] || 'desc'
|
||||||
|
|
||||||
points = current_api_user
|
points = current_api_user
|
||||||
.points
|
.points
|
||||||
|
.without_raw_data
|
||||||
.where(timestamp: start_at..end_at)
|
.where(timestamp: start_at..end_at)
|
||||||
|
|
||||||
|
# Filter by geographic bounds if provided
|
||||||
|
if params[:min_longitude].present? && params[:max_longitude].present? &&
|
||||||
|
params[:min_latitude].present? && params[:max_latitude].present?
|
||||||
|
min_lng = params[:min_longitude].to_f
|
||||||
|
max_lng = params[:max_longitude].to_f
|
||||||
|
min_lat = params[:min_latitude].to_f
|
||||||
|
max_lat = params[:max_latitude].to_f
|
||||||
|
|
||||||
|
# Use PostGIS to filter points within bounding box
|
||||||
|
points = points.where(
|
||||||
|
'ST_X(lonlat::geometry) BETWEEN ? AND ? AND ST_Y(lonlat::geometry) BETWEEN ? AND ?',
|
||||||
|
min_lng, max_lng, min_lat, max_lat
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
points = points
|
||||||
.order(timestamp: order)
|
.order(timestamp: order)
|
||||||
.page(params[:page])
|
.page(params[:page])
|
||||||
.per(params[:per_page] || 100)
|
.per(params[:per_page] || 100)
|
||||||
|
|
@ -45,6 +65,16 @@ class Api::V1::PointsController < ApiController
|
||||||
render json: { message: 'Point deleted successfully' }
|
render json: { message: 'Point deleted successfully' }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bulk_destroy
|
||||||
|
point_ids = bulk_destroy_params[:point_ids]
|
||||||
|
|
||||||
|
render json: { error: 'No points selected' }, status: :unprocessable_entity and return if point_ids.blank?
|
||||||
|
|
||||||
|
deleted_count = current_api_user.points.where(id: point_ids).destroy_all.count
|
||||||
|
|
||||||
|
render json: { message: 'Points were successfully destroyed', count: deleted_count }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def point_params
|
def point_params
|
||||||
|
|
@ -55,6 +85,10 @@ class Api::V1::PointsController < ApiController
|
||||||
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
|
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bulk_destroy_params
|
||||||
|
params.permit(point_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
def point_serializer
|
def point_serializer
|
||||||
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
|
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: {
|
render json: {
|
||||||
settings: current_api_user.safe_settings,
|
settings: current_api_user.safe_settings.config,
|
||||||
status: 'success'
|
status: 'success'
|
||||||
}, status: :ok
|
}, status: :ok
|
||||||
end
|
end
|
||||||
|
|
@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController
|
||||||
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
||||||
|
|
||||||
if current_api_user.save
|
if current_api_user.save
|
||||||
render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
|
render json: { message: 'Settings updated', settings: current_api_user.safe_settings.config, status: 'success' },
|
||||||
status: :ok
|
status: :ok
|
||||||
else
|
else
|
||||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
||||||
|
|
@ -31,6 +31,7 @@ class Api::V1::SettingsController < ApiController
|
||||||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||||
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
|
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
|
||||||
|
:maps_v2_style, :maps_maplibre_style, :globe_projection,
|
||||||
enabled_map_layers: []
|
enabled_map_layers: []
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
13
app/controllers/api/v1/tags_controller.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class TagsController < ApiController
|
||||||
|
def privacy_zones
|
||||||
|
zones = current_api_user.tags.privacy_zones.includes(:places)
|
||||||
|
|
||||||
|
render json: zones.map { |tag| TagSerializer.new(tag).call }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -10,6 +10,11 @@ class Api::V1::VisitsController < ApiController
|
||||||
render json: serialized_visits
|
render json: serialized_visits
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
visit = current_api_user.visits.find(params[:id])
|
||||||
|
render json: Api::VisitSerializer.new(visit).call
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
service = Visits::Create.new(current_api_user, visit_params)
|
service = Visits::Create.new(current_api_user, visit_params)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,14 @@ class ApiController < ApplicationController
|
||||||
before_action :set_version_header
|
before_action :set_version_header
|
||||||
before_action :authenticate_api_key
|
before_action :authenticate_api_key
|
||||||
|
|
||||||
|
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def record_not_found
|
||||||
|
render json: { error: 'Record not found' }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
def set_version_header
|
def set_version_header
|
||||||
message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!"
|
message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ class ApplicationController < ActionController::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_sign_in_path_for(resource)
|
def after_sign_in_path_for(resource)
|
||||||
|
# Check for family invitation first
|
||||||
|
invitation_token = params[:invitation_token] || session[:invitation_token]
|
||||||
|
if invitation_token.present?
|
||||||
|
invitation = Family::Invitation.find_by(token: invitation_token)
|
||||||
|
return family_invitation_path(invitation.token) if invitation&.can_be_accepted?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle iOS client flow
|
||||||
client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client]
|
client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client]
|
||||||
|
|
||||||
case client_type
|
case client_type
|
||||||
|
|
@ -75,8 +83,8 @@ class ApplicationController < ActionController::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_not_authorized
|
def user_not_authorized
|
||||||
redirect_to (request.referer || root_path),
|
redirect_back fallback_location: root_path,
|
||||||
alert: 'You are not authorized to perform this action.',
|
alert: 'You are not authorized to perform this action.',
|
||||||
status: :see_other
|
status: :see_other
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
24
app/controllers/concerns/safe_timestamp_parser.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SafeTimestampParser
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def safe_timestamp(date_string)
|
||||||
|
return Time.zone.now.to_i if date_string.blank?
|
||||||
|
|
||||||
|
parsed_time = Time.zone.parse(date_string)
|
||||||
|
|
||||||
|
# Time.zone.parse returns epoch time (2000-01-01) for unparseable strings
|
||||||
|
# Check if it's a valid parse by seeing if year is suspiciously at epoch
|
||||||
|
return Time.zone.now.to_i if parsed_time.nil? || (parsed_time.year == 2000 && !date_string.include?('2000'))
|
||||||
|
|
||||||
|
min_timestamp = Time.zone.parse('1970-01-01').to_i
|
||||||
|
max_timestamp = Time.zone.parse('2100-01-01').to_i
|
||||||
|
|
||||||
|
parsed_time.to_i.clamp(min_timestamp, max_timestamp)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
Time.zone.now.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
34
app/controllers/concerns/utm_trackable.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module UtmTrackable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
UTM_PARAMS = %w[utm_source utm_medium utm_campaign utm_term utm_content].freeze
|
||||||
|
|
||||||
|
def store_utm_params
|
||||||
|
UTM_PARAMS.each do |param|
|
||||||
|
session[param] = params[param] if params[param].present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_utm_params(record)
|
||||||
|
utm_data = extract_utm_data_from_session
|
||||||
|
|
||||||
|
return unless utm_data.any?
|
||||||
|
|
||||||
|
record.update_columns(utm_data)
|
||||||
|
clear_utm_session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_utm_data_from_session
|
||||||
|
UTM_PARAMS.each_with_object({}) do |param, hash|
|
||||||
|
hash[param] = session[param] if session[param].present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_utm_session
|
||||||
|
UTM_PARAMS.each { |param| session.delete(param) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -7,7 +7,7 @@ class ExportsController < ApplicationController
|
||||||
before_action :set_export, only: %i[destroy]
|
before_action :set_export, only: %i[destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
|
@exports = current_user.exports.with_attached_file.order(created_at: :desc).page(params[:page])
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
class FamiliesController < ApplicationController
|
class FamiliesController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :ensure_family_feature_enabled!
|
before_action :ensure_family_feature_enabled!
|
||||||
before_action :set_family, only: %i[show edit update destroy update_location_sharing]
|
before_action :set_family, only: %i[show edit update destroy]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
authorize @family
|
authorize @family
|
||||||
|
|
@ -76,16 +76,6 @@ class FamiliesController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_location_sharing
|
|
||||||
result = Families::UpdateLocationSharing.new(
|
|
||||||
user: current_user,
|
|
||||||
enabled: params[:enabled],
|
|
||||||
duration: params[:duration]
|
|
||||||
).call
|
|
||||||
|
|
||||||
render json: result.payload, status: result.status
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_family
|
def set_family
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ class Family::InvitationsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@invitation = Family::Invitation.find_by!(token: params[:token])
|
token = params[:token] || params[:id]
|
||||||
|
@invitation = Family::Invitation.find_by!(token: token)
|
||||||
|
|
||||||
if @invitation.expired?
|
if @invitation.expired?
|
||||||
redirect_to root_path, alert: 'This invitation has expired.' and return
|
redirect_to root_path, alert: 'This invitation has expired.' and return
|
||||||
|
|
|
||||||
25
app/controllers/family/location_sharing_controller.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Family::LocationSharingController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :ensure_family_feature_enabled!
|
||||||
|
before_action :ensure_user_in_family!
|
||||||
|
|
||||||
|
def update
|
||||||
|
result = Families::UpdateLocationSharing.new(
|
||||||
|
user: current_user,
|
||||||
|
enabled: params[:enabled],
|
||||||
|
duration: params[:duration]
|
||||||
|
).call
|
||||||
|
|
||||||
|
render json: result.payload, status: result.status
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_user_in_family!
|
||||||
|
return if current_user.in_family?
|
||||||
|
|
||||||
|
render json: { error: 'User is not part of a family' }, status: :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -21,17 +21,17 @@ class Family::MembershipsController < ApplicationController
|
||||||
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
||||||
end
|
end
|
||||||
rescue Pundit::NotAuthorizedError
|
rescue Pundit::NotAuthorizedError
|
||||||
if @invitation.expired?
|
alert = case
|
||||||
redirect_to root_path, alert: 'This invitation is no longer valid or has expired'
|
when @invitation.expired? then 'This invitation is no longer valid or has expired'
|
||||||
elsif !@invitation.pending?
|
when !@invitation.pending? then 'This invitation has already been processed'
|
||||||
redirect_to root_path, alert: 'This invitation has already been processed'
|
when @invitation.email != current_user.email then 'This invitation is not for your email address'
|
||||||
elsif @invitation.email != current_user.email
|
else 'You are not authorized to accept this invitation'
|
||||||
redirect_to root_path, alert: 'This invitation is not for your email address'
|
end
|
||||||
else
|
|
||||||
redirect_to root_path, alert: 'You are not authorized to accept this invitation'
|
redirect_to root_path, alert: alert
|
||||||
end
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "Error accepting family invitation: #{e.message}"
|
Rails.logger.error "Error accepting family invitation: #{e.message}"
|
||||||
|
|
||||||
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
|
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class HomeController < ApplicationController
|
class HomeController < ApplicationController
|
||||||
|
include ApplicationHelper
|
||||||
|
|
||||||
def index
|
def index
|
||||||
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED
|
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED
|
||||||
|
|
||||||
redirect_to map_url if current_user
|
redirect_to preferred_map_path if current_user
|
||||||
|
|
||||||
@points = current_user.points.without_raw_data if current_user
|
@points = current_user.points.without_raw_data if current_user
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class ImportsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
@imports = policy_scope(Import)
|
@imports = policy_scope(Import)
|
||||||
.select(:id, :name, :source, :created_at, :processed, :status)
|
.select(:id, :name, :source, :created_at, :processed, :status)
|
||||||
|
.with_attached_file
|
||||||
.order(created_at: :desc)
|
.order(created_at: :desc)
|
||||||
.page(params[:page])
|
.page(params[:page])
|
||||||
end
|
end
|
||||||
|
|
@ -78,9 +79,13 @@ class ImportsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
Imports::Destroy.new(current_user, @import).call
|
@import.deleting!
|
||||||
|
Imports::DestroyJob.perform_later(@import.id)
|
||||||
|
|
||||||
redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other
|
respond_to do |format|
|
||||||
|
format.html { redirect_to imports_url, notice: 'Import is being deleted.', status: :see_other }
|
||||||
|
format.turbo_stream
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class MapController < ApplicationController
|
class Map::LeafletController < ApplicationController
|
||||||
|
include SafeTimestampParser
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
layout 'map', only: :index
|
layout 'map', only: :index
|
||||||
|
|
||||||
|
|
@ -14,6 +16,7 @@ class MapController < ApplicationController
|
||||||
@years = years_range
|
@years = years_range
|
||||||
@points_number = points_count
|
@points_number = points_count
|
||||||
@features = DawarichSettings.features
|
@features = DawarichSettings.features
|
||||||
|
@home_coordinates = current_user.home_place_coordinates
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
@ -70,14 +73,14 @@ class MapController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def start_at
|
def start_at
|
||||||
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
|
return safe_timestamp(params[:start_at]) if params[:start_at].present?
|
||||||
return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any?
|
return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any?
|
||||||
|
|
||||||
Time.zone.today.beginning_of_day.to_i
|
Time.zone.today.beginning_of_day.to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
def end_at
|
def end_at
|
||||||
return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present?
|
return safe_timestamp(params[:end_at]) if params[:end_at].present?
|
||||||
return Time.zone.at(points.last.timestamp).end_of_day.to_i if points.any?
|
return Time.zone.at(points.last.timestamp).end_of_day.to_i if points.any?
|
||||||
|
|
||||||
Time.zone.today.end_of_day.to_i
|
Time.zone.today.end_of_day.to_i
|
||||||
35
app/controllers/map/maplibre_controller.rb
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
module Map
|
||||||
|
class MaplibreController < ApplicationController
|
||||||
|
include SafeTimestampParser
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
layout 'map'
|
||||||
|
|
||||||
|
def index
|
||||||
|
@start_at = parsed_start_at
|
||||||
|
@end_at = parsed_end_at
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def start_at
|
||||||
|
return safe_timestamp(params[:start_at]) if params[:start_at].present?
|
||||||
|
|
||||||
|
Time.zone.today.beginning_of_day.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def end_at
|
||||||
|
return safe_timestamp(params[:end_at]) if params[:end_at].present?
|
||||||
|
|
||||||
|
Time.zone.today.end_of_day.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def parsed_start_at
|
||||||
|
Time.zone.at(start_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parsed_end_at
|
||||||
|
Time.zone.at(end_at)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PointsController < ApplicationController
|
class PointsController < ApplicationController
|
||||||
|
include SafeTimestampParser
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
@ -40,13 +42,13 @@ class PointsController < ApplicationController
|
||||||
def start_at
|
def start_at
|
||||||
return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil?
|
return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil?
|
||||||
|
|
||||||
Time.zone.parse(params[:start_at]).to_i
|
safe_timestamp(params[:start_at])
|
||||||
end
|
end
|
||||||
|
|
||||||
def end_at
|
def end_at
|
||||||
return Time.zone.today.end_of_day.to_i if params[:end_at].nil?
|
return Time.zone.today.end_of_day.to_i if params[:end_at].nil?
|
||||||
|
|
||||||
Time.zone.parse(params[:end_at]).to_i
|
safe_timestamp(params[:end_at])
|
||||||
end
|
end
|
||||||
|
|
||||||
def points
|
def points
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.require(:maps).permit(:name, :url, :distance_unit)
|
params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class SettingsController < ApplicationController
|
||||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||||
:visits_suggestions_enabled
|
:visits_suggestions_enabled, :digest_emails_enabled
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
55
app/controllers/shared/digests_controller.rb
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Shared::DigestsController < ApplicationController
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
before_action :authenticate_user!, except: [:show]
|
||||||
|
before_action :authenticate_active_user!, only: [:update]
|
||||||
|
|
||||||
|
def show
|
||||||
|
@digest = Users::Digest.find_by(sharing_uuid: params[:uuid])
|
||||||
|
|
||||||
|
unless @digest&.public_accessible?
|
||||||
|
return redirect_to root_path,
|
||||||
|
alert: 'Shared digest not found or no longer available'
|
||||||
|
end
|
||||||
|
|
||||||
|
@year = @digest.year
|
||||||
|
@user = @digest.user
|
||||||
|
@distance_unit = @user.safe_settings.distance_unit || 'km'
|
||||||
|
@is_public_view = true
|
||||||
|
|
||||||
|
render 'users/digests/public_year'
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@year = params[:year].to_i
|
||||||
|
@digest = current_user.digests.yearly.find_by(year: @year)
|
||||||
|
|
||||||
|
return head :not_found unless @digest
|
||||||
|
|
||||||
|
if params[:enabled] == '1'
|
||||||
|
@digest.enable_sharing!(expiration: params[:expiration] || '24h')
|
||||||
|
sharing_url = shared_users_digest_url(@digest.sharing_uuid)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
sharing_url: sharing_url,
|
||||||
|
message: 'Sharing enabled successfully'
|
||||||
|
}
|
||||||
|
else
|
||||||
|
@digest.disable_sharing!
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
success: true,
|
||||||
|
message: 'Sharing disabled successfully'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
rescue StandardError
|
||||||
|
render json: {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update sharing settings'
|
||||||
|
}, status: :unprocessable_content
|
||||||
|
end
|
||||||
|
end
|
||||||
62
app/controllers/tags_controller.rb
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TagsController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_tag, only: [:edit, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@tags = policy_scope(Tag).ordered
|
||||||
|
|
||||||
|
authorize Tag
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@tag = current_user.tags.build
|
||||||
|
|
||||||
|
authorize @tag
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@tag = current_user.tags.build(tag_params)
|
||||||
|
|
||||||
|
authorize @tag
|
||||||
|
|
||||||
|
if @tag.save
|
||||||
|
redirect_to tags_path, notice: 'Tag was successfully created.'
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize @tag
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @tag
|
||||||
|
|
||||||
|
if @tag.update(tag_params)
|
||||||
|
redirect_to tags_path, notice: 'Tag was successfully updated.'
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @tag
|
||||||
|
|
||||||
|
@tag.destroy!
|
||||||
|
|
||||||
|
redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_tag
|
||||||
|
@tag = current_user.tags.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_params
|
||||||
|
params.require(:tag).permit(:name, :icon, :color, :privacy_radius_meters)
|
||||||
|
end
|
||||||
|
end
|
||||||
59
app/controllers/users/digests_controller.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::DigestsController < ApplicationController
|
||||||
|
helper Users::DigestsHelper
|
||||||
|
helper CountryFlagHelper
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :authenticate_active_user!, only: [:create]
|
||||||
|
before_action :set_digest, only: %i[show destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@digests = current_user.digests.yearly.order(year: :desc)
|
||||||
|
@available_years = available_years_for_generation
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@distance_unit = current_user.safe_settings.distance_unit || 'km'
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
year = params[:year].to_i
|
||||||
|
|
||||||
|
if valid_year?(year)
|
||||||
|
Users::Digests::CalculatingJob.perform_later(current_user.id, year)
|
||||||
|
redirect_to users_digests_path,
|
||||||
|
notice: "Year-end digest for #{year} is being generated. Check back soon!",
|
||||||
|
status: :see_other
|
||||||
|
else
|
||||||
|
redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
year = @digest.year
|
||||||
|
@digest.destroy!
|
||||||
|
redirect_to users_digests_path, notice: "Year-end digest for #{year} has been deleted", status: :see_other
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_digest
|
||||||
|
@digest = current_user.digests.yearly.find_by!(year: params[:year])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to users_digests_path, alert: 'Digest not found'
|
||||||
|
end
|
||||||
|
|
||||||
|
def available_years_for_generation
|
||||||
|
tracked_years = current_user.stats.select(:year).distinct.pluck(:year)
|
||||||
|
existing_digests = current_user.digests.yearly.pluck(:year)
|
||||||
|
|
||||||
|
(tracked_years - existing_digests - [Time.current.year]).sort.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_year?(year)
|
||||||
|
return false if year < 2000 || year > Time.current.year
|
||||||
|
|
||||||
|
current_user.stats.exists?(year: year)
|
||||||
|
end
|
||||||
|
end
|
||||||
67
app/controllers/users/omniauth_callbacks_controller.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
|
def github
|
||||||
|
handle_auth('GitHub')
|
||||||
|
end
|
||||||
|
|
||||||
|
def google_oauth2
|
||||||
|
handle_auth('Google')
|
||||||
|
end
|
||||||
|
|
||||||
|
def openid_connect
|
||||||
|
handle_auth('OpenID Connect')
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure
|
||||||
|
error_type = request.env['omniauth.error.type']
|
||||||
|
error = request.env['omniauth.error']
|
||||||
|
|
||||||
|
# Provide user-friendly error messages
|
||||||
|
error_message =
|
||||||
|
case error_type
|
||||||
|
when :invalid_credentials
|
||||||
|
'Invalid credentials. Please check your username and password.'
|
||||||
|
when :timeout
|
||||||
|
'Connection timeout. Please try again.'
|
||||||
|
when :csrf_detected
|
||||||
|
'Security error detected. Please try again.'
|
||||||
|
else
|
||||||
|
if error&.message&.include?('Discovery')
|
||||||
|
'Unable to connect to authentication provider. Please contact your administrator.'
|
||||||
|
elsif error&.message&.include?('Issuer mismatch')
|
||||||
|
'Authentication provider configuration error. Please contact your administrator.'
|
||||||
|
else
|
||||||
|
"Authentication failed: #{params[:message] || error&.message || 'Unknown error'}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to root_path, alert: error_message
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def handle_auth(provider)
|
||||||
|
@user = User.from_omniauth(request.env['omniauth.auth'])
|
||||||
|
|
||||||
|
if @user&.persisted?
|
||||||
|
flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider
|
||||||
|
sign_in_and_redirect @user, event: :authentication
|
||||||
|
elsif @user.nil?
|
||||||
|
# User creation was rejected (e.g., OIDC auto-register disabled)
|
||||||
|
error_message = if provider == 'OpenID Connect' && !oidc_auto_register_enabled?
|
||||||
|
'Your account must be created by an administrator before you can sign in with OIDC. ' \
|
||||||
|
'Please contact your administrator.'
|
||||||
|
else
|
||||||
|
'Unable to create your account. Please try again or contact support.'
|
||||||
|
end
|
||||||
|
redirect_to root_path, alert: error_message
|
||||||
|
else
|
||||||
|
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def oidc_auto_register_enabled?
|
||||||
|
OIDC_AUTO_REGISTER
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Users::RegistrationsController < Devise::RegistrationsController
|
class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
|
include UtmTrackable
|
||||||
|
|
||||||
before_action :set_invitation, only: %i[new create]
|
before_action :set_invitation, only: %i[new create]
|
||||||
before_action :check_registration_allowed, only: %i[new create]
|
before_action :check_registration_allowed, only: %i[new create]
|
||||||
|
before_action :store_utm_params, only: %i[new], unless: -> { DawarichSettings.self_hosted? }
|
||||||
|
|
||||||
def new
|
def new
|
||||||
build_resource({})
|
build_resource({})
|
||||||
|
|
@ -16,8 +19,9 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
|
|
||||||
def create
|
def create
|
||||||
super do |resource|
|
super do |resource|
|
||||||
if resource.persisted? && @invitation
|
if resource.persisted?
|
||||||
accept_invitation_for_user(resource)
|
assign_utm_params(resource)
|
||||||
|
accept_invitation_for_user(resource) if @invitation
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -41,13 +45,14 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
def check_registration_allowed
|
def check_registration_allowed
|
||||||
return unless self_hosted_mode?
|
return unless self_hosted_mode?
|
||||||
return if valid_invitation_token?
|
return if valid_invitation_token?
|
||||||
|
return if email_password_registration_allowed?
|
||||||
|
|
||||||
redirect_to root_path,
|
redirect_to root_path,
|
||||||
alert: 'Registration is not available. Please contact your administrator for access.'
|
alert: 'Registration is not available. Please contact your administrator for access.'
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_invitation
|
def set_invitation
|
||||||
return unless invitation_token.present?
|
return if invitation_token.blank?
|
||||||
|
|
||||||
@invitation = Family::Invitation.find_by(token: invitation_token)
|
@invitation = Family::Invitation.find_by(token: invitation_token)
|
||||||
end
|
end
|
||||||
|
|
@ -65,8 +70,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
|
|
||||||
def invitation_token
|
def invitation_token
|
||||||
@invitation_token ||= params[:invitation_token] ||
|
@invitation_token ||= params[:invitation_token] ||
|
||||||
params.dig(:user, :invitation_token) ||
|
params.dig(:user, :invitation_token) ||
|
||||||
session[:invitation_token]
|
session[:invitation_token]
|
||||||
end
|
end
|
||||||
|
|
||||||
def accept_invitation_for_user(user)
|
def accept_invitation_for_user(user)
|
||||||
|
|
@ -80,14 +85,20 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
||||||
if service.call
|
if service.call
|
||||||
flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family."
|
flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family."
|
||||||
else
|
else
|
||||||
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
|
flash[:alert] =
|
||||||
|
"Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
|
||||||
end
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "Error accepting invitation during registration: #{e.message}"
|
Rails.logger.error "Error accepting invitation during registration: #{e.message}"
|
||||||
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again."
|
flash[:alert] =
|
||||||
|
'Account created successfully, but there was an issue accepting the invitation. Please try accepting it again.'
|
||||||
end
|
end
|
||||||
|
|
||||||
def sign_up_params
|
def sign_up_params
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def email_password_registration_allowed?
|
||||||
|
ALLOW_EMAIL_PASSWORD_REGISTRATION
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,26 +7,14 @@ class Users::SessionsController < Devise::SessionsController
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
|
||||||
|
|
||||||
def after_sign_in_path_for(resource)
|
|
||||||
if invitation_token.present?
|
|
||||||
invitation = Family::Invitation.find_by(token: invitation_token)
|
|
||||||
|
|
||||||
if invitation&.can_be_accepted?
|
|
||||||
return family_invitation_path(invitation.token)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
super(resource)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def load_invitation_context
|
def load_invitation_context
|
||||||
return unless invitation_token.present?
|
return unless invitation_token.present?
|
||||||
|
|
||||||
@invitation = Family::Invitation.find_by(token: invitation_token)
|
@invitation = Family::Invitation.find_by(token: invitation_token)
|
||||||
|
# Store token in session so it persists through the sign-in process
|
||||||
|
session[:invitation_token] = invitation_token if invitation_token.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def invitation_token
|
def invitation_token
|
||||||
|
|
|
||||||
|
|
@ -130,4 +130,23 @@ module ApplicationHelper
|
||||||
'btn-success'
|
'btn-success'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def oauth_provider_name(provider)
|
||||||
|
return OIDC_PROVIDER_NAME if provider == :openid_connect
|
||||||
|
|
||||||
|
OmniAuth::Utils.camelize(provider)
|
||||||
|
end
|
||||||
|
|
||||||
|
def email_password_registration_enabled?
|
||||||
|
return true unless DawarichSettings.self_hosted?
|
||||||
|
|
||||||
|
ALLOW_EMAIL_PASSWORD_REGISTRATION
|
||||||
|
end
|
||||||
|
|
||||||
|
def preferred_map_path
|
||||||
|
return map_v1_path unless user_signed_in?
|
||||||
|
|
||||||
|
preferred_version = current_user.safe_settings.maps&.dig('preferred_version')
|
||||||
|
preferred_version == 'v2' ? map_v2_path : map_v1_path
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@
|
||||||
module CountryFlagHelper
|
module CountryFlagHelper
|
||||||
def country_flag(country_name)
|
def country_flag(country_name)
|
||||||
country_code = country_to_code(country_name)
|
country_code = country_to_code(country_name)
|
||||||
return "" unless country_code
|
return '' unless country_code
|
||||||
|
|
||||||
|
country_code = 'TW' if country_code == 'CN-TW'
|
||||||
|
|
||||||
# Convert country code to regional indicator symbols (flag emoji)
|
# Convert country code to regional indicator symbols (flag emoji)
|
||||||
country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
|
country_code.upcase.each_char.map { |c| (c.ord + 127_397).chr(Encoding::UTF_8) }.join
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def country_to_code(country_name)
|
def country_to_code(country_name)
|
||||||
|
|
|
||||||
20
app/helpers/tags_helper.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module TagsHelper
|
||||||
|
COMMON_TAG_EMOJIS = %w[
|
||||||
|
🏠 🏢 🏫 🏥 🏪 🏨 🏦 🏛️ 🏟️ 🏖️
|
||||||
|
⛪ 🕌 🕍 ⛩️ 🗼 🗽 🗿 💒 🏰 🏯
|
||||||
|
🍕 🍔 🍟 🍣 🍱 🍜 🍝 🍛 🥘 🍲
|
||||||
|
☕ 🍺 🍷 🥂 🍹 🍸 🥃 🍻 🥤 🧃
|
||||||
|
🏃 ⚽ 🏀 🏈 ⚾ 🎾 🏐 🏓 🏸 🏒
|
||||||
|
🚗 🚕 🚙 🚌 🚎 🏎️ 🚓 🚑 🚒 🚐
|
||||||
|
✈️ 🚁 ⛵ 🚤 🛥️ ⛴️ 🚂 🚆 🚇 🚊
|
||||||
|
🎭 🎪 🎨 🎬 🎤 🎧 🎼 🎹 🎸 🎺
|
||||||
|
📚 📖 ✏️ 🖊️ 📝 📋 📌 📍 🗺️ 🧭
|
||||||
|
💼 👔 🎓 🏆 🎯 🎲 🎮 🎰 🛍️ 💍
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def random_tag_emoji
|
||||||
|
COMMON_TAG_EMOJIS.sample
|
||||||
|
end
|
||||||
|
end
|
||||||
71
app/helpers/users/digests_helper.rb
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Users
|
||||||
|
module DigestsHelper
|
||||||
|
PROGRESS_COLORS = %w[
|
||||||
|
progress-primary progress-secondary progress-accent
|
||||||
|
progress-info progress-success progress-warning
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def progress_color_for_index(index)
|
||||||
|
PROGRESS_COLORS[index % PROGRESS_COLORS.length]
|
||||||
|
end
|
||||||
|
|
||||||
|
def city_progress_value(city_count, max_cities)
|
||||||
|
return 0 unless max_cities&.positive?
|
||||||
|
|
||||||
|
(city_count.to_f / max_cities * 100).round
|
||||||
|
end
|
||||||
|
|
||||||
|
def max_cities_count(toponyms)
|
||||||
|
return 0 if toponyms.blank?
|
||||||
|
|
||||||
|
toponyms.map { |country| country['cities']&.length || 0 }.max
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance_with_unit(distance_meters, unit)
|
||||||
|
value = Users::Digest.convert_distance(distance_meters, unit).round
|
||||||
|
"#{number_with_delimiter(value)} #{unit}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance_comparison_text(distance_meters)
|
||||||
|
distance_km = distance_meters.to_f / 1000
|
||||||
|
|
||||||
|
if distance_km >= Users::Digest::MOON_DISTANCE_KM
|
||||||
|
percentage = ((distance_km / Users::Digest::MOON_DISTANCE_KM) * 100).round(1)
|
||||||
|
"That's #{percentage}% of the distance to the Moon!"
|
||||||
|
else
|
||||||
|
percentage = ((distance_km / Users::Digest::EARTH_CIRCUMFERENCE_KM) * 100).round(1)
|
||||||
|
"That's #{percentage}% of Earth's circumference!"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_time_spent(minutes)
|
||||||
|
return "#{minutes} minutes" if minutes < 60
|
||||||
|
|
||||||
|
hours = minutes / 60
|
||||||
|
remaining_minutes = minutes % 60
|
||||||
|
|
||||||
|
if hours < 24
|
||||||
|
"#{hours}h #{remaining_minutes}m"
|
||||||
|
else
|
||||||
|
days = hours / 24
|
||||||
|
remaining_hours = hours % 24
|
||||||
|
"#{days}d #{remaining_hours}h"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def yoy_change_class(change)
|
||||||
|
return '' if change.nil?
|
||||||
|
|
||||||
|
change.negative? ? 'negative' : 'positive'
|
||||||
|
end
|
||||||
|
|
||||||
|
def yoy_change_text(change)
|
||||||
|
return '' if change.nil?
|
||||||
|
|
||||||
|
prefix = change.positive? ? '+' : ''
|
||||||
|
"#{prefix}#{change}%"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
724
app/javascript/README.md
Normal file
|
|
@ -0,0 +1,724 @@
|
||||||
|
# Dawarich JavaScript Architecture
|
||||||
|
|
||||||
|
This document provides a comprehensive guide to the JavaScript architecture used in the Dawarich application, with a focus on the Maps (MapLibre) implementation.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Technology Stack](#technology-stack)
|
||||||
|
- [Architecture Patterns](#architecture-patterns)
|
||||||
|
- [Directory Structure](#directory-structure)
|
||||||
|
- [Core Concepts](#core-concepts)
|
||||||
|
- [Maps (MapLibre) Architecture](#maps-maplibre-architecture)
|
||||||
|
- [Creating New Features](#creating-new-features)
|
||||||
|
- [Best Practices](#best-practices)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Dawarich uses a modern JavaScript architecture built on **Hotwire (Turbo + Stimulus)** for page interactions and **MapLibre GL JS** for map rendering. The Maps (MapLibre) implementation follows object-oriented principles with clear separation of concerns.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Stimulus** - Modest JavaScript framework for sprinkles of interactivity
|
||||||
|
- **Turbo Rails** - SPA-like page navigation without building an SPA
|
||||||
|
- **MapLibre GL JS** - Open-source map rendering engine
|
||||||
|
- **ES6 Modules** - Modern JavaScript module system
|
||||||
|
- **Tailwind CSS + DaisyUI** - Utility-first CSS framework
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### 1. Stimulus Controllers
|
||||||
|
|
||||||
|
**Purpose:** Connect DOM elements to JavaScript behavior
|
||||||
|
|
||||||
|
**Location:** `app/javascript/controllers/`
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['element']
|
||||||
|
static values = { apiKey: String }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Initialize when element appears in DOM
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
// Cleanup when element is removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principles:**
|
||||||
|
- Controllers should be stateless when possible
|
||||||
|
- Use `targets` for DOM element references
|
||||||
|
- Use `values` for passing data from HTML
|
||||||
|
- Always cleanup in `disconnect()`
|
||||||
|
|
||||||
|
### 2. Service Classes
|
||||||
|
|
||||||
|
**Purpose:** Encapsulate business logic and API communication
|
||||||
|
|
||||||
|
**Location:** `app/javascript/maps_maplibre/services/`
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
export class ApiClient {
|
||||||
|
constructor(apiKey) {
|
||||||
|
this.apiKey = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchData() {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: this.getHeaders()
|
||||||
|
})
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principles:**
|
||||||
|
- Single responsibility - one service per concern
|
||||||
|
- Consistent error handling
|
||||||
|
- Return promises for async operations
|
||||||
|
- Use constructor injection for dependencies
|
||||||
|
|
||||||
|
### 3. Layer Classes (Map Layers)
|
||||||
|
|
||||||
|
**Purpose:** Manage map visualization layers
|
||||||
|
|
||||||
|
**Location:** `app/javascript/maps_maplibre/layers/`
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
import { BaseLayer } from './base_layer'
|
||||||
|
|
||||||
|
export class CustomLayer extends BaseLayer {
|
||||||
|
constructor(map, options = {}) {
|
||||||
|
super(map, { id: 'custom', ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
getSourceConfig() {
|
||||||
|
return {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayerConfigs() {
|
||||||
|
return [{
|
||||||
|
id: this.id,
|
||||||
|
type: 'circle',
|
||||||
|
source: this.sourceId,
|
||||||
|
paint: { /* ... */ }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principles:**
|
||||||
|
- All layers extend `BaseLayer`
|
||||||
|
- Implement `getSourceConfig()` and `getLayerConfigs()`
|
||||||
|
- Store data in `this.data`
|
||||||
|
- Use `this.visible` for visibility state
|
||||||
|
- Inherit common methods: `add()`, `update()`, `show()`, `hide()`, `toggle()`
|
||||||
|
|
||||||
|
### 4. Utility Modules
|
||||||
|
|
||||||
|
**Purpose:** Provide reusable helper functions
|
||||||
|
|
||||||
|
**Location:** `app/javascript/maps_maplibre/utils/`
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
export class UtilityClass {
|
||||||
|
static helperMethod(param) {
|
||||||
|
// Static methods for stateless utilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or singleton pattern
|
||||||
|
export const utilityInstance = new UtilityClass()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Component Classes
|
||||||
|
|
||||||
|
**Purpose:** Reusable UI components
|
||||||
|
|
||||||
|
**Location:** `app/javascript/maps_maplibre/components/`
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
export class PopupFactory {
|
||||||
|
static createPopup(data) {
|
||||||
|
return `<div>${data.name}</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/javascript/
|
||||||
|
├── application.js # Entry point
|
||||||
|
├── controllers/ # Stimulus controllers
|
||||||
|
│ ├── maps/maplibre_controller.js # Main map controller
|
||||||
|
│ ├── maps_maplibre/ # Controller modules
|
||||||
|
│ │ ├── layer_manager.js # Layer lifecycle management
|
||||||
|
│ │ ├── data_loader.js # API data fetching
|
||||||
|
│ │ ├── event_handlers.js # Map event handling
|
||||||
|
│ │ ├── filter_manager.js # Data filtering
|
||||||
|
│ │ └── date_manager.js # Date range management
|
||||||
|
│ └── ... # Other controllers
|
||||||
|
├── maps_maplibre/ # Maps (MapLibre) implementation
|
||||||
|
│ ├── layers/ # Map layer classes
|
||||||
|
│ │ ├── base_layer.js # Abstract base class
|
||||||
|
│ │ ├── points_layer.js # Point markers
|
||||||
|
│ │ ├── routes_layer.js # Route lines
|
||||||
|
│ │ ├── heatmap_layer.js # Heatmap visualization
|
||||||
|
│ │ ├── visits_layer.js # Visit markers
|
||||||
|
│ │ ├── photos_layer.js # Photo markers
|
||||||
|
│ │ ├── places_layer.js # Places markers
|
||||||
|
│ │ ├── areas_layer.js # User-defined areas
|
||||||
|
│ │ ├── fog_layer.js # Fog of war overlay
|
||||||
|
│ │ └── scratch_layer.js # Scratch map
|
||||||
|
│ ├── services/ # API and external services
|
||||||
|
│ │ ├── api_client.js # REST API wrapper
|
||||||
|
│ │ └── location_search_service.js
|
||||||
|
│ ├── utils/ # Helper utilities
|
||||||
|
│ │ ├── settings_manager.js # User preferences
|
||||||
|
│ │ ├── geojson_transformers.js
|
||||||
|
│ │ ├── performance_monitor.js
|
||||||
|
│ │ ├── lazy_loader.js # Code splitting
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── popup_factory.js # Map popup generator
|
||||||
|
│ │ ├── toast.js # Toast notifications
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── channels/ # ActionCable channels
|
||||||
|
│ └── map_channel.js # Real-time updates
|
||||||
|
└── maps/ # Legacy Maps V1 (being phased out)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Manager Pattern
|
||||||
|
|
||||||
|
The Maps (MapLibre) controller delegates responsibilities to specialized managers:
|
||||||
|
|
||||||
|
1. **LayerManager** - Layer lifecycle (add/remove/toggle/update)
|
||||||
|
2. **DataLoader** - API data fetching and transformation
|
||||||
|
3. **EventHandlers** - Map interaction events
|
||||||
|
4. **FilterManager** - Data filtering and searching
|
||||||
|
5. **DateManager** - Date range calculations
|
||||||
|
6. **SettingsManager** - User preferences persistence
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Single Responsibility Principle
|
||||||
|
- Easier testing
|
||||||
|
- Improved code organization
|
||||||
|
- Better reusability
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action
|
||||||
|
↓
|
||||||
|
Stimulus Controller Method
|
||||||
|
↓
|
||||||
|
Manager (e.g., DataLoader)
|
||||||
|
↓
|
||||||
|
Service (e.g., ApiClient)
|
||||||
|
↓
|
||||||
|
API Endpoint
|
||||||
|
↓
|
||||||
|
Transform to GeoJSON
|
||||||
|
↓
|
||||||
|
Update Layer
|
||||||
|
↓
|
||||||
|
MapLibre Renders
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
**Settings Persistence:**
|
||||||
|
- Primary: Backend API (`/api/v1/settings`)
|
||||||
|
- Fallback: localStorage
|
||||||
|
- Sync on initialization
|
||||||
|
- Save on every change (debounced)
|
||||||
|
|
||||||
|
**Layer State:**
|
||||||
|
- Stored in layer instances (`this.visible`, `this.data`)
|
||||||
|
- Synced with SettingsManager
|
||||||
|
- Persisted across sessions
|
||||||
|
|
||||||
|
### Event System
|
||||||
|
|
||||||
|
**Custom Events:**
|
||||||
|
```javascript
|
||||||
|
// Dispatch
|
||||||
|
document.dispatchEvent(new CustomEvent('visit:created', {
|
||||||
|
detail: { visitId: 123 }
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Listen
|
||||||
|
document.addEventListener('visit:created', (event) => {
|
||||||
|
console.log(event.detail.visitId)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Map Events:**
|
||||||
|
```javascript
|
||||||
|
map.on('click', 'layer-id', (e) => {
|
||||||
|
const feature = e.features[0]
|
||||||
|
// Handle click
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maps (MapLibre) Architecture
|
||||||
|
|
||||||
|
### Layer Hierarchy
|
||||||
|
|
||||||
|
Layers are rendered in specific order (bottom to top):
|
||||||
|
|
||||||
|
1. **Scratch Layer** - Visited countries/regions overlay
|
||||||
|
2. **Heatmap Layer** - Point density visualization
|
||||||
|
3. **Areas Layer** - User-defined circular areas
|
||||||
|
4. **Tracks Layer** - Imported GPS tracks
|
||||||
|
5. **Routes Layer** - Generated routes from points
|
||||||
|
6. **Visits Layer** - Detected visits to places
|
||||||
|
7. **Places Layer** - Named locations
|
||||||
|
8. **Photos Layer** - Photos with geolocation
|
||||||
|
9. **Family Layer** - Real-time family member locations
|
||||||
|
10. **Points Layer** - Individual location points
|
||||||
|
11. **Fog Layer** - Canvas overlay showing unexplored areas
|
||||||
|
|
||||||
|
### BaseLayer Pattern
|
||||||
|
|
||||||
|
All layers extend `BaseLayer` which provides:
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `add(data)` - Add layer to map
|
||||||
|
- `update(data)` - Update layer data
|
||||||
|
- `remove()` - Remove layer from map
|
||||||
|
- `show()` / `hide()` - Toggle visibility
|
||||||
|
- `toggle(visible)` - Set visibility state
|
||||||
|
|
||||||
|
**Abstract Methods (must implement):**
|
||||||
|
- `getSourceConfig()` - MapLibre source configuration
|
||||||
|
- `getLayerConfigs()` - Array of MapLibre layer configurations
|
||||||
|
|
||||||
|
**Example Implementation:**
|
||||||
|
```javascript
|
||||||
|
export class PointsLayer extends BaseLayer {
|
||||||
|
constructor(map, options = {}) {
|
||||||
|
super(map, { id: 'points', ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
getSourceConfig() {
|
||||||
|
return {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.data || { type: 'FeatureCollection', features: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayerConfigs() {
|
||||||
|
return [{
|
||||||
|
id: 'points',
|
||||||
|
type: 'circle',
|
||||||
|
source: this.sourceId,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 4,
|
||||||
|
'circle-color': '#3b82f6'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lazy Loading
|
||||||
|
|
||||||
|
Heavy layers are lazy-loaded to reduce initial bundle size:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In lazy_loader.js
|
||||||
|
const paths = {
|
||||||
|
'fog': () => import('../layers/fog_layer.js'),
|
||||||
|
'scratch': () => import('../layers/scratch_layer.js')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||||
|
const layer = new ScratchLayer(map, options)
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Large dependencies (e.g., canvas-based rendering)
|
||||||
|
- Rarely-used features
|
||||||
|
- Heavy computations
|
||||||
|
|
||||||
|
### GeoJSON Transformations
|
||||||
|
|
||||||
|
All data is transformed to GeoJSON before rendering:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Points
|
||||||
|
{
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [longitude, latitude]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: 1,
|
||||||
|
timestamp: '2024-01-01T12:00:00Z',
|
||||||
|
// ... other properties
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Functions:**
|
||||||
|
- `pointsToGeoJSON(points)` - Convert points array
|
||||||
|
- `visitsToGeoJSON(visits)` - Convert visits
|
||||||
|
- `photosToGeoJSON(photos)` - Convert photos
|
||||||
|
- `placesToGeoJSON(places)` - Convert places
|
||||||
|
- `areasToGeoJSON(areas)` - Convert circular areas to polygons
|
||||||
|
|
||||||
|
## Creating New Features
|
||||||
|
|
||||||
|
### Adding a New Layer
|
||||||
|
|
||||||
|
1. **Create layer class** in `app/javascript/maps_maplibre/layers/`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { BaseLayer } from './base_layer'
|
||||||
|
|
||||||
|
export class NewLayer extends BaseLayer {
|
||||||
|
constructor(map, options = {}) {
|
||||||
|
super(map, { id: 'new-layer', ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
getSourceConfig() {
|
||||||
|
return {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.data || { type: 'FeatureCollection', features: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayerConfigs() {
|
||||||
|
return [{
|
||||||
|
id: this.id,
|
||||||
|
type: 'symbol', // or 'circle', 'line', 'fill', 'heatmap'
|
||||||
|
source: this.sourceId,
|
||||||
|
paint: { /* styling */ },
|
||||||
|
layout: { /* layout */ }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Register in LayerManager** (`controllers/maps_maplibre/layer_manager.js`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { NewLayer } from 'maps_maplibre/layers/new_layer'
|
||||||
|
|
||||||
|
// In addAllLayers method
|
||||||
|
_addNewLayer(dataGeoJSON) {
|
||||||
|
if (!this.layers.newLayer) {
|
||||||
|
this.layers.newLayer = new NewLayer(this.map, {
|
||||||
|
visible: this.settings.newLayerEnabled || false
|
||||||
|
})
|
||||||
|
this.layers.newLayer.add(dataGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.newLayer.update(dataGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add to settings** (`utils/settings_manager.js`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
// ...
|
||||||
|
newLayerEnabled: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYER_NAME_MAP = {
|
||||||
|
// ...
|
||||||
|
'New Layer': 'newLayerEnabled'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Add UI controls** in view template.
|
||||||
|
|
||||||
|
### Adding a New API Endpoint
|
||||||
|
|
||||||
|
1. **Add method to ApiClient** (`services/api_client.js`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async fetchNewData({ param1, param2 }) {
|
||||||
|
const params = new URLSearchParams({ param1, param2 })
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}/new-endpoint?${params}`, {
|
||||||
|
headers: this.getHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add transformation** in DataLoader:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
newDataToGeoJSON(data) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: data.map(item => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { /* ... */ },
|
||||||
|
properties: { /* ... */ }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use in controller:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const data = await this.api.fetchNewData({ param1, param2 })
|
||||||
|
const geojson = this.dataLoader.newDataToGeoJSON(data)
|
||||||
|
this.layerManager.updateLayer('new-layer', geojson)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Utility
|
||||||
|
|
||||||
|
1. **Create utility file** in `utils/`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export class NewUtility {
|
||||||
|
static calculate(input) {
|
||||||
|
// Pure function - no side effects
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or singleton for stateful utilities
|
||||||
|
class NewManager {
|
||||||
|
constructor() {
|
||||||
|
this.state = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
doSomething() {
|
||||||
|
// Stateful operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newManager = new NewManager()
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Import and use:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { NewUtility } from 'maps_maplibre/utils/new_utility'
|
||||||
|
|
||||||
|
const result = NewUtility.calculate(input)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
1. **Use ES6+ features:**
|
||||||
|
- Arrow functions
|
||||||
|
- Template literals
|
||||||
|
- Destructuring
|
||||||
|
- Async/await
|
||||||
|
- Classes
|
||||||
|
|
||||||
|
2. **Naming conventions:**
|
||||||
|
- Classes: `PascalCase`
|
||||||
|
- Methods/variables: `camelCase`
|
||||||
|
- Constants: `UPPER_SNAKE_CASE`
|
||||||
|
- Files: `snake_case.js`
|
||||||
|
|
||||||
|
3. **Always use semicolons** for statement termination
|
||||||
|
|
||||||
|
4. **Prefer `const` over `let`**, avoid `var`
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
1. **Lazy load heavy features:**
|
||||||
|
```javascript
|
||||||
|
const Layer = await lazyLoader.loadLayer('name')
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Debounce frequent operations:**
|
||||||
|
```javascript
|
||||||
|
let timeout
|
||||||
|
function onInput(e) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => actualWork(e), 300)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use performance monitoring:**
|
||||||
|
```javascript
|
||||||
|
performanceMonitor.mark('operation')
|
||||||
|
// ... do work
|
||||||
|
performanceMonitor.measure('operation')
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Minimize DOM manipulations** - batch updates when possible
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
1. **Always handle promise rejections:**
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const data = await fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed:', error)
|
||||||
|
Toast.error('Operation failed')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Provide user feedback:**
|
||||||
|
```javascript
|
||||||
|
Toast.success('Data loaded')
|
||||||
|
Toast.error('Failed to load data')
|
||||||
|
Toast.info('Click map to add point')
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Log errors for debugging:**
|
||||||
|
```javascript
|
||||||
|
console.error('[Component] Error details:', error)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Management
|
||||||
|
|
||||||
|
1. **Always cleanup in disconnect():**
|
||||||
|
```javascript
|
||||||
|
disconnect() {
|
||||||
|
this.searchManager?.destroy()
|
||||||
|
this.cleanup.cleanup()
|
||||||
|
this.map?.remove()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use CleanupHelper for event listeners:**
|
||||||
|
```javascript
|
||||||
|
this.cleanup = new CleanupHelper()
|
||||||
|
this.cleanup.addEventListener(element, 'click', handler)
|
||||||
|
|
||||||
|
// In disconnect():
|
||||||
|
this.cleanup.cleanup() // Removes all listeners
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Remove map layers and sources:**
|
||||||
|
```javascript
|
||||||
|
remove() {
|
||||||
|
this.getLayerIds().forEach(id => {
|
||||||
|
if (this.map.getLayer(id)) {
|
||||||
|
this.map.removeLayer(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (this.map.getSource(this.sourceId)) {
|
||||||
|
this.map.removeSource(this.sourceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Considerations
|
||||||
|
|
||||||
|
1. **Keep methods small and focused** - easier to test
|
||||||
|
2. **Avoid tight coupling** - use dependency injection
|
||||||
|
3. **Separate pure functions** from side effects
|
||||||
|
4. **Use static methods** for stateless utilities
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
1. **Single source of truth:**
|
||||||
|
- Settings: `SettingsManager`
|
||||||
|
- Layer data: Layer instances
|
||||||
|
- UI state: Controller properties
|
||||||
|
|
||||||
|
2. **Sync state with backend:**
|
||||||
|
```javascript
|
||||||
|
SettingsManager.updateSetting('key', value)
|
||||||
|
// Saves to both localStorage and backend
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restore state on load:**
|
||||||
|
```javascript
|
||||||
|
async connect() {
|
||||||
|
this.settings = await SettingsManager.sync()
|
||||||
|
this.syncToggleStates()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
1. **Add JSDoc comments for public APIs:**
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Fetch all points for date range
|
||||||
|
* @param {Object} options - { start_at, end_at, onProgress }
|
||||||
|
* @returns {Promise<Array>} All points
|
||||||
|
*/
|
||||||
|
async fetchAllPoints({ start_at, end_at, onProgress }) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Document complex logic with inline comments**
|
||||||
|
|
||||||
|
3. **Keep this README updated** when adding major features
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
1. **One class per file** - easier to find and maintain
|
||||||
|
2. **Group related functionality** in directories
|
||||||
|
3. **Use index files** for barrel exports when needed
|
||||||
|
4. **Avoid circular dependencies** - use dependency injection
|
||||||
|
|
||||||
|
### Migration from Maps V1 to V2
|
||||||
|
|
||||||
|
When updating features, follow this pattern:
|
||||||
|
|
||||||
|
1. **Keep V1 working** - V2 is opt-in
|
||||||
|
2. **Share utilities** where possible (e.g., color calculations)
|
||||||
|
3. **Use same API endpoints** - maintain compatibility
|
||||||
|
4. **Document differences** in code comments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Layer Implementation
|
||||||
|
|
||||||
|
See `app/javascript/maps_maplibre/layers/heatmap_layer.js` for a simple example.
|
||||||
|
|
||||||
|
### Complete Utility Implementation
|
||||||
|
|
||||||
|
See `app/javascript/maps_maplibre/utils/settings_manager.js` for state management.
|
||||||
|
|
||||||
|
### Complete Service Implementation
|
||||||
|
|
||||||
|
See `app/javascript/maps_maplibre/services/api_client.js` for API communication.
|
||||||
|
|
||||||
|
### Complete Controller Implementation
|
||||||
|
|
||||||
|
See `app/javascript/controllers/maps/maplibre_controller.js` for orchestration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Questions or need help?** Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8
|
||||||
|
|
@ -148,6 +148,10 @@ export default class extends Controller {
|
||||||
if (this.currentPopup) {
|
if (this.currentPopup) {
|
||||||
this.map.closePopup(this.currentPopup);
|
this.map.closePopup(this.currentPopup);
|
||||||
this.currentPopup = null;
|
this.currentPopup = null;
|
||||||
|
} else {
|
||||||
|
console.warn('No currentPopup reference found');
|
||||||
|
// Fallback: try to close any open popup
|
||||||
|
this.map.closePopup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,7 +267,10 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelButton) {
|
if (cancelButton) {
|
||||||
cancelButton.addEventListener('click', () => {
|
cancelButton.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
this.exitAddVisitMode(this.addVisitButton);
|
this.exitAddVisitMode(this.addVisitButton);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -346,8 +353,6 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
addCreatedVisitToMap(visitData, latitude, longitude) {
|
addCreatedVisitToMap(visitData, latitude, longitude) {
|
||||||
console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData });
|
|
||||||
|
|
||||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||||
if (!mapsController) {
|
if (!mapsController) {
|
||||||
console.log('Could not find maps controller element');
|
console.log('Could not find maps controller element');
|
||||||
|
|
@ -357,6 +362,7 @@ export default class extends Controller {
|
||||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||||
if (!stimulusController || !stimulusController.visitsManager) {
|
if (!stimulusController || !stimulusController.visitsManager) {
|
||||||
console.log('Could not find maps controller or visits manager');
|
console.log('Could not find maps controller or visits manager');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,16 +382,10 @@ export default class extends Controller {
|
||||||
|
|
||||||
// Add the circle to the confirmed visits layer
|
// Add the circle to the confirmed visits layer
|
||||||
visitsManager.confirmedVisitCircles.addLayer(circle);
|
visitsManager.confirmedVisitCircles.addLayer(circle);
|
||||||
console.log('✅ Added newly created confirmed visit circle to layer');
|
|
||||||
console.log('Confirmed visits layer info:', {
|
|
||||||
layerCount: visitsManager.confirmedVisitCircles.getLayers().length,
|
|
||||||
isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make sure the layer is visible on the map
|
// Make sure the layer is visible on the map
|
||||||
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
|
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
|
||||||
this.map.addLayer(visitsManager.confirmedVisitCircles);
|
this.map.addLayer(visitsManager.confirmedVisitCircles);
|
||||||
console.log('✅ Added confirmed visits layer to map');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the layer control has the confirmed visits layer enabled
|
// Check if the layer control has the confirmed visits layer enabled
|
||||||
|
|
@ -411,9 +411,7 @@ export default class extends Controller {
|
||||||
inputs.forEach(input => {
|
inputs.forEach(input => {
|
||||||
const label = input.nextElementSibling;
|
const label = input.nextElementSibling;
|
||||||
if (label && label.textContent.trim().includes('Confirmed Visits')) {
|
if (label && label.textContent.trim().includes('Confirmed Visits')) {
|
||||||
console.log('Found Confirmed Visits checkbox, current state:', input.checked);
|
|
||||||
if (!input.checked) {
|
if (!input.checked) {
|
||||||
console.log('Enabling Confirmed Visits layer via checkbox');
|
|
||||||
input.checked = true;
|
input.checked = true;
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
161
app/javascript/controllers/area_creation_v2_controller.js
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Area creation controller
|
||||||
|
* Handles the area creation modal and form submission
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [
|
||||||
|
'modal',
|
||||||
|
'form',
|
||||||
|
'nameInput',
|
||||||
|
'latitudeInput',
|
||||||
|
'longitudeInput',
|
||||||
|
'radiusInput',
|
||||||
|
'radiusDisplay',
|
||||||
|
'submitButton',
|
||||||
|
'submitSpinner',
|
||||||
|
'submitText'
|
||||||
|
]
|
||||||
|
|
||||||
|
static values = {
|
||||||
|
apiKey: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.area = null
|
||||||
|
this.setupEventListeners()
|
||||||
|
console.log('[Area Creation V2] Controller connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners for area drawing
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
document.addEventListener('area:drawn', (e) => {
|
||||||
|
this.open(e.detail.center, e.detail.radius)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the modal with area data
|
||||||
|
*/
|
||||||
|
open(center, radius) {
|
||||||
|
// Store area data
|
||||||
|
this.area = { center, radius }
|
||||||
|
|
||||||
|
// Update form fields
|
||||||
|
this.latitudeInputTarget.value = center[1]
|
||||||
|
this.longitudeInputTarget.value = center[0]
|
||||||
|
this.radiusInputTarget.value = Math.round(radius)
|
||||||
|
this.radiusDisplayTarget.textContent = Math.round(radius)
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
this.modalTarget.classList.add('modal-open')
|
||||||
|
this.nameInputTarget.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the modal
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
this.modalTarget.classList.remove('modal-open')
|
||||||
|
this.resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the form
|
||||||
|
*/
|
||||||
|
async submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!this.area) {
|
||||||
|
console.error('No area data available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this.formTarget)
|
||||||
|
const name = formData.get('name')
|
||||||
|
const latitude = parseFloat(formData.get('latitude'))
|
||||||
|
const longitude = parseFloat(formData.get('longitude'))
|
||||||
|
const radius = parseFloat(formData.get('radius'))
|
||||||
|
|
||||||
|
if (!name || !latitude || !longitude || !radius) {
|
||||||
|
alert('Please fill in all required fields')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/areas', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKeyValue}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
radius
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || 'Failed to create area')
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = await response.json()
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
this.close()
|
||||||
|
|
||||||
|
// Dispatch document event for area created
|
||||||
|
document.dispatchEvent(new CustomEvent('area:created', {
|
||||||
|
detail: { area }
|
||||||
|
}))
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating area:', error)
|
||||||
|
alert(`Error creating area: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set loading state
|
||||||
|
*/
|
||||||
|
setLoading(loading) {
|
||||||
|
this.submitButtonTarget.disabled = loading
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
this.submitSpinnerTarget.classList.remove('hidden')
|
||||||
|
this.submitTextTarget.textContent = 'Creating...'
|
||||||
|
} else {
|
||||||
|
this.submitSpinnerTarget.classList.add('hidden')
|
||||||
|
this.submitTextTarget.textContent = 'Create Area'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset form
|
||||||
|
*/
|
||||||
|
resetForm() {
|
||||||
|
this.formTarget.reset()
|
||||||
|
this.area = null
|
||||||
|
this.radiusDisplayTarget.textContent = '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show success message
|
||||||
|
*/
|
||||||
|
showSuccess(message) {
|
||||||
|
// Try to use the Toast component if available
|
||||||
|
if (window.Toast) {
|
||||||
|
window.Toast.show(message, 'success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/javascript/controllers/area_drawer_controller.js
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import { createCircle, calculateDistance } from 'maps_maplibre/utils/geometry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Area drawer controller
|
||||||
|
* Draw circular areas on map
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
this.isDrawing = false
|
||||||
|
this.center = null
|
||||||
|
this.radius = 0
|
||||||
|
this.map = null
|
||||||
|
|
||||||
|
// Bind event handlers to maintain context
|
||||||
|
this.onClick = this.onClick.bind(this)
|
||||||
|
this.onMouseMove = this.onMouseMove.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start drawing mode
|
||||||
|
* @param {maplibregl.Map} map - The MapLibre map instance
|
||||||
|
*/
|
||||||
|
startDrawing(map) {
|
||||||
|
if (!map) {
|
||||||
|
console.error('[Area Drawer] Map instance not provided')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDrawing = true
|
||||||
|
this.map = map
|
||||||
|
map.getCanvas().style.cursor = 'crosshair'
|
||||||
|
|
||||||
|
// Add temporary layer
|
||||||
|
if (!map.getSource('draw-source')) {
|
||||||
|
map.addSource('draw-source', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'draw-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'draw-source',
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#22c55e',
|
||||||
|
'fill-opacity': 0.2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'draw-outline',
|
||||||
|
type: 'line',
|
||||||
|
source: 'draw-source',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#22c55e',
|
||||||
|
'line-width': 2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
map.on('click', this.onClick)
|
||||||
|
map.on('mousemove', this.onMouseMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel drawing mode
|
||||||
|
*/
|
||||||
|
cancelDrawing() {
|
||||||
|
if (!this.map) return
|
||||||
|
|
||||||
|
this.isDrawing = false
|
||||||
|
this.center = null
|
||||||
|
this.radius = 0
|
||||||
|
|
||||||
|
this.map.getCanvas().style.cursor = ''
|
||||||
|
|
||||||
|
// Clear drawing
|
||||||
|
const source = this.map.getSource('draw-source')
|
||||||
|
if (source) {
|
||||||
|
source.setData({ type: 'FeatureCollection', features: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
this.map.off('click', this.onClick)
|
||||||
|
this.map.off('mousemove', this.onMouseMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler
|
||||||
|
*/
|
||||||
|
onClick(e) {
|
||||||
|
if (!this.isDrawing || !this.map) return
|
||||||
|
|
||||||
|
if (!this.center) {
|
||||||
|
// First click - set center
|
||||||
|
this.center = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
} else {
|
||||||
|
// Second click - finish drawing
|
||||||
|
document.dispatchEvent(new CustomEvent('area:drawn', {
|
||||||
|
detail: {
|
||||||
|
center: this.center,
|
||||||
|
radius: this.radius
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.cancelDrawing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse move handler
|
||||||
|
*/
|
||||||
|
onMouseMove(e) {
|
||||||
|
if (!this.isDrawing || !this.center || !this.map) return
|
||||||
|
|
||||||
|
const currentPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
this.radius = calculateDistance(this.center, currentPoint)
|
||||||
|
|
||||||
|
this.updateDrawing()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update drawing visualization
|
||||||
|
*/
|
||||||
|
updateDrawing() {
|
||||||
|
if (!this.center || this.radius === 0 || !this.map) return
|
||||||
|
|
||||||
|
const coordinates = createCircle(this.center, this.radius)
|
||||||
|
|
||||||
|
const source = this.map.getSource('draw-source')
|
||||||
|
if (source) {
|
||||||
|
source.setData({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [coordinates]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/javascript/controllers/area_selector_controller.js
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import { createRectangle } from 'maps_maplibre/utils/geometry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Area selector controller
|
||||||
|
* Draw rectangle selection on map
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static outlets = ['mapsV2']
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.isSelecting = false
|
||||||
|
this.startPoint = null
|
||||||
|
this.currentPoint = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start rectangle selection mode
|
||||||
|
*/
|
||||||
|
startSelection() {
|
||||||
|
if (!this.hasMapsV2Outlet) {
|
||||||
|
console.error('Maps V2 outlet not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSelecting = true
|
||||||
|
const map = this.mapsV2Outlet.map
|
||||||
|
map.getCanvas().style.cursor = 'crosshair'
|
||||||
|
|
||||||
|
// Add temporary layer for selection
|
||||||
|
if (!map.getSource('selection-source')) {
|
||||||
|
map.addSource('selection-source', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'selection-source',
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#3b82f6',
|
||||||
|
'fill-opacity': 0.2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-outline',
|
||||||
|
type: 'line',
|
||||||
|
source: 'selection-source',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#3b82f6',
|
||||||
|
'line-width': 2,
|
||||||
|
'line-dasharray': [2, 2]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
map.on('mousedown', this.onMouseDown)
|
||||||
|
map.on('mousemove', this.onMouseMove)
|
||||||
|
map.on('mouseup', this.onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel selection mode
|
||||||
|
*/
|
||||||
|
cancelSelection() {
|
||||||
|
if (!this.hasMapsV2Outlet) return
|
||||||
|
|
||||||
|
this.isSelecting = false
|
||||||
|
this.startPoint = null
|
||||||
|
this.currentPoint = null
|
||||||
|
|
||||||
|
const map = this.mapsV2Outlet.map
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
const source = map.getSource('selection-source')
|
||||||
|
if (source) {
|
||||||
|
source.setData({ type: 'FeatureCollection', features: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
map.off('mousedown', this.onMouseDown)
|
||||||
|
map.off('mousemove', this.onMouseMove)
|
||||||
|
map.off('mouseup', this.onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse down handler
|
||||||
|
*/
|
||||||
|
onMouseDown = (e) => {
|
||||||
|
if (!this.isSelecting || !this.hasMapsV2Outlet) return
|
||||||
|
|
||||||
|
this.startPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
this.mapsV2Outlet.map.dragPan.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse move handler
|
||||||
|
*/
|
||||||
|
onMouseMove = (e) => {
|
||||||
|
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
|
||||||
|
|
||||||
|
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
this.updateSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse up handler
|
||||||
|
*/
|
||||||
|
onMouseUp = (e) => {
|
||||||
|
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
|
||||||
|
|
||||||
|
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||||
|
this.mapsV2Outlet.map.dragPan.enable()
|
||||||
|
|
||||||
|
// Emit selection event
|
||||||
|
const bounds = this.getSelectionBounds()
|
||||||
|
this.dispatch('selected', { detail: { bounds } })
|
||||||
|
|
||||||
|
this.cancelSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update selection visualization
|
||||||
|
*/
|
||||||
|
updateSelection() {
|
||||||
|
if (!this.startPoint || !this.currentPoint || !this.hasMapsV2Outlet) return
|
||||||
|
|
||||||
|
const bounds = this.getSelectionBounds()
|
||||||
|
const rectangle = createRectangle(bounds)
|
||||||
|
|
||||||
|
const source = this.mapsV2Outlet.map.getSource('selection-source')
|
||||||
|
if (source) {
|
||||||
|
source.setData({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: rectangle
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get selection bounds
|
||||||
|
*/
|
||||||
|
getSelectionBounds() {
|
||||||
|
return {
|
||||||
|
minLng: Math.min(this.startPoint[0], this.currentPoint[0]),
|
||||||
|
minLat: Math.min(this.startPoint[1], this.currentPoint[1]),
|
||||||
|
maxLng: Math.max(this.startPoint[0], this.currentPoint[0]),
|
||||||
|
maxLat: Math.max(this.startPoint[1], this.currentPoint[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/javascript/controllers/color_picker_controller.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Enhanced Color Picker Controller
|
||||||
|
// Based on RailsBlocks pattern: https://railsblocks.com/docs/color-picker
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["picker", "display", "displayText", "input", "swatch"]
|
||||||
|
static values = {
|
||||||
|
default: { type: String, default: "#6ab0a4" }
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Initialize with current value
|
||||||
|
const currentColor = this.inputTarget.value || this.defaultValue
|
||||||
|
this.updateColor(currentColor, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle color picker (main input) change
|
||||||
|
updateFromPicker(event) {
|
||||||
|
const color = event.target.value
|
||||||
|
this.updateColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle swatch click
|
||||||
|
selectSwatch(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const color = event.currentTarget.dataset.color
|
||||||
|
|
||||||
|
if (color) {
|
||||||
|
this.updateColor(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all color displays and inputs
|
||||||
|
updateColor(color, updatePicker = true) {
|
||||||
|
if (!color) return
|
||||||
|
|
||||||
|
// Update hidden input
|
||||||
|
if (this.hasInputTarget) {
|
||||||
|
this.inputTarget.value = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update main color picker
|
||||||
|
if (updatePicker && this.hasPickerTarget) {
|
||||||
|
this.pickerTarget.value = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display
|
||||||
|
if (this.hasDisplayTarget) {
|
||||||
|
this.displayTarget.style.backgroundColor = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display text
|
||||||
|
if (this.hasDisplayTextTarget) {
|
||||||
|
this.displayTextTarget.textContent = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active swatch styling
|
||||||
|
this.updateActiveSwatchWithColor(color)
|
||||||
|
|
||||||
|
// Dispatch custom event
|
||||||
|
this.dispatch("change", { detail: { color } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update which swatch appears active
|
||||||
|
updateActiveSwatchWithColor(color) {
|
||||||
|
if (!this.hasSwatchTarget) return
|
||||||
|
|
||||||
|
// Remove active state from all swatches
|
||||||
|
this.swatchTargets.forEach(swatch => {
|
||||||
|
swatch.classList.remove("ring-2", "ring-primary", "ring-offset-2")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find and activate matching swatch
|
||||||
|
const matchingSwatch = this.swatchTargets.find(
|
||||||
|
swatch => swatch.dataset.color?.toLowerCase() === color.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingSwatch) {
|
||||||
|
matchingSwatch.classList.add("ring-2", "ring-primary", "ring-offset-2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,9 +11,57 @@ export default class extends BaseController {
|
||||||
connect() {
|
connect() {
|
||||||
console.log("Datetime controller connected")
|
console.log("Datetime controller connected")
|
||||||
this.debounceTimer = null;
|
this.debounceTimer = null;
|
||||||
|
|
||||||
|
// Add validation listeners
|
||||||
|
if (this.hasStartedAtTarget && this.hasEndedAtTarget) {
|
||||||
|
// Validate on change to set validation state
|
||||||
|
this.startedAtTarget.addEventListener('change', () => this.validateDates())
|
||||||
|
this.endedAtTarget.addEventListener('change', () => this.validateDates())
|
||||||
|
|
||||||
|
// Validate on blur to set validation state
|
||||||
|
this.startedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||||
|
this.endedAtTarget.addEventListener('blur', () => this.validateDates())
|
||||||
|
|
||||||
|
// Add form submit validation
|
||||||
|
const form = this.element.closest('form')
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
if (!this.validateDates()) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.endedAtTarget.reportValidity()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCoordinates(event) {
|
validateDates(showPopup = false) {
|
||||||
|
const startDate = new Date(this.startedAtTarget.value)
|
||||||
|
const endDate = new Date(this.endedAtTarget.value)
|
||||||
|
|
||||||
|
// Clear any existing custom validity
|
||||||
|
this.startedAtTarget.setCustomValidity('')
|
||||||
|
this.endedAtTarget.setCustomValidity('')
|
||||||
|
|
||||||
|
// Check if both dates are valid
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that start date is before end date
|
||||||
|
if (startDate >= endDate) {
|
||||||
|
const errorMessage = 'Start date must be earlier than end date'
|
||||||
|
this.endedAtTarget.setCustomValidity(errorMessage)
|
||||||
|
if (showPopup) {
|
||||||
|
this.endedAtTarget.reportValidity()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCoordinates() {
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer);
|
clearTimeout(this.debounceTimer);
|
||||||
|
|
@ -25,6 +73,11 @@ export default class extends BaseController {
|
||||||
const endedAt = this.endedAtTarget.value
|
const endedAt = this.endedAtTarget.value
|
||||||
const apiKey = this.apiKeyTarget.value
|
const apiKey = this.apiKeyTarget.value
|
||||||
|
|
||||||
|
// Validate dates before making API call (don't show popup, already shown on change)
|
||||||
|
if (!this.validateDates(false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (startedAt && endedAt) {
|
if (startedAt && endedAt) {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default class extends Controller {
|
||||||
if (this.isUploading) {
|
if (this.isUploading) {
|
||||||
// If still uploading, prevent submission
|
// If still uploading, prevent submission
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
console.log("Form submission prevented during upload")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default class extends Controller {
|
||||||
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
|
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
|
||||||
if (signedIds.length === 0) {
|
if (signedIds.length === 0) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
console.log("No files uploaded yet")
|
|
||||||
alert("Please select and upload files first")
|
alert("Please select and upload files first")
|
||||||
} else {
|
} else {
|
||||||
console.log(`Submitting form with ${signedIds.length} uploaded files`)
|
console.log(`Submitting form with ${signedIds.length} uploaded files`)
|
||||||
|
|
@ -78,7 +78,6 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Uploading ${files.length} files`)
|
|
||||||
this.isUploading = true
|
this.isUploading = true
|
||||||
|
|
||||||
// Disable submit button during upload
|
// Disable submit button during upload
|
||||||
|
|
@ -124,8 +123,6 @@ export default class extends Controller {
|
||||||
// Add the progress wrapper AFTER the file input field but BEFORE the submit button
|
// Add the progress wrapper AFTER the file input field but BEFORE the submit button
|
||||||
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
||||||
|
|
||||||
console.log("Progress bar created and inserted before submit button")
|
|
||||||
|
|
||||||
let uploadCount = 0
|
let uploadCount = 0
|
||||||
const totalFiles = files.length
|
const totalFiles = files.length
|
||||||
|
|
||||||
|
|
@ -137,17 +134,13 @@ export default class extends Controller {
|
||||||
});
|
});
|
||||||
|
|
||||||
Array.from(files).forEach(file => {
|
Array.from(files).forEach(file => {
|
||||||
console.log(`Starting upload for ${file.name}`)
|
|
||||||
const upload = new DirectUpload(file, this.urlValue, this)
|
const upload = new DirectUpload(file, this.urlValue, this)
|
||||||
upload.create((error, blob) => {
|
upload.create((error, blob) => {
|
||||||
uploadCount++
|
uploadCount++
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error uploading file:", error)
|
|
||||||
// Show error to user using flash
|
|
||||||
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
|
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
|
||||||
} else {
|
} else {
|
||||||
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
|
|
||||||
|
|
||||||
// Create a hidden field with the correct name
|
// Create a hidden field with the correct name
|
||||||
const hiddenField = document.createElement("input")
|
const hiddenField = document.createElement("input")
|
||||||
|
|
@ -155,8 +148,6 @@ export default class extends Controller {
|
||||||
hiddenField.setAttribute("name", "import[files][]")
|
hiddenField.setAttribute("name", "import[files][]")
|
||||||
hiddenField.setAttribute("value", blob.signed_id)
|
hiddenField.setAttribute("value", blob.signed_id)
|
||||||
this.element.appendChild(hiddenField)
|
this.element.appendChild(hiddenField)
|
||||||
|
|
||||||
console.log("Added hidden field with signed ID:", blob.signed_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable submit button when all uploads are complete
|
// Enable submit button when all uploads are complete
|
||||||
|
|
@ -186,8 +177,6 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.isUploading = false
|
this.isUploading = false
|
||||||
console.log("All uploads completed")
|
|
||||||
console.log(`Ready to submit with ${successfulUploads} files`)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
180
app/javascript/controllers/emoji_picker_controller.js
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { Picker } from "emoji-mart"
|
||||||
|
|
||||||
|
// Emoji Picker Controller
|
||||||
|
// Based on RailsBlocks pattern: https://railsblocks.com/docs/emoji-picker
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["input", "button", "pickerContainer"]
|
||||||
|
static values = {
|
||||||
|
autoSubmit: { type: Boolean, default: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.picker = null
|
||||||
|
this.setupKeyboardListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.removePicker()
|
||||||
|
this.removeKeyboardListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (this.pickerContainerTarget.classList.contains("hidden")) {
|
||||||
|
this.open()
|
||||||
|
} else {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
if (!this.picker) {
|
||||||
|
this.createPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pickerContainerTarget.classList.remove("hidden")
|
||||||
|
this.setupOutsideClickListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.pickerContainerTarget.classList.add("hidden")
|
||||||
|
this.removeOutsideClickListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
createPicker() {
|
||||||
|
this.picker = new Picker({
|
||||||
|
onEmojiSelect: this.onEmojiSelect.bind(this),
|
||||||
|
theme: this.getTheme(),
|
||||||
|
previewPosition: "none",
|
||||||
|
skinTonePosition: "search",
|
||||||
|
maxFrequentRows: 2,
|
||||||
|
perLine: 8,
|
||||||
|
navPosition: "bottom",
|
||||||
|
categories: [
|
||||||
|
"frequent",
|
||||||
|
"people",
|
||||||
|
"nature",
|
||||||
|
"foods",
|
||||||
|
"activity",
|
||||||
|
"places",
|
||||||
|
"objects",
|
||||||
|
"symbols",
|
||||||
|
"flags"
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.pickerContainerTarget.appendChild(this.picker)
|
||||||
|
}
|
||||||
|
|
||||||
|
onEmojiSelect(emoji) {
|
||||||
|
if (!emoji || !emoji.native) return
|
||||||
|
|
||||||
|
// Update input value
|
||||||
|
this.inputTarget.value = emoji.native
|
||||||
|
|
||||||
|
// Update button to show selected emoji
|
||||||
|
if (this.hasButtonTarget) {
|
||||||
|
// Find the display element (could be a span or the button itself)
|
||||||
|
const display = this.buttonTarget.querySelector('[data-emoji-picker-display]') || this.buttonTarget
|
||||||
|
display.textContent = emoji.native
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close picker
|
||||||
|
this.close()
|
||||||
|
|
||||||
|
// Auto-submit if enabled
|
||||||
|
if (this.autoSubmitValue) {
|
||||||
|
this.submitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch custom event for advanced use cases
|
||||||
|
this.dispatch("select", { detail: { emoji: emoji.native } })
|
||||||
|
}
|
||||||
|
|
||||||
|
submitForm() {
|
||||||
|
const form = this.element.closest("form")
|
||||||
|
if (form && !form.requestSubmit) {
|
||||||
|
// Fallback for older browsers
|
||||||
|
form.submit()
|
||||||
|
} else if (form) {
|
||||||
|
form.requestSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEmoji(event) {
|
||||||
|
event?.preventDefault()
|
||||||
|
this.inputTarget.value = ""
|
||||||
|
|
||||||
|
if (this.hasButtonTarget) {
|
||||||
|
const display = this.buttonTarget.querySelector('[data-emoji-picker-display]') || this.buttonTarget
|
||||||
|
// Reset to default emoji or icon
|
||||||
|
const defaultIcon = this.buttonTarget.dataset.defaultIcon || "😀"
|
||||||
|
display.textContent = defaultIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatch("clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
getTheme() {
|
||||||
|
// Detect dark mode from document
|
||||||
|
if (document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||||
|
document.documentElement.classList.contains('dark')) {
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
setupKeyboardListeners() {
|
||||||
|
this.handleKeydown = this.handleKeydown.bind(this)
|
||||||
|
document.addEventListener("keydown", this.handleKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeKeyboardListeners() {
|
||||||
|
document.removeEventListener("keydown", this.handleKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(event) {
|
||||||
|
// Close on Escape
|
||||||
|
if (event.key === "Escape" && !this.pickerContainerTarget.classList.contains("hidden")) {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear on Delete/Backspace (when picker is open)
|
||||||
|
if ((event.key === "Delete" || event.key === "Backspace") &&
|
||||||
|
!this.pickerContainerTarget.classList.contains("hidden") &&
|
||||||
|
event.target === this.inputTarget) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.clearEmoji()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupOutsideClickListener() {
|
||||||
|
this.handleOutsideClick = this.handleOutsideClick.bind(this)
|
||||||
|
// Use setTimeout to avoid immediate triggering from the toggle click
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener("click", this.handleOutsideClick)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOutsideClickListener() {
|
||||||
|
if (this.handleOutsideClick) {
|
||||||
|
document.removeEventListener("click", this.handleOutsideClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOutsideClick(event) {
|
||||||
|
if (!this.element.contains(event.target)) {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePicker() {
|
||||||
|
if (this.picker && this.picker.remove) {
|
||||||
|
this.picker.remove()
|
||||||
|
}
|
||||||
|
this.picker = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,8 @@ export default class extends Controller {
|
||||||
|
|
||||||
static values = {
|
static values = {
|
||||||
features: Object,
|
features: Object,
|
||||||
userTheme: String
|
userTheme: String,
|
||||||
|
timezone: String
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
|
@ -106,7 +107,8 @@ export default class extends Controller {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Format timestamp for display
|
// Format timestamp for display
|
||||||
const lastSeen = new Date(location.updated_at).toLocaleString();
|
const timezone = this.timezoneValue || 'UTC';
|
||||||
|
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||||
|
|
||||||
// Create small tooltip that shows automatically
|
// Create small tooltip that shows automatically
|
||||||
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
||||||
|
|
@ -176,7 +178,8 @@ export default class extends Controller {
|
||||||
existingMarker.setIcon(newIcon);
|
existingMarker.setIcon(newIcon);
|
||||||
|
|
||||||
// Update tooltip content
|
// Update tooltip content
|
||||||
const lastSeen = new Date(locationData.updated_at).toLocaleString();
|
const timezone = this.timezoneValue || 'UTC';
|
||||||
|
const lastSeen = new Date(locationData.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||||
const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery);
|
const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery);
|
||||||
existingMarker.setTooltipContent(tooltipContent);
|
existingMarker.setTooltipContent(tooltipContent);
|
||||||
|
|
||||||
|
|
@ -214,7 +217,8 @@ export default class extends Controller {
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastSeen = new Date(location.updated_at).toLocaleString();
|
const timezone = this.timezoneValue || 'UTC';
|
||||||
|
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||||
|
|
||||||
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
||||||
familyMarker.bindTooltip(tooltipContent, {
|
familyMarker.bindTooltip(tooltipContent, {
|
||||||
|
|
@ -341,6 +345,11 @@ export default class extends Controller {
|
||||||
mapsController.updateLayerControl({
|
mapsController.updateLayerControl({
|
||||||
"Family Members": this.familyMarkersLayer
|
"Family Members": this.familyMarkersLayer
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dispatch event to notify that Family Members layer is now available
|
||||||
|
document.dispatchEvent(new CustomEvent('family:layer:ready', {
|
||||||
|
detail: { layer: this.familyMarkersLayer }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,23 @@ export default class extends BaseController {
|
||||||
received: (data) => {
|
received: (data) => {
|
||||||
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
|
||||||
|
|
||||||
if (row) {
|
if (!row) return;
|
||||||
const pointsCell = row.querySelector('[data-points-count]');
|
|
||||||
if (pointsCell) {
|
|
||||||
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusCell = row.querySelector('[data-status-display]');
|
// Handle deletion complete - remove the row
|
||||||
if (statusCell && data.import.status) {
|
if (data.action === 'delete') {
|
||||||
statusCell.textContent = data.import.status;
|
row.remove();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle status and points updates
|
||||||
|
const pointsCell = row.querySelector('[data-points-count]');
|
||||||
|
if (pointsCell && data.import.points_count !== undefined) {
|
||||||
|
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCell = row.querySelector('[data-status-display]');
|
||||||
|
if (statusCell && data.import.status) {
|
||||||
|
statusCell.textContent = data.import.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export default class extends Controller {
|
||||||
try {
|
try {
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||||
|
|
||||||
const response = await fetch(`/family/update_location_sharing`, {
|
const response = await fetch(`/family/location_sharing`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
|
|
||||||
68
app/javascript/controllers/map_panel_controller.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Panel Controller
|
||||||
|
* Handles tab switching in the map control panel
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['tabButton', 'tabContent', 'title']
|
||||||
|
|
||||||
|
// Tab title mappings
|
||||||
|
static titles = {
|
||||||
|
search: 'Search',
|
||||||
|
layers: 'Map Layers',
|
||||||
|
tools: 'Tools',
|
||||||
|
links: 'Links',
|
||||||
|
settings: 'Settings'
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
console.log('[Map Panel] Connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different tab
|
||||||
|
*/
|
||||||
|
switchTab(event) {
|
||||||
|
const button = event.currentTarget
|
||||||
|
const tabName = button.dataset.tab
|
||||||
|
|
||||||
|
this.activateTab(tabName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically switch to a tab by name
|
||||||
|
*/
|
||||||
|
switchToTab(tabName) {
|
||||||
|
this.activateTab(tabName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to activate a tab
|
||||||
|
*/
|
||||||
|
activateTab(tabName) {
|
||||||
|
// Find the button for this tab
|
||||||
|
const button = this.tabButtonTargets.find(btn => btn.dataset.tab === tabName)
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
this.tabButtonTargets.forEach(btn => {
|
||||||
|
btn.classList.remove('active')
|
||||||
|
})
|
||||||
|
if (button) {
|
||||||
|
button.classList.add('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tab content
|
||||||
|
this.tabContentTargets.forEach(content => {
|
||||||
|
const contentTab = content.dataset.tabContent
|
||||||
|
if (contentTab === tabName) {
|
||||||
|
content.classList.add('active')
|
||||||
|
} else {
|
||||||
|
content.classList.remove('active')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
this.titleTarget.textContent = this.constructor.titles[tabName] || tabName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,540 @@
|
||||||
|
import { SelectionLayer } from 'maps_maplibre/layers/selection_layer'
|
||||||
|
import { SelectedPointsLayer } from 'maps_maplibre/layers/selected_points_layer'
|
||||||
|
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
|
||||||
|
import { VisitCard } from 'maps_maplibre/components/visit_card'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages area selection and bulk operations for Maps V2
|
||||||
|
* Handles selection mode, visit cards, and bulk actions (merge, confirm, decline)
|
||||||
|
*/
|
||||||
|
export class AreaSelectionManager {
|
||||||
|
constructor(controller) {
|
||||||
|
this.controller = controller
|
||||||
|
this.map = controller.map
|
||||||
|
this.api = controller.api
|
||||||
|
this.selectionLayer = null
|
||||||
|
this.selectedPointsLayer = null
|
||||||
|
this.selectedVisits = []
|
||||||
|
this.selectedVisitIds = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start area selection mode
|
||||||
|
*/
|
||||||
|
async startSelectArea() {
|
||||||
|
console.log('[Maps V2] Starting area selection mode')
|
||||||
|
|
||||||
|
// Initialize selection layer if not exists
|
||||||
|
if (!this.selectionLayer) {
|
||||||
|
this.selectionLayer = new SelectionLayer(this.map, {
|
||||||
|
visible: true,
|
||||||
|
onSelectionComplete: this.handleAreaSelected.bind(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.selectionLayer.add({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: []
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Maps V2] Selection layer initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize selected points layer if not exists
|
||||||
|
if (!this.selectedPointsLayer) {
|
||||||
|
this.selectedPointsLayer = new SelectedPointsLayer(this.map, {
|
||||||
|
visible: true
|
||||||
|
})
|
||||||
|
|
||||||
|
this.selectedPointsLayer.add({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: []
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Maps V2] Selected points layer initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable selection mode
|
||||||
|
this.selectionLayer.enableSelectionMode()
|
||||||
|
|
||||||
|
// Update UI - replace Select Area button with Cancel Selection button
|
||||||
|
if (this.controller.hasSelectAreaButtonTarget) {
|
||||||
|
this.controller.selectAreaButtonTarget.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
Cancel Selection
|
||||||
|
`
|
||||||
|
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#cancelAreaSelection'
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.info('Draw a rectangle on the map to select points')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle area selection completion
|
||||||
|
*/
|
||||||
|
async handleAreaSelected(bounds) {
|
||||||
|
console.log('[Maps V2] Area selected:', bounds)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Fetching data in selected area...')
|
||||||
|
|
||||||
|
const [points, visits] = await Promise.all([
|
||||||
|
this.api.fetchPointsInArea({
|
||||||
|
start_at: this.controller.startDateValue,
|
||||||
|
end_at: this.controller.endDateValue,
|
||||||
|
min_longitude: bounds.minLng,
|
||||||
|
max_longitude: bounds.maxLng,
|
||||||
|
min_latitude: bounds.minLat,
|
||||||
|
max_latitude: bounds.maxLat
|
||||||
|
}),
|
||||||
|
this.api.fetchVisitsInArea({
|
||||||
|
start_at: this.controller.startDateValue,
|
||||||
|
end_at: this.controller.endDateValue,
|
||||||
|
sw_lat: bounds.minLat,
|
||||||
|
sw_lng: bounds.minLng,
|
||||||
|
ne_lat: bounds.maxLat,
|
||||||
|
ne_lng: bounds.maxLng
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area')
|
||||||
|
|
||||||
|
if (points.length === 0 && visits.length === 0) {
|
||||||
|
Toast.info('No data found in selected area')
|
||||||
|
this.cancelAreaSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert points to GeoJSON and display
|
||||||
|
if (points.length > 0) {
|
||||||
|
const geojson = pointsToGeoJSON(points)
|
||||||
|
this.selectedPointsLayer.updateSelectedPoints(geojson)
|
||||||
|
this.selectedPointsLayer.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display visits in side panel and on map
|
||||||
|
if (visits.length > 0) {
|
||||||
|
this.displaySelectedVisits(visits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI - show action buttons
|
||||||
|
if (this.controller.hasSelectionActionsTarget) {
|
||||||
|
this.controller.selectionActionsTarget.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update delete button text with count
|
||||||
|
if (this.controller.hasDeleteButtonTextTarget) {
|
||||||
|
this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable selection mode
|
||||||
|
this.selectionLayer.disableSelectionMode()
|
||||||
|
|
||||||
|
const messages = []
|
||||||
|
if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`)
|
||||||
|
if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`)
|
||||||
|
|
||||||
|
Toast.success(`Selected ${messages.join(' and ')}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to fetch data in area:', error)
|
||||||
|
Toast.error('Failed to fetch data in selected area')
|
||||||
|
this.cancelAreaSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display selected visits in side panel
|
||||||
|
*/
|
||||||
|
displaySelectedVisits(visits) {
|
||||||
|
if (!this.controller.hasSelectedVisitsContainerTarget) return
|
||||||
|
|
||||||
|
this.selectedVisits = visits
|
||||||
|
this.selectedVisitIds = new Set()
|
||||||
|
|
||||||
|
const cardsHTML = visits.map(visit =>
|
||||||
|
VisitCard.create(visit, { isSelected: false })
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
this.controller.selectedVisitsContainerTarget.innerHTML = `
|
||||||
|
<div class="selected-visits-list">
|
||||||
|
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-base-300">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-sm font-bold">Visits in Area (${visits.length})</h3>
|
||||||
|
</div>
|
||||||
|
${cardsHTML}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
this.controller.selectedVisitsContainerTarget.classList.remove('hidden')
|
||||||
|
this.attachVisitCardListeners()
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.updateBulkActions()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach event listeners to visit cards
|
||||||
|
*/
|
||||||
|
attachVisitCardListeners() {
|
||||||
|
this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', (e) => {
|
||||||
|
const visitId = parseInt(e.target.dataset.visitSelect)
|
||||||
|
if (e.target.checked) {
|
||||||
|
this.selectedVisitIds.add(visitId)
|
||||||
|
} else {
|
||||||
|
this.selectedVisitIds.delete(visitId)
|
||||||
|
}
|
||||||
|
this.updateBulkActions()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const visitId = parseInt(e.currentTarget.dataset.visitConfirm)
|
||||||
|
await this.confirmVisit(visitId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const visitId = parseInt(e.currentTarget.dataset.visitDecline)
|
||||||
|
await this.declineVisit(visitId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update bulk action buttons visibility and attach listeners
|
||||||
|
*/
|
||||||
|
updateBulkActions() {
|
||||||
|
const selectedCount = this.selectedVisitIds.size
|
||||||
|
|
||||||
|
const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline')
|
||||||
|
existingBulkActions.forEach(el => el.remove())
|
||||||
|
|
||||||
|
if (selectedCount >= 2) {
|
||||||
|
const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card'))
|
||||||
|
.filter(card => {
|
||||||
|
const visitId = parseInt(card.dataset.visitId)
|
||||||
|
return this.selectedVisitIds.has(visitId)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selectedVisitCards.length > 0) {
|
||||||
|
const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1]
|
||||||
|
|
||||||
|
const bulkActionsDiv = document.createElement('div')
|
||||||
|
bulkActionsDiv.className = 'bulk-actions-inline mb-2'
|
||||||
|
bulkActionsDiv.innerHTML = `
|
||||||
|
<div class="bg-primary/10 border-2 border-primary border-dashed rounded-lg p-3">
|
||||||
|
<div class="text-xs font-semibold mb-2 text-primary flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-1.5">
|
||||||
|
<button class="btn btn-xs btn-outline normal-case" data-bulk-merge>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
Merge
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs btn-primary normal-case" data-bulk-confirm>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs btn-outline btn-error normal-case" data-bulk-decline>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv)
|
||||||
|
|
||||||
|
const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]')
|
||||||
|
const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]')
|
||||||
|
const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]')
|
||||||
|
|
||||||
|
if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits())
|
||||||
|
if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits())
|
||||||
|
if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a single visit
|
||||||
|
*/
|
||||||
|
async confirmVisit(visitId) {
|
||||||
|
try {
|
||||||
|
await this.api.updateVisitStatus(visitId, 'confirmed')
|
||||||
|
Toast.success('Visit confirmed')
|
||||||
|
await this.refreshSelectedVisits()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to confirm visit:', error)
|
||||||
|
Toast.error('Failed to confirm visit')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline a single visit
|
||||||
|
*/
|
||||||
|
async declineVisit(visitId) {
|
||||||
|
try {
|
||||||
|
await this.api.updateVisitStatus(visitId, 'declined')
|
||||||
|
Toast.success('Visit declined')
|
||||||
|
await this.refreshSelectedVisits()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to decline visit:', error)
|
||||||
|
Toast.error('Failed to decline visit')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk merge selected visits
|
||||||
|
*/
|
||||||
|
async bulkMergeVisits() {
|
||||||
|
const visitIds = Array.from(this.selectedVisitIds)
|
||||||
|
|
||||||
|
if (visitIds.length < 2) {
|
||||||
|
Toast.error('Select at least 2 visits to merge')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Merge ${visitIds.length} visits into one?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Merging visits...')
|
||||||
|
const mergedVisit = await this.api.mergeVisits(visitIds)
|
||||||
|
Toast.success('Visits merged successfully')
|
||||||
|
|
||||||
|
this.selectedVisitIds.clear()
|
||||||
|
this.replaceVisitsWithMerged(visitIds, mergedVisit)
|
||||||
|
this.updateBulkActions()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to merge visits:', error)
|
||||||
|
Toast.error('Failed to merge visits')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk confirm selected visits
|
||||||
|
*/
|
||||||
|
async bulkConfirmVisits() {
|
||||||
|
const visitIds = Array.from(this.selectedVisitIds)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Confirming visits...')
|
||||||
|
await this.api.bulkUpdateVisits(visitIds, 'confirmed')
|
||||||
|
Toast.success(`Confirmed ${visitIds.length} visits`)
|
||||||
|
|
||||||
|
this.selectedVisitIds.clear()
|
||||||
|
await this.refreshSelectedVisits()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to confirm visits:', error)
|
||||||
|
Toast.error('Failed to confirm visits')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk decline selected visits
|
||||||
|
*/
|
||||||
|
async bulkDeclineVisits() {
|
||||||
|
const visitIds = Array.from(this.selectedVisitIds)
|
||||||
|
|
||||||
|
if (!confirm(`Decline ${visitIds.length} visits?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Declining visits...')
|
||||||
|
await this.api.bulkUpdateVisits(visitIds, 'declined')
|
||||||
|
Toast.success(`Declined ${visitIds.length} visits`)
|
||||||
|
|
||||||
|
this.selectedVisitIds.clear()
|
||||||
|
await this.refreshSelectedVisits()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to decline visits:', error)
|
||||||
|
Toast.error('Failed to decline visits')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace merged visit cards with the new merged visit
|
||||||
|
*/
|
||||||
|
replaceVisitsWithMerged(oldVisitIds, mergedVisit) {
|
||||||
|
const container = this.controller.element.querySelector('.selected-visits-list')
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const mergedStartTime = new Date(mergedVisit.started_at).getTime()
|
||||||
|
const allCards = Array.from(container.querySelectorAll('.visit-card'))
|
||||||
|
|
||||||
|
let insertBeforeCard = null
|
||||||
|
for (const card of allCards) {
|
||||||
|
const cardId = parseInt(card.dataset.visitId)
|
||||||
|
if (oldVisitIds.includes(cardId)) continue
|
||||||
|
|
||||||
|
const cardVisit = this.selectedVisits.find(v => v.id === cardId)
|
||||||
|
if (cardVisit) {
|
||||||
|
const cardStartTime = new Date(cardVisit.started_at).getTime()
|
||||||
|
if (cardStartTime > mergedStartTime) {
|
||||||
|
insertBeforeCard = card
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldVisitIds.forEach(id => {
|
||||||
|
const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`)
|
||||||
|
if (card) card.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id))
|
||||||
|
this.selectedVisits.push(mergedVisit)
|
||||||
|
this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at))
|
||||||
|
|
||||||
|
const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false })
|
||||||
|
|
||||||
|
if (insertBeforeCard) {
|
||||||
|
insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML)
|
||||||
|
} else {
|
||||||
|
container.insertAdjacentHTML('beforeend', newCardHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = container.querySelector('h3')
|
||||||
|
if (header) {
|
||||||
|
header.textContent = `Visits in Area (${this.selectedVisits.length})`
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attachVisitCardListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh selected visits after changes
|
||||||
|
*/
|
||||||
|
async refreshSelectedVisits() {
|
||||||
|
const bounds = this.selectionLayer.currentRect
|
||||||
|
if (!bounds) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const visits = await this.api.fetchVisitsInArea({
|
||||||
|
start_at: this.controller.startDateValue,
|
||||||
|
end_at: this.controller.endDateValue,
|
||||||
|
sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
||||||
|
sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng,
|
||||||
|
ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
||||||
|
ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng
|
||||||
|
})
|
||||||
|
|
||||||
|
this.displaySelectedVisits(visits)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to refresh visits:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel area selection
|
||||||
|
*/
|
||||||
|
cancelAreaSelection() {
|
||||||
|
console.log('[Maps V2] Cancelling area selection')
|
||||||
|
|
||||||
|
if (this.selectionLayer) {
|
||||||
|
this.selectionLayer.disableSelectionMode()
|
||||||
|
this.selectionLayer.clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedPointsLayer) {
|
||||||
|
this.selectedPointsLayer.clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controller.hasSelectedVisitsContainerTarget) {
|
||||||
|
this.controller.selectedVisitsContainerTarget.classList.add('hidden')
|
||||||
|
this.controller.selectedVisitsContainerTarget.innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controller.hasSelectedVisitsBulkActionsTarget) {
|
||||||
|
this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedVisits = []
|
||||||
|
this.selectedVisitIds = new Set()
|
||||||
|
|
||||||
|
if (this.controller.hasSelectAreaButtonTarget) {
|
||||||
|
this.controller.selectAreaButtonTarget.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<path d="M9 3v18"></path>
|
||||||
|
<path d="M15 3v18"></path>
|
||||||
|
<path d="M3 9h18"></path>
|
||||||
|
<path d="M3 15h18"></path>
|
||||||
|
</svg>
|
||||||
|
Select Area
|
||||||
|
`
|
||||||
|
this.controller.selectAreaButtonTarget.classList.remove('btn-error')
|
||||||
|
this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline')
|
||||||
|
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#startSelectArea'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controller.hasSelectionActionsTarget) {
|
||||||
|
this.controller.selectionActionsTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.info('Selection cancelled')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete selected points
|
||||||
|
*/
|
||||||
|
async deleteSelectedPoints() {
|
||||||
|
const pointCount = this.selectedPointsLayer.getCount()
|
||||||
|
const pointIds = this.selectedPointsLayer.getSelectedPointIds()
|
||||||
|
|
||||||
|
if (pointIds.length === 0) {
|
||||||
|
Toast.error('No points selected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = confirm(
|
||||||
|
`Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
console.log('[Maps V2] Deleting', pointIds.length, 'points')
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Deleting points...')
|
||||||
|
const result = await this.api.bulkDeletePoints(pointIds)
|
||||||
|
|
||||||
|
console.log('[Maps V2] Deleted', result.count, 'points')
|
||||||
|
|
||||||
|
this.cancelAreaSelection()
|
||||||
|
|
||||||
|
await this.controller.loadMapData({
|
||||||
|
showLoading: false,
|
||||||
|
fitBounds: false,
|
||||||
|
showToast: false
|
||||||
|
})
|
||||||
|
|
||||||
|
Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to delete points:', error)
|
||||||
|
Toast.error('Failed to delete points. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
app/javascript/controllers/maps/maplibre/data_loader.js
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
|
||||||
|
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
|
||||||
|
import { createCircle } from 'maps_maplibre/utils/geometry'
|
||||||
|
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading and transforming data from API
|
||||||
|
*/
|
||||||
|
export class DataLoader {
|
||||||
|
constructor(api, apiKey, settings = {}) {
|
||||||
|
this.api = api
|
||||||
|
this.apiKey = apiKey
|
||||||
|
this.settings = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings (called when user changes settings)
|
||||||
|
*/
|
||||||
|
updateSettings(settings) {
|
||||||
|
this.settings = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all map data (points, visits, photos, areas, tracks)
|
||||||
|
*/
|
||||||
|
async fetchMapData(startDate, endDate, onProgress) {
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
// Fetch points
|
||||||
|
performanceMonitor.mark('fetch-points')
|
||||||
|
data.points = await this.api.fetchAllPoints({
|
||||||
|
start_at: startDate,
|
||||||
|
end_at: endDate,
|
||||||
|
onProgress: onProgress
|
||||||
|
})
|
||||||
|
performanceMonitor.measure('fetch-points')
|
||||||
|
|
||||||
|
// Transform points to GeoJSON
|
||||||
|
performanceMonitor.mark('transform-geojson')
|
||||||
|
data.pointsGeoJSON = pointsToGeoJSON(data.points)
|
||||||
|
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, {
|
||||||
|
distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000,
|
||||||
|
timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60
|
||||||
|
})
|
||||||
|
performanceMonitor.measure('transform-geojson')
|
||||||
|
|
||||||
|
// Fetch visits
|
||||||
|
try {
|
||||||
|
data.visits = await this.api.fetchVisits({
|
||||||
|
start_at: startDate,
|
||||||
|
end_at: endDate
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch visits:', error)
|
||||||
|
data.visits = []
|
||||||
|
}
|
||||||
|
data.visitsGeoJSON = this.visitsToGeoJSON(data.visits)
|
||||||
|
|
||||||
|
// Fetch photos - only if photos layer is enabled and integration is configured
|
||||||
|
// Skip API call if photos are disabled to avoid blocking on failed integrations
|
||||||
|
if (this.settings.photosEnabled) {
|
||||||
|
try {
|
||||||
|
console.log('[Photos] Fetching photos from:', startDate, 'to', endDate)
|
||||||
|
// Use Promise.race to enforce a client-side timeout
|
||||||
|
const photosPromise = this.api.fetchPhotos({
|
||||||
|
start_at: startDate,
|
||||||
|
end_at: endDate
|
||||||
|
})
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Photo fetch timeout')), 15000) // 15 second timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
data.photos = await Promise.race([photosPromise, timeoutPromise])
|
||||||
|
console.log('[Photos] Fetched photos:', data.photos.length, 'photos')
|
||||||
|
console.log('[Photos] Sample photo:', data.photos[0])
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Photos] Failed to fetch photos (non-blocking):', error.message)
|
||||||
|
data.photos = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Photos] Photos layer disabled, skipping fetch')
|
||||||
|
data.photos = []
|
||||||
|
}
|
||||||
|
data.photosGeoJSON = this.photosToGeoJSON(data.photos)
|
||||||
|
console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features')
|
||||||
|
if (data.photosGeoJSON.features.length > 0) {
|
||||||
|
console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch areas
|
||||||
|
try {
|
||||||
|
data.areas = await this.api.fetchAreas()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch areas:', error)
|
||||||
|
data.areas = []
|
||||||
|
}
|
||||||
|
data.areasGeoJSON = this.areasToGeoJSON(data.areas)
|
||||||
|
|
||||||
|
// Fetch places (no date filtering)
|
||||||
|
try {
|
||||||
|
data.places = await this.api.fetchPlaces()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch places:', error)
|
||||||
|
data.places = []
|
||||||
|
}
|
||||||
|
data.placesGeoJSON = this.placesToGeoJSON(data.places)
|
||||||
|
|
||||||
|
// Tracks - DISABLED: Backend API not yet implemented
|
||||||
|
// TODO: Re-enable when /api/v1/tracks endpoint is created
|
||||||
|
data.tracks = []
|
||||||
|
data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks)
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert visits to GeoJSON
|
||||||
|
*/
|
||||||
|
visitsToGeoJSON(visits) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: visits.map(visit => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [visit.place.longitude, visit.place.latitude]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: visit.id,
|
||||||
|
name: visit.name,
|
||||||
|
place_name: visit.place?.name,
|
||||||
|
status: visit.status,
|
||||||
|
started_at: visit.started_at,
|
||||||
|
ended_at: visit.ended_at,
|
||||||
|
duration: visit.duration
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert photos to GeoJSON
|
||||||
|
*/
|
||||||
|
photosToGeoJSON(photos) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: photos.map(photo => {
|
||||||
|
// Construct thumbnail URL
|
||||||
|
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [photo.longitude, photo.latitude]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: photo.id,
|
||||||
|
thumbnail_url: thumbnailUrl,
|
||||||
|
taken_at: photo.localDateTime,
|
||||||
|
filename: photo.originalFileName,
|
||||||
|
city: photo.city,
|
||||||
|
state: photo.state,
|
||||||
|
country: photo.country,
|
||||||
|
type: photo.type,
|
||||||
|
source: photo.source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert places to GeoJSON
|
||||||
|
*/
|
||||||
|
placesToGeoJSON(places) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: places.map(place => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [place.longitude, place.latitude]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: place.id,
|
||||||
|
name: place.name,
|
||||||
|
latitude: place.latitude,
|
||||||
|
longitude: place.longitude,
|
||||||
|
note: place.note,
|
||||||
|
// Stringify tags for MapLibre GL JS compatibility
|
||||||
|
tags: JSON.stringify(place.tags || []),
|
||||||
|
// Use first tag's color if available
|
||||||
|
color: place.tags?.[0]?.color || '#6366f1'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert areas to GeoJSON
|
||||||
|
* Backend returns circular areas with latitude, longitude, radius
|
||||||
|
*/
|
||||||
|
areasToGeoJSON(areas) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: areas.map(area => {
|
||||||
|
// Create circle polygon from center and radius
|
||||||
|
// Parse as floats since API returns strings
|
||||||
|
const center = [parseFloat(area.longitude), parseFloat(area.latitude)]
|
||||||
|
const coordinates = createCircle(center, area.radius)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [coordinates]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: area.id,
|
||||||
|
name: area.name,
|
||||||
|
color: area.color || '#ef4444',
|
||||||
|
radius: area.radius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert tracks to GeoJSON
|
||||||
|
*/
|
||||||
|
tracksToGeoJSON(tracks) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: tracks.map(track => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: track.coordinates
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
color: track.color || '#8b5cf6'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/javascript/controllers/maps/maplibre/date_manager.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Manages date formatting and range calculations
|
||||||
|
*/
|
||||||
|
export class DateManager {
|
||||||
|
/**
|
||||||
|
* Format date for API requests (matching V1 format)
|
||||||
|
* Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59")
|
||||||
|
*/
|
||||||
|
static formatDateForAPI(date) {
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = pad(date.getMonth() + 1)
|
||||||
|
const day = pad(date.getDate())
|
||||||
|
const hours = pad(date.getHours())
|
||||||
|
const minutes = pad(date.getMinutes())
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse month selector value to date range
|
||||||
|
*/
|
||||||
|
static parseMonthSelector(value) {
|
||||||
|
const [year, month] = value.split('-')
|
||||||
|
|
||||||
|
const startDate = new Date(year, month - 1, 1, 0, 0, 0)
|
||||||
|
const lastDay = new Date(year, month, 0).getDate()
|
||||||
|
const endDate = new Date(year, month - 1, lastDay, 23, 59, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: this.formatDateForAPI(startDate),
|
||||||
|
endDate: this.formatDateForAPI(endDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/javascript/controllers/maps/maplibre/event_handlers.js
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles map interaction events (clicks, info display)
|
||||||
|
*/
|
||||||
|
export class EventHandlers {
|
||||||
|
constructor(map, controller) {
|
||||||
|
this.map = map
|
||||||
|
this.controller = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle point click
|
||||||
|
*/
|
||||||
|
handlePointClick(e) {
|
||||||
|
const feature = e.features[0]
|
||||||
|
const properties = feature.properties
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div><span class="font-semibold">Time:</span> ${formatTimestamp(properties.timestamp, this.controller.timezoneValue)}</div>
|
||||||
|
${properties.battery ? `<div><span class="font-semibold">Battery:</span> ${properties.battery}%</div>` : ''}
|
||||||
|
${properties.altitude ? `<div><span class="font-semibold">Altitude:</span> ${Math.round(properties.altitude)}m</div>` : ''}
|
||||||
|
${properties.velocity ? `<div><span class="font-semibold">Speed:</span> ${Math.round(properties.velocity)} km/h</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
this.controller.showInfo('Location Point', content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle visit click
|
||||||
|
*/
|
||||||
|
handleVisitClick(e) {
|
||||||
|
const feature = e.features[0]
|
||||||
|
const properties = feature.properties
|
||||||
|
|
||||||
|
const startTime = formatTimestamp(properties.started_at, this.controller.timezoneValue)
|
||||||
|
const endTime = formatTimestamp(properties.ended_at, this.controller.timezoneValue)
|
||||||
|
const durationHours = Math.round(properties.duration / 3600)
|
||||||
|
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m`
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="badge badge-sm ${properties.status === 'confirmed' ? 'badge-success' : 'badge-warning'}">${properties.status}</div>
|
||||||
|
<div><span class="font-semibold">Arrived:</span> ${startTime}</div>
|
||||||
|
<div><span class="font-semibold">Left:</span> ${endTime}</div>
|
||||||
|
<div><span class="font-semibold">Duration:</span> ${durationDisplay}</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
const actions = [{
|
||||||
|
type: 'button',
|
||||||
|
handler: 'handleEdit',
|
||||||
|
id: properties.id,
|
||||||
|
entityType: 'visit',
|
||||||
|
label: 'Edit'
|
||||||
|
}]
|
||||||
|
|
||||||
|
this.controller.showInfo(properties.name || properties.place_name || 'Visit', content, actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle photo click
|
||||||
|
*/
|
||||||
|
handlePhotoClick(e) {
|
||||||
|
const feature = e.features[0]
|
||||||
|
const properties = feature.properties
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="space-y-2">
|
||||||
|
${properties.photo_url ? `<img src="${properties.photo_url}" alt="Photo" class="w-full rounded-lg mb-2" />` : ''}
|
||||||
|
${properties.taken_at ? `<div><span class="font-semibold">Taken:</span> ${formatTimestamp(properties.taken_at, this.controller.timezoneValue)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
this.controller.showInfo('Photo', content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle place click
|
||||||
|
*/
|
||||||
|
handlePlaceClick(e) {
|
||||||
|
const feature = e.features[0]
|
||||||
|
const properties = feature.properties
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="space-y-2">
|
||||||
|
${properties.tag ? `<div class="badge badge-sm badge-primary">${properties.tag}</div>` : ''}
|
||||||
|
${properties.description ? `<div>${properties.description}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
const actions = properties.id ? [{
|
||||||
|
type: 'button',
|
||||||
|
handler: 'handleEdit',
|
||||||
|
id: properties.id,
|
||||||
|
entityType: 'place',
|
||||||
|
label: 'Edit'
|
||||||
|
}] : []
|
||||||
|
|
||||||
|
this.controller.showInfo(properties.name || 'Place', content, actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle area click
|
||||||
|
*/
|
||||||
|
handleAreaClick(e) {
|
||||||
|
const feature = e.features[0]
|
||||||
|
const properties = feature.properties
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="space-y-2">
|
||||||
|
${properties.radius ? `<div><span class="font-semibold">Radius:</span> ${Math.round(properties.radius)}m</div>` : ''}
|
||||||
|
${properties.latitude && properties.longitude ? `<div><span class="font-semibold">Center:</span> ${properties.latitude.toFixed(6)}, ${properties.longitude.toFixed(6)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
const actions = properties.id ? [{
|
||||||
|
type: 'button',
|
||||||
|
handler: 'handleDelete',
|
||||||
|
id: properties.id,
|
||||||
|
entityType: 'area',
|
||||||
|
label: 'Delete'
|
||||||
|
}] : []
|
||||||
|
|
||||||
|
this.controller.showInfo(properties.name || 'Area', content, actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/javascript/controllers/maps/maplibre/filter_manager.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Manages filtering and searching of map data
|
||||||
|
*/
|
||||||
|
export class FilterManager {
|
||||||
|
constructor(dataLoader) {
|
||||||
|
this.dataLoader = dataLoader
|
||||||
|
this.currentVisitFilter = 'all'
|
||||||
|
this.allVisits = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store all visits for filtering
|
||||||
|
*/
|
||||||
|
setAllVisits(visits) {
|
||||||
|
this.allVisits = visits
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter and update visits display
|
||||||
|
*/
|
||||||
|
filterAndUpdateVisits(searchTerm, statusFilter, visitsLayer) {
|
||||||
|
if (!this.allVisits || !visitsLayer) return
|
||||||
|
|
||||||
|
const filtered = this.allVisits.filter(visit => {
|
||||||
|
// Apply search
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
visit.name?.toLowerCase().includes(searchTerm) ||
|
||||||
|
visit.place?.name?.toLowerCase().includes(searchTerm)
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
const matchesStatus = statusFilter === 'all' || visit.status === statusFilter
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const geojson = this.dataLoader.visitsToGeoJSON(filtered)
|
||||||
|
visitsLayer.update(geojson)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current visit filter
|
||||||
|
*/
|
||||||
|
getCurrentVisitFilter() {
|
||||||
|
return this.currentVisitFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set current visit filter
|
||||||
|
*/
|
||||||
|
setCurrentVisitFilter(filter) {
|
||||||
|
this.currentVisitFilter = filter
|
||||||
|
}
|
||||||
|
}
|
||||||
281
app/javascript/controllers/maps/maplibre/layer_manager.js
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { PointsLayer } from 'maps_maplibre/layers/points_layer'
|
||||||
|
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
|
||||||
|
import { HeatmapLayer } from 'maps_maplibre/layers/heatmap_layer'
|
||||||
|
import { VisitsLayer } from 'maps_maplibre/layers/visits_layer'
|
||||||
|
import { PhotosLayer } from 'maps_maplibre/layers/photos_layer'
|
||||||
|
import { AreasLayer } from 'maps_maplibre/layers/areas_layer'
|
||||||
|
import { TracksLayer } from 'maps_maplibre/layers/tracks_layer'
|
||||||
|
import { PlacesLayer } from 'maps_maplibre/layers/places_layer'
|
||||||
|
import { FogLayer } from 'maps_maplibre/layers/fog_layer'
|
||||||
|
import { FamilyLayer } from 'maps_maplibre/layers/family_layer'
|
||||||
|
import { RecentPointLayer } from 'maps_maplibre/layers/recent_point_layer'
|
||||||
|
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
|
||||||
|
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages all map layers lifecycle and visibility
|
||||||
|
*/
|
||||||
|
export class LayerManager {
|
||||||
|
constructor(map, settings, api) {
|
||||||
|
this.map = map
|
||||||
|
this.settings = settings
|
||||||
|
this.api = api
|
||||||
|
this.layers = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update all layers with provided data
|
||||||
|
*/
|
||||||
|
async addAllLayers(pointsGeoJSON, routesGeoJSON, visitsGeoJSON, photosGeoJSON, areasGeoJSON, tracksGeoJSON, placesGeoJSON) {
|
||||||
|
performanceMonitor.mark('add-layers')
|
||||||
|
|
||||||
|
// Layer order matters - layers added first render below layers added later
|
||||||
|
// Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> places -> photos -> family -> points -> recent-point (top) -> fog (canvas overlay)
|
||||||
|
|
||||||
|
await this._addScratchLayer(pointsGeoJSON)
|
||||||
|
this._addHeatmapLayer(pointsGeoJSON)
|
||||||
|
this._addAreasLayer(areasGeoJSON)
|
||||||
|
this._addTracksLayer(tracksGeoJSON)
|
||||||
|
this._addRoutesLayer(routesGeoJSON)
|
||||||
|
this._addVisitsLayer(visitsGeoJSON)
|
||||||
|
this._addPlacesLayer(placesGeoJSON)
|
||||||
|
|
||||||
|
// Add photos layer with error handling (async, might fail loading images)
|
||||||
|
try {
|
||||||
|
await this._addPhotosLayer(photosGeoJSON)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to add photos layer:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._addFamilyLayer()
|
||||||
|
this._addPointsLayer(pointsGeoJSON)
|
||||||
|
this._addRecentPointLayer()
|
||||||
|
this._addFogLayer(pointsGeoJSON)
|
||||||
|
|
||||||
|
performanceMonitor.measure('add-layers')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event handlers for layer interactions
|
||||||
|
*/
|
||||||
|
setupLayerEventHandlers(handlers) {
|
||||||
|
// Click handlers
|
||||||
|
this.map.on('click', 'points', handlers.handlePointClick)
|
||||||
|
this.map.on('click', 'visits', handlers.handleVisitClick)
|
||||||
|
this.map.on('click', 'photos', handlers.handlePhotoClick)
|
||||||
|
this.map.on('click', 'places', handlers.handlePlaceClick)
|
||||||
|
// Areas have multiple layers (fill, outline, labels)
|
||||||
|
this.map.on('click', 'areas-fill', handlers.handleAreaClick)
|
||||||
|
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
|
||||||
|
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
|
||||||
|
|
||||||
|
// Cursor change on hover
|
||||||
|
this.map.on('mouseenter', 'points', () => {
|
||||||
|
this.map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
this.map.on('mouseleave', 'points', () => {
|
||||||
|
this.map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
this.map.on('mouseenter', 'visits', () => {
|
||||||
|
this.map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
this.map.on('mouseleave', 'visits', () => {
|
||||||
|
this.map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
this.map.on('mouseenter', 'photos', () => {
|
||||||
|
this.map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
this.map.on('mouseleave', 'photos', () => {
|
||||||
|
this.map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
this.map.on('mouseenter', 'places', () => {
|
||||||
|
this.map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
this.map.on('mouseleave', 'places', () => {
|
||||||
|
this.map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
// Areas hover handlers for all sub-layers
|
||||||
|
const areaLayers = ['areas-fill', 'areas-outline', 'areas-labels']
|
||||||
|
areaLayers.forEach(layerId => {
|
||||||
|
// Only add handlers if layer exists
|
||||||
|
if (this.map.getLayer(layerId)) {
|
||||||
|
this.map.on('mouseenter', layerId, () => {
|
||||||
|
this.map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
this.map.on('mouseleave', layerId, () => {
|
||||||
|
this.map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle layer visibility
|
||||||
|
*/
|
||||||
|
toggleLayer(layerName) {
|
||||||
|
const layer = this.layers[`${layerName}Layer`]
|
||||||
|
if (!layer) return null
|
||||||
|
|
||||||
|
layer.toggle()
|
||||||
|
return layer.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get layer instance
|
||||||
|
*/
|
||||||
|
getLayer(layerName) {
|
||||||
|
return this.layers[`${layerName}Layer`]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all layer references (for style changes)
|
||||||
|
*/
|
||||||
|
clearLayerReferences() {
|
||||||
|
this.layers = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private methods for individual layer management
|
||||||
|
|
||||||
|
async _addScratchLayer(pointsGeoJSON) {
|
||||||
|
try {
|
||||||
|
if (!this.layers.scratchLayer && this.settings.scratchEnabled) {
|
||||||
|
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||||
|
this.layers.scratchLayer = new ScratchLayer(this.map, {
|
||||||
|
visible: true,
|
||||||
|
apiClient: this.api
|
||||||
|
})
|
||||||
|
await this.layers.scratchLayer.add(pointsGeoJSON)
|
||||||
|
} else if (this.layers.scratchLayer) {
|
||||||
|
await this.layers.scratchLayer.update(pointsGeoJSON)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load scratch layer:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addHeatmapLayer(pointsGeoJSON) {
|
||||||
|
if (!this.layers.heatmapLayer) {
|
||||||
|
this.layers.heatmapLayer = new HeatmapLayer(this.map, {
|
||||||
|
visible: this.settings.heatmapEnabled
|
||||||
|
})
|
||||||
|
this.layers.heatmapLayer.add(pointsGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.heatmapLayer.update(pointsGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addAreasLayer(areasGeoJSON) {
|
||||||
|
if (!this.layers.areasLayer) {
|
||||||
|
this.layers.areasLayer = new AreasLayer(this.map, {
|
||||||
|
visible: this.settings.areasEnabled || false
|
||||||
|
})
|
||||||
|
this.layers.areasLayer.add(areasGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.areasLayer.update(areasGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addTracksLayer(tracksGeoJSON) {
|
||||||
|
if (!this.layers.tracksLayer) {
|
||||||
|
this.layers.tracksLayer = new TracksLayer(this.map, {
|
||||||
|
visible: this.settings.tracksEnabled || false
|
||||||
|
})
|
||||||
|
this.layers.tracksLayer.add(tracksGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.tracksLayer.update(tracksGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addRoutesLayer(routesGeoJSON) {
|
||||||
|
if (!this.layers.routesLayer) {
|
||||||
|
this.layers.routesLayer = new RoutesLayer(this.map, {
|
||||||
|
visible: this.settings.routesVisible !== false // Default true unless explicitly false
|
||||||
|
})
|
||||||
|
this.layers.routesLayer.add(routesGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.routesLayer.update(routesGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addVisitsLayer(visitsGeoJSON) {
|
||||||
|
if (!this.layers.visitsLayer) {
|
||||||
|
this.layers.visitsLayer = new VisitsLayer(this.map, {
|
||||||
|
visible: this.settings.visitsEnabled || false
|
||||||
|
})
|
||||||
|
this.layers.visitsLayer.add(visitsGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.visitsLayer.update(visitsGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addPlacesLayer(placesGeoJSON) {
|
||||||
|
if (!this.layers.placesLayer) {
|
||||||
|
this.layers.placesLayer = new PlacesLayer(this.map, {
|
||||||
|
visible: this.settings.placesEnabled || false
|
||||||
|
})
|
||||||
|
this.layers.placesLayer.add(placesGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.placesLayer.update(placesGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _addPhotosLayer(photosGeoJSON) {
|
||||||
|
console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled)
|
||||||
|
if (!this.layers.photosLayer) {
|
||||||
|
this.layers.photosLayer = new PhotosLayer(this.map, {
|
||||||
|
visible: this.settings.photosEnabled || false
|
||||||
|
})
|
||||||
|
console.log('[Photos] Created new PhotosLayer instance')
|
||||||
|
await this.layers.photosLayer.add(photosGeoJSON)
|
||||||
|
console.log('[Photos] Added photos to layer')
|
||||||
|
} else {
|
||||||
|
console.log('[Photos] Updating existing PhotosLayer')
|
||||||
|
await this.layers.photosLayer.update(photosGeoJSON)
|
||||||
|
console.log('[Photos] Updated photos layer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addFamilyLayer() {
|
||||||
|
if (!this.layers.familyLayer) {
|
||||||
|
this.layers.familyLayer = new FamilyLayer(this.map, {
|
||||||
|
visible: false // Initially hidden, shown when family locations arrive via ActionCable
|
||||||
|
})
|
||||||
|
this.layers.familyLayer.add({ type: 'FeatureCollection', features: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addPointsLayer(pointsGeoJSON) {
|
||||||
|
if (!this.layers.pointsLayer) {
|
||||||
|
this.layers.pointsLayer = new PointsLayer(this.map, {
|
||||||
|
visible: this.settings.pointsVisible !== false, // Default true unless explicitly false
|
||||||
|
apiClient: this.api,
|
||||||
|
layerManager: this
|
||||||
|
})
|
||||||
|
this.layers.pointsLayer.add(pointsGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.pointsLayer.update(pointsGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addRecentPointLayer() {
|
||||||
|
if (!this.layers.recentPointLayer) {
|
||||||
|
this.layers.recentPointLayer = new RecentPointLayer(this.map, {
|
||||||
|
visible: false // Initially hidden, shown only when live mode is enabled
|
||||||
|
})
|
||||||
|
this.layers.recentPointLayer.add({ type: 'FeatureCollection', features: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addFogLayer(pointsGeoJSON) {
|
||||||
|
// Always create fog layer for backward compatibility
|
||||||
|
if (!this.layers.fogLayer) {
|
||||||
|
this.layers.fogLayer = new FogLayer(this.map, {
|
||||||
|
clearRadius: this.settings.fogOfWarRadius || 1000,
|
||||||
|
visible: this.settings.fogEnabled || false
|
||||||
|
})
|
||||||
|
this.layers.fogLayer.add(pointsGeoJSON)
|
||||||
|
} else {
|
||||||
|
this.layers.fogLayer.update(pointsGeoJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/javascript/controllers/maps/maplibre/map_data_manager.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages data loading and layer setup for the map
|
||||||
|
*/
|
||||||
|
export class MapDataManager {
|
||||||
|
constructor(controller) {
|
||||||
|
this.controller = controller
|
||||||
|
this.map = controller.map
|
||||||
|
this.dataLoader = controller.dataLoader
|
||||||
|
this.layerManager = controller.layerManager
|
||||||
|
this.filterManager = controller.filterManager
|
||||||
|
this.eventHandlers = controller.eventHandlers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load map data from API and setup layers
|
||||||
|
* @param {string} startDate - Start date for data range
|
||||||
|
* @param {string} endDate - End date for data range
|
||||||
|
* @param {Object} options - Loading options
|
||||||
|
*/
|
||||||
|
async loadMapData(startDate, endDate, options = {}) {
|
||||||
|
const {
|
||||||
|
showLoading = true,
|
||||||
|
fitBounds = true,
|
||||||
|
showToast = true,
|
||||||
|
onProgress = null
|
||||||
|
} = options
|
||||||
|
|
||||||
|
performanceMonitor.mark('load-map-data')
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
this.controller.showLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch data from API
|
||||||
|
const data = await this.dataLoader.fetchMapData(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
showLoading ? onProgress : null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store visits for filtering
|
||||||
|
this.filterManager.setAllVisits(data.visits)
|
||||||
|
|
||||||
|
// Setup layers
|
||||||
|
await this._setupLayers(data)
|
||||||
|
|
||||||
|
// Fit bounds if requested
|
||||||
|
if (fitBounds && data.points.length > 0) {
|
||||||
|
this._fitMapToBounds(data.pointsGeoJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
if (showToast) {
|
||||||
|
const pointText = data.points.length === 1 ? 'point' : 'points'
|
||||||
|
Toast.success(`Loaded ${data.points.length} location ${pointText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MapDataManager] Failed to load map data:', error)
|
||||||
|
Toast.error('Failed to load location data. Please try again.')
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
this.controller.hideLoading()
|
||||||
|
}
|
||||||
|
const duration = performanceMonitor.measure('load-map-data')
|
||||||
|
console.log(`[Performance] Map data loaded in ${duration}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup all map layers with loaded data
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _setupLayers(data) {
|
||||||
|
const addAllLayers = async () => {
|
||||||
|
await this.layerManager.addAllLayers(
|
||||||
|
data.pointsGeoJSON,
|
||||||
|
data.routesGeoJSON,
|
||||||
|
data.visitsGeoJSON,
|
||||||
|
data.photosGeoJSON,
|
||||||
|
data.areasGeoJSON,
|
||||||
|
data.tracksGeoJSON,
|
||||||
|
data.placesGeoJSON
|
||||||
|
)
|
||||||
|
|
||||||
|
this.layerManager.setupLayerEventHandlers({
|
||||||
|
handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers),
|
||||||
|
handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers),
|
||||||
|
handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers),
|
||||||
|
handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers),
|
||||||
|
handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.map.loaded()) {
|
||||||
|
await addAllLayers()
|
||||||
|
} else {
|
||||||
|
this.map.once('load', async () => {
|
||||||
|
await addAllLayers()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit map to data bounds
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_fitMapToBounds(geojson) {
|
||||||
|
if (!geojson?.features?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = geojson.features.map(f => f.geometry.coordinates)
|
||||||
|
|
||||||
|
const bounds = coordinates.reduce((bounds, coord) => {
|
||||||
|
return bounds.extend(coord)
|
||||||
|
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
|
||||||
|
|
||||||
|
this.map.fitBounds(bounds, {
|
||||||
|
padding: 50,
|
||||||
|
maxZoom: 15
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/javascript/controllers/maps/maplibre/map_initializer.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles map initialization for Maps V2
|
||||||
|
*/
|
||||||
|
export class MapInitializer {
|
||||||
|
/**
|
||||||
|
* Initialize MapLibre map instance
|
||||||
|
* @param {HTMLElement} container - The container element for the map
|
||||||
|
* @param {Object} settings - Map settings (style, center, zoom)
|
||||||
|
* @returns {Promise<maplibregl.Map>} The initialized map instance
|
||||||
|
*/
|
||||||
|
static async initialize(container, settings = {}) {
|
||||||
|
const {
|
||||||
|
mapStyle = 'streets',
|
||||||
|
center = [0, 0],
|
||||||
|
zoom = 2,
|
||||||
|
showControls = true,
|
||||||
|
globeProjection = false
|
||||||
|
} = settings
|
||||||
|
|
||||||
|
const style = await getMapStyle(mapStyle)
|
||||||
|
|
||||||
|
const mapOptions = {
|
||||||
|
container,
|
||||||
|
style,
|
||||||
|
center,
|
||||||
|
zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new maplibregl.Map(mapOptions)
|
||||||
|
|
||||||
|
// Set globe projection after map loads
|
||||||
|
if (globeProjection === true || globeProjection === 'true') {
|
||||||
|
map.on('load', () => {
|
||||||
|
map.setProjection({ type: 'globe' })
|
||||||
|
|
||||||
|
// Add atmosphere effect
|
||||||
|
map.setSky({
|
||||||
|
'atmosphere-blend': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
0, 1, 5, 1, 7, 0
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showControls) {
|
||||||
|
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit map to bounds of GeoJSON features
|
||||||
|
* @param {maplibregl.Map} map - The map instance
|
||||||
|
* @param {Object} geojson - GeoJSON FeatureCollection
|
||||||
|
* @param {Object} options - Fit bounds options
|
||||||
|
*/
|
||||||
|
static fitToBounds(map, geojson, options = {}) {
|
||||||
|
const {
|
||||||
|
padding = 50,
|
||||||
|
maxZoom = 15
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!geojson?.features?.length) {
|
||||||
|
console.warn('[MapInitializer] No features to fit bounds to')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = geojson.features.map(f => f.geometry.coordinates)
|
||||||
|
|
||||||
|
const bounds = coordinates.reduce((bounds, coord) => {
|
||||||
|
return bounds.extend(coord)
|
||||||
|
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
|
||||||
|
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding,
|
||||||
|
maxZoom
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
281
app/javascript/controllers/maps/maplibre/places_manager.js
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages places-related operations for Maps V2
|
||||||
|
* Including place creation, tag filtering, and layer management
|
||||||
|
*/
|
||||||
|
export class PlacesManager {
|
||||||
|
constructor(controller) {
|
||||||
|
this.controller = controller
|
||||||
|
this.layerManager = controller.layerManager
|
||||||
|
this.api = controller.api
|
||||||
|
this.dataLoader = controller.dataLoader
|
||||||
|
this.settings = controller.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle places layer
|
||||||
|
*/
|
||||||
|
togglePlaces(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('placesEnabled', enabled)
|
||||||
|
|
||||||
|
const placesLayer = this.layerManager.getLayer('places')
|
||||||
|
if (placesLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
placesLayer.show()
|
||||||
|
if (this.controller.hasPlacesFiltersTarget) {
|
||||||
|
this.controller.placesFiltersTarget.style.display = 'block'
|
||||||
|
}
|
||||||
|
this.initializePlaceTagFilters()
|
||||||
|
} else {
|
||||||
|
placesLayer.hide()
|
||||||
|
if (this.controller.hasPlacesFiltersTarget) {
|
||||||
|
this.controller.placesFiltersTarget.style.display = 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize place tag filters (enable all by default or restore saved state)
|
||||||
|
*/
|
||||||
|
initializePlaceTagFilters() {
|
||||||
|
const savedFilters = this.settings.placesTagFilters
|
||||||
|
|
||||||
|
if (savedFilters && savedFilters.length > 0) {
|
||||||
|
this.restoreSavedTagFilters(savedFilters)
|
||||||
|
} else {
|
||||||
|
this.enableAllTagsInitial()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore saved tag filters
|
||||||
|
*/
|
||||||
|
restoreSavedTagFilters(savedFilters) {
|
||||||
|
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||||
|
|
||||||
|
tagCheckboxes.forEach(checkbox => {
|
||||||
|
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
|
||||||
|
const shouldBeChecked = savedFilters.includes(value)
|
||||||
|
|
||||||
|
if (checkbox.checked !== shouldBeChecked) {
|
||||||
|
checkbox.checked = shouldBeChecked
|
||||||
|
|
||||||
|
const badge = checkbox.nextElementSibling
|
||||||
|
const color = badge.style.borderColor
|
||||||
|
|
||||||
|
if (shouldBeChecked) {
|
||||||
|
badge.classList.remove('badge-outline')
|
||||||
|
badge.style.backgroundColor = color
|
||||||
|
badge.style.color = 'white'
|
||||||
|
} else {
|
||||||
|
badge.classList.add('badge-outline')
|
||||||
|
badge.style.backgroundColor = 'transparent'
|
||||||
|
badge.style.color = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.syncEnableAllTagsToggle()
|
||||||
|
this.loadPlacesWithTags(savedFilters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable all tags initially
|
||||||
|
*/
|
||||||
|
enableAllTagsInitial() {
|
||||||
|
if (this.controller.hasEnableAllPlaceTagsToggleTarget) {
|
||||||
|
this.controller.enableAllPlaceTagsToggleTarget.checked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||||
|
const allTagIds = []
|
||||||
|
|
||||||
|
tagCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = true
|
||||||
|
|
||||||
|
const badge = checkbox.nextElementSibling
|
||||||
|
const color = badge.style.borderColor
|
||||||
|
badge.classList.remove('badge-outline')
|
||||||
|
badge.style.backgroundColor = color
|
||||||
|
badge.style.color = 'white'
|
||||||
|
|
||||||
|
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
|
||||||
|
allTagIds.push(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
SettingsManager.updateSetting('placesTagFilters', allTagIds)
|
||||||
|
this.loadPlacesWithTags(allTagIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get selected place tag IDs
|
||||||
|
*/
|
||||||
|
getSelectedPlaceTags() {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll('input[name="place_tag_ids[]"]:checked')
|
||||||
|
).map(cb => {
|
||||||
|
const value = cb.value
|
||||||
|
return value === 'untagged' ? value : parseInt(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter places by selected tags
|
||||||
|
*/
|
||||||
|
filterPlacesByTags(event) {
|
||||||
|
const badge = event.target.nextElementSibling
|
||||||
|
const color = badge.style.borderColor
|
||||||
|
|
||||||
|
if (event.target.checked) {
|
||||||
|
badge.classList.remove('badge-outline')
|
||||||
|
badge.style.backgroundColor = color
|
||||||
|
badge.style.color = 'white'
|
||||||
|
} else {
|
||||||
|
badge.classList.add('badge-outline')
|
||||||
|
badge.style.backgroundColor = 'transparent'
|
||||||
|
badge.style.color = color
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncEnableAllTagsToggle()
|
||||||
|
|
||||||
|
const checkedTags = this.getSelectedPlaceTags()
|
||||||
|
SettingsManager.updateSetting('placesTagFilters', checkedTags)
|
||||||
|
this.loadPlacesWithTags(checkedTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync "Enable All Tags" toggle with individual tag states
|
||||||
|
*/
|
||||||
|
syncEnableAllTagsToggle() {
|
||||||
|
if (!this.controller.hasEnableAllPlaceTagsToggleTarget) return
|
||||||
|
|
||||||
|
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||||
|
const allChecked = Array.from(tagCheckboxes).every(cb => cb.checked)
|
||||||
|
|
||||||
|
this.controller.enableAllPlaceTagsToggleTarget.checked = allChecked
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load places filtered by tags
|
||||||
|
*/
|
||||||
|
async loadPlacesWithTags(tagIds = []) {
|
||||||
|
try {
|
||||||
|
let places = []
|
||||||
|
|
||||||
|
if (tagIds.length > 0) {
|
||||||
|
places = await this.api.fetchPlaces({ tag_ids: tagIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
|
||||||
|
|
||||||
|
const placesLayer = this.layerManager.getLayer('places')
|
||||||
|
if (placesLayer) {
|
||||||
|
placesLayer.update(placesGeoJSON)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to load places:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle all place tags on/off
|
||||||
|
*/
|
||||||
|
toggleAllPlaceTags(event) {
|
||||||
|
const enableAll = event.target.checked
|
||||||
|
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||||
|
|
||||||
|
tagCheckboxes.forEach(checkbox => {
|
||||||
|
if (checkbox.checked !== enableAll) {
|
||||||
|
checkbox.checked = enableAll
|
||||||
|
|
||||||
|
const badge = checkbox.nextElementSibling
|
||||||
|
const color = badge.style.borderColor
|
||||||
|
|
||||||
|
if (enableAll) {
|
||||||
|
badge.classList.remove('badge-outline')
|
||||||
|
badge.style.backgroundColor = color
|
||||||
|
badge.style.color = 'white'
|
||||||
|
} else {
|
||||||
|
badge.classList.add('badge-outline')
|
||||||
|
badge.style.backgroundColor = 'transparent'
|
||||||
|
badge.style.color = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTags = this.getSelectedPlaceTags()
|
||||||
|
SettingsManager.updateSetting('placesTagFilters', selectedTags)
|
||||||
|
this.loadPlacesWithTags(selectedTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start create place mode
|
||||||
|
*/
|
||||||
|
startCreatePlace() {
|
||||||
|
console.log('[Maps V2] Starting create place mode')
|
||||||
|
|
||||||
|
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||||
|
this.controller.toggleSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controller.map.getCanvas().style.cursor = 'crosshair'
|
||||||
|
Toast.info('Click on the map to place a place')
|
||||||
|
|
||||||
|
this.handleCreatePlaceClick = (e) => {
|
||||||
|
const { lng, lat } = e.lngLat
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('place:create', {
|
||||||
|
detail: { latitude: lat, longitude: lng }
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.controller.map.getCanvas().style.cursor = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controller.map.once('click', this.handleCreatePlaceClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle place creation event - reload places and update layer
|
||||||
|
*/
|
||||||
|
async handlePlaceCreated(event) {
|
||||||
|
console.log('[Maps V2] Place created, reloading places...', event.detail)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selectedTags = this.getSelectedPlaceTags()
|
||||||
|
|
||||||
|
const places = await this.api.fetchPlaces({
|
||||||
|
tag_ids: selectedTags
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Maps V2] Fetched places:', places.length)
|
||||||
|
|
||||||
|
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
|
||||||
|
|
||||||
|
console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features')
|
||||||
|
|
||||||
|
const placesLayer = this.layerManager.getLayer('places')
|
||||||
|
if (placesLayer) {
|
||||||
|
placesLayer.update(placesGeoJSON)
|
||||||
|
console.log('[Maps V2] Places layer updated successfully')
|
||||||
|
} else {
|
||||||
|
console.warn('[Maps V2] Places layer not found, cannot update')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to reload places:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle place update event - reload places and update layer
|
||||||
|
*/
|
||||||
|
async handlePlaceUpdated(event) {
|
||||||
|
console.log('[Maps V2] Place updated, reloading places...', event.detail)
|
||||||
|
|
||||||
|
// Reuse the same logic as creation
|
||||||
|
await this.handlePlaceCreated(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
384
app/javascript/controllers/maps/maplibre/routes_manager.js
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages routes-related operations for Maps V2
|
||||||
|
* Including speed-colored routes, route generation, and layer management
|
||||||
|
*/
|
||||||
|
export class RoutesManager {
|
||||||
|
constructor(controller) {
|
||||||
|
this.controller = controller
|
||||||
|
this.map = controller.map
|
||||||
|
this.layerManager = controller.layerManager
|
||||||
|
this.settings = controller.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle routes layer visibility
|
||||||
|
*/
|
||||||
|
toggleRoutes(event) {
|
||||||
|
const element = event.currentTarget
|
||||||
|
const visible = element.checked
|
||||||
|
|
||||||
|
const routesLayer = this.layerManager.getLayer('routes')
|
||||||
|
if (routesLayer) {
|
||||||
|
routesLayer.toggle(visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controller.hasRoutesOptionsTarget) {
|
||||||
|
this.controller.routesOptionsTarget.style.display = visible ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsManager.updateSetting('routesVisible', visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle speed-colored routes
|
||||||
|
*/
|
||||||
|
async toggleSpeedColoredRoutes(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled)
|
||||||
|
|
||||||
|
if (this.controller.hasSpeedColorScaleContainerTarget) {
|
||||||
|
this.controller.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.reloadRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open speed color editor modal
|
||||||
|
*/
|
||||||
|
openSpeedColorEditor() {
|
||||||
|
const currentScale = this.controller.speedColorScaleInputTarget.value ||
|
||||||
|
'0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||||
|
|
||||||
|
let modal = document.getElementById('speed-color-editor-modal')
|
||||||
|
if (!modal) {
|
||||||
|
modal = this.createSpeedColorEditorModal(currentScale)
|
||||||
|
document.body.appendChild(modal)
|
||||||
|
} else {
|
||||||
|
const controller = this.controller.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor')
|
||||||
|
if (controller) {
|
||||||
|
controller.colorStopsValue = currentScale
|
||||||
|
controller.loadColorStops()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkbox = modal.querySelector('.modal-toggle')
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create speed color editor modal element
|
||||||
|
*/
|
||||||
|
createSpeedColorEditorModal(currentScale) {
|
||||||
|
const modal = document.createElement('div')
|
||||||
|
modal.id = 'speed-color-editor-modal'
|
||||||
|
modal.setAttribute('data-controller', 'speed-color-editor')
|
||||||
|
modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale)
|
||||||
|
modal.setAttribute('data-action', 'speed-color-editor:save->maps--maplibre#handleSpeedColorSave')
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<input type="checkbox" id="speed-color-editor-toggle" class="modal-toggle" />
|
||||||
|
<div class="modal" role="dialog" data-speed-color-editor-target="modal">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="text-lg font-bold mb-4">Edit Speed Color Gradient</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Gradient Preview -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Preview</span>
|
||||||
|
</label>
|
||||||
|
<div class="h-12 rounded-lg border-2 border-base-300"
|
||||||
|
data-speed-color-editor-target="preview"></div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">This gradient will be applied to routes based on speed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Stops List -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Color Stops</span>
|
||||||
|
</label>
|
||||||
|
<div class="space-y-2" data-speed-color-editor-target="stopsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Stop Button -->
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline w-full"
|
||||||
|
data-action="click->speed-color-editor#addStop">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Color Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
data-action="click->speed-color-editor#resetToDefault">
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn"
|
||||||
|
data-action="click->speed-color-editor#close">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
data-action="click->speed-color-editor#save">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="modal-backdrop" for="speed-color-editor-toggle"></label>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
return modal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle speed color save event from editor
|
||||||
|
*/
|
||||||
|
handleSpeedColorSave(event) {
|
||||||
|
const newScale = event.detail.colorStops
|
||||||
|
|
||||||
|
this.controller.speedColorScaleInputTarget.value = newScale
|
||||||
|
SettingsManager.updateSetting('speedColorScale', newScale)
|
||||||
|
|
||||||
|
if (this.controller.speedColoredToggleTarget.checked) {
|
||||||
|
this.reloadRoutes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload routes layer
|
||||||
|
*/
|
||||||
|
async reloadRoutes() {
|
||||||
|
this.controller.showLoading('Reloading routes...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pointsLayer = this.layerManager.getLayer('points')
|
||||||
|
const points = pointsLayer?.data?.features?.map(f => ({
|
||||||
|
latitude: f.geometry.coordinates[1],
|
||||||
|
longitude: f.geometry.coordinates[0],
|
||||||
|
timestamp: f.properties.timestamp
|
||||||
|
})) || []
|
||||||
|
|
||||||
|
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 1000
|
||||||
|
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
|
||||||
|
|
||||||
|
const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors')
|
||||||
|
|
||||||
|
const routesGeoJSON = await this.generateRoutesWithSpeedColors(
|
||||||
|
points,
|
||||||
|
{ distanceThresholdMeters, timeThresholdMinutes },
|
||||||
|
calculateSpeed,
|
||||||
|
getSpeedColor
|
||||||
|
)
|
||||||
|
|
||||||
|
this.layerManager.updateLayer('routes', routesGeoJSON)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reload routes:', error)
|
||||||
|
Toast.error('Failed to reload routes')
|
||||||
|
} finally {
|
||||||
|
this.controller.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate routes with speed coloring
|
||||||
|
*/
|
||||||
|
async generateRoutesWithSpeedColors(points, options, calculateSpeed, getSpeedColor) {
|
||||||
|
const { RoutesLayer } = await import('maps_maplibre/layers/routes_layer')
|
||||||
|
const useSpeedColors = this.settings.speedColoredRoutesEnabled || false
|
||||||
|
const speedColorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||||
|
|
||||||
|
const routesGeoJSON = RoutesLayer.pointsToRoutes(points, options)
|
||||||
|
|
||||||
|
if (!useSpeedColors) {
|
||||||
|
return routesGeoJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
routesGeoJSON.features = routesGeoJSON.features.map((feature, index) => {
|
||||||
|
const segment = points.slice(
|
||||||
|
points.findIndex(p => p.timestamp === feature.properties.startTime),
|
||||||
|
points.findIndex(p => p.timestamp === feature.properties.endTime) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (segment.length >= 2) {
|
||||||
|
const speed = calculateSpeed(segment[0], segment[segment.length - 1])
|
||||||
|
const color = getSpeedColor(speed, useSpeedColors, speedColorScale)
|
||||||
|
feature.properties.speed = speed
|
||||||
|
feature.properties.color = color
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature
|
||||||
|
})
|
||||||
|
|
||||||
|
return routesGeoJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle heatmap visibility
|
||||||
|
*/
|
||||||
|
toggleHeatmap(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('heatmapEnabled', enabled)
|
||||||
|
|
||||||
|
const heatmapLayer = this.layerManager.getLayer('heatmap')
|
||||||
|
if (heatmapLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
heatmapLayer.show()
|
||||||
|
} else {
|
||||||
|
heatmapLayer.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle fog of war layer
|
||||||
|
*/
|
||||||
|
toggleFog(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('fogEnabled', enabled)
|
||||||
|
|
||||||
|
const fogLayer = this.layerManager.getLayer('fog')
|
||||||
|
if (fogLayer) {
|
||||||
|
fogLayer.toggle(enabled)
|
||||||
|
} else {
|
||||||
|
console.warn('Fog layer not yet initialized')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle scratch map layer
|
||||||
|
*/
|
||||||
|
async toggleScratch(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('scratchEnabled', enabled)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scratchLayer = this.layerManager.getLayer('scratch')
|
||||||
|
if (!scratchLayer && enabled) {
|
||||||
|
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||||
|
const newScratchLayer = new ScratchLayer(this.map, {
|
||||||
|
visible: true,
|
||||||
|
apiClient: this.controller.api
|
||||||
|
})
|
||||||
|
const pointsLayer = this.layerManager.getLayer('points')
|
||||||
|
const pointsData = pointsLayer?.data || { type: 'FeatureCollection', features: [] }
|
||||||
|
await newScratchLayer.add(pointsData)
|
||||||
|
this.layerManager.layers.scratchLayer = newScratchLayer
|
||||||
|
} else if (scratchLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
scratchLayer.show()
|
||||||
|
} else {
|
||||||
|
scratchLayer.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle scratch layer:', error)
|
||||||
|
Toast.error('Failed to load scratch layer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle photos layer
|
||||||
|
*/
|
||||||
|
togglePhotos(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('photosEnabled', enabled)
|
||||||
|
|
||||||
|
const photosLayer = this.layerManager.getLayer('photos')
|
||||||
|
if (photosLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
photosLayer.show()
|
||||||
|
} else {
|
||||||
|
photosLayer.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle areas layer
|
||||||
|
*/
|
||||||
|
toggleAreas(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('areasEnabled', enabled)
|
||||||
|
|
||||||
|
const areasLayer = this.layerManager.getLayer('areas')
|
||||||
|
if (areasLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
areasLayer.show()
|
||||||
|
} else {
|
||||||
|
areasLayer.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle tracks layer
|
||||||
|
*/
|
||||||
|
toggleTracks(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('tracksEnabled', enabled)
|
||||||
|
|
||||||
|
const tracksLayer = this.layerManager.getLayer('tracks')
|
||||||
|
if (tracksLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
tracksLayer.show()
|
||||||
|
} else {
|
||||||
|
tracksLayer.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle points layer visibility
|
||||||
|
*/
|
||||||
|
togglePoints(event) {
|
||||||
|
const element = event.currentTarget
|
||||||
|
const visible = element.checked
|
||||||
|
|
||||||
|
const pointsLayer = this.layerManager.getLayer('points')
|
||||||
|
if (pointsLayer) {
|
||||||
|
pointsLayer.toggle(visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsManager.updateSetting('pointsVisible', visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle family members layer
|
||||||
|
*/
|
||||||
|
async toggleFamily(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('familyEnabled', enabled)
|
||||||
|
|
||||||
|
const familyLayer = this.layerManager.getLayer('family')
|
||||||
|
if (familyLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
familyLayer.show()
|
||||||
|
// Load family members data
|
||||||
|
await this.controller.loadFamilyMembers()
|
||||||
|
} else {
|
||||||
|
familyLayer.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the family members list
|
||||||
|
if (this.controller.hasFamilyMembersListTarget) {
|
||||||
|
this.controller.familyMembersListTarget.style.display = enabled ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
307
app/javascript/controllers/maps/maplibre/settings_manager.js
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||||
|
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all settings-related operations for Maps V2
|
||||||
|
* Including toggles, advanced settings, and UI synchronization
|
||||||
|
*/
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(controller) {
|
||||||
|
this.controller = controller
|
||||||
|
this.settings = controller.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy getters for properties that may not be initialized yet
|
||||||
|
get map() {
|
||||||
|
return this.controller.map
|
||||||
|
}
|
||||||
|
|
||||||
|
get layerManager() {
|
||||||
|
return this.controller.layerManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load settings (sync from backend)
|
||||||
|
*/
|
||||||
|
async loadSettings() {
|
||||||
|
this.settings = await SettingsManager.sync()
|
||||||
|
this.controller.settings = this.settings
|
||||||
|
|
||||||
|
// Update dataLoader with new settings
|
||||||
|
if (this.controller.dataLoader) {
|
||||||
|
this.controller.dataLoader.updateSettings(this.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync UI controls with loaded settings
|
||||||
|
*/
|
||||||
|
syncToggleStates() {
|
||||||
|
const controller = this.controller
|
||||||
|
|
||||||
|
// Sync layer toggles
|
||||||
|
const toggleMap = {
|
||||||
|
pointsToggle: 'pointsVisible',
|
||||||
|
routesToggle: 'routesVisible',
|
||||||
|
heatmapToggle: 'heatmapEnabled',
|
||||||
|
visitsToggle: 'visitsEnabled',
|
||||||
|
photosToggle: 'photosEnabled',
|
||||||
|
areasToggle: 'areasEnabled',
|
||||||
|
placesToggle: 'placesEnabled',
|
||||||
|
fogToggle: 'fogEnabled',
|
||||||
|
scratchToggle: 'scratchEnabled',
|
||||||
|
familyToggle: 'familyEnabled',
|
||||||
|
speedColoredToggle: 'speedColoredRoutesEnabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
|
||||||
|
const target = `${targetName}Target`
|
||||||
|
const hasTarget = `has${targetName.charAt(0).toUpperCase()}${targetName.slice(1)}Target`
|
||||||
|
if (controller[hasTarget]) {
|
||||||
|
controller[target].checked = this.settings[settingKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show/hide visits search based on initial toggle state
|
||||||
|
if (controller.hasVisitsToggleTarget && controller.hasVisitsSearchTarget) {
|
||||||
|
controller.visitsSearchTarget.style.display = controller.visitsToggleTarget.checked ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide places filters based on initial toggle state
|
||||||
|
if (controller.hasPlacesToggleTarget && controller.hasPlacesFiltersTarget) {
|
||||||
|
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide family members list based on initial toggle state
|
||||||
|
if (controller.hasFamilyToggleTarget && controller.hasFamilyMembersListTarget && controller.familyToggleTarget) {
|
||||||
|
controller.familyMembersListTarget.style.display = controller.familyToggleTarget.checked ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync route opacity slider
|
||||||
|
if (controller.hasRouteOpacityRangeTarget) {
|
||||||
|
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync map style dropdown
|
||||||
|
const mapStyleSelect = controller.element.querySelector('select[name="mapStyle"]')
|
||||||
|
if (mapStyleSelect) {
|
||||||
|
mapStyleSelect.value = this.settings.mapStyle || 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync globe projection toggle
|
||||||
|
if (controller.hasGlobeToggleTarget) {
|
||||||
|
controller.globeToggleTarget.checked = this.settings.globeProjection || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync fog of war settings
|
||||||
|
const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]')
|
||||||
|
if (fogRadiusInput) {
|
||||||
|
fogRadiusInput.value = this.settings.fogOfWarRadius || 1000
|
||||||
|
if (controller.hasFogRadiusValueTarget) {
|
||||||
|
controller.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fogThresholdInput = controller.element.querySelector('input[name="fogOfWarThreshold"]')
|
||||||
|
if (fogThresholdInput) {
|
||||||
|
fogThresholdInput.value = this.settings.fogOfWarThreshold || 1
|
||||||
|
if (controller.hasFogThresholdValueTarget) {
|
||||||
|
controller.fogThresholdValueTarget.textContent = fogThresholdInput.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync route generation settings
|
||||||
|
const metersBetweenInput = controller.element.querySelector('input[name="metersBetweenRoutes"]')
|
||||||
|
if (metersBetweenInput) {
|
||||||
|
metersBetweenInput.value = this.settings.metersBetweenRoutes || 500
|
||||||
|
if (controller.hasMetersBetweenValueTarget) {
|
||||||
|
controller.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesBetweenInput = controller.element.querySelector('input[name="minutesBetweenRoutes"]')
|
||||||
|
if (minutesBetweenInput) {
|
||||||
|
minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60
|
||||||
|
if (controller.hasMinutesBetweenValueTarget) {
|
||||||
|
controller.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync speed-colored routes settings
|
||||||
|
if (controller.hasSpeedColorScaleInputTarget) {
|
||||||
|
const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||||
|
controller.speedColorScaleInputTarget.value = colorScale
|
||||||
|
}
|
||||||
|
if (controller.hasSpeedColorScaleContainerTarget && controller.hasSpeedColoredToggleTarget) {
|
||||||
|
const isEnabled = controller.speedColoredToggleTarget.checked
|
||||||
|
controller.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync points rendering mode radio buttons
|
||||||
|
const pointsRenderingRadios = controller.element.querySelectorAll('input[name="pointsRenderingMode"]')
|
||||||
|
pointsRenderingRadios.forEach(radio => {
|
||||||
|
radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync speed-colored routes toggle
|
||||||
|
const speedColoredRoutesToggle = controller.element.querySelector('input[name="speedColoredRoutes"]')
|
||||||
|
if (speedColoredRoutesToggle) {
|
||||||
|
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update map style from settings
|
||||||
|
*/
|
||||||
|
async updateMapStyle(event) {
|
||||||
|
const styleName = event.target.value
|
||||||
|
SettingsManager.updateSetting('mapStyle', styleName)
|
||||||
|
|
||||||
|
const style = await getMapStyle(styleName)
|
||||||
|
|
||||||
|
// Clear layer references
|
||||||
|
this.layerManager.clearLayerReferences()
|
||||||
|
|
||||||
|
this.map.setStyle(style)
|
||||||
|
|
||||||
|
// Reload layers after style change
|
||||||
|
this.map.once('style.load', () => {
|
||||||
|
this.controller.loadMapData()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset settings to defaults
|
||||||
|
*/
|
||||||
|
resetSettings() {
|
||||||
|
if (confirm('Reset all settings to defaults? This will reload the page.')) {
|
||||||
|
SettingsManager.resetToDefaults()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle globe projection
|
||||||
|
* Requires page reload to apply since projection is set at map initialization
|
||||||
|
*/
|
||||||
|
async toggleGlobe(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
await SettingsManager.updateSetting('globeProjection', enabled)
|
||||||
|
|
||||||
|
Toast.info('Globe view will be applied after page reload')
|
||||||
|
|
||||||
|
// Prompt user to reload
|
||||||
|
if (confirm('Globe view requires a page reload to take effect. Reload now?')) {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update route opacity in real-time
|
||||||
|
*/
|
||||||
|
updateRouteOpacity(event) {
|
||||||
|
const opacity = parseInt(event.target.value) / 100
|
||||||
|
|
||||||
|
const routesLayer = this.layerManager.getLayer('routes')
|
||||||
|
if (routesLayer && this.map.getLayer('routes')) {
|
||||||
|
this.map.setPaintProperty('routes', 'line-opacity', opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsManager.updateSetting('routeOpacity', opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update advanced settings from form submission
|
||||||
|
*/
|
||||||
|
async updateAdvancedSettings(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const formData = new FormData(event.target)
|
||||||
|
const settings = {
|
||||||
|
routeOpacity: parseFloat(formData.get('routeOpacity')) / 100,
|
||||||
|
fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')),
|
||||||
|
fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')),
|
||||||
|
metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')),
|
||||||
|
minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')),
|
||||||
|
pointsRenderingMode: formData.get('pointsRenderingMode'),
|
||||||
|
speedColoredRoutes: formData.get('speedColoredRoutes') === 'on'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply settings to current map
|
||||||
|
await this.applySettingsToMap(settings)
|
||||||
|
|
||||||
|
// Save to backend
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
await SettingsManager.updateSetting(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update controller settings and dataLoader
|
||||||
|
this.controller.settings = { ...this.controller.settings, ...settings }
|
||||||
|
if (this.controller.dataLoader) {
|
||||||
|
this.controller.dataLoader.updateSettings(this.controller.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.success('Settings updated successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply settings to map without reload
|
||||||
|
*/
|
||||||
|
async applySettingsToMap(settings) {
|
||||||
|
// Update route opacity
|
||||||
|
if (settings.routeOpacity !== undefined) {
|
||||||
|
const routesLayer = this.layerManager.getLayer('routes')
|
||||||
|
if (routesLayer && this.map.getLayer('routes')) {
|
||||||
|
this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fog of war settings
|
||||||
|
if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) {
|
||||||
|
const fogLayer = this.layerManager.getLayer('fog')
|
||||||
|
if (fogLayer) {
|
||||||
|
if (settings.fogOfWarRadius) {
|
||||||
|
fogLayer.clearRadius = settings.fogOfWarRadius
|
||||||
|
}
|
||||||
|
// Redraw fog layer if it has data and is visible
|
||||||
|
if (fogLayer.visible && fogLayer.data) {
|
||||||
|
await fogLayer.update(fogLayer.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For settings that require data reload
|
||||||
|
if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) {
|
||||||
|
Toast.info('Reloading map data with new settings...')
|
||||||
|
await this.controller.loadMapData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display value update methods
|
||||||
|
updateFogRadiusDisplay(event) {
|
||||||
|
if (this.controller.hasFogRadiusValueTarget) {
|
||||||
|
this.controller.fogRadiusValueTarget.textContent = `${event.target.value}m`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFogThresholdDisplay(event) {
|
||||||
|
if (this.controller.hasFogThresholdValueTarget) {
|
||||||
|
this.controller.fogThresholdValueTarget.textContent = event.target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMetersBetweenDisplay(event) {
|
||||||
|
if (this.controller.hasMetersBetweenValueTarget) {
|
||||||
|
this.controller.metersBetweenValueTarget.textContent = `${event.target.value}m`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMinutesBetweenDisplay(event) {
|
||||||
|
if (this.controller.hasMinutesBetweenValueTarget) {
|
||||||
|
this.controller.minutesBetweenValueTarget.textContent = `${event.target.value}min`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/javascript/controllers/maps/maplibre/visits_manager.js
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages visits-related operations for Maps V2
|
||||||
|
* Including visit creation, filtering, and layer management
|
||||||
|
*/
|
||||||
|
export class VisitsManager {
|
||||||
|
constructor(controller) {
|
||||||
|
this.controller = controller
|
||||||
|
this.layerManager = controller.layerManager
|
||||||
|
this.filterManager = controller.filterManager
|
||||||
|
this.api = controller.api
|
||||||
|
this.dataLoader = controller.dataLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle visits layer
|
||||||
|
*/
|
||||||
|
toggleVisits(event) {
|
||||||
|
const enabled = event.target.checked
|
||||||
|
SettingsManager.updateSetting('visitsEnabled', enabled)
|
||||||
|
|
||||||
|
const visitsLayer = this.layerManager.getLayer('visits')
|
||||||
|
if (visitsLayer) {
|
||||||
|
if (enabled) {
|
||||||
|
visitsLayer.show()
|
||||||
|
if (this.controller.hasVisitsSearchTarget) {
|
||||||
|
this.controller.visitsSearchTarget.style.display = 'block'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
visitsLayer.hide()
|
||||||
|
if (this.controller.hasVisitsSearchTarget) {
|
||||||
|
this.controller.visitsSearchTarget.style.display = 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search visits
|
||||||
|
*/
|
||||||
|
searchVisits(event) {
|
||||||
|
const searchTerm = event.target.value.toLowerCase()
|
||||||
|
const visitsLayer = this.layerManager.getLayer('visits')
|
||||||
|
this.filterManager.filterAndUpdateVisits(
|
||||||
|
searchTerm,
|
||||||
|
this.filterManager.getCurrentVisitFilter(),
|
||||||
|
visitsLayer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter visits by status
|
||||||
|
*/
|
||||||
|
filterVisits(event) {
|
||||||
|
const filter = event.target.value
|
||||||
|
this.filterManager.setCurrentVisitFilter(filter)
|
||||||
|
const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || ''
|
||||||
|
const visitsLayer = this.layerManager.getLayer('visits')
|
||||||
|
this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start create visit mode
|
||||||
|
*/
|
||||||
|
startCreateVisit() {
|
||||||
|
console.log('[Maps V2] Starting create visit mode')
|
||||||
|
|
||||||
|
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||||
|
this.controller.toggleSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controller.map.getCanvas().style.cursor = 'crosshair'
|
||||||
|
Toast.info('Click on the map to place a visit')
|
||||||
|
|
||||||
|
this.handleCreateVisitClick = (e) => {
|
||||||
|
const { lng, lat } = e.lngLat
|
||||||
|
this.openVisitCreationModal(lat, lng)
|
||||||
|
this.controller.map.getCanvas().style.cursor = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controller.map.once('click', this.handleCreateVisitClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open visit creation modal
|
||||||
|
*/
|
||||||
|
openVisitCreationModal(lat, lng) {
|
||||||
|
console.log('[Maps V2] Opening visit creation modal', { lat, lng })
|
||||||
|
|
||||||
|
const modalElement = document.querySelector('[data-controller="visit-creation-v2"]')
|
||||||
|
|
||||||
|
if (!modalElement) {
|
||||||
|
console.error('[Maps V2] Visit creation modal not found')
|
||||||
|
Toast.error('Visit creation modal not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = this.controller.application.getControllerForElementAndIdentifier(
|
||||||
|
modalElement,
|
||||||
|
'visit-creation-v2'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (controller) {
|
||||||
|
controller.open(lat, lng, this.controller)
|
||||||
|
} else {
|
||||||
|
console.error('[Maps V2] Visit creation controller not found')
|
||||||
|
Toast.error('Visit creation controller not available')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle visit creation event - reload visits and update layer
|
||||||
|
*/
|
||||||
|
async handleVisitCreated(event) {
|
||||||
|
console.log('[Maps V2] Visit created, reloading visits...', event.detail)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const visits = await this.api.fetchVisits({
|
||||||
|
start_at: this.controller.startDateValue,
|
||||||
|
end_at: this.controller.endDateValue
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Maps V2] Fetched visits:', visits.length)
|
||||||
|
|
||||||
|
this.filterManager.setAllVisits(visits)
|
||||||
|
const visitsGeoJSON = this.dataLoader.visitsToGeoJSON(visits)
|
||||||
|
|
||||||
|
console.log('[Maps V2] Converted to GeoJSON:', visitsGeoJSON.features.length, 'features')
|
||||||
|
|
||||||
|
const visitsLayer = this.layerManager.getLayer('visits')
|
||||||
|
if (visitsLayer) {
|
||||||
|
visitsLayer.update(visitsGeoJSON)
|
||||||
|
console.log('[Maps V2] Visits layer updated successfully')
|
||||||
|
} else {
|
||||||
|
console.warn('[Maps V2] Visits layer not found, cannot update')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to reload visits:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle visit update event - reload visits and update layer
|
||||||
|
*/
|
||||||
|
async handleVisitUpdated(event) {
|
||||||
|
console.log('[Maps V2] Visit updated, reloading visits...', event.detail)
|
||||||
|
|
||||||
|
// Reuse the same logic as creation
|
||||||
|
await this.handleVisitCreated(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
649
app/javascript/controllers/maps/maplibre_controller.js
Normal file
|
|
@ -0,0 +1,649 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import { ApiClient } from 'maps_maplibre/services/api_client'
|
||||||
|
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||||
|
import { SearchManager } from 'maps_maplibre/utils/search_manager'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||||
|
import { CleanupHelper } from 'maps_maplibre/utils/cleanup_helper'
|
||||||
|
import { MapInitializer } from './maplibre/map_initializer'
|
||||||
|
import { MapDataManager } from './maplibre/map_data_manager'
|
||||||
|
import { LayerManager } from './maplibre/layer_manager'
|
||||||
|
import { DataLoader } from './maplibre/data_loader'
|
||||||
|
import { EventHandlers } from './maplibre/event_handlers'
|
||||||
|
import { FilterManager } from './maplibre/filter_manager'
|
||||||
|
import { DateManager } from './maplibre/date_manager'
|
||||||
|
import { SettingsController } from './maplibre/settings_manager'
|
||||||
|
import { AreaSelectionManager } from './maplibre/area_selection_manager'
|
||||||
|
import { VisitsManager } from './maplibre/visits_manager'
|
||||||
|
import { PlacesManager } from './maplibre/places_manager'
|
||||||
|
import { RoutesManager } from './maplibre/routes_manager'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main map controller for Maps V2
|
||||||
|
* Coordinates between different managers and handles UI interactions
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
apiKey: String,
|
||||||
|
startDate: String,
|
||||||
|
endDate: String,
|
||||||
|
timezone: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static targets = [
|
||||||
|
'container',
|
||||||
|
'loading',
|
||||||
|
'loadingText',
|
||||||
|
'monthSelect',
|
||||||
|
'clusterToggle',
|
||||||
|
'settingsPanel',
|
||||||
|
'visitsSearch',
|
||||||
|
'routeOpacityRange',
|
||||||
|
'placesFilters',
|
||||||
|
'enableAllPlaceTagsToggle',
|
||||||
|
'fogRadiusValue',
|
||||||
|
'fogThresholdValue',
|
||||||
|
'metersBetweenValue',
|
||||||
|
'minutesBetweenValue',
|
||||||
|
// Search
|
||||||
|
'searchInput',
|
||||||
|
'searchResults',
|
||||||
|
// Layer toggles
|
||||||
|
'pointsToggle',
|
||||||
|
'routesToggle',
|
||||||
|
'heatmapToggle',
|
||||||
|
'visitsToggle',
|
||||||
|
'photosToggle',
|
||||||
|
'areasToggle',
|
||||||
|
'placesToggle',
|
||||||
|
'fogToggle',
|
||||||
|
'scratchToggle',
|
||||||
|
'familyToggle',
|
||||||
|
// Speed-colored routes
|
||||||
|
'routesOptions',
|
||||||
|
'speedColoredToggle',
|
||||||
|
'speedColorScaleContainer',
|
||||||
|
'speedColorScaleInput',
|
||||||
|
// Globe projection
|
||||||
|
'globeToggle',
|
||||||
|
// Family members
|
||||||
|
'familyMembersList',
|
||||||
|
'familyMembersContainer',
|
||||||
|
// Area selection
|
||||||
|
'selectAreaButton',
|
||||||
|
'selectionActions',
|
||||||
|
'deleteButtonText',
|
||||||
|
'selectedVisitsContainer',
|
||||||
|
'selectedVisitsBulkActions',
|
||||||
|
// Info display
|
||||||
|
'infoDisplay',
|
||||||
|
'infoTitle',
|
||||||
|
'infoContent',
|
||||||
|
'infoActions'
|
||||||
|
]
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
this.cleanup = new CleanupHelper()
|
||||||
|
|
||||||
|
// Initialize API and settings
|
||||||
|
SettingsManager.initialize(this.apiKeyValue)
|
||||||
|
this.settingsController = new SettingsController(this)
|
||||||
|
await this.settingsController.loadSettings()
|
||||||
|
this.settings = this.settingsController.settings
|
||||||
|
|
||||||
|
// Sync toggle states with loaded settings
|
||||||
|
this.settingsController.syncToggleStates()
|
||||||
|
|
||||||
|
await this.initializeMap()
|
||||||
|
this.initializeAPI()
|
||||||
|
|
||||||
|
// Initialize managers
|
||||||
|
this.layerManager = new LayerManager(this.map, this.settings, this.api)
|
||||||
|
this.dataLoader = new DataLoader(this.api, this.apiKeyValue, this.settings)
|
||||||
|
this.eventHandlers = new EventHandlers(this.map, this)
|
||||||
|
this.filterManager = new FilterManager(this.dataLoader)
|
||||||
|
this.mapDataManager = new MapDataManager(this)
|
||||||
|
|
||||||
|
// Initialize feature managers
|
||||||
|
this.areaSelectionManager = new AreaSelectionManager(this)
|
||||||
|
this.visitsManager = new VisitsManager(this)
|
||||||
|
this.placesManager = new PlacesManager(this)
|
||||||
|
this.routesManager = new RoutesManager(this)
|
||||||
|
|
||||||
|
// Initialize search manager
|
||||||
|
this.initializeSearch()
|
||||||
|
|
||||||
|
// Listen for visit and place creation/update events
|
||||||
|
this.boundHandleVisitCreated = this.visitsManager.handleVisitCreated.bind(this.visitsManager)
|
||||||
|
this.cleanup.addEventListener(document, 'visit:created', this.boundHandleVisitCreated)
|
||||||
|
|
||||||
|
this.boundHandleVisitUpdated = this.visitsManager.handleVisitUpdated.bind(this.visitsManager)
|
||||||
|
this.cleanup.addEventListener(document, 'visit:updated', this.boundHandleVisitUpdated)
|
||||||
|
|
||||||
|
this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager)
|
||||||
|
this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated)
|
||||||
|
|
||||||
|
this.boundHandlePlaceUpdated = this.placesManager.handlePlaceUpdated.bind(this.placesManager)
|
||||||
|
this.cleanup.addEventListener(document, 'place:updated', this.boundHandlePlaceUpdated)
|
||||||
|
|
||||||
|
this.boundHandleAreaCreated = this.handleAreaCreated.bind(this)
|
||||||
|
this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated)
|
||||||
|
|
||||||
|
// Format initial dates
|
||||||
|
this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue))
|
||||||
|
this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue))
|
||||||
|
console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue)
|
||||||
|
|
||||||
|
this.loadMapData()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.searchManager?.destroy()
|
||||||
|
this.cleanup.cleanup()
|
||||||
|
this.map?.remove()
|
||||||
|
performanceMonitor.logReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize MapLibre map
|
||||||
|
*/
|
||||||
|
async initializeMap() {
|
||||||
|
this.map = await MapInitializer.initialize(this.containerTarget, {
|
||||||
|
mapStyle: this.settings.mapStyle,
|
||||||
|
globeProjection: this.settings.globeProjection
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize API client
|
||||||
|
*/
|
||||||
|
initializeAPI() {
|
||||||
|
this.api = new ApiClient(this.apiKeyValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize location search
|
||||||
|
*/
|
||||||
|
initializeSearch() {
|
||||||
|
if (!this.hasSearchInputTarget || !this.hasSearchResultsTarget) {
|
||||||
|
console.warn('[Maps V2] Search targets not found, search functionality disabled')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchManager = new SearchManager(this.map, this.apiKeyValue)
|
||||||
|
this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget)
|
||||||
|
|
||||||
|
console.log('[Maps V2] Search manager initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load map data from API
|
||||||
|
*/
|
||||||
|
async loadMapData(options = {}) {
|
||||||
|
return this.mapDataManager.loadMapData(
|
||||||
|
this.startDateValue,
|
||||||
|
this.endDateValue,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
onProgress: this.updateLoadingProgress.bind(this)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month selector changed
|
||||||
|
*/
|
||||||
|
monthChanged(event) {
|
||||||
|
const { startDate, endDate } = DateManager.parseMonthSelector(event.target.value)
|
||||||
|
this.startDateValue = startDate
|
||||||
|
this.endDateValue = endDate
|
||||||
|
|
||||||
|
console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue)
|
||||||
|
this.loadMapData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show loading indicator
|
||||||
|
*/
|
||||||
|
showLoading() {
|
||||||
|
this.loadingTarget.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide loading indicator
|
||||||
|
*/
|
||||||
|
hideLoading() {
|
||||||
|
this.loadingTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update loading progress
|
||||||
|
*/
|
||||||
|
updateLoadingProgress({ loaded, totalPages, progress }) {
|
||||||
|
if (this.hasLoadingTextTarget) {
|
||||||
|
const percentage = Math.round(progress * 100)
|
||||||
|
this.loadingTextTarget.textContent = `Loading... ${percentage}%`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle settings panel
|
||||||
|
*/
|
||||||
|
toggleSettings() {
|
||||||
|
if (this.hasSettingsPanelTarget) {
|
||||||
|
this.settingsPanelTarget.classList.toggle('open')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Delegated Methods to Managers =====
|
||||||
|
|
||||||
|
// Settings Controller methods
|
||||||
|
updateMapStyle(event) { return this.settingsController.updateMapStyle(event) }
|
||||||
|
resetSettings() { return this.settingsController.resetSettings() }
|
||||||
|
updateRouteOpacity(event) { return this.settingsController.updateRouteOpacity(event) }
|
||||||
|
updateAdvancedSettings(event) { return this.settingsController.updateAdvancedSettings(event) }
|
||||||
|
updateFogRadiusDisplay(event) { return this.settingsController.updateFogRadiusDisplay(event) }
|
||||||
|
updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) }
|
||||||
|
updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) }
|
||||||
|
updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) }
|
||||||
|
toggleGlobe(event) { return this.settingsController.toggleGlobe(event) }
|
||||||
|
|
||||||
|
// Area Selection Manager methods
|
||||||
|
startSelectArea() { return this.areaSelectionManager.startSelectArea() }
|
||||||
|
cancelAreaSelection() { return this.areaSelectionManager.cancelAreaSelection() }
|
||||||
|
deleteSelectedPoints() { return this.areaSelectionManager.deleteSelectedPoints() }
|
||||||
|
|
||||||
|
// Visits Manager methods
|
||||||
|
toggleVisits(event) { return this.visitsManager.toggleVisits(event) }
|
||||||
|
searchVisits(event) { return this.visitsManager.searchVisits(event) }
|
||||||
|
filterVisits(event) { return this.visitsManager.filterVisits(event) }
|
||||||
|
startCreateVisit() { return this.visitsManager.startCreateVisit() }
|
||||||
|
|
||||||
|
// Places Manager methods
|
||||||
|
togglePlaces(event) { return this.placesManager.togglePlaces(event) }
|
||||||
|
filterPlacesByTags(event) { return this.placesManager.filterPlacesByTags(event) }
|
||||||
|
toggleAllPlaceTags(event) { return this.placesManager.toggleAllPlaceTags(event) }
|
||||||
|
startCreatePlace() { return this.placesManager.startCreatePlace() }
|
||||||
|
|
||||||
|
// Area creation
|
||||||
|
startCreateArea() {
|
||||||
|
console.log('[Maps V2] Starting create area mode')
|
||||||
|
|
||||||
|
if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) {
|
||||||
|
this.toggleSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find area drawer controller on the same element
|
||||||
|
const drawerController = this.application.getControllerForElementAndIdentifier(
|
||||||
|
this.element,
|
||||||
|
'area-drawer'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (drawerController) {
|
||||||
|
console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map)
|
||||||
|
drawerController.startDrawing(this.map)
|
||||||
|
} else {
|
||||||
|
console.error('[Maps V2] Area drawer controller not found')
|
||||||
|
Toast.error('Area drawer controller not available')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAreaCreated(event) {
|
||||||
|
console.log('[Maps V2] Area created:', event.detail.area)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all areas from API
|
||||||
|
const areas = await this.api.fetchAreas()
|
||||||
|
console.log('[Maps V2] Fetched areas:', areas.length)
|
||||||
|
|
||||||
|
// Convert to GeoJSON
|
||||||
|
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
|
||||||
|
console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features')
|
||||||
|
if (areasGeoJSON.features.length > 0) {
|
||||||
|
console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create the areas layer
|
||||||
|
let areasLayer = this.layerManager.getLayer('areas')
|
||||||
|
console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible)
|
||||||
|
|
||||||
|
if (areasLayer) {
|
||||||
|
// Update existing layer
|
||||||
|
areasLayer.update(areasGeoJSON)
|
||||||
|
console.log('[Maps V2] Areas layer updated')
|
||||||
|
} else {
|
||||||
|
// Create the layer if it doesn't exist yet
|
||||||
|
console.log('[Maps V2] Creating areas layer')
|
||||||
|
this.layerManager._addAreasLayer(areasGeoJSON)
|
||||||
|
areasLayer = this.layerManager.getLayer('areas')
|
||||||
|
console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable the layer if it wasn't already
|
||||||
|
if (areasLayer) {
|
||||||
|
if (!areasLayer.visible) {
|
||||||
|
console.log('[Maps V2] Showing areas layer')
|
||||||
|
areasLayer.show()
|
||||||
|
this.settings.layers.areas = true
|
||||||
|
this.settingsController.saveSetting('layers.areas', true)
|
||||||
|
|
||||||
|
// Update toggle state
|
||||||
|
if (this.hasAreasToggleTarget) {
|
||||||
|
this.areasToggleTarget.checked = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Maps V2] Areas layer already visible')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.success('Area created successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to reload areas:', error)
|
||||||
|
Toast.error('Failed to reload areas')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes Manager methods
|
||||||
|
togglePoints(event) { return this.routesManager.togglePoints(event) }
|
||||||
|
toggleRoutes(event) { return this.routesManager.toggleRoutes(event) }
|
||||||
|
toggleHeatmap(event) { return this.routesManager.toggleHeatmap(event) }
|
||||||
|
toggleFog(event) { return this.routesManager.toggleFog(event) }
|
||||||
|
toggleScratch(event) { return this.routesManager.toggleScratch(event) }
|
||||||
|
togglePhotos(event) { return this.routesManager.togglePhotos(event) }
|
||||||
|
toggleAreas(event) { return this.routesManager.toggleAreas(event) }
|
||||||
|
toggleTracks(event) { return this.routesManager.toggleTracks(event) }
|
||||||
|
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
|
||||||
|
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
|
||||||
|
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
|
||||||
|
toggleFamily(event) { return this.routesManager.toggleFamily(event) }
|
||||||
|
|
||||||
|
// Family Members methods
|
||||||
|
async loadFamilyMembers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/families/locations?api_key=${this.apiKeyValue}`, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
console.warn('[Maps V2] Family feature not enabled or user not in family')
|
||||||
|
Toast.info('Family feature not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const locations = data.locations || []
|
||||||
|
|
||||||
|
// Update family layer with locations
|
||||||
|
const familyLayer = this.layerManager.getLayer('family')
|
||||||
|
if (familyLayer) {
|
||||||
|
familyLayer.loadMembers(locations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render family members list
|
||||||
|
this.renderFamilyMembersList(locations)
|
||||||
|
|
||||||
|
Toast.success(`Loaded ${locations.length} family member(s)`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to load family members:', error)
|
||||||
|
Toast.error('Failed to load family members')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFamilyMembersList(locations) {
|
||||||
|
if (!this.hasFamilyMembersContainerTarget) return
|
||||||
|
|
||||||
|
const container = this.familyMembersContainerTarget
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-xs text-base-content/60">No family members sharing location</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = locations.map(location => {
|
||||||
|
const emailInitial = location.email?.charAt(0)?.toUpperCase() || '?'
|
||||||
|
const color = this.getFamilyMemberColor(location.user_id)
|
||||||
|
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', {
|
||||||
|
timeZone: this.timezoneValue || 'UTC',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex items-center gap-2 p-2 hover:bg-base-200 rounded-lg cursor-pointer transition-colors"
|
||||||
|
data-action="click->maps--maplibre#centerOnFamilyMember"
|
||||||
|
data-member-id="${location.user_id}">
|
||||||
|
<div style="background-color: ${color}; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; flex-shrink: 0;">
|
||||||
|
${emailInitial}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate">${location.email || 'Unknown'}</div>
|
||||||
|
<div class="text-xs text-base-content/60">${lastSeen}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
getFamilyMemberColor(userId) {
|
||||||
|
const colors = [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b',
|
||||||
|
'#ef4444', '#8b5cf6', '#ec4899'
|
||||||
|
]
|
||||||
|
// Use user ID to get consistent color
|
||||||
|
const hash = userId.toString().split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||||
|
return colors[hash % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
centerOnFamilyMember(event) {
|
||||||
|
const memberId = event.currentTarget.dataset.memberId
|
||||||
|
if (!memberId) return
|
||||||
|
|
||||||
|
const familyLayer = this.layerManager.getLayer('family')
|
||||||
|
if (familyLayer) {
|
||||||
|
familyLayer.centerOnMember(parseInt(memberId))
|
||||||
|
Toast.success('Centered on family member')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info Display methods
|
||||||
|
showInfo(title, content, actions = []) {
|
||||||
|
if (!this.hasInfoDisplayTarget) return
|
||||||
|
|
||||||
|
// Set title
|
||||||
|
this.infoTitleTarget.textContent = title
|
||||||
|
|
||||||
|
// Set content
|
||||||
|
this.infoContentTarget.innerHTML = content
|
||||||
|
|
||||||
|
// Set actions
|
||||||
|
if (actions.length > 0) {
|
||||||
|
this.infoActionsTarget.innerHTML = actions.map(action => {
|
||||||
|
if (action.type === 'button') {
|
||||||
|
// For button actions (modals, etc.), create a button with data-action
|
||||||
|
// Use error styling for delete buttons
|
||||||
|
const buttonClass = action.label === 'Delete' ? 'btn btn-sm btn-error' : 'btn btn-sm btn-primary'
|
||||||
|
return `<button class="${buttonClass}" data-action="click->maps--maplibre#${action.handler}" data-id="${action.id}" data-entity-type="${action.entityType}">${action.label}</button>`
|
||||||
|
} else {
|
||||||
|
// For link actions, keep the original behavior
|
||||||
|
return `<a href="${action.url}" class="btn btn-sm btn-primary">${action.label}</a>`
|
||||||
|
}
|
||||||
|
}).join('')
|
||||||
|
} else {
|
||||||
|
this.infoActionsTarget.innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show info display
|
||||||
|
this.infoDisplayTarget.classList.remove('hidden')
|
||||||
|
|
||||||
|
// Switch to tools tab and open panel
|
||||||
|
this.switchToToolsTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
closeInfo() {
|
||||||
|
if (!this.hasInfoDisplayTarget) return
|
||||||
|
this.infoDisplayTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle edit action from info display
|
||||||
|
*/
|
||||||
|
handleEdit(event) {
|
||||||
|
const button = event.currentTarget
|
||||||
|
const id = button.dataset.id
|
||||||
|
const entityType = button.dataset.entityType
|
||||||
|
|
||||||
|
console.log('[Maps V2] Opening edit for', entityType, id)
|
||||||
|
|
||||||
|
switch (entityType) {
|
||||||
|
case 'visit':
|
||||||
|
this.openVisitModal(id)
|
||||||
|
break
|
||||||
|
case 'place':
|
||||||
|
this.openPlaceEditModal(id)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn('[Maps V2] Unknown entity type:', entityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle delete action from info display
|
||||||
|
*/
|
||||||
|
handleDelete(event) {
|
||||||
|
const button = event.currentTarget
|
||||||
|
const id = button.dataset.id
|
||||||
|
const entityType = button.dataset.entityType
|
||||||
|
|
||||||
|
console.log('[Maps V2] Deleting', entityType, id)
|
||||||
|
|
||||||
|
switch (entityType) {
|
||||||
|
case 'area':
|
||||||
|
this.deleteArea(id)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.warn('[Maps V2] Unknown entity type for delete:', entityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open visit edit modal
|
||||||
|
*/
|
||||||
|
async openVisitModal(visitId) {
|
||||||
|
try {
|
||||||
|
// Fetch visit details
|
||||||
|
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch visit: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visit = await response.json()
|
||||||
|
|
||||||
|
// Trigger visit edit event
|
||||||
|
const event = new CustomEvent('visit:edit', {
|
||||||
|
detail: { visit },
|
||||||
|
bubbles: true
|
||||||
|
})
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to load visit:', error)
|
||||||
|
Toast.error('Failed to load visit details')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete area with confirmation
|
||||||
|
*/
|
||||||
|
async deleteArea(areaId) {
|
||||||
|
try {
|
||||||
|
// Fetch area details
|
||||||
|
const area = await this.api.fetchArea(areaId)
|
||||||
|
|
||||||
|
// Show delete confirmation
|
||||||
|
const confirmed = confirm(`Delete area "${area.name}"?\n\nThis action cannot be undone.`)
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
Toast.info('Deleting area...')
|
||||||
|
|
||||||
|
// Delete the area
|
||||||
|
await this.api.deleteArea(areaId)
|
||||||
|
|
||||||
|
// Reload areas
|
||||||
|
const areas = await this.api.fetchAreas()
|
||||||
|
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
|
||||||
|
|
||||||
|
const areasLayer = this.layerManager.getLayer('areas')
|
||||||
|
if (areasLayer) {
|
||||||
|
areasLayer.update(areasGeoJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close info display
|
||||||
|
this.closeInfo()
|
||||||
|
|
||||||
|
Toast.success('Area deleted successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to delete area:', error)
|
||||||
|
Toast.error('Failed to delete area')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open place edit modal
|
||||||
|
*/
|
||||||
|
async openPlaceEditModal(placeId) {
|
||||||
|
try {
|
||||||
|
// Fetch place details
|
||||||
|
const response = await fetch(`/api/v1/places/${placeId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch place: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = await response.json()
|
||||||
|
|
||||||
|
// Trigger place edit event
|
||||||
|
const event = new CustomEvent('place:edit', {
|
||||||
|
detail: { place },
|
||||||
|
bubbles: true
|
||||||
|
})
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Maps V2] Failed to load place:', error)
|
||||||
|
Toast.error('Failed to load place details')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switchToToolsTab() {
|
||||||
|
// Open the panel if it's not already open
|
||||||
|
if (!this.settingsPanelTarget.classList.contains('open')) {
|
||||||
|
this.toggleSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the map-panel controller and switch to tools tab
|
||||||
|
const panelElement = this.settingsPanelTarget
|
||||||
|
const panelController = this.application.getControllerForElementAndIdentifier(panelElement, 'map-panel')
|
||||||
|
|
||||||
|
if (panelController && panelController.switchToTab) {
|
||||||
|
panelController.switchToTab('tools')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
323
app/javascript/controllers/maps/maplibre_realtime_controller.js
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import { createMapChannel } from 'maps_maplibre/channels/map_channel'
|
||||||
|
import { WebSocketManager } from 'maps_maplibre/utils/websocket_manager'
|
||||||
|
import { Toast } from 'maps_maplibre/components/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time controller
|
||||||
|
* Manages ActionCable connection and real-time updates
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['liveModeToggle']
|
||||||
|
|
||||||
|
static values = {
|
||||||
|
enabled: { type: Boolean, default: true },
|
||||||
|
liveMode: { type: Boolean, default: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
console.log('[Realtime Controller] Connecting...')
|
||||||
|
|
||||||
|
if (!this.enabledValue) {
|
||||||
|
console.log('[Realtime Controller] Disabled, skipping setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.connectedChannels = new Set()
|
||||||
|
this.liveModeEnabled = false // Start with live mode disabled
|
||||||
|
|
||||||
|
// Delay channel setup to ensure ActionCable is ready
|
||||||
|
// This prevents race condition with page initialization
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
this.setupChannels()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Realtime Controller] Failed to setup channels in setTimeout:', error)
|
||||||
|
this.updateConnectionIndicator(false)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
// Initialize toggle state from settings
|
||||||
|
if (this.hasLiveModeToggleTarget) {
|
||||||
|
this.liveModeToggleTarget.checked = this.liveModeEnabled
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Realtime Controller] Failed to initialize:', error)
|
||||||
|
// Don't throw - allow page to continue loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.channels?.unsubscribeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup ActionCable channels
|
||||||
|
* Family channel is always enabled when family feature is on
|
||||||
|
* Points channel (live mode) is controlled by user toggle
|
||||||
|
*/
|
||||||
|
setupChannels() {
|
||||||
|
try {
|
||||||
|
console.log('[Realtime Controller] Setting up channels...')
|
||||||
|
this.channels = createMapChannel({
|
||||||
|
connected: this.handleConnected.bind(this),
|
||||||
|
disconnected: this.handleDisconnected.bind(this),
|
||||||
|
received: this.handleReceived.bind(this),
|
||||||
|
enableLiveMode: this.liveModeEnabled // Control points channel
|
||||||
|
})
|
||||||
|
console.log('[Realtime Controller] Channels setup complete')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Realtime Controller] Failed to setup channels:', error)
|
||||||
|
console.error('[Realtime Controller] Error stack:', error.stack)
|
||||||
|
this.updateConnectionIndicator(false)
|
||||||
|
// Don't throw - page should continue to work
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle live mode (new points appearing in real-time)
|
||||||
|
*/
|
||||||
|
toggleLiveMode(event) {
|
||||||
|
this.liveModeEnabled = event.target.checked
|
||||||
|
|
||||||
|
// Update recent point layer visibility
|
||||||
|
this.updateRecentPointLayerVisibility()
|
||||||
|
|
||||||
|
// Reconnect channels with new settings
|
||||||
|
if (this.channels) {
|
||||||
|
this.channels.unsubscribeAll()
|
||||||
|
}
|
||||||
|
this.setupChannels()
|
||||||
|
|
||||||
|
const message = this.liveModeEnabled ? 'Live mode enabled' : 'Live mode disabled'
|
||||||
|
Toast.info(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update recent point layer visibility based on live mode state
|
||||||
|
*/
|
||||||
|
updateRecentPointLayerVisibility() {
|
||||||
|
const mapsController = this.mapsV2Controller
|
||||||
|
if (!mapsController) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
|
||||||
|
if (!recentPointLayer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.liveModeEnabled) {
|
||||||
|
recentPointLayer.show()
|
||||||
|
} else {
|
||||||
|
recentPointLayer.hide()
|
||||||
|
recentPointLayer.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle connection
|
||||||
|
*/
|
||||||
|
handleConnected(channelName) {
|
||||||
|
this.connectedChannels.add(channelName)
|
||||||
|
|
||||||
|
// Only show toast when at least one channel is connected
|
||||||
|
if (this.connectedChannels.size === 1) {
|
||||||
|
Toast.success('Connected to real-time updates')
|
||||||
|
this.updateConnectionIndicator(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle disconnection
|
||||||
|
*/
|
||||||
|
handleDisconnected(channelName) {
|
||||||
|
this.connectedChannels.delete(channelName)
|
||||||
|
|
||||||
|
// Show warning only when all channels are disconnected
|
||||||
|
if (this.connectedChannels.size === 0) {
|
||||||
|
Toast.warning('Disconnected from real-time updates')
|
||||||
|
this.updateConnectionIndicator(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle received data
|
||||||
|
*/
|
||||||
|
handleReceived(data) {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'new_point':
|
||||||
|
this.handleNewPoint(data.point)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'family_location':
|
||||||
|
this.handleFamilyLocation(data.member)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'notification':
|
||||||
|
this.handleNotification(data.notification)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the maps--maplibre controller (on same element)
|
||||||
|
*/
|
||||||
|
get mapsV2Controller() {
|
||||||
|
const element = this.element
|
||||||
|
const app = this.application
|
||||||
|
return app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new point
|
||||||
|
* Point data is broadcast as: [lat, lon, battery, altitude, timestamp, velocity, id, country_name]
|
||||||
|
*/
|
||||||
|
handleNewPoint(pointData) {
|
||||||
|
const mapsController = this.mapsV2Controller
|
||||||
|
if (!mapsController) {
|
||||||
|
console.warn('[Realtime Controller] Maps controller not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Realtime Controller] Received point data:', pointData)
|
||||||
|
|
||||||
|
// Parse point data from array format
|
||||||
|
const [lat, lon, battery, altitude, timestamp, velocity, id, countryName] = pointData
|
||||||
|
|
||||||
|
// Get points layer from layer manager
|
||||||
|
const pointsLayer = mapsController.layerManager?.getLayer('points')
|
||||||
|
if (!pointsLayer) {
|
||||||
|
console.warn('[Realtime Controller] Points layer not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current data
|
||||||
|
const currentData = pointsLayer.data || { type: 'FeatureCollection', features: [] }
|
||||||
|
const features = [...(currentData.features || [])]
|
||||||
|
|
||||||
|
// Add new point
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [parseFloat(lon), parseFloat(lat)]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: parseInt(id),
|
||||||
|
latitude: parseFloat(lat),
|
||||||
|
longitude: parseFloat(lon),
|
||||||
|
battery: parseFloat(battery) || null,
|
||||||
|
altitude: parseFloat(altitude) || null,
|
||||||
|
timestamp: timestamp,
|
||||||
|
velocity: parseFloat(velocity) || null,
|
||||||
|
country_name: countryName || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update layer with new data
|
||||||
|
pointsLayer.update({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Realtime Controller] Added new point to map:', id)
|
||||||
|
|
||||||
|
// Update recent point marker (always visible in live mode)
|
||||||
|
this.updateRecentPoint(parseFloat(lon), parseFloat(lat), {
|
||||||
|
id: parseInt(id),
|
||||||
|
battery: parseFloat(battery) || null,
|
||||||
|
altitude: parseFloat(altitude) || null,
|
||||||
|
timestamp: timestamp,
|
||||||
|
velocity: parseFloat(velocity) || null,
|
||||||
|
country_name: countryName || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zoom to the new point
|
||||||
|
this.zoomToPoint(parseFloat(lon), parseFloat(lat))
|
||||||
|
|
||||||
|
Toast.info('New location recorded')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle family member location update
|
||||||
|
*/
|
||||||
|
handleFamilyLocation(member) {
|
||||||
|
const mapsController = this.mapsV2Controller
|
||||||
|
if (!mapsController) return
|
||||||
|
|
||||||
|
const familyLayer = mapsController.familyLayer
|
||||||
|
if (familyLayer) {
|
||||||
|
familyLayer.updateMember(member)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle notification
|
||||||
|
*/
|
||||||
|
handleNotification(notification) {
|
||||||
|
Toast.info(notification.message || 'New notification')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the recent point marker
|
||||||
|
* This marker is always visible in live mode, independent of points layer visibility
|
||||||
|
*/
|
||||||
|
updateRecentPoint(longitude, latitude, properties = {}) {
|
||||||
|
const mapsController = this.mapsV2Controller
|
||||||
|
if (!mapsController) {
|
||||||
|
console.warn('[Realtime Controller] Maps controller not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
|
||||||
|
if (!recentPointLayer) {
|
||||||
|
console.warn('[Realtime Controller] Recent point layer not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the layer if live mode is enabled and update with new point
|
||||||
|
if (this.liveModeEnabled) {
|
||||||
|
recentPointLayer.show()
|
||||||
|
recentPointLayer.updateRecentPoint(longitude, latitude, properties)
|
||||||
|
console.log('[Realtime Controller] Updated recent point marker:', longitude, latitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom map to a specific point
|
||||||
|
*/
|
||||||
|
zoomToPoint(longitude, latitude) {
|
||||||
|
const mapsController = this.mapsV2Controller
|
||||||
|
if (!mapsController || !mapsController.map) {
|
||||||
|
console.warn('[Realtime Controller] Map not available for zooming')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = mapsController.map
|
||||||
|
|
||||||
|
// Fly to the new point with a smooth animation
|
||||||
|
map.flyTo({
|
||||||
|
center: [longitude, latitude],
|
||||||
|
zoom: Math.max(map.getZoom(), 14), // Zoom to at least level 14, or keep current zoom if higher
|
||||||
|
duration: 2000, // 2 second animation
|
||||||
|
essential: true // This animation is considered essential with respect to prefers-reduced-motion
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Realtime Controller] Zoomed to point:', longitude, latitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update connection indicator
|
||||||
|
*/
|
||||||
|
updateConnectionIndicator(connected) {
|
||||||
|
const indicator = document.querySelector('.connection-indicator')
|
||||||
|
if (indicator) {
|
||||||
|
// Show the indicator when connection is attempted
|
||||||
|
indicator.classList.add('active')
|
||||||
|
indicator.classList.toggle('connected', connected)
|
||||||
|
indicator.classList.toggle('disconnected', !connected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
app/javascript/controllers/place_creation_controller.js
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", "noteInput",
|
||||||
|
"nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton",
|
||||||
|
"modalTitle", "submitButton", "placeIdInput"]
|
||||||
|
static values = {
|
||||||
|
apiKey: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.setupEventListeners()
|
||||||
|
this.currentRadius = 0.5 // Start with 500m (0.5km)
|
||||||
|
this.maxRadius = 1.5 // Max 1500m (1.5km)
|
||||||
|
this.setupTagListeners()
|
||||||
|
this.editingPlaceId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
document.addEventListener('place:create', (e) => {
|
||||||
|
this.open(e.detail.latitude, e.detail.longitude)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('place:edit', (e) => {
|
||||||
|
this.openForEdit(e.detail.place)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setupTagListeners() {
|
||||||
|
// Listen for checkbox changes to update badge styling
|
||||||
|
if (this.hasTagCheckboxesTarget) {
|
||||||
|
this.tagCheckboxesTarget.addEventListener('change', (e) => {
|
||||||
|
if (e.target.type === 'checkbox' && e.target.name === 'tag_ids[]') {
|
||||||
|
const badge = e.target.nextElementSibling
|
||||||
|
const color = badge.dataset.color
|
||||||
|
|
||||||
|
if (e.target.checked) {
|
||||||
|
// Filled style
|
||||||
|
badge.classList.remove('badge-outline')
|
||||||
|
badge.style.backgroundColor = color
|
||||||
|
badge.style.borderColor = color
|
||||||
|
badge.style.color = 'white'
|
||||||
|
} else {
|
||||||
|
// Outline style
|
||||||
|
badge.classList.add('badge-outline')
|
||||||
|
badge.style.backgroundColor = 'transparent'
|
||||||
|
badge.style.borderColor = color
|
||||||
|
badge.style.color = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(latitude, longitude) {
|
||||||
|
this.editingPlaceId = null
|
||||||
|
this.latitudeInputTarget.value = latitude
|
||||||
|
this.longitudeInputTarget.value = longitude
|
||||||
|
this.currentRadius = 0.5 // Reset radius when opening modal
|
||||||
|
|
||||||
|
// Update modal for creation mode
|
||||||
|
if (this.hasModalTitleTarget) {
|
||||||
|
this.modalTitleTarget.textContent = 'Create New Place'
|
||||||
|
}
|
||||||
|
if (this.hasSubmitButtonTarget) {
|
||||||
|
this.submitButtonTarget.textContent = 'Create Place'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modalTarget.classList.add('modal-open')
|
||||||
|
this.nameInputTarget.focus()
|
||||||
|
|
||||||
|
await this.loadNearbyPlaces(latitude, longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
async openForEdit(place) {
|
||||||
|
this.editingPlaceId = place.id
|
||||||
|
this.currentRadius = 0.5
|
||||||
|
|
||||||
|
// Fill in form with place data
|
||||||
|
this.nameInputTarget.value = place.name
|
||||||
|
this.latitudeInputTarget.value = place.latitude
|
||||||
|
this.longitudeInputTarget.value = place.longitude
|
||||||
|
|
||||||
|
if (this.hasNoteInputTarget && place.note) {
|
||||||
|
this.noteInputTarget.value = place.note
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modal for edit mode
|
||||||
|
if (this.hasModalTitleTarget) {
|
||||||
|
this.modalTitleTarget.textContent = 'Edit Place'
|
||||||
|
}
|
||||||
|
if (this.hasSubmitButtonTarget) {
|
||||||
|
this.submitButtonTarget.textContent = 'Update Place'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the appropriate tag checkboxes
|
||||||
|
const tagCheckboxes = this.formTarget.querySelectorAll('input[name="tag_ids[]"]')
|
||||||
|
tagCheckboxes.forEach(checkbox => {
|
||||||
|
const isSelected = place.tags.some(tag => tag.id === parseInt(checkbox.value))
|
||||||
|
checkbox.checked = isSelected
|
||||||
|
|
||||||
|
// Trigger change event to update badge styling
|
||||||
|
const event = new Event('change', { bubbles: true })
|
||||||
|
checkbox.dispatchEvent(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.modalTarget.classList.add('modal-open')
|
||||||
|
this.nameInputTarget.focus()
|
||||||
|
|
||||||
|
// Load nearby places for suggestions
|
||||||
|
await this.loadNearbyPlaces(place.latitude, place.longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.modalTarget.classList.remove('modal-open')
|
||||||
|
this.formTarget.reset()
|
||||||
|
this.nearbyListTarget.innerHTML = ''
|
||||||
|
this.loadMoreContainerTarget.classList.add('hidden')
|
||||||
|
this.currentRadius = 0.5
|
||||||
|
this.editingPlaceId = null
|
||||||
|
|
||||||
|
const event = new CustomEvent('place:create:cancelled')
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNearbyPlaces(latitude, longitude, radius = null) {
|
||||||
|
this.loadingSpinnerTarget.classList.remove('hidden')
|
||||||
|
|
||||||
|
// Use provided radius or current radius
|
||||||
|
const searchRadius = radius || this.currentRadius
|
||||||
|
const isLoadingMore = radius !== null && radius > this.currentRadius - 0.5
|
||||||
|
|
||||||
|
// Only clear the list on initial load, not when loading more
|
||||||
|
if (!isLoadingMore) {
|
||||||
|
this.nearbyListTarget.innerHTML = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/places/nearby?latitude=${latitude}&longitude=${longitude}&radius=${searchRadius}&limit=5`,
|
||||||
|
{ headers: { 'Authorization': `Bearer ${this.apiKeyValue}` } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to load nearby places')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
this.renderNearbyPlaces(data.places, isLoadingMore)
|
||||||
|
|
||||||
|
// Show load more button if we can expand radius further
|
||||||
|
if (searchRadius < this.maxRadius) {
|
||||||
|
this.loadMoreContainerTarget.classList.remove('hidden')
|
||||||
|
this.updateLoadMoreButton(searchRadius)
|
||||||
|
} else {
|
||||||
|
this.loadMoreContainerTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading nearby places:', error)
|
||||||
|
this.nearbyListTarget.innerHTML = '<p class="text-error">Failed to load suggestions</p>'
|
||||||
|
} finally {
|
||||||
|
this.loadingSpinnerTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNearbyPlaces(places, append = false) {
|
||||||
|
if (!places || places.length === 0) {
|
||||||
|
if (!append) {
|
||||||
|
this.nearbyListTarget.innerHTML = '<p class="text-sm text-gray-500">No nearby places found</p>'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate starting index based on existing items
|
||||||
|
const currentCount = append ? this.nearbyListTarget.querySelectorAll('.card').length : 0
|
||||||
|
|
||||||
|
const html = places.map((place, index) => `
|
||||||
|
<div class="card card-compact bg-base-200 cursor-pointer hover:bg-base-300 transition"
|
||||||
|
data-action="click->place-creation#selectNearby"
|
||||||
|
data-place-name="${this.escapeHtml(place.name)}"
|
||||||
|
data-place-latitude="${place.latitude}"
|
||||||
|
data-place-longitude="${place.longitude}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="badge badge-primary badge-sm">#${currentCount + index + 1}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold">${this.escapeHtml(place.name)}</h4>
|
||||||
|
${place.street ? `<p class="text-sm">${this.escapeHtml(place.street)}</p>` : ''}
|
||||||
|
${place.city ? `<p class="text-xs text-gray-500">${this.escapeHtml(place.city)}, ${this.escapeHtml(place.country || '')}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
this.nearbyListTarget.insertAdjacentHTML('beforeend', html)
|
||||||
|
} else {
|
||||||
|
this.nearbyListTarget.innerHTML = html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMore() {
|
||||||
|
// Increase radius by 500m (0.5km) up to max of 1500m (1.5km)
|
||||||
|
if (this.currentRadius >= this.maxRadius) return
|
||||||
|
|
||||||
|
this.currentRadius = Math.min(this.currentRadius + 0.5, this.maxRadius)
|
||||||
|
|
||||||
|
const latitude = parseFloat(this.latitudeInputTarget.value)
|
||||||
|
const longitude = parseFloat(this.longitudeInputTarget.value)
|
||||||
|
|
||||||
|
await this.loadNearbyPlaces(latitude, longitude, this.currentRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLoadMoreButton(currentRadius) {
|
||||||
|
const nextRadius = Math.min(currentRadius + 0.5, this.maxRadius)
|
||||||
|
const radiusInMeters = Math.round(nextRadius * 1000)
|
||||||
|
this.loadMoreButtonTarget.textContent = `Load More (search up to ${radiusInMeters}m)`
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNearby(event) {
|
||||||
|
const element = event.currentTarget
|
||||||
|
this.nameInputTarget.value = element.dataset.placeName
|
||||||
|
this.latitudeInputTarget.value = element.dataset.placeLatitude
|
||||||
|
this.longitudeInputTarget.value = element.dataset.placeLongitude
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const formData = new FormData(this.formTarget)
|
||||||
|
const tagIds = Array.from(this.formTarget.querySelectorAll('input[name="tag_ids[]"]:checked'))
|
||||||
|
.map(cb => cb.value)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
place: {
|
||||||
|
name: formData.get('name'),
|
||||||
|
latitude: parseFloat(formData.get('latitude')),
|
||||||
|
longitude: parseFloat(formData.get('longitude')),
|
||||||
|
note: formData.get('note') || null,
|
||||||
|
source: 'manual',
|
||||||
|
tag_ids: tagIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isEdit = this.editingPlaceId !== null
|
||||||
|
const url = isEdit ? `/api/v1/places/${this.editingPlaceId}` : '/api/v1/places'
|
||||||
|
const method = isEdit ? 'PATCH' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKeyValue}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.errors?.join(', ') || `Failed to ${isEdit ? 'update' : 'create'} place`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = await response.json()
|
||||||
|
|
||||||
|
this.close()
|
||||||
|
this.showNotification(`Place ${isEdit ? 'updated' : 'created'} successfully!`, 'success')
|
||||||
|
|
||||||
|
const eventName = isEdit ? 'place:updated' : 'place:created'
|
||||||
|
const customEvent = new CustomEvent(eventName, { detail: { place } })
|
||||||
|
document.dispatchEvent(customEvent)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error ${this.editingPlaceId ? 'updating' : 'creating'} place:`, error)
|
||||||
|
this.showNotification(error.message, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
const event = new CustomEvent('notification:show', {
|
||||||
|
detail: { message, type },
|
||||||
|
bubbles: true
|
||||||
|
})
|
||||||
|
document.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return ''
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = text
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/javascript/controllers/places_filter_controller.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
console.log("Places filter controller connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
filterPlaces(event) {
|
||||||
|
// Get reference to the maps controller's placesManager
|
||||||
|
const mapsController = window.mapsController;
|
||||||
|
if (!mapsController || !mapsController.placesManager) {
|
||||||
|
console.warn("Maps controller or placesManager not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all checked tag IDs
|
||||||
|
const checkboxes = this.element.querySelectorAll('input[type="checkbox"][data-tag-id]');
|
||||||
|
const selectedTagIds = Array.from(checkboxes)
|
||||||
|
.filter(cb => cb.checked)
|
||||||
|
.map(cb => parseInt(cb.dataset.tagId));
|
||||||
|
|
||||||
|
console.log("Filtering places by tags:", selectedTagIds);
|
||||||
|
|
||||||
|
// Filter places by selected tags (or show all if none selected)
|
||||||
|
mapsController.placesManager.filterByTags(selectedTagIds.length > 0 ? selectedTagIds : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Uncheck all checkboxes
|
||||||
|
const checkboxes = this.element.querySelectorAll('input[type="checkbox"][data-tag-id]');
|
||||||
|
checkboxes.forEach(cb => cb.checked = false);
|
||||||
|
|
||||||
|
// Show all places
|
||||||
|
const mapsController = window.mapsController;
|
||||||
|
if (mapsController && mapsController.placesManager) {
|
||||||
|
mapsController.placesManager.filterByTags(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/javascript/controllers/privacy_radius_controller.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["toggle", "radiusInput", "slider", "field", "label"]
|
||||||
|
|
||||||
|
toggleRadius(event) {
|
||||||
|
if (event.target.checked) {
|
||||||
|
// Enable privacy zone
|
||||||
|
this.radiusInputTarget.classList.remove('hidden')
|
||||||
|
|
||||||
|
// Set default value if not already set
|
||||||
|
if (!this.fieldTarget.value || this.fieldTarget.value === '') {
|
||||||
|
const defaultValue = 1000
|
||||||
|
this.fieldTarget.value = defaultValue
|
||||||
|
this.sliderTarget.value = defaultValue
|
||||||
|
this.labelTarget.textContent = `${defaultValue}m`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Disable privacy zone
|
||||||
|
this.radiusInputTarget.classList.add('hidden')
|
||||||
|
this.fieldTarget.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromSlider(event) {
|
||||||
|
const value = event.target.value
|
||||||
|
this.fieldTarget.value = value
|
||||||
|
this.labelTarget.textContent = `${value}m`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,8 @@ export default class extends BaseController {
|
||||||
uuid: String,
|
uuid: String,
|
||||||
dataBounds: Object,
|
dataBounds: Object,
|
||||||
hexagonsAvailable: Boolean,
|
hexagonsAvailable: Boolean,
|
||||||
selfHosted: String
|
selfHosted: String,
|
||||||
|
timezone: String
|
||||||
};
|
};
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
|
@ -72,9 +73,7 @@ export default class extends BaseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadHexagons() {
|
async loadHexagons() {
|
||||||
console.log('🎯 loadHexagons started - checking overlay state');
|
|
||||||
const initialLoadingElement = document.getElementById('map-loading');
|
const initialLoadingElement = document.getElementById('map-loading');
|
||||||
console.log('📊 Initial overlay display:', initialLoadingElement?.style.display || 'default');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use server-provided data bounds
|
// Use server-provided data bounds
|
||||||
|
|
@ -94,9 +93,6 @@ export default class extends BaseController {
|
||||||
// Fallback timeout in case moveend doesn't fire
|
// Fallback timeout in case moveend doesn't fire
|
||||||
setTimeout(resolve, 1000);
|
setTimeout(resolve, 1000);
|
||||||
});
|
});
|
||||||
console.log('✅ Map fitBounds complete - checking overlay state');
|
|
||||||
const afterFitBoundsElement = document.getElementById('map-loading');
|
|
||||||
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load hexagons only if they are pre-calculated and data exists
|
// Load hexagons only if they are pre-calculated and data exists
|
||||||
|
|
@ -138,7 +134,6 @@ export default class extends BaseController {
|
||||||
loadingElement.style.display = 'flex';
|
loadingElement.style.display = 'flex';
|
||||||
loadingElement.style.visibility = 'visible';
|
loadingElement.style.visibility = 'visible';
|
||||||
loadingElement.style.zIndex = '9999';
|
loadingElement.style.zIndex = '9999';
|
||||||
console.log('👁️ Loading overlay ENSURED visible - should be visible now');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable map interaction during loading
|
// Disable map interaction during loading
|
||||||
|
|
@ -187,7 +182,6 @@ export default class extends BaseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const geojsonData = await response.json();
|
const geojsonData = await response.json();
|
||||||
console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`);
|
|
||||||
|
|
||||||
// Add hexagons directly to map as a static layer
|
// Add hexagons directly to map as a static layer
|
||||||
if (geojsonData.features && geojsonData.features.length > 0) {
|
if (geojsonData.features && geojsonData.features.length > 0) {
|
||||||
|
|
@ -210,7 +204,6 @@ export default class extends BaseController {
|
||||||
const loadingElement = document.getElementById('map-loading');
|
const loadingElement = document.getElementById('map-loading');
|
||||||
if (loadingElement) {
|
if (loadingElement) {
|
||||||
loadingElement.style.display = 'none';
|
loadingElement.style.display = 'none';
|
||||||
console.log('🚫 Loading overlay hidden - hexagons are fully loaded');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,10 +248,11 @@ export default class extends BaseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildPopupContent(props) {
|
buildPopupContent(props) {
|
||||||
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
|
const timezone = this.timezoneValue || 'UTC';
|
||||||
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
|
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A';
|
||||||
const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString() : '';
|
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A';
|
||||||
const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString() : '';
|
const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : '';
|
||||||
|
const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-size: 12px; line-height: 1.6; max-width: 300px;">
|
<div style="font-size: 12px; line-height: 1.6; max-width: 300px;">
|
||||||
|
|
|
||||||
184
app/javascript/controllers/speed_color_editor_controller.js
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speed Color Editor Controller
|
||||||
|
* Manages the gradient editor modal for speed-colored routes
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['modal', 'stopsList', 'preview']
|
||||||
|
static values = {
|
||||||
|
colorStops: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.loadColorStops()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadColorStops() {
|
||||||
|
const stopsString = this.colorStopsValue || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||||
|
this.stops = this.parseColorStops(stopsString)
|
||||||
|
this.renderStops()
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
parseColorStops(stopsString) {
|
||||||
|
return stopsString.split('|').map(segment => {
|
||||||
|
const [speed, color] = segment.split(':')
|
||||||
|
return { speed: Number(speed), color }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
serializeColorStops() {
|
||||||
|
return this.stops.map(stop => `${stop.speed}:${stop.color}`).join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStops() {
|
||||||
|
if (!this.hasStopsListTarget) return
|
||||||
|
|
||||||
|
this.stopsListTarget.innerHTML = this.stops.map((stop, index) => `
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg" data-index="${index}">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-sm">Speed (km/h)</span>
|
||||||
|
</label>
|
||||||
|
<input type="number"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
value="${stop.speed}"
|
||||||
|
min="0"
|
||||||
|
max="200"
|
||||||
|
data-action="input->speed-color-editor#updateSpeed"
|
||||||
|
data-index="${index}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-sm">Color</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input type="color"
|
||||||
|
class="w-12 h-10 rounded cursor-pointer border-2 border-base-300"
|
||||||
|
value="${stop.color}"
|
||||||
|
data-action="input->speed-color-editor#updateColor"
|
||||||
|
data-index="${index}" />
|
||||||
|
<input type="text"
|
||||||
|
class="input input-bordered input-sm w-24 font-mono text-xs"
|
||||||
|
value="${stop.color}"
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
data-action="input->speed-color-editor#updateColorText"
|
||||||
|
data-index="${index}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-ghost btn-circle text-error mt-6"
|
||||||
|
data-action="click->speed-color-editor#removeStop"
|
||||||
|
data-index="${index}"
|
||||||
|
${this.stops.length <= 2 ? 'disabled' : ''}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSpeed(event) {
|
||||||
|
const index = parseInt(event.target.dataset.index)
|
||||||
|
this.stops[index].speed = Number(event.target.value)
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColor(event) {
|
||||||
|
const index = parseInt(event.target.dataset.index)
|
||||||
|
const color = event.target.value
|
||||||
|
this.stops[index].color = color
|
||||||
|
|
||||||
|
// Update text input
|
||||||
|
const textInput = event.target.parentElement.querySelector('input[type="text"]')
|
||||||
|
if (textInput) {
|
||||||
|
textInput.value = color
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColorText(event) {
|
||||||
|
const index = parseInt(event.target.dataset.index)
|
||||||
|
const color = event.target.value
|
||||||
|
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||||
|
this.stops[index].color = color
|
||||||
|
|
||||||
|
// Update color picker
|
||||||
|
const colorInput = event.target.parentElement.querySelector('input[type="color"]')
|
||||||
|
if (colorInput) {
|
||||||
|
colorInput.value = color
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addStop() {
|
||||||
|
// Find a good speed value between existing stops
|
||||||
|
const lastStop = this.stops[this.stops.length - 1]
|
||||||
|
const newSpeed = lastStop.speed + 10
|
||||||
|
|
||||||
|
this.stops.push({
|
||||||
|
speed: newSpeed,
|
||||||
|
color: '#ff0000'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by speed
|
||||||
|
this.stops.sort((a, b) => a.speed - b.speed)
|
||||||
|
|
||||||
|
this.renderStops()
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
removeStop(event) {
|
||||||
|
const index = parseInt(event.target.dataset.index)
|
||||||
|
|
||||||
|
if (this.stops.length > 2) {
|
||||||
|
this.stops.splice(index, 1)
|
||||||
|
this.renderStops()
|
||||||
|
this.updatePreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreview() {
|
||||||
|
if (!this.hasPreviewTarget) return
|
||||||
|
|
||||||
|
const gradient = this.stops.map((stop, index) => {
|
||||||
|
const percentage = (index / (this.stops.length - 1)) * 100
|
||||||
|
return `${stop.color} ${percentage}%`
|
||||||
|
}).join(', ')
|
||||||
|
|
||||||
|
this.previewTarget.style.background = `linear-gradient(to right, ${gradient})`
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
const serialized = this.serializeColorStops()
|
||||||
|
|
||||||
|
// Dispatch event with the new color stops
|
||||||
|
this.dispatch('save', {
|
||||||
|
detail: { colorStops: serialized }
|
||||||
|
})
|
||||||
|
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.hasModalTarget) {
|
||||||
|
const checkbox = this.modalTarget.querySelector('.modal-toggle')
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToDefault() {
|
||||||
|
this.colorStopsValue = '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||||
|
this.loadColorStops()
|
||||||
|
}
|
||||||
|
}
|
||||||