Compare commits
242 commits
| 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 | ||
|
|
07216e00dd | ||
|
|
18551fb940 | ||
|
|
1e63b03b49 | ||
|
|
1bf02bc063 | ||
|
|
b2f831c9fa | ||
|
|
801e0c9bfa | ||
|
|
04a9d4b418 | ||
|
|
632f389ace | ||
|
|
e7884b1f4f | ||
|
|
2accbeef3d | ||
|
|
b413c51c4f | ||
|
|
84f8ec0d04 | ||
|
|
cdd5525ff4 | ||
|
|
1671a781b0 | ||
|
|
d46cd2dc74 | ||
|
|
e3c6da1332 | ||
|
|
44cbfff8ff | ||
|
|
36289d2469 | ||
|
|
79a2140e6f | ||
|
|
6da1019bf3 | ||
|
|
e72b2d9182 | ||
|
|
4d5088a014 | ||
|
|
9953c2fb88 | ||
|
|
b8ad1a8a5c | ||
|
|
b1dd654463 | ||
|
|
732839d586 | ||
|
|
b4fbe6dbda | ||
|
|
ce33cf3fb6 | ||
|
|
ea340df343 | ||
|
|
ff70773ea8 | ||
|
|
39c3c157c8 | ||
|
|
0ee3deedd8 | ||
|
|
aff44d6669 | ||
|
|
99281317d7 | ||
|
|
a93cb8ff41 | ||
|
|
29ae5c04f1 | ||
|
|
923ea113c8 | ||
|
|
62725a55e7 | ||
|
|
e711ff25fe | ||
|
|
6fb5d98b19 | ||
|
|
018760812a | ||
|
|
c1cff10de3 | ||
|
|
0b9a1005e5 | ||
|
|
6057240888 | ||
|
|
cd303bce01 | ||
|
|
e7df54d738 | ||
|
|
54661a1d52 | ||
|
|
9bc0e2accc | ||
|
|
cfe319df9b | ||
|
|
f898f3aab0 | ||
|
|
8389cd85a3 | ||
|
|
0a61f9bf68 | ||
|
|
c6fc4328d7 | ||
|
|
2a85735aee | ||
|
|
a3b5210b41 | ||
|
|
78693f3001 | ||
|
|
db8d886ee2 | ||
|
|
dd2e6a49bc | ||
|
|
698198db4b | ||
|
|
240d90cea1 | ||
|
|
480142b494 | ||
|
|
f6b32371ec | ||
|
|
fa3d926a92 | ||
|
|
f30b4bcafd | ||
|
|
2eb374676a | ||
|
|
5252388b8c | ||
|
|
1f67e889e3 | ||
|
|
e17f732706 | ||
|
|
2af0147505 | ||
|
|
f817e3513c | ||
|
|
f0f0f20200 | ||
|
|
cc5da3e7e2 | ||
|
|
40fff59ec6 | ||
|
|
976a4cf361 | ||
|
|
0d02f08199 | ||
|
|
4a704ed608 | ||
|
|
86a76db2c0 | ||
|
|
c3c997be02 |
|
|
@ -1 +1 @@
|
|||
0.33.1
|
||||
0.37.2
|
||||
|
|
|
|||
|
|
@ -4,3 +4,6 @@ DATABASE_PASSWORD=password
|
|||
DATABASE_NAME=dawarich_development
|
||||
DATABASE_PORT=5432
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Fix for macOS fork() issues with Sidekiq
|
||||
OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
|
||||
|
|
|
|||
14
.github/workflows/build_and_push.yml
vendored
|
|
@ -74,18 +74,6 @@ jobs:
|
|||
# Set platforms based on version type and release type
|
||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
|
||||
|
||||
# Check if this is a patch version (x.y.z where z > 0)
|
||||
if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then
|
||||
echo "Detected patch version ($VERSION) - building for AMD64 only"
|
||||
PLATFORMS="linux/amd64"
|
||||
elif [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
|
||||
echo "Detected minor version ($VERSION) - building for all platforms"
|
||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
|
||||
else
|
||||
echo "Version format not recognized or non-semver - using AMD64 only for safety"
|
||||
PLATFORMS="linux/amd64"
|
||||
fi
|
||||
|
||||
# Add :rc tag for pre-releases
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
TAGS="${TAGS},freikin/dawarich:rc"
|
||||
|
|
@ -108,7 +96,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.dev
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
|
|
|||
1
.gitignore
vendored
|
|
@ -84,3 +84,4 @@ node_modules/
|
|||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/e2e/temp/
|
||||
|
|
|
|||
221
CHANGELOG.md
|
|
@ -4,7 +4,226 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [0.33.1]
|
||||
# [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
|
||||
|
||||
## The Family release
|
||||
|
||||
In this release we're introducing family features that allow users to create family groups, invite members, and share location data. Family owners can manage members, control sharing settings, and ensure secure access to shared information. Location sharing is optional and can be enabled or disabled by each member individually. Users can join only one family at a time. Location sharing settings can be set to share location for 1, 6, 12, 24 hours or permanently. Family features are now available only for self-hosted instances and will be available in the cloud in the future. When "Family members" layer is enabled on the map, family member markers will be updated in real-time.
|
||||
|
||||
## Added
|
||||
|
||||
- Users can now create family groups and invite members to join.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Sign out button works again. #1844
|
||||
- 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
|
||||
- All confirmation dialogs are now showing only once.
|
||||
|
||||
## Changed
|
||||
|
||||
- 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.
|
||||
- The Map page now features a full-screen map.
|
||||
|
||||
|
||||
# [0.33.1] - 2025-10-07
|
||||
|
||||
## Changed
|
||||
|
||||
|
|
|
|||
35
Gemfile
|
|
@ -5,15 +5,17 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
|||
|
||||
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
|
||||
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
||||
gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
||||
gem 'aws-sdk-s3', '~> 1.177.0', require: false
|
||||
gem 'bootsnap', require: false
|
||||
gem 'chartkick'
|
||||
gem 'connection_pool', '< 3' # Pin to 2.x - version 3.0+ has breaking API changes with Rails RedisCacheStore
|
||||
gem 'data_migrate'
|
||||
gem 'devise'
|
||||
gem 'foreman'
|
||||
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
|
||||
gem 'gpx'
|
||||
gem 'groupdate'
|
||||
|
|
@ -24,34 +26,39 @@ gem 'jwt', '~> 2.8'
|
|||
gem 'kaminari'
|
||||
gem 'lograge'
|
||||
gem 'oj'
|
||||
gem 'omniauth-github', '~> 2.0.0'
|
||||
gem 'omniauth-google-oauth2'
|
||||
gem 'omniauth_openid_connect'
|
||||
gem 'omniauth-rails_csrf_protection'
|
||||
gem 'parallel'
|
||||
gem 'pg'
|
||||
gem 'prometheus_exporter'
|
||||
gem 'puma'
|
||||
gem 'pundit'
|
||||
gem 'pundit', '>= 2.5.1'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'rails_icons'
|
||||
gem 'rails_pulse'
|
||||
gem 'redis'
|
||||
gem 'rexml'
|
||||
gem 'rgeo'
|
||||
gem 'rgeo-activerecord'
|
||||
gem 'rgeo-activerecord', '~> 8.0.0'
|
||||
gem 'rgeo-geojson'
|
||||
gem 'rqrcode', '~> 3.0'
|
||||
gem 'rswag-api'
|
||||
gem 'rswag-ui'
|
||||
gem 'rubyzip', '~> 3.1'
|
||||
gem 'sentry-rails'
|
||||
gem 'rubyzip', '~> 3.2'
|
||||
gem 'sentry-rails', '>= 5.27.0'
|
||||
gem 'sentry-ruby'
|
||||
gem 'sidekiq'
|
||||
gem 'sidekiq-cron'
|
||||
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', '>= 2.3.1'
|
||||
gem 'sidekiq-limit_fetch'
|
||||
gem 'sprockets-rails'
|
||||
gem 'stackprof'
|
||||
gem 'stimulus-rails'
|
||||
gem 'strong_migrations'
|
||||
gem 'tailwindcss-rails'
|
||||
gem 'turbo-rails'
|
||||
gem 'tailwindcss-rails', '= 3.3.2'
|
||||
gem 'turbo-rails', '>= 2.0.17'
|
||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||
gem 'with_advisory_lock'
|
||||
|
||||
group :development, :test, :staging do
|
||||
gem 'brakeman', require: false
|
||||
|
|
@ -62,7 +69,7 @@ group :development, :test, :staging do
|
|||
gem 'ffaker'
|
||||
gem 'pry-byebug'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec-rails'
|
||||
gem 'rspec-rails', '>= 8.0.1'
|
||||
gem 'rswag-specs'
|
||||
end
|
||||
|
||||
|
|
@ -77,7 +84,7 @@ group :test do
|
|||
end
|
||||
|
||||
group :development do
|
||||
gem 'database_consistency', require: false
|
||||
gem 'foreman'
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'database_consistency', '>= 2.0.5', require: false
|
||||
gem 'rubocop-rails', '>= 2.33.4', require: false
|
||||
gem 'strong_migrations', '>= 2.4.0'
|
||||
end
|
||||
|
|
|
|||
415
Gemfile.lock
|
|
@ -10,29 +10,29 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actioncable (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionmailbox (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionmailer (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionpack (8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
|
@ -40,38 +40,38 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actiontext (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
actionview (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activejob (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activerecord (8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activemodel (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
activerecord (8.0.3)
|
||||
activemodel (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-postgis-adapter (11.0.0)
|
||||
activerecord (~> 8.0.0)
|
||||
rgeo-activerecord (~> 8.0.0)
|
||||
activestorage (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activestorage (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2.1)
|
||||
activesupport (8.0.3)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
|
|
@ -86,8 +86,10 @@ GEM
|
|||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
ast (2.4.3)
|
||||
attr_extras (7.1.0)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1072.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
|
|
@ -106,11 +108,12 @@ GEM
|
|||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.3)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bindata (2.5.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.1)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.2)
|
||||
|
|
@ -126,25 +129,26 @@ GEM
|
|||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chartkick (5.2.0)
|
||||
chartkick (5.2.1)
|
||||
chunky_png (1.4.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
crack (1.0.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (2.5.5)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
css-zero (1.1.15)
|
||||
csv (3.3.4)
|
||||
data_migrate (11.3.0)
|
||||
data_migrate (11.3.1)
|
||||
activerecord (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
database_consistency (2.0.4)
|
||||
database_consistency (2.0.6)
|
||||
activerecord (>= 3.2)
|
||||
date (3.4.1)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
|
|
@ -161,9 +165,11 @@ GEM
|
|||
dotenv (= 3.1.8)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.3)
|
||||
erb (5.0.2)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
factory_bot (6.5.5)
|
||||
activesupport (>= 6.1.0)
|
||||
|
|
@ -171,6 +177,14 @@ GEM
|
|||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
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)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
|
|
@ -180,10 +194,10 @@ GEM
|
|||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
gpx (1.2.1)
|
||||
csv
|
||||
|
|
@ -195,24 +209,32 @@ GEM
|
|||
ffi (~> 1.9)
|
||||
rgeo-geojson (~> 2.1)
|
||||
zeitwerk (~> 2.5)
|
||||
hashdiff (1.1.2)
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.0.0)
|
||||
httparty (0.23.1)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.2.2)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
irb (1.15.3)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.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)
|
||||
addressable (~> 2.8)
|
||||
jwt (2.10.1)
|
||||
|
|
@ -240,22 +262,26 @@ GEM
|
|||
loofah (2.24.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.2)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.7.3)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
net-imap (0.5.9)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
|
@ -265,27 +291,73 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.9)
|
||||
nokogiri (1.18.10)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-aarch64-linux-gnu)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-arm-linux-gnu)
|
||||
nokogiri (1.18.10-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-arm64-darwin)
|
||||
nokogiri (1.18.10-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-darwin)
|
||||
nokogiri (1.18.10-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||
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)
|
||||
bigdecimal (>= 3.0)
|
||||
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)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
pagy (43.2.2)
|
||||
json
|
||||
yaml
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
parser (3.3.10.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
patience_diff (1.2.0)
|
||||
|
|
@ -295,10 +367,10 @@ GEM
|
|||
pg (1.6.2-arm64-darwin)
|
||||
pg (1.6.2-x86_64-darwin)
|
||||
pg (1.6.2-x86_64-linux)
|
||||
pp (0.6.2)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.5.1)
|
||||
prism (1.7.0)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
pry (0.15.2)
|
||||
|
|
@ -312,14 +384,25 @@ GEM
|
|||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.1)
|
||||
public_suffix (6.0.2)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
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)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
|
|
@ -327,20 +410,20 @@ GEM
|
|||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2.1)
|
||||
actioncable (= 8.0.2.1)
|
||||
actionmailbox (= 8.0.2.1)
|
||||
actionmailer (= 8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actiontext (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
rails (8.0.3)
|
||||
actioncable (= 8.0.3)
|
||||
actionmailbox (= 8.0.3)
|
||||
actionmailer (= 8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
actiontext (= 8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activemodel (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2.1)
|
||||
railties (= 8.0.3)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -351,25 +434,39 @@ GEM
|
|||
rails_icons (1.4.0)
|
||||
nokogiri (~> 1.16, >= 1.16.4)
|
||||
rails (> 6.1)
|
||||
railties (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
rails_pulse (0.2.4)
|
||||
css-zero (~> 1.1, >= 1.1.4)
|
||||
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)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdoc (6.14.2)
|
||||
rake (13.3.1)
|
||||
ransack (4.4.1)
|
||||
activerecord (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
i18n
|
||||
rdoc (6.16.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redis (5.4.0)
|
||||
tsort
|
||||
redis (5.4.1)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
redis-client (0.26.2)
|
||||
connection_pool
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.2)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
|
|
@ -390,13 +487,13 @@ GEM
|
|||
rqrcode_core (2.0.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.4)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.4)
|
||||
rspec-mocks (3.13.6)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.0)
|
||||
rspec-rails (8.0.2)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
|
|
@ -405,18 +502,18 @@ GEM
|
|||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.3)
|
||||
rswag-api (2.16.0)
|
||||
activesupport (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rswag-specs (2.16.0)
|
||||
activesupport (>= 5.2, < 8.1)
|
||||
json-schema (>= 2.2, < 6.0)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rswag-api (2.17.0)
|
||||
activesupport (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rswag-specs (2.17.0)
|
||||
activesupport (>= 5.2, < 8.2)
|
||||
json-schema (>= 2.2, < 7.0)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rspec-core (>= 2.14)
|
||||
rswag-ui (2.16.0)
|
||||
actionpack (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rubocop (1.80.2)
|
||||
rswag-ui (2.17.0)
|
||||
actionpack (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rubocop (1.82.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -424,20 +521,20 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.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)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.46.0)
|
||||
rubocop-ast (1.49.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-rails (2.33.3)
|
||||
prism (~> 1.7)
|
||||
rubocop-rails (2.34.2)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (3.1.0)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.35.0)
|
||||
base64 (~> 0.2)
|
||||
|
|
@ -445,21 +542,21 @@ GEM
|
|||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.26.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.26.0)
|
||||
sentry-ruby (5.26.0)
|
||||
sentry-rails (6.2.0)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 6.2.0)
|
||||
sentry-ruby (6.2.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (8.0.4)
|
||||
sidekiq (8.0.10)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
rack (>= 3.1.0)
|
||||
redis-client (>= 0.23.2)
|
||||
sidekiq-cron (2.3.0)
|
||||
sidekiq-cron (2.3.1)
|
||||
cronex (>= 0.13.0)
|
||||
fugit (~> 1.8, >= 1.11.1)
|
||||
globalid (>= 1.0.1)
|
||||
|
|
@ -472,6 +569,9 @@ GEM
|
|||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
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)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (>= 2.2.4, < 4)
|
||||
|
|
@ -482,14 +582,19 @@ GEM
|
|||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.3.0)
|
||||
activerecord (>= 7)
|
||||
super_diff (0.16.0)
|
||||
stringio (3.1.8)
|
||||
strong_migrations (2.5.1)
|
||||
activerecord (>= 7.1)
|
||||
super_diff (0.17.0)
|
||||
attr_extras (>= 6.2.4)
|
||||
diff-lcs
|
||||
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)
|
||||
tailwindcss-ruby (~> 3.0)
|
||||
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-linux)
|
||||
thor (1.4.0)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.16)
|
||||
timeout (0.4.4)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.20)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
|
|
@ -508,12 +614,20 @@ GEM
|
|||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.3)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
public_suffix
|
||||
version_gem (1.1.9)
|
||||
warden (1.2.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)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
|
|
@ -523,8 +637,12 @@ GEM
|
|||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
with_advisory_lock (7.0.2)
|
||||
activerecord (>= 7.2)
|
||||
zeitwerk (>= 2.7)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
|
|
@ -536,7 +654,7 @@ PLATFORMS
|
|||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
activerecord-postgis-adapter
|
||||
activerecord-postgis-adapter (= 11.0)
|
||||
aws-sdk-core (~> 3.215.1)
|
||||
aws-sdk-kms (~> 1.96.0)
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
|
|
@ -545,8 +663,9 @@ DEPENDENCIES
|
|||
bundler-audit
|
||||
capybara
|
||||
chartkick
|
||||
connection_pool (< 3)
|
||||
data_migrate
|
||||
database_consistency
|
||||
database_consistency (>= 2.0.5)
|
||||
debug
|
||||
devise
|
||||
dotenv-rails
|
||||
|
|
@ -564,44 +683,50 @@ DEPENDENCIES
|
|||
kaminari
|
||||
lograge
|
||||
oj
|
||||
omniauth-github (~> 2.0.0)
|
||||
omniauth-google-oauth2
|
||||
omniauth-rails_csrf_protection
|
||||
omniauth_openid_connect
|
||||
parallel
|
||||
pg
|
||||
prometheus_exporter
|
||||
pry-byebug
|
||||
pry-rails
|
||||
puma
|
||||
pundit
|
||||
pundit (>= 2.5.1)
|
||||
rails (~> 8.0)
|
||||
rails_icons
|
||||
rails_pulse
|
||||
redis
|
||||
rexml
|
||||
rgeo
|
||||
rgeo-activerecord
|
||||
rgeo-activerecord (~> 8.0.0)
|
||||
rgeo-geojson
|
||||
rqrcode (~> 3.0)
|
||||
rspec-rails
|
||||
rspec-rails (>= 8.0.1)
|
||||
rswag-api
|
||||
rswag-specs
|
||||
rswag-ui
|
||||
rubocop-rails
|
||||
rubyzip (~> 3.1)
|
||||
rubocop-rails (>= 2.33.4)
|
||||
rubyzip (~> 3.2)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-rails (>= 5.27.0)
|
||||
sentry-ruby
|
||||
shoulda-matchers
|
||||
sidekiq
|
||||
sidekiq-cron
|
||||
sidekiq (= 8.0.10)
|
||||
sidekiq-cron (>= 2.3.1)
|
||||
sidekiq-limit_fetch
|
||||
simplecov
|
||||
sprockets-rails
|
||||
stackprof
|
||||
stimulus-rails
|
||||
strong_migrations
|
||||
strong_migrations (>= 2.4.0)
|
||||
super_diff
|
||||
tailwindcss-rails
|
||||
turbo-rails
|
||||
tailwindcss-rails (= 3.3.2)
|
||||
turbo-rails (>= 2.0.17)
|
||||
tzinfo-data
|
||||
webmock
|
||||
with_advisory_lock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.6p54
|
||||
|
|
|
|||
1
Procfile
|
|
@ -1,2 +1,3 @@
|
|||
release: bundle exec rails db:migrate
|
||||
web: bundle exec puma -C config/puma.rb
|
||||
worker: bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
|
|
|||
26
README.md
|
|
@ -2,20 +2,21 @@
|
|||
|
||||
[](https://discord.gg/pHsBjpt5J8) | [](https://ko-fi.com/H2H3IDYDD) | [](https://www.patreon.com/freika)
|
||||
|
||||
[](https://app.circleci.com/pipelines/github/Freika/dawarich)
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||

|
||||

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

|
||||

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

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

|
||||
*Imports page*
|
||||

|
||||
*Trips page*
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,6 +29,9 @@ It enables you to:
|
|||
|
||||
- Track your location history.
|
||||
- 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
|
||||
- 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.
|
||||
2. Run the following command to start the app:
|
||||
```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`.
|
||||
|
||||
⏹️ **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
|
||||
|
|
@ -99,6 +105,11 @@ Feel free to change them in the account settings.
|
|||
- Lines between points
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
### ✈️ 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
|
||||
|
|
|
|||
5
app.json
|
|
@ -5,11 +5,6 @@
|
|||
{ "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" },
|
||||
{ "url": "https://github.com/heroku/heroku-buildpack-ruby.git" }
|
||||
],
|
||||
"scripts": {
|
||||
"dokku": {
|
||||
"predeploy": "bundle exec rails db:migrate"
|
||||
}
|
||||
},
|
||||
"healthchecks": {
|
||||
"web": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,9 +27,13 @@
|
|||
/* Style for the settings panel */
|
||||
.leaflet-settings-panel {
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
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 {
|
||||
|
|
@ -101,3 +105,63 @@
|
|||
content: '✅';
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Flash message animations */
|
||||
@keyframes slideInFromRight {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToRight {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Family feature specific styles */
|
||||
.family-member-card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.family-member-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.invitation-card {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.family-invitation-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
|
||||
/* Leaflet Panel Styles */
|
||||
.leaflet-right-panel {
|
||||
margin-top: 80px; /* Give space for controls above */
|
||||
margin-top: 80px;
|
||||
/* Give space for controls above */
|
||||
margin-right: 10px;
|
||||
transform: none;
|
||||
transition: right 0.3s ease-in-out;
|
||||
|
|
@ -52,10 +53,12 @@
|
|||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
|
|
@ -76,33 +79,51 @@
|
|||
/* Drawer Panel Styles */
|
||||
.leaflet-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 338px;
|
||||
height: 100%;
|
||||
top: 10px;
|
||||
right: 70px;
|
||||
/* Position to the left of the control buttons with margin */
|
||||
width: 24rem;
|
||||
max-height: calc(100% - 20px);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
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 {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Controls transition */
|
||||
/* Controls remain in place - no transition needed */
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-button,
|
||||
.toggle-panel-button {
|
||||
transition: right 0.3s ease-in-out;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.controls-shifted {
|
||||
right: 338px !important;
|
||||
}
|
||||
|
||||
/* Selection Tool Styles */
|
||||
.leaflet-control-custom {
|
||||
background-color: white;
|
||||
|
|
@ -127,6 +148,61 @@
|
|||
|
||||
/* Cancel Selection Button */
|
||||
#cancel-selection-button {
|
||||
margin-bottom: 1rem;
|
||||
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;
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
color: var(--leaflet-text-color) !important;
|
||||
border-color: var(--leaflet-border-color) !important;
|
||||
box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important;
|
||||
|
||||
}
|
||||
|
||||
/* Leaflet zoom buttons */
|
||||
|
|
@ -48,18 +49,153 @@
|
|||
}
|
||||
|
||||
/* 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;
|
||||
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 */
|
||||
background-image: none !important;
|
||||
display: flex !important;
|
||||
align-items: 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 {
|
||||
content: '' !important;
|
||||
display: block !important;
|
||||
width: 24px !important;
|
||||
height: 24px !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="currentColor" 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-size: contain !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: center !important;
|
||||
}
|
||||
|
||||
/* Dark theme - use white stroke for the icon */
|
||||
[data-theme="dark"] .leaflet-control-layers-toggle::before {
|
||||
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="white" 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;
|
||||
}
|
||||
|
||||
/* Light theme - use black stroke for the icon */
|
||||
[data-theme="light"] .leaflet-control-layers-toggle::before {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Layer list styling */
|
||||
.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;
|
||||
}
|
||||
|
||||
.leaflet-control-layers label {
|
||||
color: var(--leaflet-text-color) !important;
|
||||
.leaflet-control-layers label:hover {
|
||||
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 */
|
||||
|
|
@ -139,3 +275,73 @@
|
|||
border-radius: 3px !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
/* Family member tooltip - dark styled like the visit popup */
|
||||
.leaflet-tooltip.family-member-tooltip {
|
||||
background-color: #374151 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #4b5563 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 11px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.leaflet-tooltip.family-member-tooltip::before {
|
||||
border-top-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Family member popup - just override colors, keep default layout */
|
||||
.leaflet-popup-content-wrapper:has(.family-member-popup) {
|
||||
background-color: #1f2937 !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper:has(.family-member-popup)+.leaflet-popup-tip {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Family member marker pulse animation for recent updates */
|
||||
@keyframes family-marker-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.family-member-marker-recent {
|
||||
animation: family-marker-pulse 2s infinite;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.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);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Fix bottom controls being cut off */
|
||||
.leaflet-bottom {
|
||||
padding-bottom: 10px !important;
|
||||
transition: padding-bottom 0.3s ease;
|
||||
}
|
||||
|
||||
.leaflet-bottom.leaflet-left {
|
||||
padding-left: 10px !important;
|
||||
}
|
||||
|
||||
.leaflet-bottom.leaflet-right {
|
||||
padding-right: 10px !important;
|
||||
}
|
||||
|
||||
/* DaisyUI tooltips on map buttons - ensure they appear above date navigation (z-index: 9999) */
|
||||
.tooltip:before,
|
||||
.tooltip:after {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
|
|
|||
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/chart-column.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-chart-column-icon lucide-chart-column"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
|
||||
|
After Width: | Height: | Size: 344 B |
1
app/assets/svg/icons/lucide/outline/chevron-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-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 272 B |
1
app/assets/svg/icons/lucide/outline/chevron-left.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-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 274 B |
1
app/assets/svg/icons/lucide/outline/chevron-right.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-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 275 B |
1
app/assets/svg/icons/lucide/outline/chevron-up.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-chevron-up-icon lucide-chevron-up"><path d="m18 15-6-6-6 6"/></svg>
|
||||
|
After Width: | Height: | Size: 270 B |
1
app/assets/svg/icons/lucide/outline/circle-alert.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-alert-icon lucide-circle-alert"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
1
app/assets/svg/icons/lucide/outline/circle-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-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 305 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/circle-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-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||
|
After Width: | Height: | Size: 313 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/heart.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-heart-icon lucide-heart"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/></svg>
|
||||
|
After Width: | Height: | Size: 395 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/search.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-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
||||
|
After Width: | Height: | Size: 295 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/shield-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-shield-check-icon lucide-shield-check"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 447 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-square-dashed-mouse-pointer-icon lucide-square-dashed-mouse-pointer"><path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h2"/><path d="M14 3h1"/><path d="M3 9v1"/><path d="M21 9v2"/><path d="M3 14v1"/></svg>
|
||||
|
After Width: | Height: | Size: 623 B |
1
app/assets/svg/icons/lucide/outline/square-pen.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-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
||||
|
After Width: | Height: | Size: 445 B |
1
app/assets/svg/icons/lucide/outline/trash-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-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
|
After Width: | Height: | Size: 398 B |
1
app/assets/svg/icons/lucide/outline/triangle-alert.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-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
app/assets/svg/icons/lucide/outline/user.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-user-icon lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
1
app/assets/svg/icons/lucide/outline/users.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-users-icon lucide-users"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/></svg>
|
||||
|
After Width: | Height: | Size: 393 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 |
14
app/channels/family_locations_channel.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyLocationsChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
return reject unless DawarichSettings.family_feature_enabled?
|
||||
return reject unless current_user.in_family?
|
||||
|
||||
stream_for current_user.family
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
# Any cleanup needed when channel is unsubscribed
|
||||
end
|
||||
end
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::AreasController < ApiController
|
||||
before_action :set_area, only: %i[update destroy]
|
||||
before_action :set_area, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@areas = current_api_user.areas
|
||||
|
|
@ -9,6 +9,10 @@ class Api::V1::AreasController < ApiController
|
|||
render json: @areas, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @area, status: :ok
|
||||
end
|
||||
|
||||
def create
|
||||
@area = current_api_user.areas.build(area_params)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Countries::VisitedCitiesController < ApiController
|
||||
include SafeTimestampParser
|
||||
|
||||
before_action :validate_params
|
||||
|
||||
def index
|
||||
start_at = DateTime.parse(params[:start_at]).to_i
|
||||
end_at = DateTime.parse(params[:end_at]).to_i
|
||||
start_at = safe_timestamp(params[:start_at])
|
||||
end_at = safe_timestamp(params[:end_at])
|
||||
|
||||
points = current_api_user
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_at..end_at)
|
||||
|
||||
render json: { data: CountriesAndCities.new(points).call }
|
||||
|
|
|
|||
24
app/controllers/api/v1/families/locations_controller.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Families::LocationsController < ApiController
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :ensure_user_in_family!
|
||||
|
||||
def index
|
||||
family_locations = Families::Locations.new(current_api_user).call
|
||||
|
||||
render json: {
|
||||
locations: family_locations,
|
||||
updated_at: Time.current.iso8601,
|
||||
sharing_enabled: current_api_user.family_sharing_enabled?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_user_in_family!
|
||||
return if current_api_user&.in_family?
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
|
||||
def index
|
||||
start_at = params[:start_at]&.to_datetime&.to_i
|
||||
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i
|
||||
start_at = params[:start_at].present? ? safe_timestamp(params[:start_at]) : nil
|
||||
end_at = params[:end_at].present? ? safe_timestamp(params[:end_at]) : Time.zone.now.to_i
|
||||
order = params[:order] || 'desc'
|
||||
|
||||
points = current_api_user
|
||||
.points
|
||||
.without_raw_data
|
||||
.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)
|
||||
.page(params[:page])
|
||||
.per(params[:per_page] || 100)
|
||||
|
|
@ -45,6 +65,16 @@ class Api::V1::PointsController < ApiController
|
|||
render json: { message: 'Point deleted successfully' }
|
||||
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
|
||||
|
||||
def point_params
|
||||
|
|
@ -55,6 +85,10 @@ class Api::V1::PointsController < ApiController
|
|||
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
|
||||
end
|
||||
|
||||
def bulk_destroy_params
|
||||
params.permit(point_ids: [])
|
||||
end
|
||||
|
||||
def point_serializer
|
||||
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
|||
|
||||
def index
|
||||
render json: {
|
||||
settings: current_api_user.safe_settings,
|
||||
settings: current_api_user.safe_settings.config,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
end
|
||||
|
|
@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController
|
|||
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
||||
|
||||
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
|
||||
else
|
||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
||||
|
|
@ -30,7 +30,9 @@ class Api::V1::SettingsController < ApiController
|
|||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||
: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: []
|
||||
)
|
||||
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
|
||||
end
|
||||
|
||||
def show
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
render json: Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
||||
def create
|
||||
service = Visits::Create.new(current_api_user, visit_params)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,14 @@ class ApiController < ApplicationController
|
|||
before_action :set_version_header
|
||||
before_action :authenticate_api_key
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
||||
|
||||
private
|
||||
|
||||
def record_not_found
|
||||
render json: { error: 'Record not found' }, status: :not_found
|
||||
end
|
||||
|
||||
def set_version_header
|
||||
message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!"
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
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]
|
||||
|
||||
case client_type
|
||||
|
|
@ -56,6 +64,12 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
end
|
||||
|
||||
def ensure_family_feature_enabled!
|
||||
return if DawarichSettings.family_feature_enabled?
|
||||
|
||||
render json: { error: 'Family feature is not enabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_self_hosted_status
|
||||
|
|
|
|||
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]
|
||||
|
||||
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
|
||||
|
||||
def create
|
||||
|
|
|
|||
89
app/controllers/families_controller.rb
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamiliesController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :set_family, only: %i[show edit update destroy]
|
||||
|
||||
def show
|
||||
authorize @family
|
||||
|
||||
@members = @family.members.includes(:family_membership).order(:email)
|
||||
@pending_invitations = @family.active_invitations.order(:created_at)
|
||||
|
||||
@member_count = @family.member_count
|
||||
@can_invite = @family.can_add_members?
|
||||
end
|
||||
|
||||
def new
|
||||
redirect_to family_path and return if current_user.in_family?
|
||||
|
||||
@family = Family.new
|
||||
authorize @family
|
||||
end
|
||||
|
||||
def create
|
||||
@family = Family.new(family_params)
|
||||
authorize @family
|
||||
|
||||
service = Families::Create.new(
|
||||
user: current_user,
|
||||
name: family_params[:name]
|
||||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path, notice: 'Family created successfully!'
|
||||
else
|
||||
@family = Family.new(family_params)
|
||||
|
||||
if service.errors.any?
|
||||
service.errors.each do |error|
|
||||
@family.errors.add(error.attribute, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
if service.error_message.present?
|
||||
@family.errors.add(:base, service.error_message)
|
||||
end
|
||||
|
||||
flash.now[:alert] = service.error_message || 'Failed to create family'
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @family
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @family
|
||||
|
||||
if @family.update(family_params)
|
||||
redirect_to family_path, notice: 'Family updated successfully!'
|
||||
else
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @family
|
||||
|
||||
if @family.members.count > 1
|
||||
redirect_to family_path, alert: 'Cannot delete family with members. Remove all members first.'
|
||||
else
|
||||
@family.destroy
|
||||
redirect_to new_family_path, notice: 'Family deleted successfully!'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_family
|
||||
@family = current_user.family
|
||||
redirect_to new_family_path, alert: 'You are not in a family' unless @family
|
||||
end
|
||||
|
||||
def family_params
|
||||
params.require(:family).permit(:name)
|
||||
end
|
||||
end
|
||||
77
app/controllers/family/invitations_controller.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::InvitationsController < ApplicationController
|
||||
before_action :authenticate_user!, except: %i[show]
|
||||
before_action :ensure_family_feature_enabled!, except: %i[show]
|
||||
before_action :set_family, except: %i[show]
|
||||
before_action :set_invitation_by_id_and_family, only: %i[destroy]
|
||||
|
||||
def index
|
||||
authorize @family, :show?
|
||||
|
||||
@pending_invitations = @family.family_invitations.active
|
||||
end
|
||||
|
||||
def show
|
||||
token = params[:token] || params[:id]
|
||||
@invitation = Family::Invitation.find_by!(token: token)
|
||||
|
||||
if @invitation.expired?
|
||||
redirect_to root_path, alert: 'This invitation has expired.' and return
|
||||
end
|
||||
|
||||
unless @invitation.pending?
|
||||
redirect_to root_path, alert: 'This invitation is no longer valid.' and return
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @family, :invite?
|
||||
|
||||
service = Families::Invite.new(
|
||||
family: @family,
|
||||
email: invitation_params[:email],
|
||||
invited_by: current_user
|
||||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path, notice: 'Invitation sent successfully!'
|
||||
else
|
||||
redirect_to family_path, alert: service.error_message || 'Failed to send invitation'
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @family, :manage_invitations?
|
||||
|
||||
begin
|
||||
if @invitation.update(status: :cancelled)
|
||||
redirect_to family_path, notice: 'Invitation cancelled'
|
||||
else
|
||||
redirect_to family_path, alert: 'Failed to cancel invitation. Please try again'
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error cancelling family invitation: #{e.message}"
|
||||
redirect_to family_path, alert: 'An unexpected error occurred while cancelling the invitation'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_family
|
||||
@family = current_user.family
|
||||
|
||||
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
|
||||
end
|
||||
|
||||
def set_invitation_by_id_and_family
|
||||
# For authenticated nested routes: /families/:family_id/invitations/:id
|
||||
# The :id param contains the token value
|
||||
@family = current_user.family
|
||||
@invitation = @family.family_invitations.find_by!(token: params[:id])
|
||||
end
|
||||
|
||||
def invitation_params
|
||||
params.require(:family_invitation).permit(:email)
|
||||
end
|
||||
end
|
||||
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
|
||||
70
app/controllers/family/memberships_controller.rb
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::MembershipsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :set_family, except: %i[create]
|
||||
before_action :set_membership, only: %i[destroy]
|
||||
before_action :set_invitation, only: %i[create]
|
||||
|
||||
def create
|
||||
authorize @invitation, policy_class: Family::MembershipPolicy
|
||||
|
||||
service = Families::AcceptInvitation.new(
|
||||
invitation: @invitation,
|
||||
user: current_user
|
||||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path, notice: 'Welcome to the family!'
|
||||
else
|
||||
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
||||
end
|
||||
rescue Pundit::NotAuthorizedError
|
||||
alert = case
|
||||
when @invitation.expired? then 'This invitation is no longer valid or has expired'
|
||||
when !@invitation.pending? then 'This invitation has already been processed'
|
||||
when @invitation.email != current_user.email then 'This invitation is not for your email address'
|
||||
else 'You are not authorized to accept this invitation'
|
||||
end
|
||||
|
||||
redirect_to root_path, alert: alert
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error accepting family invitation: #{e.message}"
|
||||
|
||||
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @membership
|
||||
|
||||
member_user = @membership.user
|
||||
service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user)
|
||||
|
||||
if service.call
|
||||
if member_user == current_user
|
||||
redirect_to new_family_path, notice: 'You have left the family'
|
||||
else
|
||||
redirect_to family_path, notice: "#{member_user.email} has been removed from the family"
|
||||
end
|
||||
else
|
||||
redirect_to family_path, alert: service.error_message || 'Failed to remove member'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_family
|
||||
@family = current_user.family
|
||||
|
||||
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
|
||||
end
|
||||
|
||||
def set_membership
|
||||
@membership = @family.family_memberships.find(params[:id])
|
||||
end
|
||||
|
||||
def set_invitation
|
||||
@invitation = Family::Invitation.find_by!(token: params[:token])
|
||||
end
|
||||
end
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class HomeController < ApplicationController
|
||||
include ApplicationHelper
|
||||
|
||||
def index
|
||||
# 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
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class ImportsController < ApplicationController
|
|||
def index
|
||||
@imports = policy_scope(Import)
|
||||
.select(:id, :name, :source, :created_at, :processed, :status)
|
||||
.with_attached_file
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
end
|
||||
|
|
@ -78,9 +79,13 @@ class ImportsController < ApplicationController
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MapController < ApplicationController
|
||||
class Map::LeafletController < ApplicationController
|
||||
include SafeTimestampParser
|
||||
|
||||
before_action :authenticate_user!
|
||||
layout 'map', only: :index
|
||||
|
||||
def index
|
||||
@points = filtered_points
|
||||
|
|
@ -13,6 +16,7 @@ class MapController < ApplicationController
|
|||
@years = years_range
|
||||
@points_number = points_count
|
||||
@features = DawarichSettings.features
|
||||
@home_coordinates = current_user.home_place_coordinates
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -69,14 +73,14 @@ class MapController < ApplicationController
|
|||
end
|
||||
|
||||
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?
|
||||
|
||||
Time.zone.today.beginning_of_day.to_i
|
||||
end
|
||||
|
||||
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?
|
||||
|
||||
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
|
||||
|
||||
class PointsController < ApplicationController
|
||||
include SafeTimestampParser
|
||||
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
|
|
@ -40,13 +42,13 @@ class PointsController < ApplicationController
|
|||
def start_at
|
||||
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
|
||||
|
||||
def end_at
|
||||
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
|
||||
|
||||
def points
|
||||
|
|
|
|||
|
|
@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController
|
|||
private
|
||||
|
||||
def settings_params
|
||||
params.require(:maps).permit(:name, :url, :distance_unit)
|
||||
params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class SettingsController < ApplicationController
|
|||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||
:visits_suggestions_enabled
|
||||
:visits_suggestions_enabled, :digest_emails_enabled
|
||||
)
|
||||
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
|
||||
104
app/controllers/users/registrations_controller.rb
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::RegistrationsController < Devise::RegistrationsController
|
||||
include UtmTrackable
|
||||
|
||||
before_action :set_invitation, 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
|
||||
build_resource({})
|
||||
|
||||
resource.email = @invitation.email if @invitation
|
||||
|
||||
yield resource if block_given?
|
||||
|
||||
respond_with resource
|
||||
end
|
||||
|
||||
def create
|
||||
super do |resource|
|
||||
if resource.persisted?
|
||||
assign_utm_params(resource)
|
||||
accept_invitation_for_user(resource) if @invitation
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def after_sign_up_path_for(resource)
|
||||
return family_path if @invitation&.family
|
||||
|
||||
super(resource)
|
||||
end
|
||||
|
||||
def after_inactive_sign_up_path_for(resource)
|
||||
return family_path if @invitation&.family
|
||||
|
||||
super(resource)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_registration_allowed
|
||||
return unless self_hosted_mode?
|
||||
return if valid_invitation_token?
|
||||
return if email_password_registration_allowed?
|
||||
|
||||
redirect_to root_path,
|
||||
alert: 'Registration is not available. Please contact your administrator for access.'
|
||||
end
|
||||
|
||||
def set_invitation
|
||||
return if invitation_token.blank?
|
||||
|
||||
@invitation = Family::Invitation.find_by(token: invitation_token)
|
||||
end
|
||||
|
||||
def self_hosted_mode?
|
||||
env_value = ENV['SELF_HOSTED']
|
||||
return ActiveModel::Type::Boolean.new.cast(env_value) unless env_value.nil?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def valid_invitation_token?
|
||||
@invitation&.can_be_accepted?
|
||||
end
|
||||
|
||||
def invitation_token
|
||||
@invitation_token ||= params[:invitation_token] ||
|
||||
params.dig(:user, :invitation_token) ||
|
||||
session[:invitation_token]
|
||||
end
|
||||
|
||||
def accept_invitation_for_user(user)
|
||||
return unless @invitation&.can_be_accepted?
|
||||
|
||||
service = Families::AcceptInvitation.new(
|
||||
invitation: @invitation,
|
||||
user: user
|
||||
)
|
||||
|
||||
if service.call
|
||||
flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family."
|
||||
else
|
||||
flash[:alert] =
|
||||
"Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
|
||||
end
|
||||
rescue StandardError => e
|
||||
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.'
|
||||
end
|
||||
|
||||
def sign_up_params
|
||||
super
|
||||
end
|
||||
|
||||
def email_password_registration_allowed?
|
||||
ALLOW_EMAIL_PASSWORD_REGISTRATION
|
||||
end
|
||||
end
|
||||
23
app/controllers/users/sessions_controller.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::SessionsController < Devise::SessionsController
|
||||
before_action :load_invitation_context, only: [:new]
|
||||
|
||||
def new
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_invitation_context
|
||||
return unless invitation_token.present?
|
||||
|
||||
@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
|
||||
|
||||
def invitation_token
|
||||
@invitation_token ||= params[:invitation_token] || session[:invitation_token]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationHelper
|
||||
def classes_for_flash(flash_type)
|
||||
case flash_type.to_sym
|
||||
when :error
|
||||
'bg-red-100 text-red-700 border-red-300'
|
||||
def flash_alert_class(type)
|
||||
case type.to_sym
|
||||
when :notice, :success then 'alert-success'
|
||||
when :alert, :error then 'alert-error'
|
||||
when :warning then 'alert-warning'
|
||||
when :info then 'alert-info'
|
||||
else 'alert-info'
|
||||
end
|
||||
end
|
||||
|
||||
def flash_icon(type)
|
||||
case type.to_sym
|
||||
when :notice, :success then icon 'circle-check'
|
||||
when :alert, :error then icon 'circle-x'
|
||||
when :warning then icon 'circle-alert'
|
||||
else
|
||||
'bg-blue-100 text-blue-700 border-blue-300'
|
||||
icon 'info'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -119,4 +130,23 @@ module ApplicationHelper
|
|||
'btn-success'
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
module CountryFlagHelper
|
||||
def country_flag(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)
|
||||
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
|
||||
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
|
||||
import "@rails/ujs"
|
||||
import "@rails/actioncable"
|
||||
import "controllers"
|
||||
import "@hotwired/turbo-rails"
|
||||
|
|
@ -12,3 +13,5 @@ import "./channels"
|
|||
|
||||
import "trix"
|
||||
import "@rails/actiontext"
|
||||
|
||||
Rails.start()
|
||||
|
|
|
|||
24
app/javascript/channels/family_locations_channel.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import consumer from "./consumer"
|
||||
|
||||
// Only create subscription if family feature is enabled
|
||||
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]');
|
||||
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {};
|
||||
|
||||
if (features.family) {
|
||||
consumer.subscriptions.create("FamilyLocationsChannel", {
|
||||
connected() {
|
||||
// Connected to family locations channel
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
// Disconnected from family locations channel
|
||||
},
|
||||
|
||||
received(data) {
|
||||
// Pass data to family members controller if it exists
|
||||
if (window.familyMembersController) {
|
||||
window.familyMembersController.updateSingleMemberLocation(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
import "notifications_channel"
|
||||
import "points_channel"
|
||||
import "imports_channel"
|
||||
import "family_locations_channel"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
import { applyThemeToButton } from "../maps/theme_utils";
|
||||
import {
|
||||
setAddVisitButtonActive,
|
||||
setAddVisitButtonInactive
|
||||
} from "../maps/map_controls";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [""];
|
||||
|
|
@ -71,39 +74,26 @@ export default class extends Controller {
|
|||
setupAddVisitButton() {
|
||||
if (!this.map || this.addVisitButton) return;
|
||||
|
||||
// Create the Add Visit control
|
||||
const AddVisitControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button');
|
||||
button.innerHTML = '➕';
|
||||
button.title = 'Add a visit';
|
||||
// The Add Visit button is now created centrally by maps_controller.js
|
||||
// via addTopRightButtons(). We just need to find it and attach our handler.
|
||||
setTimeout(() => {
|
||||
this.addVisitButton = document.querySelector('.add-visit-button');
|
||||
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userThemeValue || 'dark');
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.textAlign = 'center';
|
||||
button.style.transition = 'all 0.2s ease';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
||||
// Toggle add visit mode on button click
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
this.toggleAddVisitMode(button);
|
||||
});
|
||||
|
||||
this.addVisitButton = button;
|
||||
return button;
|
||||
if (this.addVisitButton) {
|
||||
// Attach our click handler to the existing button
|
||||
// Use event capturing and stopPropagation to prevent map click
|
||||
this.addVisitButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleAddVisitMode(this.addVisitButton);
|
||||
}, true); // Use capture phase
|
||||
} else {
|
||||
console.warn('Add visit button not found, retrying...');
|
||||
// Retry if button hasn't been created yet
|
||||
this.addVisitButton = null;
|
||||
setTimeout(() => this.setupAddVisitButton(), 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Add the control to the map (top right, below existing buttons)
|
||||
this.map.addControl(new AddVisitControl({ position: 'topright' }));
|
||||
}, 100);
|
||||
}
|
||||
|
||||
toggleAddVisitMode(button) {
|
||||
|
|
@ -120,15 +110,18 @@ export default class extends Controller {
|
|||
this.isAddingVisit = true;
|
||||
|
||||
// Update button style to show active state
|
||||
button.style.backgroundColor = '#dc3545';
|
||||
button.style.color = 'white';
|
||||
button.innerHTML = '✕';
|
||||
setAddVisitButtonActive(button);
|
||||
|
||||
// Change cursor to crosshair
|
||||
this.map.getContainer().style.cursor = 'crosshair';
|
||||
|
||||
// Add map click listener
|
||||
// Add map click listener with a small delay to prevent immediate trigger
|
||||
// This ensures the button click doesn't propagate to the map
|
||||
setTimeout(() => {
|
||||
if (this.isAddingVisit) {
|
||||
this.map.on('click', this.onMapClick, this);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
showFlashMessage('notice', 'Click on the map to place a visit');
|
||||
}
|
||||
|
|
@ -136,9 +129,8 @@ export default class extends Controller {
|
|||
exitAddVisitMode(button) {
|
||||
this.isAddingVisit = false;
|
||||
|
||||
// Reset button style with theme-aware styling
|
||||
applyThemeToButton(button, this.userThemeValue || 'dark');
|
||||
button.innerHTML = '➕';
|
||||
// Reset button style to inactive state
|
||||
setAddVisitButtonInactive(button, this.userThemeValue || 'dark');
|
||||
|
||||
// Reset cursor
|
||||
this.map.getContainer().style.cursor = '';
|
||||
|
|
@ -156,6 +148,10 @@ export default class extends Controller {
|
|||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
this.currentPopup = null;
|
||||
} else {
|
||||
console.warn('No currentPopup reference found');
|
||||
// Fallback: try to close any open popup
|
||||
this.map.closePopup();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +181,12 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
showVisitForm(lat, lng) {
|
||||
// Close any existing popup first to ensure only one popup is open
|
||||
if (this.currentPopup) {
|
||||
this.map.closePopup(this.currentPopup);
|
||||
this.currentPopup = null;
|
||||
}
|
||||
|
||||
// Get current date/time for default values
|
||||
const now = new Date();
|
||||
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000));
|
||||
|
|
@ -265,7 +267,10 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
if (cancelButton) {
|
||||
cancelButton.addEventListener('click', () => {
|
||||
cancelButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.exitAddVisitMode(this.addVisitButton);
|
||||
});
|
||||
}
|
||||
|
|
@ -290,7 +295,8 @@ export default class extends Controller {
|
|||
started_at: formData.get('started_at'),
|
||||
ended_at: formData.get('ended_at'),
|
||||
latitude: formData.get('latitude'),
|
||||
longitude: formData.get('longitude')
|
||||
longitude: formData.get('longitude'),
|
||||
status: 'confirmed' // Manually created visits should be confirmed
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -324,15 +330,14 @@ export default class extends Controller {
|
|||
|
||||
if (response.ok) {
|
||||
showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`);
|
||||
|
||||
// Store the created visit data
|
||||
const createdVisit = data;
|
||||
|
||||
this.exitAddVisitMode(this.addVisitButton);
|
||||
|
||||
// Refresh visits layer - this will clear and refetch data
|
||||
this.refreshVisitsLayer();
|
||||
|
||||
// Ensure confirmed visits layer is enabled (with a small delay for the API call to complete)
|
||||
setTimeout(() => {
|
||||
this.ensureVisitsLayersEnabled();
|
||||
}, 300);
|
||||
// Add the newly created visit marker immediately to the map
|
||||
this.addCreatedVisitToMap(createdVisit, visitData.visit.latitude, visitData.visit.longitude);
|
||||
} else {
|
||||
const errorMessage = data.error || data.message || 'Failed to create visit';
|
||||
showFlashMessage('error', errorMessage);
|
||||
|
|
@ -347,95 +352,82 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
|
||||
refreshVisitsLayer() {
|
||||
console.log('Attempting to refresh visits layer...');
|
||||
|
||||
// Try multiple approaches to refresh the visits layer
|
||||
addCreatedVisitToMap(visitData, latitude, longitude) {
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (mapsController) {
|
||||
// Try to get the Stimulus controller instance
|
||||
if (!mapsController) {
|
||||
console.log('Could not find maps controller element');
|
||||
return;
|
||||
}
|
||||
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
|
||||
if (stimulusController && stimulusController.visitsManager) {
|
||||
console.log('Found maps controller with visits manager');
|
||||
|
||||
// Clear existing visits and fetch fresh data
|
||||
if (stimulusController.visitsManager.visitCircles) {
|
||||
stimulusController.visitsManager.visitCircles.clearLayers();
|
||||
}
|
||||
if (stimulusController.visitsManager.confirmedVisitCircles) {
|
||||
stimulusController.visitsManager.confirmedVisitCircles.clearLayers();
|
||||
}
|
||||
|
||||
// Refresh the visits data
|
||||
if (typeof stimulusController.visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
console.log('Refreshing visits data...');
|
||||
stimulusController.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
} else {
|
||||
if (!stimulusController || !stimulusController.visitsManager) {
|
||||
console.log('Could not find maps controller or visits manager');
|
||||
|
||||
// Fallback: Try to dispatch a custom event
|
||||
const refreshEvent = new CustomEvent('visits:refresh', { bubbles: true });
|
||||
mapsController.dispatchEvent(refreshEvent);
|
||||
}
|
||||
} else {
|
||||
console.log('Could not find maps controller element');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ensureVisitsLayersEnabled() {
|
||||
console.log('Ensuring visits layers are enabled...');
|
||||
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (mapsController) {
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
|
||||
if (stimulusController && stimulusController.map && stimulusController.visitsManager) {
|
||||
const map = stimulusController.map;
|
||||
const visitsManager = stimulusController.visitsManager;
|
||||
|
||||
// Get the confirmed visits layer (newly created visits are always confirmed)
|
||||
const confirmedVisitsLayer = visitsManager.getConfirmedVisitCirclesLayer();
|
||||
// Create a circle for the newly created visit (always confirmed)
|
||||
const circle = L.circle([latitude, longitude], {
|
||||
color: '#4A90E2', // Border color for confirmed visits
|
||||
fillColor: '#4A90E2', // Fill color for confirmed visits
|
||||
fillOpacity: 0.5,
|
||||
radius: 110, // Confirmed visit size
|
||||
weight: 2,
|
||||
interactive: true,
|
||||
bubblingMouseEvents: false,
|
||||
pane: 'confirmedVisitsPane'
|
||||
});
|
||||
|
||||
// Ensure confirmed visits layer is added to map since we create confirmed visits
|
||||
if (confirmedVisitsLayer && !map.hasLayer(confirmedVisitsLayer)) {
|
||||
console.log('Adding confirmed visits layer to map');
|
||||
map.addLayer(confirmedVisitsLayer);
|
||||
// Add the circle to the confirmed visits layer
|
||||
visitsManager.confirmedVisitCircles.addLayer(circle);
|
||||
|
||||
// Update the layer control checkbox to reflect the layer is now active
|
||||
this.updateLayerControlCheckbox('Confirmed Visits', true);
|
||||
// Make sure the layer is visible on the map
|
||||
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
|
||||
this.map.addLayer(visitsManager.confirmedVisitCircles);
|
||||
}
|
||||
|
||||
// Refresh visits data to include the new visit
|
||||
if (typeof visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
console.log('Final refresh of visits to show new visit...');
|
||||
visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if the layer control has the confirmed visits layer enabled
|
||||
this.ensureConfirmedVisitsLayerEnabled();
|
||||
}
|
||||
|
||||
updateLayerControlCheckbox(layerName, isEnabled) {
|
||||
// Find the layer control input for the specified layer
|
||||
ensureConfirmedVisitsLayerEnabled() {
|
||||
// Find the layer control and check/enable the "Confirmed Visits" checkbox
|
||||
const layerControlContainer = document.querySelector('.leaflet-control-layers');
|
||||
if (!layerControlContainer) {
|
||||
console.log('Layer control container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand the layer control if it's collapsed
|
||||
const layerControlExpand = layerControlContainer.querySelector('.leaflet-control-layers-toggle');
|
||||
if (layerControlExpand) {
|
||||
layerControlExpand.click();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]');
|
||||
inputs.forEach(input => {
|
||||
const label = input.nextElementSibling;
|
||||
if (label && label.textContent.trim() === layerName) {
|
||||
console.log(`Updating ${layerName} checkbox to ${isEnabled}`);
|
||||
input.checked = isEnabled;
|
||||
|
||||
// Trigger change event to ensure proper state management
|
||||
if (label && label.textContent.trim().includes('Confirmed Visits')) {
|
||||
if (!input.checked) {
|
||||
input.checked = true;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
refreshVisitsLayer() {
|
||||
// Don't auto-refresh after creating a visit
|
||||
// The visit is already visible on the map from addCreatedVisitToMap()
|
||||
// Auto-refresh would clear it because fetchAndDisplayVisits uses URL date params
|
||||
// which might not include the newly created visit
|
||||
console.log('Skipping auto-refresh - visit already added to map');
|
||||
}
|
||||
|
||||
|
||||
cleanup() {
|
||||
if (this.map) {
|
||||
|
|
|
|||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/javascript/controllers/clipboard_controller.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import { showFlashMessage } from "../maps/helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
text: String
|
||||
}
|
||||
|
||||
static targets = ["icon", "text"]
|
||||
|
||||
copy() {
|
||||
navigator.clipboard.writeText(this.textValue).then(() => {
|
||||
this.showButtonFeedback()
|
||||
showFlashMessage('notice', 'Link copied to clipboard!')
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy text: ', err)
|
||||
showFlashMessage('error', 'Failed to copy link')
|
||||
})
|
||||
}
|
||||
|
||||
showButtonFeedback() {
|
||||
const button = this.element
|
||||
const originalClasses = button.className
|
||||
const originalHTML = button.innerHTML
|
||||
|
||||
// Change button appearance
|
||||
button.className = 'btn btn-success btn-xs'
|
||||
button.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block 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>
|
||||
Copied!
|
||||
`
|
||||
button.disabled = true
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
button.className = originalClasses
|
||||
button.innerHTML = originalHTML
|
||||
button.disabled = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
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() {
|
||||
console.log("Datetime controller connected")
|
||||
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
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
|
|
@ -25,6 +73,11 @@ export default class extends BaseController {
|
|||
const endedAt = this.endedAtTarget.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) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default class extends Controller {
|
|||
if (this.isUploading) {
|
||||
// If still uploading, prevent submission
|
||||
event.preventDefault()
|
||||
console.log("Form submission prevented during upload")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ export default class extends Controller {
|
|||
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
|
||||
if (signedIds.length === 0) {
|
||||
event.preventDefault()
|
||||
console.log("No files uploaded yet")
|
||||
|
||||
alert("Please select and upload files first")
|
||||
} else {
|
||||
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
|
||||
|
||||
// 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
|
||||
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
|
||||
|
||||
console.log("Progress bar created and inserted before submit button")
|
||||
|
||||
let uploadCount = 0
|
||||
const totalFiles = files.length
|
||||
|
||||
|
|
@ -137,17 +134,13 @@ export default class extends Controller {
|
|||
});
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
console.log(`Starting upload for ${file.name}`)
|
||||
const upload = new DirectUpload(file, this.urlValue, this)
|
||||
upload.create((error, blob) => {
|
||||
uploadCount++
|
||||
|
||||
if (error) {
|
||||
console.error("Error uploading file:", error)
|
||||
// Show error to user using flash
|
||||
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
|
||||
} else {
|
||||
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
|
||||
|
||||
// Create a hidden field with the correct name
|
||||
const hiddenField = document.createElement("input")
|
||||
|
|
@ -155,8 +148,6 @@ export default class extends Controller {
|
|||
hiddenField.setAttribute("name", "import[files][]")
|
||||
hiddenField.setAttribute("value", blob.signed_id)
|
||||
this.element.appendChild(hiddenField)
|
||||
|
||||
console.log("Added hidden field with signed ID:", blob.signed_id)
|
||||
}
|
||||
|
||||
// Enable submit button when all uploads are complete
|
||||
|
|
@ -186,8 +177,6 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
550
app/javascript/controllers/family_members_controller.js
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [];
|
||||
|
||||
static values = {
|
||||
features: Object,
|
||||
userTheme: String,
|
||||
timezone: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log("Family members controller connected");
|
||||
|
||||
// Wait for maps controller to be ready
|
||||
this.waitForMap();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.cleanup();
|
||||
console.log("Family members controller disconnected");
|
||||
}
|
||||
|
||||
waitForMap() {
|
||||
// Find the maps controller element
|
||||
const mapElement = document.querySelector('[data-controller*="maps"]');
|
||||
if (!mapElement) {
|
||||
console.warn('Maps controller element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the maps controller to be initialized
|
||||
const checkMapReady = () => {
|
||||
if (window.mapsController && window.mapsController.map) {
|
||||
this.initializeFamilyFeatures();
|
||||
} else {
|
||||
setTimeout(checkMapReady, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkMapReady();
|
||||
}
|
||||
|
||||
initializeFamilyFeatures() {
|
||||
this.map = window.mapsController.map;
|
||||
|
||||
if (!this.map) {
|
||||
console.warn('Map not available for family members controller');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize family member markers layer
|
||||
this.familyMarkersLayer = L.layerGroup();
|
||||
this.familyMemberLocations = {}; // Object keyed by user_id for efficient updates
|
||||
this.familyMarkers = {}; // Store marker references by user_id
|
||||
|
||||
// Expose controller globally for ActionCable channel
|
||||
window.familyMembersController = this;
|
||||
|
||||
// Add to layer control immediately (layer will be empty until data is fetched)
|
||||
this.addToLayerControl();
|
||||
|
||||
// Listen for family data updates
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
createFamilyMarkers() {
|
||||
// Clear existing family markers
|
||||
if (this.familyMarkersLayer) {
|
||||
this.familyMarkersLayer.clearLayers();
|
||||
}
|
||||
|
||||
// Clear marker references
|
||||
this.familyMarkers = {};
|
||||
|
||||
// Only proceed if family feature is enabled and we have family member locations
|
||||
if (!this.featuresValue.family ||
|
||||
!this.familyMemberLocations ||
|
||||
Object.keys(this.familyMemberLocations).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = [];
|
||||
|
||||
Object.values(this.familyMemberLocations).forEach((location) => {
|
||||
if (!location || !location.latitude || !location.longitude) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first letter of the email or use '?' as fallback
|
||||
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
|
||||
|
||||
// Check if this is a recent update (within last 5 minutes)
|
||||
const isRecent = this.isRecentUpdate(location.updated_at);
|
||||
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
|
||||
|
||||
// Create a distinct marker for family members with email initial
|
||||
const familyMarker = L.marker([location.latitude, location.longitude], {
|
||||
icon: L.divIcon({
|
||||
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
className: markerClass
|
||||
})
|
||||
});
|
||||
|
||||
// Format timestamp for display
|
||||
const timezone = this.timezoneValue || 'UTC';
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||
|
||||
// Create small tooltip that shows automatically
|
||||
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
||||
const tooltip = familyMarker.bindTooltip(tooltipContent, {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -12],
|
||||
className: 'family-member-tooltip'
|
||||
});
|
||||
|
||||
// Create detailed popup that shows on click
|
||||
const popupContent = this.createPopupContent(location, lastSeen);
|
||||
familyMarker.bindPopup(popupContent);
|
||||
|
||||
// Hide tooltip when popup opens, show when popup closes
|
||||
familyMarker.on('popupopen', () => {
|
||||
familyMarker.closeTooltip();
|
||||
});
|
||||
familyMarker.on('popupclose', () => {
|
||||
familyMarker.openTooltip();
|
||||
});
|
||||
|
||||
this.familyMarkersLayer.addLayer(familyMarker);
|
||||
|
||||
// Store marker reference by user_id for efficient updates
|
||||
this.familyMarkers[location.user_id] = familyMarker;
|
||||
|
||||
// Add to bounds array for auto-zoom
|
||||
bounds.push([location.latitude, location.longitude]);
|
||||
});
|
||||
|
||||
// Store bounds for later use
|
||||
this.familyMemberBounds = bounds;
|
||||
}
|
||||
|
||||
// Update a single family member's location in real-time
|
||||
updateSingleMemberLocation(locationData) {
|
||||
if (!this.featuresValue.family) return;
|
||||
if (!locationData || !locationData.user_id) return;
|
||||
|
||||
// Update stored location data
|
||||
this.familyMemberLocations[locationData.user_id] = locationData;
|
||||
|
||||
// If the Family Members layer is not currently visible, just store the data
|
||||
if (!this.map.hasLayer(this.familyMarkersLayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing marker for this user
|
||||
const existingMarker = this.familyMarkers[locationData.user_id];
|
||||
|
||||
if (existingMarker) {
|
||||
// Update existing marker position and content
|
||||
existingMarker.setLatLng([locationData.latitude, locationData.longitude]);
|
||||
|
||||
// Update marker icon with pulse animation for recent updates
|
||||
const emailInitial = locationData.email_initial || locationData.email?.charAt(0)?.toUpperCase() || '?';
|
||||
const isRecent = this.isRecentUpdate(locationData.updated_at);
|
||||
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
|
||||
|
||||
const newIcon = L.divIcon({
|
||||
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
className: markerClass
|
||||
});
|
||||
existingMarker.setIcon(newIcon);
|
||||
|
||||
// Update tooltip content
|
||||
const timezone = this.timezoneValue || 'UTC';
|
||||
const lastSeen = new Date(locationData.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||
const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery);
|
||||
existingMarker.setTooltipContent(tooltipContent);
|
||||
|
||||
// Update popup content
|
||||
const popupContent = this.createPopupContent(locationData, lastSeen);
|
||||
existingMarker.setPopupContent(popupContent);
|
||||
} else {
|
||||
// Create new marker for this user
|
||||
this.createSingleFamilyMarker(locationData);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if location was updated within the last 5 minutes
|
||||
isRecentUpdate(updatedAt) {
|
||||
const updateTime = new Date(updatedAt);
|
||||
const now = new Date();
|
||||
const diffMinutes = (now - updateTime) / 1000 / 60;
|
||||
return diffMinutes < 5;
|
||||
}
|
||||
|
||||
// Create a marker for a single family member
|
||||
createSingleFamilyMarker(location) {
|
||||
if (!location || !location.latitude || !location.longitude) return;
|
||||
|
||||
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
|
||||
const isRecent = this.isRecentUpdate(location.updated_at);
|
||||
const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker';
|
||||
|
||||
const familyMarker = L.marker([location.latitude, location.longitude], {
|
||||
icon: L.divIcon({
|
||||
html: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
className: markerClass
|
||||
})
|
||||
});
|
||||
|
||||
const timezone = this.timezoneValue || 'UTC';
|
||||
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
|
||||
|
||||
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
|
||||
familyMarker.bindTooltip(tooltipContent, {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -12],
|
||||
className: 'family-member-tooltip'
|
||||
});
|
||||
|
||||
const popupContent = this.createPopupContent(location, lastSeen);
|
||||
familyMarker.bindPopup(popupContent);
|
||||
|
||||
familyMarker.on('popupopen', () => {
|
||||
familyMarker.closeTooltip();
|
||||
});
|
||||
familyMarker.on('popupclose', () => {
|
||||
familyMarker.openTooltip();
|
||||
});
|
||||
|
||||
this.familyMarkersLayer.addLayer(familyMarker);
|
||||
this.familyMarkers[location.user_id] = familyMarker;
|
||||
}
|
||||
|
||||
createTooltipContent(lastSeen, battery) {
|
||||
const batteryInfo = battery !== null && battery !== undefined ? ` | Battery: ${battery}%` : '';
|
||||
return `Last seen: ${lastSeen}${batteryInfo}`;
|
||||
}
|
||||
|
||||
createPopupContent(location, lastSeen) {
|
||||
const isDark = this.userThemeValue === 'dark';
|
||||
const bgColor = isDark ? '#1f2937' : '#ffffff';
|
||||
const textColor = isDark ? '#f9fafb' : '#111827';
|
||||
const mutedColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
|
||||
const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?';
|
||||
|
||||
// Battery display with icon
|
||||
const battery = location.battery;
|
||||
const batteryStatus = location.battery_status;
|
||||
let batteryDisplay = '';
|
||||
|
||||
if (battery !== null && battery !== undefined) {
|
||||
// Determine battery color based on level and status
|
||||
let batteryColor = '#10B981'; // green
|
||||
if (batteryStatus === 'charging') {
|
||||
batteryColor = battery <= 50 ? '#F59E0B' : '#10B981'; // orange if low, green if high
|
||||
} else if (battery <= 20) {
|
||||
batteryColor = '#EF4444'; // red
|
||||
} else if (battery <= 50) {
|
||||
batteryColor = '#F59E0B'; // orange
|
||||
}
|
||||
|
||||
// Helper function to get appropriate Lucide battery icon
|
||||
const getBatteryIcon = (battery, batteryStatus, batteryColor) => {
|
||||
const baseAttrs = `width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${batteryColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"`;
|
||||
|
||||
// Charging icon
|
||||
if (batteryStatus === 'charging') {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="m11 7-3 5h4l-3 5"/><path d="M14.856 6H16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.935"/><path d="M22 14v-4"/><path d="M5.14 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.936"/></svg>`;
|
||||
}
|
||||
|
||||
// Full battery
|
||||
if (battery === 100 || batteryStatus === 'full') {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 10v4"/><path d="M14 10v4"/><path d="M22 14v-4"/><path d="M6 10v4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
||||
}
|
||||
|
||||
// Low battery (≤20%)
|
||||
if (battery <= 20) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M22 14v-4"/><path d="M6 14v-4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
||||
}
|
||||
|
||||
// Medium battery (21-50%)
|
||||
if (battery <= 50) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 14v-4"/><path d="M22 14v-4"/><path d="M6 14v-4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
||||
}
|
||||
|
||||
// High battery (>50%, default to full)
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 10v4"/><path d="M14 10v4"/><path d="M22 14v-4"/><path d="M6 10v4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
||||
};
|
||||
|
||||
const batteryIcon = getBatteryIcon(battery, batteryStatus, batteryColor);
|
||||
|
||||
batteryDisplay = `
|
||||
<p style="margin: 0 0 8px 0; font-size: 13px;">
|
||||
${batteryIcon}<strong>Battery:</strong> ${battery}%${batteryStatus ? ` (${batteryStatus})` : ''}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="family-member-popup" style="background-color: ${bgColor}; color: ${textColor}; padding: 12px; border-radius: 8px; min-width: 220px;">
|
||||
<h3 style="margin: 0 0 12px 0; color: #10B981; font-size: 15px; font-weight: bold; display: flex; align-items: center; gap: 8px;">
|
||||
<span style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold;">${emailInitial}</span>
|
||||
Family Member
|
||||
</h3>
|
||||
<p style="margin: 0 0 8px 0; font-size: 13px;">
|
||||
<strong>Email:</strong> ${location.email || 'Unknown'}
|
||||
</p>
|
||||
<p style="margin: 0 0 8px 0; font-size: 13px;">
|
||||
<strong>Coordinates:</strong><br/>
|
||||
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
|
||||
</p>
|
||||
${batteryDisplay}
|
||||
<p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};">
|
||||
<strong>Last seen:</strong> ${lastSeen}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
addToLayerControl() {
|
||||
// Add family markers layer to the maps controller's layer control
|
||||
if (window.mapsController && window.mapsController.layerControl && this.familyMarkersLayer) {
|
||||
// We need to recreate the layer control to include our new layer
|
||||
this.updateMapsControllerLayerControl();
|
||||
}
|
||||
}
|
||||
|
||||
updateMapsControllerLayerControl() {
|
||||
const mapsController = window.mapsController;
|
||||
if (!mapsController || typeof mapsController.updateLayerControl !== 'function') return;
|
||||
|
||||
// Use the maps controller's helper method to update layer control
|
||||
mapsController.updateLayerControl({
|
||||
"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() {
|
||||
// Listen for family data updates (for real-time updates in the future)
|
||||
document.addEventListener('family:locations:updated', (event) => {
|
||||
this.familyMemberLocations = event.detail.locations;
|
||||
this.createFamilyMarkers();
|
||||
});
|
||||
|
||||
// Listen for theme changes
|
||||
document.addEventListener('theme:changed', (event) => {
|
||||
this.userThemeValue = event.detail.theme;
|
||||
// Recreate popups with new theme
|
||||
this.createFamilyMarkers();
|
||||
});
|
||||
|
||||
// Listen for layer control events
|
||||
this.setupLayerControlEvents();
|
||||
}
|
||||
|
||||
setupLayerControlEvents() {
|
||||
if (!this.map) return;
|
||||
|
||||
// Listen for when the Family Members layer is added
|
||||
this.map.on('overlayadd', (event) => {
|
||||
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
|
||||
// Refresh locations and zoom after data is loaded
|
||||
this.refreshFamilyLocations().then(() => {
|
||||
this.zoomToFitAllMembers();
|
||||
});
|
||||
|
||||
// Set up periodic refresh while layer is active
|
||||
this.startPeriodicRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for when the Family Members layer is removed
|
||||
this.map.on('overlayremove', (event) => {
|
||||
if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) {
|
||||
// Stop periodic refresh when layer is disabled
|
||||
this.stopPeriodicRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
zoomToFitAllMembers() {
|
||||
if (!this.familyMemberBounds || this.familyMemberBounds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's only one member, center on them with a reasonable zoom
|
||||
if (this.familyMemberBounds.length === 1) {
|
||||
this.map.setView(this.familyMemberBounds[0], 13);
|
||||
return;
|
||||
}
|
||||
|
||||
// For multiple members, fit bounds to show all of them
|
||||
const bounds = L.latLngBounds(this.familyMemberBounds);
|
||||
this.map.fitBounds(bounds, {
|
||||
padding: [50, 50], // Add padding around the edges
|
||||
maxZoom: 15 // Don't zoom in too close
|
||||
});
|
||||
}
|
||||
|
||||
startPeriodicRefresh() {
|
||||
// Clear any existing refresh interval
|
||||
this.stopPeriodicRefresh();
|
||||
|
||||
// Refresh family locations every 60 seconds while layer is active (as fallback to real-time)
|
||||
this.refreshInterval = setInterval(() => {
|
||||
if (this.map && this.map.hasLayer(this.familyMarkersLayer)) {
|
||||
this.refreshFamilyLocations();
|
||||
} else {
|
||||
// Layer is no longer active, stop refreshing
|
||||
this.stopPeriodicRefresh();
|
||||
}
|
||||
}, 60000); // 60 seconds (real-time updates via ActionCable are primary)
|
||||
}
|
||||
|
||||
stopPeriodicRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Method to manually update family member locations (for API calls)
|
||||
updateFamilyLocations(locations) {
|
||||
// Convert array to object keyed by user_id
|
||||
if (Array.isArray(locations)) {
|
||||
this.familyMemberLocations = {};
|
||||
locations.forEach(location => {
|
||||
if (location.user_id) {
|
||||
this.familyMemberLocations[location.user_id] = location;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.familyMemberLocations = locations;
|
||||
}
|
||||
|
||||
this.createFamilyMarkers();
|
||||
|
||||
// Dispatch event for other controllers that might be interested
|
||||
document.dispatchEvent(new CustomEvent('family:locations:updated', {
|
||||
detail: { locations: this.familyMemberLocations }
|
||||
}));
|
||||
}
|
||||
|
||||
// Method to refresh family locations from API
|
||||
async refreshFamilyLocations() {
|
||||
if (!window.mapsController?.apiKey) {
|
||||
console.warn('API key not available for family locations refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/families/locations?api_key=${window.mapsController.apiKey}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
console.warn('Family feature not enabled or user not in family');
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.updateFamilyLocations(data.locations || []);
|
||||
|
||||
// Show user feedback if this was a manual refresh
|
||||
if (this.showUserFeedback) {
|
||||
const count = data.locations?.length || 0;
|
||||
this.showFlashMessageToUser('notice', `Family locations updated (${count} members)`);
|
||||
this.showUserFeedback = false; // Reset flag
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing family locations:', error);
|
||||
|
||||
// Show error to user if this was a manual refresh
|
||||
if (this.showUserFeedback) {
|
||||
this.showFlashMessageToUser('error', 'Failed to refresh family locations');
|
||||
this.showUserFeedback = false; // Reset flag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to show flash messages using the imported helper
|
||||
showFlashMessageToUser(type, message) {
|
||||
showFlashMessage(type, message);
|
||||
}
|
||||
|
||||
// Method for manual refresh with user feedback
|
||||
async manualRefreshFamilyLocations() {
|
||||
this.showUserFeedback = true; // Enable user feedback for this refresh
|
||||
await this.refreshFamilyLocations();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Stop periodic refresh
|
||||
this.stopPeriodicRefresh();
|
||||
|
||||
// Remove family markers layer from map if it exists
|
||||
if (this.familyMarkersLayer && this.map && this.map.hasLayer(this.familyMarkersLayer)) {
|
||||
this.map.removeLayer(this.familyMarkersLayer);
|
||||
}
|
||||
|
||||
// Remove map event listeners
|
||||
if (this.map) {
|
||||
this.map.off('overlayadd');
|
||||
this.map.off('overlayremove');
|
||||
}
|
||||
|
||||
// Remove document event listeners
|
||||
document.removeEventListener('family:locations:updated', this.handleLocationUpdates);
|
||||
document.removeEventListener('theme:changed', this.handleThemeChange);
|
||||
}
|
||||
|
||||
// Expose layer for external access
|
||||
getFamilyMarkersLayer() {
|
||||
return this.familyMarkersLayer;
|
||||
}
|
||||
|
||||
// Check if family features are enabled
|
||||
isFamilyFeatureEnabled() {
|
||||
return this.featuresValue.family === true;
|
||||
}
|
||||
|
||||
// Get family marker count
|
||||
getFamilyMemberCount() {
|
||||
return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["indicator"];
|
||||
static values = {
|
||||
enabled: Boolean
|
||||
};
|
||||
|
||||
connect() {
|
||||
console.log("Family navbar indicator controller connected");
|
||||
this.updateIndicator();
|
||||
|
||||
// Listen for location sharing updates
|
||||
document.addEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this));
|
||||
document.addEventListener('location-sharing:expired', this.handleSharingExpired.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this));
|
||||
document.removeEventListener('location-sharing:expired', this.handleSharingExpired.bind(this));
|
||||
}
|
||||
|
||||
handleSharingUpdate(event) {
|
||||
// Only update if this is the current user's sharing change
|
||||
// (we're only showing the current user's status in navbar)
|
||||
this.enabledValue = event.detail.enabled;
|
||||
this.updateIndicator();
|
||||
}
|
||||
|
||||
handleSharingExpired(event) {
|
||||
this.enabledValue = false;
|
||||
this.updateIndicator();
|
||||
}
|
||||
|
||||
updateIndicator() {
|
||||
if (!this.hasIndicatorTarget) return;
|
||||
|
||||
if (this.enabledValue) {
|
||||
// Green pulsing indicator for enabled
|
||||
this.indicatorTarget.className = "w-2 h-2 bg-green-500 rounded-full animate-pulse";
|
||||
this.indicatorTarget.title = "Location sharing enabled";
|
||||
} else {
|
||||
// Gray indicator for disabled
|
||||
this.indicatorTarget.className = "w-2 h-2 bg-gray-400 rounded-full";
|
||||
this.indicatorTarget.title = "Location sharing disabled";
|
||||
}
|
||||
}
|
||||
}
|
||||