|
|
@ -1 +1 @@
|
|||
0.33.1
|
||||
0.34.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
.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"
|
||||
|
|
|
|||
26
CHANGELOG.md
|
|
@ -4,7 +4,31 @@ 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.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
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -101,3 +101,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
@ -51,6 +52,32 @@
|
|||
.leaflet-control-layers-toggle {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
/* Replace default icon with custom SVG */
|
||||
background-image: none !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !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;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-expanded {
|
||||
|
|
@ -138,4 +165,72 @@
|
|||
background: var(--leaflet-scale-bg) !important;
|
||||
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/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-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/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/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/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 |
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
|
||||
24
app/controllers/api/v1/families_controller.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::FamiliesController < ApiController
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :ensure_user_in_family!
|
||||
|
||||
def locations
|
||||
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
|
||||
|
|
@ -30,7 +30,8 @@ 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,
|
||||
enabled_map_layers: []
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
app/controllers/families_controller.rb
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 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 update_location_sharing]
|
||||
|
||||
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
|
||||
|
||||
def update_location_sharing
|
||||
result = Families::UpdateLocationSharing.new(
|
||||
user: current_user,
|
||||
enabled: params[:enabled],
|
||||
duration: params[:duration]
|
||||
).call
|
||||
|
||||
render json: result.payload, status: result.status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
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
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class MapController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
layout 'map', only: :index
|
||||
|
||||
def index
|
||||
@points = filtered_points
|
||||
|
|
|
|||
93
app/controllers/users/registrations_controller.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Users::RegistrationsController < Devise::RegistrationsController
|
||||
before_action :set_invitation, only: %i[new create]
|
||||
before_action :check_registration_allowed, only: %i[new create]
|
||||
|
||||
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? && @invitation
|
||||
accept_invitation_for_user(resource)
|
||||
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?
|
||||
|
||||
redirect_to root_path,
|
||||
alert: 'Registration is not available. Please contact your administrator for access.'
|
||||
end
|
||||
|
||||
def set_invitation
|
||||
return unless invitation_token.present?
|
||||
|
||||
@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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
this.map.on('click', this.onMapClick, this);
|
||||
// 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 = '';
|
||||
|
|
@ -185,6 +177,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));
|
||||
|
|
@ -290,7 +288,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 +323,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,96 +345,92 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
|
||||
refreshVisitsLayer() {
|
||||
console.log('Attempting to refresh visits layer...');
|
||||
addCreatedVisitToMap(visitData, latitude, longitude) {
|
||||
console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData });
|
||||
|
||||
// Try multiple approaches to refresh the visits layer
|
||||
const mapsController = document.querySelector('[data-controller*="maps"]');
|
||||
if (mapsController) {
|
||||
// Try to get the Stimulus controller instance
|
||||
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 {
|
||||
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 {
|
||||
if (!mapsController) {
|
||||
console.log('Could not find maps controller element');
|
||||
return;
|
||||
}
|
||||
|
||||
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
|
||||
if (!stimulusController || !stimulusController.visitsManager) {
|
||||
console.log('Could not find maps controller or visits manager');
|
||||
return;
|
||||
}
|
||||
|
||||
const visitsManager = stimulusController.visitsManager;
|
||||
|
||||
// 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'
|
||||
});
|
||||
|
||||
// Add the circle to the confirmed visits layer
|
||||
visitsManager.confirmedVisitCircles.addLayer(circle);
|
||||
console.log('✅ Added newly created confirmed visit circle to layer');
|
||||
console.log('Confirmed visits layer info:', {
|
||||
layerCount: visitsManager.confirmedVisitCircles.getLayers().length,
|
||||
isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles)
|
||||
});
|
||||
|
||||
// Make sure the layer is visible on the map
|
||||
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
|
||||
this.map.addLayer(visitsManager.confirmedVisitCircles);
|
||||
console.log('✅ Added confirmed visits layer to map');
|
||||
}
|
||||
|
||||
// Check if the layer control has the confirmed visits layer enabled
|
||||
this.ensureConfirmedVisitsLayerEnabled();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 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);
|
||||
|
||||
// Update the layer control checkbox to reflect the layer is now active
|
||||
this.updateLayerControlCheckbox('Confirmed Visits', true);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
// Expand the layer control if it's collapsed
|
||||
const layerControlExpand = layerControlContainer.querySelector('.leaflet-control-layers-toggle');
|
||||
if (layerControlExpand) {
|
||||
layerControlExpand.click();
|
||||
}
|
||||
|
||||
// Trigger change event to ensure proper state management
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]');
|
||||
inputs.forEach(input => {
|
||||
const label = input.nextElementSibling;
|
||||
if (label && label.textContent.trim().includes('Confirmed Visits')) {
|
||||
console.log('Found Confirmed Visits checkbox, current state:', input.checked);
|
||||
if (!input.checked) {
|
||||
console.log('Enabling Confirmed Visits layer via checkbox');
|
||||
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) {
|
||||
this.map.off('click', this.onMapClick, this);
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
546
app/javascript/controllers/family_members_controller.js
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
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
|
||||
}
|
||||
|
||||
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 lastSeen = new Date(location.updated_at).toLocaleString();
|
||||
|
||||
// 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 lastSeen = new Date(locationData.updated_at).toLocaleString();
|
||||
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 lastSeen = new Date(location.updated_at).toLocaleString();
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
276
app/javascript/controllers/location_sharing_toggle_controller.js
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["checkbox", "durationContainer", "durationSelect", "expirationInfo"];
|
||||
static values = {
|
||||
memberId: Number,
|
||||
enabled: Boolean,
|
||||
familyId: Number,
|
||||
duration: String,
|
||||
expiresAt: String
|
||||
};
|
||||
|
||||
connect() {
|
||||
console.log("Location sharing toggle controller connected");
|
||||
this.updateToggleState();
|
||||
this.setupExpirationTimer();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.clearExpirationTimer();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const newState = !this.enabledValue;
|
||||
const duration = this.hasDurationSelectTarget ? this.durationSelectTarget.value : 'permanent';
|
||||
|
||||
// Optimistically update UI
|
||||
this.enabledValue = newState;
|
||||
this.updateToggleState();
|
||||
|
||||
// Send the update to server
|
||||
this.updateLocationSharing(newState, duration);
|
||||
}
|
||||
|
||||
changeDuration() {
|
||||
if (!this.enabledValue) return; // Only allow duration changes when sharing is enabled
|
||||
|
||||
const duration = this.durationSelectTarget.value;
|
||||
this.durationValue = duration;
|
||||
|
||||
// Update sharing with new duration
|
||||
this.updateLocationSharing(true, duration);
|
||||
}
|
||||
|
||||
updateToggleState() {
|
||||
const isEnabled = this.enabledValue;
|
||||
|
||||
// Update checkbox (DaisyUI toggle)
|
||||
this.checkboxTarget.checked = isEnabled;
|
||||
|
||||
// Show/hide duration container
|
||||
if (this.hasDurationContainerTarget) {
|
||||
if (isEnabled) {
|
||||
this.durationContainerTarget.classList.remove('hidden');
|
||||
} else {
|
||||
this.durationContainerTarget.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateLocationSharing(enabled, duration = 'permanent') {
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
|
||||
const response = await fetch(`/family/update_location_sharing`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: enabled,
|
||||
duration: duration
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update local values from server response
|
||||
this.durationValue = data.duration;
|
||||
this.expiresAtValue = data.expires_at;
|
||||
|
||||
// Update duration select if it exists
|
||||
if (this.hasDurationSelectTarget) {
|
||||
this.durationSelectTarget.value = data.duration;
|
||||
}
|
||||
|
||||
// Update expiration info
|
||||
this.updateExpirationInfo(data.expires_at_formatted);
|
||||
|
||||
// Show success message
|
||||
this.showFlashMessage('success', data.message);
|
||||
|
||||
// Setup/clear expiration timer
|
||||
this.setupExpirationTimer();
|
||||
|
||||
// Trigger custom event for other controllers to listen to
|
||||
document.dispatchEvent(new CustomEvent('location-sharing:updated', {
|
||||
detail: {
|
||||
userId: this.memberIdValue,
|
||||
enabled: enabled,
|
||||
duration: data.duration,
|
||||
expiresAt: data.expires_at
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Revert the UI change if server update failed
|
||||
this.enabledValue = !enabled;
|
||||
this.updateToggleState();
|
||||
this.showFlashMessage('error', data.message || 'Failed to update location sharing');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating location sharing:', error);
|
||||
|
||||
// Revert the UI change if request failed
|
||||
this.enabledValue = !enabled;
|
||||
this.updateToggleState();
|
||||
this.showFlashMessage('error', 'Network error occurred while updating location sharing');
|
||||
}
|
||||
}
|
||||
|
||||
setupExpirationTimer() {
|
||||
this.clearExpirationTimer();
|
||||
|
||||
if (this.enabledValue && this.expiresAtValue) {
|
||||
const expiresAt = new Date(this.expiresAtValue);
|
||||
const now = new Date();
|
||||
const msUntilExpiration = expiresAt.getTime() - now.getTime();
|
||||
|
||||
if (msUntilExpiration > 0) {
|
||||
// Set timer to automatically disable sharing when it expires
|
||||
this.expirationTimer = setTimeout(() => {
|
||||
this.enabledValue = false;
|
||||
this.updateToggleState();
|
||||
this.showFlashMessage('info', 'Location sharing has expired');
|
||||
|
||||
// Trigger update event
|
||||
document.dispatchEvent(new CustomEvent('location-sharing:expired', {
|
||||
detail: { userId: this.memberIdValue }
|
||||
}));
|
||||
}, msUntilExpiration);
|
||||
|
||||
// Also set up periodic updates to show countdown
|
||||
this.updateExpirationCountdown();
|
||||
this.countdownInterval = setInterval(() => {
|
||||
this.updateExpirationCountdown();
|
||||
}, 60000); // Update every minute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearExpirationTimer() {
|
||||
if (this.expirationTimer) {
|
||||
clearTimeout(this.expirationTimer);
|
||||
this.expirationTimer = null;
|
||||
}
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
this.countdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateExpirationInfo(formattedTime) {
|
||||
if (this.hasExpirationInfoTarget && formattedTime) {
|
||||
this.expirationInfoTarget.textContent = `Expires ${formattedTime}`;
|
||||
this.expirationInfoTarget.style.display = 'block';
|
||||
} else if (this.hasExpirationInfoTarget) {
|
||||
this.expirationInfoTarget.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateExpirationCountdown() {
|
||||
if (!this.hasExpirationInfoTarget || !this.expiresAtValue) return;
|
||||
|
||||
const expiresAt = new Date(this.expiresAtValue);
|
||||
const now = new Date();
|
||||
const msUntilExpiration = expiresAt.getTime() - now.getTime();
|
||||
|
||||
if (msUntilExpiration <= 0) {
|
||||
this.expirationInfoTarget.textContent = 'Expired';
|
||||
this.expirationInfoTarget.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const hoursLeft = Math.floor(msUntilExpiration / (1000 * 60 * 60));
|
||||
const minutesLeft = Math.floor((msUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
let timeText;
|
||||
if (hoursLeft > 0) {
|
||||
timeText = `${hoursLeft}h ${minutesLeft}m remaining`;
|
||||
} else {
|
||||
timeText = `${minutesLeft}m remaining`;
|
||||
}
|
||||
|
||||
this.expirationInfoTarget.textContent = `Expires in ${timeText}`;
|
||||
}
|
||||
|
||||
showFlashMessage(type, message) {
|
||||
// Create a flash message element matching the project style (_flash.html.erb)
|
||||
const flashContainer = document.getElementById('flash-messages') ||
|
||||
this.createFlashContainer();
|
||||
|
||||
const bgClass = this.getFlashClasses(type);
|
||||
|
||||
const flashElement = document.createElement('div');
|
||||
flashElement.className = `flex items-center ${bgClass} py-3 px-5 rounded-lg z-[6000]`;
|
||||
flashElement.innerHTML = `
|
||||
<div class="mr-4">${message}</div>
|
||||
<button type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click handler to dismiss button
|
||||
const dismissButton = flashElement.querySelector('button');
|
||||
dismissButton.addEventListener('click', () => {
|
||||
flashElement.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
flashElement.remove();
|
||||
// Remove the container if it's empty
|
||||
if (flashContainer && !flashContainer.hasChildNodes()) {
|
||||
flashContainer.remove();
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
flashContainer.appendChild(flashElement);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (flashElement.parentNode) {
|
||||
flashElement.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
flashElement.remove();
|
||||
// Remove the container if it's empty
|
||||
if (flashContainer && !flashContainer.hasChildNodes()) {
|
||||
flashContainer.remove();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
createFlashContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'flash-messages';
|
||||
container.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
getFlashClasses(type) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
case 'alert':
|
||||
return 'bg-red-100 text-red-700 border-red-300';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to check if user's own location sharing is enabled
|
||||
// This can be used by other controllers
|
||||
static getUserLocationSharingStatus() {
|
||||
const toggleController = document.querySelector('[data-controller*="location-sharing-toggle"]');
|
||||
if (toggleController) {
|
||||
const controller = this.application.getControllerForElementAndIdentifier(toggleController, 'location-sharing-toggle');
|
||||
return controller?.enabledValue || false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
45
app/javascript/controllers/map_controls_controller.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["panel", "toggleIcon"]
|
||||
|
||||
connect() {
|
||||
// Restore panel state from sessionStorage on page load
|
||||
const panelState = sessionStorage.getItem('mapControlsPanelState')
|
||||
if (panelState === 'visible') {
|
||||
this.showPanel()
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const isHidden = this.panelTarget.classList.contains("hidden")
|
||||
|
||||
if (isHidden) {
|
||||
this.showPanel()
|
||||
sessionStorage.setItem('mapControlsPanelState', 'visible')
|
||||
} else {
|
||||
this.hidePanel()
|
||||
sessionStorage.setItem('mapControlsPanelState', 'hidden')
|
||||
}
|
||||
}
|
||||
|
||||
showPanel() {
|
||||
this.panelTarget.classList.remove("hidden")
|
||||
|
||||
// Update icon to chevron-up
|
||||
const currentIcon = this.toggleIconTarget.querySelector('svg')
|
||||
currentIcon.classList.remove('lucide-chevron-down')
|
||||
currentIcon.classList.add('lucide-chevron-up')
|
||||
currentIcon.innerHTML = '<path d="m18 15-6-6-6 6"/>'
|
||||
}
|
||||
|
||||
hidePanel() {
|
||||
this.panelTarget.classList.add("hidden")
|
||||
|
||||
// Update icon to chevron-down
|
||||
const currentIcon = this.toggleIconTarget.querySelector('svg')
|
||||
currentIcon.classList.remove('lucide-chevron-up')
|
||||
currentIcon.classList.add('lucide-chevron-down')
|
||||
currentIcon.innerHTML = '<path d="m6 9 6 6 6-6"/>'
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ import { TileMonitor } from "../maps/tile_monitor";
|
|||
import BaseController from "./base_controller";
|
||||
import { createAllMapLayers } from "../maps/layers";
|
||||
import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils";
|
||||
import { addTopRightButtons } from "../maps/map_controls";
|
||||
|
||||
export default class extends BaseController {
|
||||
static targets = ["container"];
|
||||
|
|
@ -100,6 +101,9 @@ export default class extends BaseController {
|
|||
this.speedColoredPolylines = this.userSettings.speed_colored_routes || false;
|
||||
this.speedColorScale = this.userSettings.speed_color_scale || colorFormatEncode(colorStopsFallback);
|
||||
|
||||
// Flag to prevent saving layers during initialization/restoration
|
||||
this.isRestoringLayers = false;
|
||||
|
||||
// Ensure we have valid markers array
|
||||
if (!Array.isArray(this.markers)) {
|
||||
console.warn('Markers is not an array, setting to empty array');
|
||||
|
|
@ -112,7 +116,7 @@ export default class extends BaseController {
|
|||
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
|
||||
|
||||
// Add scale control
|
||||
L.control.scale({
|
||||
this.scaleControl = L.control.scale({
|
||||
position: 'bottomright',
|
||||
imperial: this.distanceUnit === 'mi',
|
||||
metric: this.distanceUnit === 'km',
|
||||
|
|
@ -145,7 +149,7 @@ export default class extends BaseController {
|
|||
}
|
||||
});
|
||||
|
||||
new StatsControl().addTo(this.map);
|
||||
this.statsControl = new StatsControl().addTo(this.map);
|
||||
|
||||
// Set the maximum bounds to prevent infinite scroll
|
||||
var southWest = L.latLng(-120, -210);
|
||||
|
|
@ -200,27 +204,17 @@ export default class extends BaseController {
|
|||
this.addSettingsButton();
|
||||
}
|
||||
|
||||
// Add info toggle button
|
||||
this.addInfoToggleButton();
|
||||
|
||||
// Initialize the visits manager
|
||||
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme);
|
||||
|
||||
// Expose visits manager globally for location search integration
|
||||
window.visitsManager = this.visitsManager;
|
||||
|
||||
// Initialize layers for the layer control
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer,
|
||||
Routes: this.polylinesLayer,
|
||||
Tracks: this.tracksLayer,
|
||||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
|
||||
Areas: this.areasLayer,
|
||||
Photos: this.photoMarkers,
|
||||
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
|
||||
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer()
|
||||
};
|
||||
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
// Expose maps controller globally for family integration
|
||||
window.mapsController = this;
|
||||
|
||||
// Initialize tile monitor
|
||||
this.tileMonitor = new TileMonitor(this.map, this.apiKey);
|
||||
|
|
@ -238,6 +232,9 @@ export default class extends BaseController {
|
|||
// Initialize layers based on settings
|
||||
this.initializeLayersFromSettings();
|
||||
|
||||
// Listen for Family Members layer becoming ready
|
||||
this.setupFamilyLayerListener();
|
||||
|
||||
// Initialize tracks layer
|
||||
this.initializeTracksLayer();
|
||||
|
||||
|
|
@ -247,11 +244,25 @@ export default class extends BaseController {
|
|||
// Preload areas
|
||||
fetchAndDrawAreas(this.areasLayer, this.apiKey);
|
||||
|
||||
// Add right panel toggle
|
||||
this.addTogglePanelButton();
|
||||
// Add all top-right buttons in the correct order
|
||||
this.initializeTopRightButtons();
|
||||
|
||||
// Initialize layers for the layer control
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer,
|
||||
Routes: this.polylinesLayer,
|
||||
Tracks: this.tracksLayer,
|
||||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
|
||||
Areas: this.areasLayer,
|
||||
Photos: this.photoMarkers,
|
||||
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
|
||||
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer()
|
||||
};
|
||||
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
|
||||
// Add visits buttons after calendar button to position them below
|
||||
this.visitsManager.addDrawerButton();
|
||||
|
||||
// Initialize Live Map Handler
|
||||
this.initializeLiveMapHandler();
|
||||
|
|
@ -460,6 +471,11 @@ export default class extends BaseController {
|
|||
|
||||
// Add event listeners for overlay layer changes to keep routes/tracks selector in sync
|
||||
this.map.on('overlayadd', (event) => {
|
||||
// Save enabled layers whenever a layer is added (unless we're restoring from settings)
|
||||
if (!this.isRestoringLayers) {
|
||||
this.saveEnabledLayers();
|
||||
}
|
||||
|
||||
if (event.name === 'Routes') {
|
||||
this.handleRouteLayerToggle('routes');
|
||||
// Re-establish event handlers when routes are manually added
|
||||
|
|
@ -515,6 +531,11 @@ export default class extends BaseController {
|
|||
});
|
||||
|
||||
this.map.on('overlayremove', (event) => {
|
||||
// Save enabled layers whenever a layer is removed (unless we're restoring from settings)
|
||||
if (!this.isRestoringLayers) {
|
||||
this.saveEnabledLayers();
|
||||
}
|
||||
|
||||
if (event.name === 'Routes' || event.name === 'Tracks') {
|
||||
// Don't auto-switch when layers are manually turned off
|
||||
// Just update the radio button state to reflect current visibility
|
||||
|
|
@ -548,9 +569,12 @@ export default class extends BaseController {
|
|||
}
|
||||
|
||||
updatePreferredBaseLayer(selectedLayerName) {
|
||||
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
|
||||
fetch('/api/v1/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
preferred_map_layer: selectedLayerName
|
||||
|
|
@ -567,6 +591,63 @@ export default class extends BaseController {
|
|||
});
|
||||
}
|
||||
|
||||
saveEnabledLayers() {
|
||||
const enabledLayers = [];
|
||||
const layerNames = [
|
||||
'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War',
|
||||
'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits',
|
||||
'Family Members'
|
||||
];
|
||||
|
||||
const controlsLayer = {
|
||||
'Points': this.markersLayer,
|
||||
'Routes': this.polylinesLayer,
|
||||
'Tracks': this.tracksLayer,
|
||||
'Heatmap': this.heatmapLayer,
|
||||
'Fog of War': this.fogOverlay,
|
||||
'Scratch map': this.scratchLayerManager?.getLayer(),
|
||||
'Areas': this.areasLayer,
|
||||
'Photos': this.photoMarkers,
|
||||
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
|
||||
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
|
||||
'Family Members': window.familyMembersController?.familyMarkersLayer
|
||||
};
|
||||
|
||||
layerNames.forEach(name => {
|
||||
const layer = controlsLayer[name];
|
||||
if (layer && this.map.hasLayer(layer)) {
|
||||
enabledLayers.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/api/v1/settings', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
enabled_map_layers: enabledLayers
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
console.log('Enabled layers saved:', enabledLayers);
|
||||
showFlashMessage('notice', 'Map layer preferences saved');
|
||||
} else {
|
||||
console.error('Failed to save enabled layers:', data.message);
|
||||
showFlashMessage('error', `Failed to save layer preferences: ${data.message}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving enabled layers:', error);
|
||||
showFlashMessage('error', 'Error saving layer preferences');
|
||||
});
|
||||
}
|
||||
|
||||
deletePoint(id, apiKey) {
|
||||
fetch(`/api/v1/points/${id}`, {
|
||||
method: 'DELETE',
|
||||
|
|
@ -723,13 +804,19 @@ export default class extends BaseController {
|
|||
// Define the custom control
|
||||
const SettingsControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'map-settings-button');
|
||||
button.innerHTML = '⚙️'; // Gear icon
|
||||
const button = L.DomUtil.create('button', 'map-settings-button tooltip tooltip-right');
|
||||
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog-icon lucide-cog"><path d="M11 10.27 7 3.34"/><path d="m11 13.73-4 6.93"/><path d="M12 22v-2"/><path d="M12 2v2"/><path d="M14 12h8"/><path d="m17 20.66-1-1.73"/><path d="m17 3.34-1 1.73"/><path d="M2 12h2"/><path d="m20.66 17-1.73-1"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m3.34 7 1.73 1"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="12" r="8"/></svg>'; // Gear icon
|
||||
button.setAttribute('data-tip', 'Settings');
|
||||
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '32px';
|
||||
button.style.height = '32px';
|
||||
button.style.width = '30px';
|
||||
button.style.height = '30px';
|
||||
button.style.display = 'flex';
|
||||
button.style.alignItems = 'center';
|
||||
button.style.justifyContent = 'center';
|
||||
button.style.padding = '0';
|
||||
button.style.borderRadius = '4px';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
|
@ -748,6 +835,104 @@ export default class extends BaseController {
|
|||
this.settingsButtonAdded = true;
|
||||
}
|
||||
|
||||
addInfoToggleButton() {
|
||||
// Store reference to the controller instance for use in the control
|
||||
const controller = this;
|
||||
|
||||
const InfoToggleControl = L.Control.extend({
|
||||
options: {
|
||||
position: 'bottomleft'
|
||||
},
|
||||
onAdd: function(map) {
|
||||
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
|
||||
const button = L.DomUtil.create('button', 'map-info-toggle-button tooltip tooltip-right', container);
|
||||
button.setAttribute('data-tip', 'Toggle footer visibility');
|
||||
|
||||
// Lucide info icon
|
||||
button.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, controller.userTheme);
|
||||
button.style.width = '34px';
|
||||
button.style.height = '34px';
|
||||
button.style.display = 'flex';
|
||||
button.style.alignItems = 'center';
|
||||
button.style.justifyContent = 'center';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.border = 'none';
|
||||
button.style.borderRadius = '4px';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
|
||||
// Toggle footer visibility on button click
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
controller.toggleFooterVisibility();
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the control to the map
|
||||
this.map.addControl(new InfoToggleControl());
|
||||
}
|
||||
|
||||
toggleFooterVisibility() {
|
||||
// Toggle the page footer
|
||||
const footer = document.getElementById('map-footer');
|
||||
if (!footer) return;
|
||||
|
||||
const isCurrentlyHidden = footer.classList.contains('hidden');
|
||||
|
||||
// Toggle Tailwind's hidden class
|
||||
footer.classList.toggle('hidden');
|
||||
|
||||
// Adjust bottom controls position based on footer visibility
|
||||
if (isCurrentlyHidden) {
|
||||
// Footer is being shown - move controls up
|
||||
setTimeout(() => {
|
||||
const footerHeight = footer.offsetHeight;
|
||||
// Add extra 20px margin above footer
|
||||
this.adjustBottomControls(footerHeight + 20);
|
||||
}, 10); // Small delay to ensure footer is rendered
|
||||
} else {
|
||||
// Footer is being hidden - reset controls position
|
||||
this.adjustBottomControls(10); // Back to default padding
|
||||
}
|
||||
|
||||
// Add click event to close footer when clicking on it (only add once)
|
||||
if (!footer.dataset.clickHandlerAdded) {
|
||||
footer.addEventListener('click', (e) => {
|
||||
// Only close if clicking the footer itself, not its contents
|
||||
if (e.target === footer) {
|
||||
footer.classList.add('hidden');
|
||||
this.adjustBottomControls(10); // Reset controls position
|
||||
}
|
||||
});
|
||||
footer.dataset.clickHandlerAdded = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
adjustBottomControls(paddingBottom) {
|
||||
// Adjust all bottom Leaflet controls
|
||||
const bottomLeftControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-left');
|
||||
const bottomRightControls = this.map.getContainer().querySelector('.leaflet-bottom.leaflet-right');
|
||||
|
||||
if (bottomLeftControls) {
|
||||
bottomLeftControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important');
|
||||
}
|
||||
if (bottomRightControls) {
|
||||
bottomRightControls.style.setProperty('padding-bottom', `${paddingBottom}px`, 'important');
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettingsMenu() {
|
||||
// If the settings panel already exists, just show/hide it
|
||||
if (this.settingsPanel) {
|
||||
|
|
@ -907,9 +1092,12 @@ export default class extends BaseController {
|
|||
const opacityValue = event.target.route_opacity.value.replace('%', '');
|
||||
const decimalOpacity = parseFloat(opacityValue) / 100;
|
||||
|
||||
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
|
||||
fetch('/api/v1/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
route_opacity: decimalOpacity.toString(),
|
||||
|
|
@ -1081,40 +1269,35 @@ export default class extends BaseController {
|
|||
}
|
||||
}
|
||||
|
||||
initializeTopRightButtons() {
|
||||
// Add all top-right buttons in the correct order:
|
||||
// 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer
|
||||
// Note: Layer control is added separately and appears at the top
|
||||
|
||||
addTogglePanelButton() {
|
||||
// Store reference to the controller instance for use in the control
|
||||
const controller = this;
|
||||
this.topRightControls = addTopRightButtons(
|
||||
this.map,
|
||||
{
|
||||
onSelectArea: () => this.visitsManager.toggleSelectionMode(),
|
||||
// onAddVisit is intentionally null - the add_visit_controller will attach its handler
|
||||
onAddVisit: null,
|
||||
onToggleCalendar: () => this.toggleRightPanel(),
|
||||
onToggleDrawer: () => this.visitsManager.toggleDrawer()
|
||||
},
|
||||
this.userTheme
|
||||
);
|
||||
|
||||
const TogglePanelControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const button = L.DomUtil.create('button', 'toggle-panel-button');
|
||||
button.innerHTML = '📅';
|
||||
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, controller.userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.textAlign = 'center';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
||||
// Toggle panel on button click
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
controller.toggleRightPanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
// Add the control to the map
|
||||
this.map.addControl(new TogglePanelControl({ position: 'topright' }));
|
||||
// Add CSS for selection button active state (needed by visits manager)
|
||||
if (!document.getElementById('selection-tool-active-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'selection-tool-active-style';
|
||||
style.textContent = `
|
||||
#selection-tool-button.active {
|
||||
border: 2px dashed #3388ff !important;
|
||||
box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
shouldShowTracksSelector() {
|
||||
|
|
@ -1286,45 +1469,123 @@ export default class extends BaseController {
|
|||
// Initialize layer visibility based on user settings or defaults
|
||||
// This method sets up the initial state of overlay layers
|
||||
|
||||
// Note: Don't automatically add layers to map here - let the layer control and user preferences handle it
|
||||
// The layer control will manage which layers are visible based on user interaction
|
||||
// Get enabled layers from user settings
|
||||
const enabledLayers = this.userSettings.enabled_map_layers || ['Points', 'Routes', 'Heatmap'];
|
||||
console.log('Initializing layers from settings:', enabledLayers);
|
||||
|
||||
// Initialize photos layer if user wants it visible
|
||||
if (this.userSettings.photos_enabled) {
|
||||
console.log('Photos layer enabled via user settings');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const endDate = urlParams.get('end_at') || new Date().toISOString();
|
||||
const controlsLayer = {
|
||||
'Points': this.markersLayer,
|
||||
'Routes': this.polylinesLayer,
|
||||
'Tracks': this.tracksLayer,
|
||||
'Heatmap': this.heatmapLayer,
|
||||
'Fog of War': this.fogOverlay,
|
||||
'Scratch map': this.scratchLayerManager?.getLayer(),
|
||||
'Areas': this.areasLayer,
|
||||
'Photos': this.photoMarkers,
|
||||
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
|
||||
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
|
||||
'Family Members': window.familyMembersController?.familyMarkersLayer
|
||||
};
|
||||
|
||||
console.log('Auto-fetching photos for date range:', { startDate, endDate });
|
||||
fetchAndDisplayPhotos({
|
||||
map: this.map,
|
||||
photoMarkers: this.photoMarkers,
|
||||
apiKey: this.apiKey,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
userSettings: this.userSettings
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize fog of war if enabled in settings
|
||||
if (this.userSettings.fog_of_war_enabled) {
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
|
||||
}
|
||||
|
||||
// Initialize visits manager functionality
|
||||
// Check if any visits layers are enabled by default and load data
|
||||
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
// Check if confirmed visits layer is enabled by default (it's added to map in constructor)
|
||||
const confirmedVisitsEnabled = this.map.hasLayer(this.visitsManager.getConfirmedVisitCirclesLayer());
|
||||
|
||||
console.log('Visits initialization - confirmedVisitsEnabled:', confirmedVisitsEnabled);
|
||||
|
||||
if (confirmedVisitsEnabled) {
|
||||
console.log('Confirmed visits layer enabled by default - fetching visits data');
|
||||
this.visitsManager.fetchAndDisplayVisits();
|
||||
// Apply saved layer preferences
|
||||
Object.entries(controlsLayer).forEach(([name, layer]) => {
|
||||
if (!layer) {
|
||||
if (enabledLayers.includes(name)) {
|
||||
console.log(`Layer ${name} is in enabled layers but layer object is null/undefined`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldBeEnabled = enabledLayers.includes(name);
|
||||
const isCurrentlyEnabled = this.map.hasLayer(layer);
|
||||
|
||||
if (name === 'Family Members') {
|
||||
console.log('Family Members layer check:', {
|
||||
shouldBeEnabled,
|
||||
isCurrentlyEnabled,
|
||||
layerExists: !!layer,
|
||||
controllerExists: !!window.familyMembersController
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldBeEnabled && !isCurrentlyEnabled) {
|
||||
// Add layer to map
|
||||
layer.addTo(this.map);
|
||||
console.log(`Enabled layer: ${name}`);
|
||||
|
||||
// Trigger special initialization for certain layers
|
||||
if (name === 'Photos') {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at') || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const endDate = urlParams.get('end_at') || new Date().toISOString();
|
||||
fetchAndDisplayPhotos({
|
||||
map: this.map,
|
||||
photoMarkers: this.photoMarkers,
|
||||
apiKey: this.apiKey,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
userSettings: this.userSettings
|
||||
});
|
||||
} else if (name === 'Fog of War') {
|
||||
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
|
||||
} else if (name === 'Suggested Visits' || name === 'Confirmed Visits') {
|
||||
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
|
||||
this.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
} else if (name === 'Scratch map') {
|
||||
if (this.scratchLayerManager) {
|
||||
this.scratchLayerManager.addToMap();
|
||||
}
|
||||
} else if (name === 'Routes') {
|
||||
// Re-establish event handlers for routes layer
|
||||
reestablishPolylineEventHandlers(this.polylinesLayer, this.map, this.userSettings, this.distanceUnit);
|
||||
} else if (name === 'Areas') {
|
||||
// Show draw control when Areas layer is enabled
|
||||
if (this.drawControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) {
|
||||
this.map.addControl(this.drawControl);
|
||||
}
|
||||
} else if (name === 'Family Members') {
|
||||
// Refresh family locations when layer is restored
|
||||
if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') {
|
||||
window.familyMembersController.refreshFamilyLocations();
|
||||
}
|
||||
}
|
||||
} else if (!shouldBeEnabled && isCurrentlyEnabled) {
|
||||
// Remove layer from map
|
||||
this.map.removeLayer(layer);
|
||||
console.log(`Disabled layer: ${name}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupFamilyLayerListener() {
|
||||
// Listen for when the Family Members layer becomes available
|
||||
document.addEventListener('family:layer:ready', (event) => {
|
||||
console.log('Family layer ready event received');
|
||||
const enabledLayers = this.userSettings.enabled_map_layers || [];
|
||||
|
||||
// Check if Family Members should be enabled based on saved settings
|
||||
if (enabledLayers.includes('Family Members')) {
|
||||
const layer = event.detail.layer;
|
||||
if (layer && !this.map.hasLayer(layer)) {
|
||||
// Set flag to prevent saving during restoration
|
||||
this.isRestoringLayers = true;
|
||||
|
||||
layer.addTo(this.map);
|
||||
console.log('Enabled layer: Family Members (from ready event)');
|
||||
|
||||
// Refresh family locations
|
||||
if (window.familyMembersController && typeof window.familyMembersController.refreshFamilyLocations === 'function') {
|
||||
window.familyMembersController.refreshFamilyLocations();
|
||||
}
|
||||
|
||||
// Reset flag after a short delay to allow all events to complete
|
||||
setTimeout(() => {
|
||||
this.isRestoringLayers = false;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, { once: true }); // Only listen once
|
||||
}
|
||||
|
||||
toggleRightPanel() {
|
||||
|
|
@ -1839,4 +2100,77 @@ export default class extends BaseController {
|
|||
this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method for family controller to update layer control
|
||||
updateLayerControl(additionalLayers = {}) {
|
||||
if (!this.layerControl) return;
|
||||
|
||||
// Store which base and overlay layers are currently visible
|
||||
const overlayStates = {};
|
||||
let activeBaseLayer = null;
|
||||
let activeBaseLayerName = null;
|
||||
|
||||
if (this.layerControl._layers) {
|
||||
Object.values(this.layerControl._layers).forEach(layerObj => {
|
||||
if (layerObj.overlay && layerObj.layer) {
|
||||
// Store overlay layer states
|
||||
overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer);
|
||||
} else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) {
|
||||
// Store the currently active base layer
|
||||
activeBaseLayer = layerObj.layer;
|
||||
activeBaseLayerName = layerObj.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove existing layer control
|
||||
this.map.removeControl(this.layerControl);
|
||||
|
||||
// Create base controls layer object
|
||||
const baseControlsLayer = {
|
||||
Points: this.markersLayer || L.layerGroup(),
|
||||
Routes: this.polylinesLayer || L.layerGroup(),
|
||||
Tracks: this.tracksLayer || L.layerGroup(),
|
||||
Heatmap: this.heatmapLayer || L.heatLayer([]),
|
||||
"Fog of War": this.fogOverlay,
|
||||
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
|
||||
Areas: this.areasLayer || L.layerGroup(),
|
||||
Photos: this.photoMarkers || L.layerGroup(),
|
||||
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
|
||||
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
|
||||
};
|
||||
|
||||
// Merge with additional layers (like family members)
|
||||
const controlsLayer = { ...baseControlsLayer, ...additionalLayers };
|
||||
|
||||
// Get base maps and re-add the layer control
|
||||
const baseMaps = this.baseMaps();
|
||||
this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map);
|
||||
|
||||
// Restore the active base layer if we had one
|
||||
if (activeBaseLayer && activeBaseLayerName) {
|
||||
console.log(`Restoring base layer: ${activeBaseLayerName}`);
|
||||
// Make sure the base layer is added to the map
|
||||
if (!this.map.hasLayer(activeBaseLayer)) {
|
||||
activeBaseLayer.addTo(this.map);
|
||||
}
|
||||
} else {
|
||||
// If no active base layer was found, ensure we have a default one
|
||||
console.log('No active base layer found, adding default');
|
||||
const defaultBaseLayer = Object.values(baseMaps)[0];
|
||||
if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) {
|
||||
defaultBaseLayer.addTo(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore overlay layer visibility states
|
||||
Object.entries(overlayStates).forEach(([name, wasVisible]) => {
|
||||
const layer = controlsLayer[name];
|
||||
if (layer && wasVisible && !this.map.hasLayer(layer)) {
|
||||
layer.addTo(this.map);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,13 +99,6 @@ export default class extends BaseController {
|
|||
console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
|
||||
}
|
||||
|
||||
console.log('🎯 Public sharing: using manual hexagon loading');
|
||||
console.log('🔍 Debug values:');
|
||||
console.log(' dataBounds:', dataBounds);
|
||||
console.log(' point_count:', dataBounds?.point_count);
|
||||
console.log(' hexagonsAvailableValue:', this.hexagonsAvailableValue);
|
||||
console.log(' hexagonsAvailableValue type:', typeof this.hexagonsAvailableValue);
|
||||
|
||||
// Load hexagons only if they are pre-calculated and data exists
|
||||
if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) {
|
||||
await this.loadStaticHexagons();
|
||||
|
|
@ -140,7 +133,7 @@ export default class extends BaseController {
|
|||
|
||||
// Ensure loading overlay is visible and disable map interaction
|
||||
const loadingElement = document.getElementById('map-loading');
|
||||
console.log('🔍 Loading element found:', !!loadingElement);
|
||||
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'flex';
|
||||
loadingElement.style.visibility = 'visible';
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@ export default class extends Controller {
|
|||
// Show temporary success feedback
|
||||
const button = this.sharingLinkTarget.nextElementSibling
|
||||
const originalText = button.innerHTML
|
||||
button.innerHTML = "✅ Copied!"
|
||||
button.classList.add("btn-success")
|
||||
button.innerHTML = "✅ Link Copied!"
|
||||
button.classList.add("btn-outline btn-success")
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText
|
||||
|
|
|
|||
|
|
@ -125,30 +125,41 @@ export function showFlashMessage(type, message) {
|
|||
if (!flashContainer) {
|
||||
flashContainer = document.createElement('div');
|
||||
flashContainer.id = 'flash-messages';
|
||||
flashContainer.className = 'fixed top-5 right-5 flex flex-col-reverse gap-2 z-50';
|
||||
flashContainer.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50';
|
||||
document.body.appendChild(flashContainer);
|
||||
}
|
||||
|
||||
// Create the flash message div
|
||||
// Create the flash message div with DaisyUI alert classes
|
||||
const flashDiv = document.createElement('div');
|
||||
flashDiv.setAttribute('data-controller', 'removals');
|
||||
flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg z-50`;
|
||||
flashDiv.setAttribute('data-removals-timeout-value', type === 'notice' || type === 'success' ? '5000' : '0');
|
||||
flashDiv.setAttribute('role', 'alert');
|
||||
flashDiv.className = `alert ${getAlertClass(type)} shadow-lg z-[6000]`;
|
||||
|
||||
// Create the message div
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'mr-4';
|
||||
messageDiv.innerText = message;
|
||||
// Create the content wrapper
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'flex items-center gap-2';
|
||||
|
||||
// Add the icon
|
||||
const icon = getFlashIcon(type);
|
||||
contentDiv.appendChild(icon);
|
||||
|
||||
// Create the message span
|
||||
const messageSpan = document.createElement('span');
|
||||
messageSpan.innerText = message;
|
||||
contentDiv.appendChild(messageSpan);
|
||||
|
||||
// Create the close button
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.setAttribute('type', 'button');
|
||||
closeButton.setAttribute('data-action', 'click->removals#remove');
|
||||
closeButton.className = 'ml-auto'; // Ensures button stays on the right
|
||||
closeButton.setAttribute('aria-label', 'Close');
|
||||
closeButton.className = 'btn btn-sm btn-circle btn-ghost';
|
||||
|
||||
// Create the SVG icon for the close button
|
||||
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
closeIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
closeIcon.setAttribute('class', 'h-6 w-6');
|
||||
closeIcon.setAttribute('class', 'h-5 w-5');
|
||||
closeIcon.setAttribute('fill', 'none');
|
||||
closeIcon.setAttribute('viewBox', '0 0 24 24');
|
||||
closeIcon.setAttribute('stroke', 'currentColor');
|
||||
|
|
@ -162,33 +173,75 @@ export function showFlashMessage(type, message) {
|
|||
// Append all elements
|
||||
closeIcon.appendChild(closeIconPath);
|
||||
closeButton.appendChild(closeIcon);
|
||||
flashDiv.appendChild(messageDiv);
|
||||
flashDiv.appendChild(contentDiv);
|
||||
flashDiv.appendChild(closeButton);
|
||||
flashContainer.appendChild(flashDiv);
|
||||
|
||||
// Automatically remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (flashDiv && flashDiv.parentNode) {
|
||||
flashDiv.remove();
|
||||
// Remove container if empty
|
||||
if (flashContainer && !flashContainer.hasChildNodes()) {
|
||||
flashContainer.remove();
|
||||
// Automatically remove after 5 seconds for notice/success
|
||||
if (type === 'notice' || type === 'success') {
|
||||
setTimeout(() => {
|
||||
if (flashDiv && flashDiv.parentNode) {
|
||||
flashDiv.remove();
|
||||
// Remove container if empty
|
||||
if (flashContainer && !flashContainer.hasChildNodes()) {
|
||||
flashContainer.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function classesForFlash(type) {
|
||||
function getAlertClass(type) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700 border-red-300';
|
||||
case 'alert':
|
||||
return 'alert-error';
|
||||
case 'notice':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
case 'info':
|
||||
return 'alert-info';
|
||||
case 'success':
|
||||
return 'alert-success';
|
||||
case 'warning':
|
||||
return 'alert-warning';
|
||||
default:
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
return 'alert-info';
|
||||
}
|
||||
}
|
||||
|
||||
function getFlashIcon(type) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
svg.setAttribute('class', 'h-6 w-6 shrink-0 stroke-current');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
|
||||
switch (type) {
|
||||
case 'error':
|
||||
case 'alert':
|
||||
path.setAttribute('d', 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z');
|
||||
break;
|
||||
case 'success':
|
||||
path.setAttribute('d', 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z');
|
||||
break;
|
||||
case 'warning':
|
||||
path.setAttribute('d', 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z');
|
||||
break;
|
||||
case 'notice':
|
||||
case 'info':
|
||||
default:
|
||||
path.setAttribute('d', 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z');
|
||||
break;
|
||||
}
|
||||
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class LocationSearch {
|
|||
const SearchToggleControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const button = L.DomUtil.create('button', 'location-search-toggle');
|
||||
button.innerHTML = '🔍';
|
||||
button.innerHTML = '<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>';
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '48px';
|
||||
|
|
@ -33,6 +33,9 @@ class LocationSearch {
|
|||
button.style.padding = '0';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.marginTop = '10px'; // Space below settings button
|
||||
button.style.display = 'flex';
|
||||
button.style.alignItems = 'center';
|
||||
button.style.justifyContent = 'center';
|
||||
button.title = 'Search locations';
|
||||
button.id = 'location-search-toggle';
|
||||
return button;
|
||||
|
|
@ -174,8 +177,6 @@ class LocationSearch {
|
|||
container.addEventListener('DOMMouseScroll', (e) => {
|
||||
e.stopPropagation();
|
||||
}, { passive: false });
|
||||
|
||||
console.log('LocationSearch: Added scroll prevention to container', container.id || 'search-bar');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
193
app/javascript/maps/map_controls.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// Map control buttons and utilities
|
||||
// This file contains all button controls that are positioned on the top-right corner of the map
|
||||
import L from "leaflet";
|
||||
import { applyThemeToButton } from "./theme_utils";
|
||||
|
||||
/**
|
||||
* Creates a standardized button element for map controls
|
||||
* @param {String} className - CSS class name for the button
|
||||
* @param {String} svgIcon - SVG icon HTML
|
||||
* @param {String} title - Button title/tooltip
|
||||
* @param {String} userTheme - User's theme preference ('dark' or 'light')
|
||||
* @param {Function} onClickCallback - Callback function to execute when button is clicked
|
||||
* @returns {HTMLElement} Button element with tooltip
|
||||
*/
|
||||
function createStandardButton(className, svgIcon, title, userTheme, onClickCallback) {
|
||||
const button = L.DomUtil.create('button', `${className} tooltip tooltip-left`);
|
||||
button.innerHTML = svgIcon;
|
||||
button.setAttribute('data-tip', title);
|
||||
|
||||
// Apply standard button styling
|
||||
applyThemeToButton(button, userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.display = 'flex';
|
||||
button.style.alignItems = 'center';
|
||||
button.style.justifyContent = 'center';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.transition = 'all 0.2s ease';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
|
||||
// Attach click handler if provided
|
||||
// Note: Some buttons (like Add Visit) have their handlers attached separately
|
||||
if (onClickCallback && typeof onClickCallback === 'function') {
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
onClickCallback(button);
|
||||
});
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a "Toggle Panel" button control for the map
|
||||
* @param {Function} onClickCallback - Callback function to execute when button is clicked
|
||||
* @param {String} userTheme - User's theme preference ('dark' or 'light')
|
||||
* @returns {L.Control} Leaflet control instance
|
||||
*/
|
||||
export function createTogglePanelControl(onClickCallback, userTheme = 'dark') {
|
||||
const TogglePanelControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const svgIcon = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="m16 20 2 2 4-4" />
|
||||
</svg>
|
||||
`;
|
||||
return createStandardButton('toggle-panel-button', svgIcon, 'Toggle Panel', userTheme, onClickCallback);
|
||||
}
|
||||
});
|
||||
|
||||
return TogglePanelControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a "Visits Drawer" button control for the map
|
||||
* @param {Function} onClickCallback - Callback function to execute when button is clicked
|
||||
* @param {String} userTheme - User's theme preference ('dark' or 'light')
|
||||
* @returns {L.Control} Leaflet control instance
|
||||
*/
|
||||
export function createVisitsDrawerControl(onClickCallback, userTheme = 'dark') {
|
||||
const DrawerControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const svgIcon = '<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-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
|
||||
return createStandardButton('leaflet-control-button drawer-button', svgIcon, 'Toggle Visits Drawer', userTheme, onClickCallback);
|
||||
}
|
||||
});
|
||||
|
||||
return DrawerControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an "Area Selection" button control for the map
|
||||
* @param {Function} onClickCallback - Callback function to execute when button is clicked
|
||||
* @param {String} userTheme - User's theme preference ('dark' or 'light')
|
||||
* @returns {L.Control} Leaflet control instance
|
||||
*/
|
||||
export function createAreaSelectionControl(onClickCallback, userTheme = 'dark') {
|
||||
const SelectionControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const svgIcon = '<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>';
|
||||
const button = createStandardButton('leaflet-bar leaflet-control leaflet-control-custom', svgIcon, 'Select Area', userTheme, onClickCallback);
|
||||
button.id = 'selection-tool-button';
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
return SelectionControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an "Add Visit" button control for the map
|
||||
* @param {Function} onClickCallback - Callback function to execute when button is clicked
|
||||
* @param {String} userTheme - User's theme preference ('dark' or 'light')
|
||||
* @returns {L.Control} Leaflet control instance
|
||||
*/
|
||||
export function createAddVisitControl(onClickCallback, userTheme = 'dark') {
|
||||
const AddVisitControl = L.Control.extend({
|
||||
onAdd: function(map) {
|
||||
const svgIcon = '<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>';
|
||||
return createStandardButton('leaflet-control-button add-visit-button', svgIcon, 'Add a visit', userTheme, onClickCallback);
|
||||
}
|
||||
});
|
||||
|
||||
return AddVisitControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds all top-right corner buttons to the map in the correct order
|
||||
* Order: 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer
|
||||
* Note: Layer control is added separately by Leaflet and appears at the top
|
||||
*
|
||||
* @param {Object} map - Leaflet map instance
|
||||
* @param {Object} callbacks - Object containing callback functions for each button
|
||||
* @param {Function} callbacks.onSelectArea - Callback for select area button
|
||||
* @param {Function} callbacks.onAddVisit - Callback for add visit button
|
||||
* @param {Function} callbacks.onToggleCalendar - Callback for toggle calendar/panel button
|
||||
* @param {Function} callbacks.onToggleDrawer - Callback for toggle drawer button
|
||||
* @param {String} userTheme - User's theme preference ('dark' or 'light')
|
||||
* @returns {Object} Object containing references to all created controls
|
||||
*/
|
||||
export function addTopRightButtons(map, callbacks, userTheme = 'dark') {
|
||||
const controls = {};
|
||||
|
||||
// 1. Select Area button
|
||||
if (callbacks.onSelectArea) {
|
||||
const SelectionControl = createAreaSelectionControl(callbacks.onSelectArea, userTheme);
|
||||
controls.selectionControl = new SelectionControl({ position: 'topright' });
|
||||
map.addControl(controls.selectionControl);
|
||||
}
|
||||
|
||||
// 2. Add Visit button
|
||||
// Note: Button is always created, callback is optional (add_visit_controller attaches its own handler)
|
||||
const AddVisitControl = createAddVisitControl(callbacks.onAddVisit, userTheme);
|
||||
controls.addVisitControl = new AddVisitControl({ position: 'topright' });
|
||||
map.addControl(controls.addVisitControl);
|
||||
|
||||
// 3. Open Calendar (Toggle Panel) button
|
||||
if (callbacks.onToggleCalendar) {
|
||||
const TogglePanelControl = createTogglePanelControl(callbacks.onToggleCalendar, userTheme);
|
||||
controls.togglePanelControl = new TogglePanelControl({ position: 'topright' });
|
||||
map.addControl(controls.togglePanelControl);
|
||||
}
|
||||
|
||||
// 4. Open Drawer button
|
||||
if (callbacks.onToggleDrawer) {
|
||||
const DrawerControl = createVisitsDrawerControl(callbacks.onToggleDrawer, userTheme);
|
||||
controls.drawerControl = new DrawerControl({ position: 'topright' });
|
||||
map.addControl(controls.drawerControl);
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the Add Visit button to show active state
|
||||
* @param {HTMLElement} button - The button element to update
|
||||
*/
|
||||
export function setAddVisitButtonActive(button) {
|
||||
if (!button) return;
|
||||
|
||||
button.style.backgroundColor = '#dc3545';
|
||||
button.style.color = 'white';
|
||||
button.innerHTML = '✕';
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the Add Visit button to show inactive/default state
|
||||
* @param {HTMLElement} button - The button element to update
|
||||
* @param {String} userTheme - User's theme preference ('dark' or 'light')
|
||||
*/
|
||||
export function setAddVisitButtonInactive(button, userTheme = 'dark') {
|
||||
if (!button) return;
|
||||
|
||||
applyThemeToButton(button, userTheme);
|
||||
button.innerHTML = '<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>';
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import L from "leaflet";
|
||||
import { showFlashMessage } from "./helpers";
|
||||
import { applyThemeToButton } from "./theme_utils";
|
||||
|
||||
/**
|
||||
* Manages visits functionality including displaying, fetching, and interacting with visits
|
||||
|
|
@ -65,74 +64,14 @@ export class VisitsManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds a button to toggle the visits drawer
|
||||
* Note: Drawer and selection buttons are now added centrally via addTopRightButtons()
|
||||
* in maps_controller.js to ensure correct button ordering.
|
||||
*
|
||||
* The methods below are kept for backwards compatibility but are no longer called
|
||||
* during initialization. Button callbacks are wired directly in maps_controller.js:
|
||||
* - onSelectArea -> this.toggleSelectionMode()
|
||||
* - onToggleDrawer -> this.toggleDrawer()
|
||||
*/
|
||||
addDrawerButton() {
|
||||
const DrawerControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button');
|
||||
button.innerHTML = '⬅️'; // Left arrow icon
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.textAlign = 'center';
|
||||
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
this.toggleDrawer();
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
this.map.addControl(new DrawerControl({ position: 'topright' }));
|
||||
|
||||
// Add the selection tool button
|
||||
this.addSelectionButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a button to enable/disable the area selection tool
|
||||
*/
|
||||
addSelectionButton() {
|
||||
const SelectionControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom');
|
||||
button.innerHTML = '⚓️';
|
||||
button.title = 'Select Area';
|
||||
button.id = 'selection-tool-button';
|
||||
// Style the button with theme-aware styling
|
||||
applyThemeToButton(button, this.userTheme);
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.textAlign = 'center';
|
||||
button.onclick = () => this.toggleSelectionMode();
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
new SelectionControl({ position: 'topright' }).addTo(this.map);
|
||||
|
||||
// Add CSS for selection button active state
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#selection-tool-button.active {
|
||||
border: 2px dashed #3388ff !important;
|
||||
box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the area selection mode
|
||||
|
|
@ -482,7 +421,7 @@ export class VisitsManager {
|
|||
|
||||
const drawerButton = document.querySelector('.drawer-button');
|
||||
if (drawerButton) {
|
||||
drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️';
|
||||
drawerButton.innerHTML = this.drawerOpen ? '<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-panel-right-close-icon lucide-panel-right-close"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>' : '<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-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
|
||||
}
|
||||
|
||||
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button');
|
||||
|
|
|
|||
24
app/jobs/family/invitations/cleanup_job.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::Invitations::CleanupJob < ApplicationJob
|
||||
queue_as :families
|
||||
|
||||
def perform
|
||||
Rails.logger.info 'Starting family invitations cleanup'
|
||||
|
||||
expired_count = Family::Invitation.where(status: :pending)
|
||||
.where('expires_at < ?', Time.current)
|
||||
.update_all(status: :expired)
|
||||
|
||||
Rails.logger.info "Updated #{expired_count} expired family invitations"
|
||||
|
||||
cleanup_threshold = 30.days.ago
|
||||
deleted_count = Family::Invitation.where(status: [:expired, :cancelled])
|
||||
.where('updated_at < ?', cleanup_threshold)
|
||||
.delete_all
|
||||
|
||||
Rails.logger.info "Deleted #{deleted_count} old family invitations"
|
||||
|
||||
Rails.logger.info 'Family invitations cleanup completed'
|
||||
end
|
||||
end
|
||||
|
|
@ -17,6 +17,8 @@ class Users::ImportDataJob < ApplicationJob
|
|||
|
||||
import_stats = Users::ImportData.new(user, archive_path).import
|
||||
|
||||
User.reset_counters(user.id, :points)
|
||||
|
||||
Rails.logger.info "Import completed successfully for user #{user.email}: #{import_stats}"
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
ExceptionReporter.call(e, "Import job failed for import_id #{import_id} - import not found")
|
||||
|
|
|
|||
25
app/mailers/family_mailer.rb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyMailer < ApplicationMailer
|
||||
def invitation(invitation)
|
||||
@invitation = invitation
|
||||
@family = invitation.family
|
||||
@invited_by = invitation.invited_by
|
||||
@accept_url = family_invitation_url(@invitation.token)
|
||||
|
||||
mail(
|
||||
to: @invitation.email,
|
||||
subject: "🎉 You've been invited to join #{@family.name} on Dawarich!"
|
||||
)
|
||||
end
|
||||
|
||||
def member_joined(family, user)
|
||||
@family = family
|
||||
@user = user
|
||||
|
||||
mail(
|
||||
to: @family.owner.email,
|
||||
subject: "👪 #{@user.name} has joined your family #{@family.name} on Dawarich!"
|
||||
)
|
||||
end
|
||||
end
|
||||
116
app/models/concerns/user_family.rb
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module UserFamily
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :family_membership, dependent: :destroy, class_name: 'Family::Membership'
|
||||
has_one :family, through: :family_membership
|
||||
has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy
|
||||
has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id',
|
||||
inverse_of: :invited_by, dependent: :destroy
|
||||
|
||||
before_destroy :check_family_ownership
|
||||
end
|
||||
|
||||
def in_family?
|
||||
family_membership.present?
|
||||
end
|
||||
|
||||
def family_owner?
|
||||
family_membership&.owner? == true
|
||||
end
|
||||
|
||||
def can_delete_account?
|
||||
return true unless family_owner?
|
||||
return true unless family
|
||||
|
||||
family.members.count <= 1
|
||||
end
|
||||
|
||||
def family_sharing_enabled?
|
||||
return false unless in_family?
|
||||
|
||||
sharing_settings = settings.dig('family', 'location_sharing')
|
||||
return false unless sharing_settings.is_a?(Hash)
|
||||
return false unless sharing_settings['enabled'] == true
|
||||
|
||||
expires_at = sharing_settings['expires_at']
|
||||
expires_at.blank? || Time.parse(expires_at).future?
|
||||
end
|
||||
|
||||
def update_family_location_sharing!(enabled, duration: nil)
|
||||
return false unless in_family?
|
||||
|
||||
current_settings = settings || {}
|
||||
current_settings['family'] ||= {}
|
||||
|
||||
if enabled
|
||||
sharing_config = { 'enabled' => true }
|
||||
|
||||
if duration.present?
|
||||
expiration_time = case duration
|
||||
when '1h' then 1.hour.from_now
|
||||
when '6h' then 6.hours.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
when '24h' then 24.hours.from_now
|
||||
when 'permanent' then nil
|
||||
else duration.to_i.hours.from_now if duration.to_i > 0
|
||||
end
|
||||
|
||||
sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time
|
||||
sharing_config['duration'] = duration
|
||||
end
|
||||
|
||||
current_settings['family']['location_sharing'] = sharing_config
|
||||
else
|
||||
current_settings['family']['location_sharing'] = { 'enabled' => false }
|
||||
end
|
||||
|
||||
update!(settings: current_settings)
|
||||
end
|
||||
|
||||
def family_sharing_expires_at
|
||||
sharing_settings = settings.dig('family', 'location_sharing')
|
||||
return nil unless sharing_settings.is_a?(Hash)
|
||||
|
||||
expires_at = sharing_settings['expires_at']
|
||||
Time.parse(expires_at) if expires_at.present?
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def family_sharing_duration
|
||||
settings.dig('family', 'location_sharing', 'duration') || 'permanent'
|
||||
end
|
||||
|
||||
def latest_location_for_family
|
||||
return nil unless family_sharing_enabled?
|
||||
|
||||
latest_point =
|
||||
points.select(:lonlat, :timestamp)
|
||||
.order(timestamp: :desc)
|
||||
.limit(1)
|
||||
.first
|
||||
|
||||
return nil unless latest_point
|
||||
|
||||
{
|
||||
user_id: id,
|
||||
email: email,
|
||||
latitude: latest_point.lat,
|
||||
longitude: latest_point.lon,
|
||||
timestamp: latest_point.timestamp,
|
||||
updated_at: Time.zone.at(latest_point.timestamp)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_family_ownership
|
||||
return if can_delete_account?
|
||||
|
||||
errors.add(:base, 'Cannot delete account while being a family owner with other members')
|
||||
raise ActiveRecord::DeleteRestrictionError, 'Cannot delete user with family members'
|
||||
end
|
||||
end
|
||||
47
app/models/family.rb
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family < ApplicationRecord
|
||||
has_many :family_memberships, dependent: :destroy, class_name: 'Family::Membership'
|
||||
has_many :members, through: :family_memberships, source: :user
|
||||
has_many :family_invitations, dependent: :destroy, class_name: 'Family::Invitation'
|
||||
belongs_to :creator, class_name: 'User'
|
||||
|
||||
validates :name, presence: true, length: { maximum: 50 }
|
||||
|
||||
MAX_MEMBERS = 5
|
||||
|
||||
def can_add_members?
|
||||
(member_count + pending_invitations_count) < MAX_MEMBERS
|
||||
end
|
||||
|
||||
def member_count
|
||||
@member_count ||= members.count
|
||||
end
|
||||
|
||||
def pending_invitations_count
|
||||
@pending_invitations_count ||= family_invitations.active.count
|
||||
end
|
||||
|
||||
def owners
|
||||
members.joins(:family_membership)
|
||||
.where(family_memberships: { role: :owner })
|
||||
end
|
||||
|
||||
def owner
|
||||
@owner ||= creator
|
||||
end
|
||||
|
||||
def full?
|
||||
(member_count + pending_invitations_count) >= MAX_MEMBERS
|
||||
end
|
||||
|
||||
def active_invitations
|
||||
family_invitations.active.includes(:invited_by)
|
||||
end
|
||||
|
||||
def clear_member_cache!
|
||||
@member_count = nil
|
||||
@pending_invitations_count = nil
|
||||
@owner = nil
|
||||
end
|
||||
end
|
||||
46
app/models/family/invitation.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::Invitation < ApplicationRecord
|
||||
self.table_name = 'family_invitations'
|
||||
|
||||
EXPIRY_DAYS = 7
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :invited_by, class_name: 'User'
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :token, presence: true, uniqueness: true
|
||||
validates :expires_at, :status, presence: true
|
||||
|
||||
enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 }
|
||||
|
||||
scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) }
|
||||
|
||||
before_validation :generate_token, :set_expiry, on: :create
|
||||
|
||||
after_create :clear_family_cache
|
||||
after_update :clear_family_cache, if: :saved_change_to_status?
|
||||
after_destroy :clear_family_cache
|
||||
|
||||
def expired?
|
||||
expires_at.past?
|
||||
end
|
||||
|
||||
def can_be_accepted?
|
||||
pending? && !expired?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.token = SecureRandom.urlsafe_base64(32) if token.blank?
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
|
||||
end
|
||||
|
||||
def clear_family_cache
|
||||
family.clear_member_cache!
|
||||
end
|
||||
end
|
||||
23
app/models/family/membership.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::Membership < ApplicationRecord
|
||||
self.table_name = 'family_memberships'
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :user
|
||||
|
||||
validates :user_id, presence: true, uniqueness: true
|
||||
validates :role, presence: true
|
||||
|
||||
enum :role, { owner: 0, member: 1 }
|
||||
|
||||
after_create :clear_family_cache
|
||||
after_update :clear_family_cache
|
||||
after_destroy :clear_family_cache
|
||||
|
||||
private
|
||||
|
||||
def clear_family_cache
|
||||
family.clear_member_cache!
|
||||
end
|
||||
end
|
||||
|
|
@ -75,24 +75,49 @@ class Point < ApplicationRecord
|
|||
|
||||
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
|
||||
def broadcast_coordinates
|
||||
return unless user.safe_settings.live_map_enabled
|
||||
if user.safe_settings.live_map_enabled
|
||||
PointsChannel.broadcast_to(
|
||||
user,
|
||||
[
|
||||
lat,
|
||||
lon,
|
||||
battery.to_s,
|
||||
altitude.to_s,
|
||||
timestamp.to_s,
|
||||
velocity.to_s,
|
||||
id.to_s,
|
||||
country_name.to_s
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
PointsChannel.broadcast_to(
|
||||
user,
|
||||
[
|
||||
lat,
|
||||
lon,
|
||||
battery.to_s,
|
||||
altitude.to_s,
|
||||
timestamp.to_s,
|
||||
velocity.to_s,
|
||||
id.to_s,
|
||||
country_name.to_s
|
||||
]
|
||||
)
|
||||
broadcast_to_family if should_broadcast_to_family?
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def should_broadcast_to_family?
|
||||
return false unless DawarichSettings.family_feature_enabled?
|
||||
return false unless user.in_family?
|
||||
return false unless user.family_sharing_enabled?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def broadcast_to_family
|
||||
FamilyLocationsChannel.broadcast_to(
|
||||
user.family,
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
email_initial: user.email.first.upcase,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: timestamp.to_i,
|
||||
updated_at: Time.zone.at(timestamp.to_i).iso8601
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def set_country
|
||||
self.country_id = found_in_country&.id
|
||||
save! if changed?
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||
include UserFamily
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable, :trackable
|
||||
|
||||
has_many :points, dependent: :destroy, counter_cache: true
|
||||
has_many :points, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :stats, dependent: :destroy
|
||||
has_many :exports, dependent: :destroy
|
||||
|
|
|
|||
19
app/policies/family/invitation_policy.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::InvitationPolicy < ApplicationPolicy
|
||||
def create?
|
||||
return false unless user
|
||||
|
||||
user.family == record.family && user.family_owner?
|
||||
end
|
||||
|
||||
def accept?
|
||||
return false unless user
|
||||
|
||||
user.email == record.email
|
||||
end
|
||||
|
||||
def destroy?
|
||||
create?
|
||||
end
|
||||
end
|
||||
17
app/policies/family/membership_policy.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::MembershipPolicy < ApplicationPolicy
|
||||
def create?
|
||||
return false unless user
|
||||
return false unless record.is_a?(Family::Invitation)
|
||||
|
||||
record.email == user.email && record.pending? && !record.expired?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
return false unless user
|
||||
return true if user == record.user
|
||||
|
||||
user.family == record.family && user.family_owner?
|
||||
end
|
||||
end
|
||||
22
app/policies/family_invitation_policy.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyInvitationPolicy < ApplicationPolicy
|
||||
def show?
|
||||
# Public endpoint for invitation acceptance - no authentication required
|
||||
true
|
||||
end
|
||||
|
||||
def create?
|
||||
user.family == record.family && user.family_owner?
|
||||
end
|
||||
|
||||
def accept?
|
||||
# Users can accept invitations sent to their email
|
||||
user.email == record.email
|
||||
end
|
||||
|
||||
def destroy?
|
||||
# Only family owners can cancel invitations
|
||||
user.family == record.family && user.family_owner?
|
||||
end
|
||||
end
|
||||
23
app/policies/family_membership_policy.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyMembershipPolicy < ApplicationPolicy
|
||||
def show?
|
||||
user.family == record.family
|
||||
end
|
||||
|
||||
def update?
|
||||
# Users can update their own settings
|
||||
return true if user == record.user
|
||||
|
||||
# Family owners can update any member's settings
|
||||
user.family == record.family && user.family_owner?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
# Users can remove themselves (handled by family leave logic)
|
||||
return true if user == record.user
|
||||
|
||||
# Family owners can remove other members
|
||||
user.family == record.family && user.family_owner?
|
||||
end
|
||||
end
|
||||
42
app/policies/family_policy.rb
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyPolicy < ApplicationPolicy
|
||||
def show?
|
||||
user.family == record
|
||||
end
|
||||
|
||||
def create?
|
||||
return false if user.in_family?
|
||||
return true if DawarichSettings.self_hosted?
|
||||
|
||||
# Add cloud subscription checks here when implemented
|
||||
# For now, allow all users to create families
|
||||
true
|
||||
end
|
||||
|
||||
def update?
|
||||
user.family == record && user.family_owner?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.family == record && user.family_owner?
|
||||
end
|
||||
|
||||
def leave?
|
||||
user.family == record && !family_owner_with_members?
|
||||
end
|
||||
|
||||
def invite?
|
||||
user.family == record && user.family_owner?
|
||||
end
|
||||
|
||||
def manage_invitations?
|
||||
user.family == record && user.family_owner?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def family_owner_with_members?
|
||||
user.family_owner? && record.members.count > 1
|
||||
end
|
||||
end
|
||||
123
app/services/families/accept_invitation.rb
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Families
|
||||
class AcceptInvitation
|
||||
attr_reader :invitation, :user, :error_message
|
||||
|
||||
def initialize(invitation:, user:)
|
||||
@invitation = invitation
|
||||
@user = user
|
||||
@error_message = nil
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless can_accept?
|
||||
|
||||
if user.in_family?
|
||||
@error_message = 'You must leave your current family before joining a new one.'
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
create_membership
|
||||
update_invitation
|
||||
send_notifications
|
||||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
handle_record_invalid_error(e)
|
||||
false
|
||||
rescue StandardError => e
|
||||
handle_generic_error(e)
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_accept?
|
||||
return false unless validate_invitation
|
||||
return false unless validate_email_match
|
||||
return false unless validate_family_capacity
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def validate_invitation
|
||||
return true if invitation.can_be_accepted?
|
||||
|
||||
@error_message = 'This invitation is no longer valid or has expired.'
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def validate_email_match
|
||||
return true if invitation.email == user.email
|
||||
|
||||
@error_message = 'This invitation is not for your email address.'
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def validate_family_capacity
|
||||
return true unless invitation.family.full?
|
||||
|
||||
@error_message = 'This family has reached the maximum number of members.'
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def create_membership
|
||||
Family::Membership.create!(
|
||||
family: invitation.family,
|
||||
user: user,
|
||||
role: :member
|
||||
)
|
||||
end
|
||||
|
||||
def update_invitation
|
||||
invitation.update!(status: :accepted)
|
||||
end
|
||||
|
||||
def send_notifications
|
||||
send_user_notification
|
||||
send_owner_notification
|
||||
end
|
||||
|
||||
def send_user_notification
|
||||
Notification.create!(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Welcome to Family!',
|
||||
content: "You've joined the family '#{invitation.family.name}'"
|
||||
)
|
||||
end
|
||||
|
||||
def send_owner_notification
|
||||
Notification.create!(
|
||||
user: invitation.family.creator,
|
||||
kind: :info,
|
||||
title: 'New Family Member!',
|
||||
content: "#{user.email} has joined your family"
|
||||
)
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, "Unexpected error in Families::AcceptInvitation: #{e.message}")
|
||||
end
|
||||
|
||||
def handle_record_invalid_error(error)
|
||||
@error_message =
|
||||
if error.record&.errors&.any?
|
||||
error.record.errors.full_messages.first
|
||||
else
|
||||
"Failed to join family: #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def handle_generic_error(error)
|
||||
ExceptionReporter.call(error, "Unexpected error in Families::AcceptInvitation: #{error.message}")
|
||||
|
||||
@error_message = 'An unexpected error occurred while joining the family. Please try again'
|
||||
end
|
||||
end
|
||||
end
|
||||
126
app/services/families/create.rb
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Families
|
||||
class Create
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_reader :user, :name, :family, :error_message
|
||||
|
||||
validates :name, presence: { message: 'Family name is required' }
|
||||
validates :name, length: {
|
||||
maximum: 50,
|
||||
message: 'Family name must be 50 characters or less'
|
||||
}
|
||||
|
||||
def initialize(user:, name:)
|
||||
@user = user
|
||||
@name = name&.strip
|
||||
@error_message = nil
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless valid?
|
||||
return false unless validate_user_eligibility
|
||||
return false unless validate_feature_access
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
create_family
|
||||
create_owner_membership
|
||||
send_notification
|
||||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
handle_record_invalid_error(e)
|
||||
|
||||
false
|
||||
rescue ActiveRecord::RecordNotUnique => e
|
||||
handle_uniqueness_error(e)
|
||||
|
||||
false
|
||||
rescue StandardError => e
|
||||
handle_generic_error(e)
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_user_eligibility
|
||||
if user.in_family?
|
||||
@error_message = 'You must leave your current family before creating a new one'
|
||||
return false
|
||||
end
|
||||
|
||||
if user.created_family.present?
|
||||
@error_message = 'You have already created a family. Each user can only create one family'
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def validate_feature_access
|
||||
return true if can_create_family?
|
||||
|
||||
@error_message =
|
||||
if DawarichSettings.self_hosted?
|
||||
'Family feature is not available on this instance'
|
||||
else
|
||||
'Family feature requires an active subscription'
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def can_create_family?
|
||||
return true if DawarichSettings.self_hosted?
|
||||
|
||||
# TODO: Add cloud plan validation here when needed
|
||||
# For now, allow all users to create families
|
||||
true
|
||||
end
|
||||
|
||||
def create_family
|
||||
@family = Family.create!(name: name, creator: user)
|
||||
end
|
||||
|
||||
def create_owner_membership
|
||||
Family::Membership.create!(
|
||||
family: family,
|
||||
user: user,
|
||||
role: :owner
|
||||
)
|
||||
end
|
||||
|
||||
def send_notification
|
||||
Notification.create!(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Family Created',
|
||||
content: "You've successfully created the family '#{family.name}'"
|
||||
)
|
||||
rescue StandardError => e
|
||||
# Don't fail the entire operation if notification fails
|
||||
ExceptionReporter.call(e, "Unexpected error in Families::Create: #{e.message}")
|
||||
end
|
||||
|
||||
def handle_record_invalid_error(error)
|
||||
@error_message =
|
||||
if family&.errors&.any?
|
||||
family.errors.full_messages.first
|
||||
else
|
||||
"Failed to create family: #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def handle_uniqueness_error(_error)
|
||||
@error_message = 'A family with this name already exists for your account'
|
||||
end
|
||||
|
||||
def handle_generic_error(error)
|
||||
ExceptionReporter.call(error, "Unexpected error in Families::Create: #{error.message}")
|
||||
@error_message = 'An unexpected error occurred while creating the family. Please try again'
|
||||
end
|
||||
end
|
||||
end
|
||||
125
app/services/families/invite.rb
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Families
|
||||
class Invite
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_reader :family, :email, :invited_by, :invitation
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
|
||||
def initialize(family:, email:, invited_by:)
|
||||
@family = family
|
||||
@email = email.downcase.strip
|
||||
@invited_by = invited_by
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless valid?
|
||||
return false unless invite_sendable?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
create_invitation
|
||||
send_invitation_email
|
||||
send_notification
|
||||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
handle_record_invalid_error(e)
|
||||
false
|
||||
rescue Net::SMTPError => e
|
||||
handle_email_error(e)
|
||||
false
|
||||
rescue StandardError => e
|
||||
handle_generic_error(e)
|
||||
false
|
||||
end
|
||||
|
||||
def error_message
|
||||
return errors.full_messages.first if errors.any?
|
||||
return @custom_error_message if @custom_error_message
|
||||
|
||||
'Failed to send invitation'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invite_sendable?
|
||||
unless invited_by.family_owner?
|
||||
return add_error_and_false(:invited_by,
|
||||
'You must be a family owner to send invitations')
|
||||
end
|
||||
return add_error_and_false(:family, 'Family is full') if family.full?
|
||||
return add_error_and_false(:email, 'User is already in a family') if user_already_in_family?
|
||||
return add_error_and_false(:email, 'Invitation already sent to this email') if pending_invitation_exists?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def add_error_and_false(attribute, message)
|
||||
errors.add(attribute, message)
|
||||
false
|
||||
end
|
||||
|
||||
def user_already_in_family?
|
||||
User.joins(:family_membership)
|
||||
.where(email: email)
|
||||
.exists?
|
||||
end
|
||||
|
||||
def pending_invitation_exists?
|
||||
family.family_invitations.active.where(email: email).exists?
|
||||
end
|
||||
|
||||
def create_invitation
|
||||
@invitation = Family::Invitation.create!(
|
||||
family: family,
|
||||
email: email,
|
||||
invited_by: invited_by
|
||||
)
|
||||
end
|
||||
|
||||
def send_invitation_email
|
||||
# Send email in background with retry logic
|
||||
FamilyMailer.invitation(@invitation).deliver_later(
|
||||
queue: :mailer,
|
||||
retry: 3,
|
||||
wait: 30.seconds
|
||||
)
|
||||
end
|
||||
|
||||
def send_notification
|
||||
Notification.create!(
|
||||
user: invited_by,
|
||||
kind: :info,
|
||||
title: 'Invitation Sent',
|
||||
content: "Family invitation sent to #{email}"
|
||||
)
|
||||
rescue StandardError => e
|
||||
# Don't fail the entire operation if notification fails
|
||||
ExceptionReporter.call(e, "Unexpected error in Families::Invite: #{e.message}")
|
||||
end
|
||||
|
||||
def handle_record_invalid_error(error)
|
||||
@custom_error_message = if invitation&.errors&.any?
|
||||
invitation.errors.full_messages.first
|
||||
else
|
||||
"Failed to create invitation: #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def handle_email_error(error)
|
||||
Rails.logger.error "Email delivery failed for family invitation: #{error.message}"
|
||||
@custom_error_message = 'Failed to send invitation email. Please try again later'
|
||||
|
||||
# Clean up the invitation if email fails
|
||||
invitation&.destroy
|
||||
end
|
||||
|
||||
def handle_generic_error(error)
|
||||
ExceptionReporter.call(error, "Unexpected error in Families::Invite: #{error.message}")
|
||||
@custom_error_message = 'An unexpected error occurred while sending the invitation. Please try again'
|
||||
end
|
||||
end
|
||||
end
|
||||
50
app/services/families/locations.rb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Families::Locations
|
||||
attr_reader :user
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
return [] unless family_feature_enabled?
|
||||
return [] unless user.in_family?
|
||||
|
||||
sharing_members = family_members_with_sharing_enabled
|
||||
return [] unless sharing_members.any?
|
||||
|
||||
build_family_locations(sharing_members)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def family_feature_enabled?
|
||||
DawarichSettings.family_feature_enabled?
|
||||
end
|
||||
|
||||
def family_members_with_sharing_enabled
|
||||
user.family.members
|
||||
.where.not(id: user.id)
|
||||
.select(&:family_sharing_enabled?)
|
||||
end
|
||||
|
||||
def build_family_locations(sharing_members)
|
||||
latest_points =
|
||||
sharing_members.map { _1.points.last }.compact
|
||||
|
||||
latest_points.map do |point|
|
||||
{
|
||||
user_id: point.user_id,
|
||||
email: point.user.email,
|
||||
email_initial: point.user.email.first.upcase,
|
||||
latitude: point.lat,
|
||||
longitude: point.lon,
|
||||
timestamp: point.timestamp.to_i,
|
||||
updated_at: Time.zone.at(point.timestamp.to_i),
|
||||
battery: point.battery,
|
||||
battery_status: point.battery_status
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
157
app/services/families/memberships/destroy.rb
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Families
|
||||
module Memberships
|
||||
class Destroy
|
||||
attr_reader :user, :member_to_remove, :error_message
|
||||
|
||||
def initialize(user:, member_to_remove: nil)
|
||||
@user = user
|
||||
@member_to_remove = member_to_remove || user
|
||||
@error_message = nil
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless validate_can_leave
|
||||
|
||||
@family_name = member_to_remove.family.name
|
||||
@family_owner = member_to_remove.family.owner
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
remove_membership
|
||||
send_notifications
|
||||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
handle_record_invalid_error(e)
|
||||
|
||||
false
|
||||
rescue StandardError => e
|
||||
handle_generic_error(e)
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_can_leave
|
||||
return false unless validate_in_family
|
||||
return false unless validate_removal_allowed
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def validate_in_family
|
||||
return true if member_to_remove.in_family?
|
||||
|
||||
@error_message = 'User is not currently in a family.'
|
||||
false
|
||||
end
|
||||
|
||||
def validate_removal_allowed
|
||||
return validate_owner_can_leave if removing_self?
|
||||
|
||||
return false unless validate_remover_is_owner
|
||||
return false unless validate_same_family
|
||||
return false unless validate_not_removing_owner
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def removing_self?
|
||||
user == member_to_remove
|
||||
end
|
||||
|
||||
def validate_owner_can_leave
|
||||
return true unless member_to_remove.family_owner?
|
||||
|
||||
@error_message = 'Family owners cannot remove their own membership. To leave the family, delete it instead.'
|
||||
false
|
||||
end
|
||||
|
||||
def validate_remover_is_owner
|
||||
return true if user.family_owner?
|
||||
|
||||
@error_message = 'Only family owners can remove other members.'
|
||||
false
|
||||
end
|
||||
|
||||
def validate_same_family
|
||||
return true if user.family == member_to_remove.family
|
||||
|
||||
@error_message = 'Cannot remove members from a different family.'
|
||||
false
|
||||
end
|
||||
|
||||
def validate_not_removing_owner
|
||||
return true unless member_to_remove.family_owner?
|
||||
|
||||
@error_message = 'Cannot remove the family owner. The owner must delete the family or leave on their own.'
|
||||
false
|
||||
end
|
||||
|
||||
def remove_membership
|
||||
member_to_remove.family_membership.destroy!
|
||||
end
|
||||
|
||||
def send_notifications
|
||||
if removing_self?
|
||||
send_self_removal_notifications
|
||||
else
|
||||
send_member_removed_notifications
|
||||
end
|
||||
end
|
||||
|
||||
def send_self_removal_notifications
|
||||
Notification.create!(
|
||||
user: member_to_remove,
|
||||
kind: :info,
|
||||
title: 'Left Family',
|
||||
content: "You've left the family \"#{@family_name}\""
|
||||
)
|
||||
|
||||
return unless @family_owner&.persisted?
|
||||
|
||||
Notification.create!(
|
||||
user: @family_owner,
|
||||
kind: :info,
|
||||
title: 'Family Member Left',
|
||||
content: "#{member_to_remove.email} has left the family \"#{@family_name}\""
|
||||
)
|
||||
end
|
||||
|
||||
def send_member_removed_notifications
|
||||
Notification.create!(
|
||||
user: member_to_remove,
|
||||
kind: :info,
|
||||
title: 'Removed from Family',
|
||||
content: "You have been removed from the family \"#{@family_name}\" by #{user.email}"
|
||||
)
|
||||
|
||||
return unless user != member_to_remove
|
||||
|
||||
Notification.create!(
|
||||
user: user,
|
||||
kind: :info,
|
||||
title: 'Member Removed',
|
||||
content: "#{member_to_remove.email} has been removed from the family \"#{@family_name}\""
|
||||
)
|
||||
end
|
||||
|
||||
def handle_record_invalid_error(error)
|
||||
@error_message =
|
||||
if error.record&.errors&.any?
|
||||
error.record.errors.full_messages.first
|
||||
else
|
||||
"Failed to leave family: #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def handle_generic_error(error)
|
||||
ExceptionReporter.call(error, "Unexpected error in Families::Memberships::Destroy: #{error.message}")
|
||||
@error_message = 'An unexpected error occurred while removing the membership. Please try again'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
68
app/services/families/update_location_sharing.rb
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Families::UpdateLocationSharing
|
||||
Result = Struct.new(:success?, :payload, :status, keyword_init: true)
|
||||
|
||||
def initialize(user:, enabled:, duration:)
|
||||
@user = user
|
||||
@enabled_param = enabled
|
||||
@duration_param = duration
|
||||
@boolean_caster = ActiveModel::Type::Boolean.new
|
||||
end
|
||||
|
||||
def call
|
||||
return success_result if update_location_sharing
|
||||
|
||||
failure_result('Failed to update location sharing setting', :unprocessable_content)
|
||||
rescue => error
|
||||
ExceptionReporter.call(error, "Error in Families::UpdateLocationSharing: #{error.message}")
|
||||
|
||||
failure_result('An error occurred while updating location sharing', :internal_server_error)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :enabled_param, :duration_param, :boolean_caster
|
||||
|
||||
def update_location_sharing
|
||||
user.update_family_location_sharing!(enabled?, duration: duration_param)
|
||||
end
|
||||
|
||||
def enabled?
|
||||
@enabled ||= boolean_caster.cast(enabled_param)
|
||||
end
|
||||
|
||||
def success_result
|
||||
payload = {
|
||||
success: true,
|
||||
enabled: enabled?,
|
||||
duration: user.family_sharing_duration,
|
||||
message: build_sharing_message
|
||||
}
|
||||
|
||||
if enabled? && user.family_sharing_expires_at.present?
|
||||
payload[:expires_at] = user.family_sharing_expires_at.iso8601
|
||||
payload[:expires_at_formatted] = user.family_sharing_expires_at.strftime('%b %d at %I:%M %p')
|
||||
end
|
||||
|
||||
Result.new(success?: true, payload: payload, status: :ok)
|
||||
end
|
||||
|
||||
def failure_result(message, status)
|
||||
Result.new(success?: false, payload: { success: false, message: message }, status: status)
|
||||
end
|
||||
|
||||
def build_sharing_message
|
||||
return 'Location sharing disabled' unless enabled?
|
||||
|
||||
case duration_param
|
||||
when '1h' then 'Location sharing enabled for 1 hour'
|
||||
when '6h' then 'Location sharing enabled for 6 hours'
|
||||
when '12h' then 'Location sharing enabled for 12 hours'
|
||||
when '24h' then 'Location sharing enabled for 24 hours'
|
||||
when 'permanent', nil then 'Location sharing enabled'
|
||||
else
|
||||
duration_param.to_i.positive? ? "Location sharing enabled for #{duration_param.to_i} hours" : 'Location sharing enabled'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,7 @@ class Geojson::Importer
|
|||
include Imports::FileLoader
|
||||
include PointValidation
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
|
|
@ -17,13 +18,46 @@ class Geojson::Importer
|
|||
json = load_json_data
|
||||
data = Geojson::Params.new(json).call
|
||||
|
||||
data.each.with_index(1) do |point, index|
|
||||
points_data = data.map do |point|
|
||||
next if point[:lonlat].nil?
|
||||
next if point_exists?(point, user_id)
|
||||
|
||||
Point.create!(point.merge(user_id:, import_id: import.id))
|
||||
point.merge(
|
||||
user_id: user_id,
|
||||
import_id: import.id,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
|
||||
bulk_insert_points(batch)
|
||||
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bulk_insert_points(batch)
|
||||
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Point.upsert_all(
|
||||
unique_batch,
|
||||
unique_by: %i[lonlat timestamp user_id],
|
||||
returning: false,
|
||||
on_duplicate: :skip
|
||||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
rescue StandardError => e
|
||||
create_notification("Failed to process GeoJSON batch: #{e.message}")
|
||||
end
|
||||
|
||||
def create_notification(message)
|
||||
Notification.create!(
|
||||
user_id: user_id,
|
||||
title: 'GeoJSON Import Error',
|
||||
content: message,
|
||||
kind: :error
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,30 +12,23 @@ class GoogleMaps::PhoneTakeoutImporter
|
|||
@file_path = file_path
|
||||
end
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
def call
|
||||
points_data = parse_json
|
||||
|
||||
points_data.compact.each.with_index(1) do |point_data, index|
|
||||
next if Point.exists?(
|
||||
timestamp: point_data[:timestamp],
|
||||
lonlat: point_data[:lonlat],
|
||||
user_id:
|
||||
)
|
||||
|
||||
Point.create(
|
||||
lonlat: point_data[:lonlat],
|
||||
timestamp: point_data[:timestamp],
|
||||
raw_data: point_data[:raw_data],
|
||||
accuracy: point_data[:accuracy],
|
||||
altitude: point_data[:altitude],
|
||||
velocity: point_data[:velocity],
|
||||
import_id: import.id,
|
||||
topic: 'Google Maps Phone Timeline Export',
|
||||
points_data = parse_json.compact.map do |point_data|
|
||||
point_data.merge(
|
||||
import_id: import.id,
|
||||
topic: 'Google Maps Phone Timeline Export',
|
||||
tracker_id: 'google-maps-phone-timeline-export',
|
||||
user_id:
|
||||
user_id: user_id,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
points_data.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
|
||||
bulk_insert_points(batch)
|
||||
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -177,4 +170,28 @@ class GoogleMaps::PhoneTakeoutImporter
|
|||
point_hash(lat, lon, timestamp, segment)
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_insert_points(batch)
|
||||
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Point.upsert_all(
|
||||
unique_batch,
|
||||
unique_by: %i[lonlat timestamp user_id],
|
||||
returning: false,
|
||||
on_duplicate: :skip
|
||||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
rescue StandardError => e
|
||||
create_notification("Failed to process phone takeout batch: #{e.message}")
|
||||
end
|
||||
|
||||
def create_notification(message)
|
||||
Notification.create!(
|
||||
user_id: user_id,
|
||||
title: 'Google Maps Phone Takeout Import Error',
|
||||
content: message,
|
||||
kind: :error
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ class GoogleMaps::RecordsImporter
|
|||
timestamp: parse_timestamp(location),
|
||||
altitude: location['altitude'],
|
||||
velocity: location['velocity'],
|
||||
accuracy: location['accuracy'],
|
||||
vertical_accuracy: location['verticalAccuracy'],
|
||||
course: location['heading'],
|
||||
battery: parse_battery_charging(location['batteryCharging']),
|
||||
raw_data: location,
|
||||
topic: 'Google Maps Timeline Export',
|
||||
tracker_id: 'google-maps-timeline-export',
|
||||
|
|
@ -74,6 +78,12 @@ class GoogleMaps::RecordsImporter
|
|||
)
|
||||
end
|
||||
|
||||
def parse_battery_charging(battery_charging)
|
||||
return nil if battery_charging.nil?
|
||||
|
||||
battery_charging ? 1 : 0
|
||||
end
|
||||
|
||||
def create_notification(message)
|
||||
Notification.create!(
|
||||
user: @import.user,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class GoogleMaps::SemanticHistoryImporter
|
|||
{
|
||||
lonlat: point_data[:lonlat],
|
||||
timestamp: point_data[:timestamp],
|
||||
accuracy: point_data[:accuracy],
|
||||
raw_data: point_data[:raw_data],
|
||||
topic: 'Google Maps Timeline Export',
|
||||
tracker_id: 'google-maps-timeline-export',
|
||||
|
|
@ -86,6 +87,7 @@ class GoogleMaps::SemanticHistoryImporter
|
|||
longitude: activity['startLocation']['longitudeE7'],
|
||||
latitude: activity['startLocation']['latitudeE7'],
|
||||
timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
|
||||
accuracy: activity.dig('startLocation', 'accuracyMetres'),
|
||||
raw_data: activity
|
||||
)
|
||||
end
|
||||
|
|
@ -111,6 +113,7 @@ class GoogleMaps::SemanticHistoryImporter
|
|||
longitude: place_visit['location']['longitudeE7'],
|
||||
latitude: place_visit['location']['latitudeE7'],
|
||||
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
|
||||
accuracy: place_visit.dig('location', 'accuracyMetres'),
|
||||
raw_data: place_visit
|
||||
)
|
||||
elsif (candidate = place_visit.dig('otherCandidateLocations', 0))
|
||||
|
|
@ -125,14 +128,16 @@ class GoogleMaps::SemanticHistoryImporter
|
|||
longitude: candidate['longitudeE7'],
|
||||
latitude: candidate['latitudeE7'],
|
||||
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
|
||||
accuracy: candidate['accuracyMetres'],
|
||||
raw_data: place_visit
|
||||
)
|
||||
end
|
||||
|
||||
def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:)
|
||||
def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:, accuracy: nil)
|
||||
{
|
||||
lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})",
|
||||
timestamp: Timestamps.parse_timestamp(timestamp),
|
||||
accuracy: accuracy,
|
||||
raw_data: raw_data
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ class Photos::Importer
|
|||
include Imports::Broadcaster
|
||||
include Imports::FileLoader
|
||||
include PointValidation
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
attr_reader :import, :user_id, :file_path
|
||||
|
||||
def initialize(import, user_id, file_path = nil)
|
||||
|
|
@ -14,25 +16,54 @@ class Photos::Importer
|
|||
|
||||
def call
|
||||
json = load_json_data
|
||||
points_data = json.map { |point| prepare_point_data(point) }
|
||||
|
||||
json.each.with_index(1) { |point, index| create_point(point, index) }
|
||||
points_data.compact.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
|
||||
bulk_insert_points(batch)
|
||||
broadcast_import_progress(import, (batch_index + 1) * BATCH_SIZE)
|
||||
end
|
||||
end
|
||||
|
||||
def create_point(point, index)
|
||||
return 0 unless valid?(point)
|
||||
return 0 if point_exists?(point, point['timestamp'])
|
||||
private
|
||||
|
||||
Point.create(
|
||||
lonlat: point['lonlat'],
|
||||
def prepare_point_data(point)
|
||||
return nil unless valid?(point)
|
||||
|
||||
{
|
||||
lonlat: point['lonlat'],
|
||||
longitude: point['longitude'],
|
||||
latitude: point['latitude'],
|
||||
latitude: point['latitude'],
|
||||
timestamp: point['timestamp'].to_i,
|
||||
raw_data: point,
|
||||
raw_data: point,
|
||||
import_id: import.id,
|
||||
user_id:
|
||||
)
|
||||
user_id: user_id,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
|
||||
broadcast_import_progress(import, index)
|
||||
def bulk_insert_points(batch)
|
||||
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Point.upsert_all(
|
||||
unique_batch,
|
||||
unique_by: %i[lonlat timestamp user_id],
|
||||
returning: false,
|
||||
on_duplicate: :skip
|
||||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
rescue StandardError => e
|
||||
create_notification("Failed to process photo location batch: #{e.message}")
|
||||
end
|
||||
|
||||
def create_notification(message)
|
||||
Notification.create!(
|
||||
user_id: user_id,
|
||||
title: 'Photos Import Error',
|
||||
content: message,
|
||||
kind: :error
|
||||
)
|
||||
end
|
||||
|
||||
def valid?(point)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ class Users::SafeSettings
|
|||
'photoprism_url' => nil,
|
||||
'photoprism_api_key' => nil,
|
||||
'maps' => { 'distance_unit' => 'km' },
|
||||
'visits_suggestions_enabled' => 'true'
|
||||
'visits_suggestions_enabled' => 'true',
|
||||
'enabled_map_layers' => ['Routes', 'Heatmap']
|
||||
}.freeze
|
||||
|
||||
def initialize(settings = {})
|
||||
|
|
@ -47,7 +48,8 @@ class Users::SafeSettings
|
|||
distance_unit: distance_unit,
|
||||
visits_suggestions_enabled: visits_suggestions_enabled?,
|
||||
speed_color_scale: speed_color_scale,
|
||||
fog_of_war_threshold: fog_of_war_threshold
|
||||
fog_of_war_threshold: fog_of_war_threshold,
|
||||
enabled_map_layers: enabled_map_layers
|
||||
}
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
|
@ -127,4 +129,8 @@ class Users::SafeSettings
|
|||
def fog_of_war_threshold
|
||||
settings['fog_of_war_threshold']
|
||||
end
|
||||
|
||||
def enabled_map_layers
|
||||
settings['enabled_map_layers']
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@
|
|||
<p><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
|
||||
</p>
|
||||
<p class='py-2'>
|
||||
<%= link_to "Generate new API key", generate_api_key_path, data: { confirm: "Are you sure? This will invalidate the current API key.", turbo_confirm: "Are you sure?", turbo_method: :post }, class: 'btn btn-primary' %>
|
||||
<%= link_to "Generate new API key", generate_api_key_path, data: { turbo_confirm: "Are you sure? This will invalidate the current API key.", turbo_method: :post }, class: 'btn btn-primary' %>
|
||||
</p>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
<%= render "devise/shared/links" %>
|
||||
<% end %>
|
||||
|
||||
<p class='mt-3'>Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: 'btn' %></p>
|
||||
<p class='mt-3'>Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: 'btn' %></p>
|
||||
<div class="divider"></div>
|
||||
<p class='mt-3 flex flex-col gap-2'>
|
||||
<%= link_to "Export my data", export_settings_users_path, class: 'btn btn-primary', data: {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
<div class="hero min-h-content bg-base-200">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
|
||||
<div class="text-center lg:text-left">
|
||||
<h1 class="text-5xl font-bold">Register now!</h1>
|
||||
<p class="py-6">and take control over your location data.</p>
|
||||
<% if @invitation %>
|
||||
<h1 class="text-5xl font-bold text-base-content">Join <%= @invitation.family.name %>!</h1>
|
||||
<p class="py-6 text-base-content opacity-70">
|
||||
You've been invited by <strong><%= @invitation.invited_by.email %></strong> to join their family.
|
||||
Create your account to accept the invitation and start sharing location data.
|
||||
</p>
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-sm">
|
||||
Your email (<%= @invitation.email %>) will be used for this account
|
||||
</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<h1 class="text-5xl font-bold text-base-content">Register now!</h1>
|
||||
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
||||
<% if @invitation %>
|
||||
<%= f.hidden_field :invitation_token, value: params[:invitation_token] %>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :email, class: 'label' do %>
|
||||
<span class="label-text">Email</span>
|
||||
<% end %>
|
||||
<%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'input input-bordered' %>
|
||||
<%= f.email_field :email, autofocus: true, autocomplete: "email",
|
||||
readonly: @invitation.present?,
|
||||
class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
|
|
@ -18,17 +40,17 @@
|
|||
<span class="label-text">Password</span>
|
||||
<% end %>
|
||||
<% if @minimum_password_length %>
|
||||
<em>(<%= @minimum_password_length %> characters minimum)</em>
|
||||
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
||||
<% end %><br />
|
||||
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :password_confirmation, class: 'label' do %>
|
||||
<span class="label-text">Password</span>
|
||||
<span class="label-text">Password Confirmation</span>
|
||||
<% end %>
|
||||
<% if @minimum_password_length %>
|
||||
<em>(<%= @minimum_password_length %> characters minimum)</em>
|
||||
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
|
||||
<% end %><br />
|
||||
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %>
|
||||
</div>
|
||||
|
|
@ -38,10 +60,13 @@
|
|||
<% end %>
|
||||
|
||||
<div class="form-control mt-6">
|
||||
<%= f.submit "Sign up", class: 'btn btn-primary' %>
|
||||
<%= f.submit (@invitation ? "Create Account & Join Family" : "Sign up"),
|
||||
class: 'btn btn-primary' %>
|
||||
</div>
|
||||
|
||||
<%= render "devise/shared/links" %>
|
||||
<% unless @invitation %>
|
||||
<%= render "devise/shared/links" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
<div class="hero min-h-content bg-base-200">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
|
||||
<div class="text-center lg:text-left">
|
||||
<h1 class="text-5xl font-bold">Login now</h1>
|
||||
<p class="py-6">and take control over your location data.</p>
|
||||
<% if ENV['DEMO_ENV'] == 'true' %>
|
||||
<p class="py-6">
|
||||
Demo account: <strong class="text-success">demo@dawarich.app</strong> / password: <strong class="text-success">password</strong>
|
||||
<% if @invitation %>
|
||||
<h1 class="text-5xl font-bold text-base-content">Sign in to join <%= @invitation.family.name %>!</h1>
|
||||
<p class="py-6 text-base-content opacity-70">
|
||||
You've been invited by <strong><%= @invitation.invited_by.email %></strong> to join their family.
|
||||
Sign in to your account to accept the invitation.
|
||||
</p>
|
||||
<div class="alert alert-info">
|
||||
<p class="text-sm">
|
||||
Don't have an account yet?
|
||||
<%= link_to "Create one here", new_user_registration_path(invitation_token: @invitation.token), class: "font-semibold underline" %>
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<h1 class="text-5xl font-bold text-base-content">Login now</h1>
|
||||
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
|
||||
<% if @invitation %>
|
||||
<%= hidden_field_tag :invitation_token, params[:invitation_token] %>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :email, class: 'label' do %>
|
||||
<span class="label-text">Email</span>
|
||||
|
|
@ -32,10 +45,12 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<div class="form-control mt-6">
|
||||
<%= f.submit "Log in", class: 'btn btn-primary' %>
|
||||
<%= f.submit (@invitation ? "Sign in & Accept Invitation" : "Log in"), class: 'btn btn-primary' %>
|
||||
</div>
|
||||
|
||||
<%= render "devise/shared/links" %>
|
||||
<% unless @invitation %>
|
||||
<%= render "devise/shared/links" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
<%= link_to 'Download', export.url, class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: export.name %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to 'Delete', export, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
<%= link_to 'Delete', export, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
|
|||
99
app/views/families/edit.html.erb
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="bg-base-200 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-base-content">
|
||||
<%= t('families.edit.title', default: 'Edit Family') %>
|
||||
</h1>
|
||||
<%= link_to family_path,
|
||||
class: "btn btn-ghost" do %>
|
||||
<%= t('families.edit.back', default: '← Back to Family') %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @family, local: true, class: "space-y-6" do |form| %>
|
||||
<% if @family.errors.any? %>
|
||||
<div class="alert alert-error">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
<%= t('families.edit.error_title', default: 'There were problems with your submission:') %>
|
||||
</h3>
|
||||
<div class="mt-2 text-sm">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% @family.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %>
|
||||
<%= form.text_field :name,
|
||||
class: "input input-bordered w-full",
|
||||
placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %>
|
||||
<p class="mt-1 text-sm text-base-content opacity-50">
|
||||
<%= t('families.edit.name_help', default: 'Choose a name that all family members will recognize.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-300 p-4 rounded-md">
|
||||
<h3 class="text-sm font-medium text-base-content mb-2">
|
||||
<%= t('families.edit.family_info', default: 'Family Information') %>
|
||||
</h3>
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content opacity-60">
|
||||
<%= t('families.edit.creator', default: 'Created by') %>
|
||||
</dt>
|
||||
<dd class="text-sm text-base-content"><%= @family.creator.email %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content opacity-60">
|
||||
<%= t('families.edit.created_on', default: 'Created on') %>
|
||||
</dt>
|
||||
<dd class="text-sm text-base-content"><%= @family.created_at.strftime('%B %d, %Y') %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content opacity-60">
|
||||
<%= t('families.edit.members_count', default: 'Members') %>
|
||||
</dt>
|
||||
<dd class="text-sm text-base-content">
|
||||
<%= pluralize(@family.members.count, 'member') %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content opacity-60">
|
||||
<%= t('families.edit.last_updated', default: 'Last updated') %>
|
||||
</dt>
|
||||
<dd class="text-sm text-base-content"><%= @family.updated_at.strftime('%B %d, %Y') %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<div class="flex space-x-3">
|
||||
<%= form.submit t('families.edit.save_changes', default: 'Save Changes'),
|
||||
class: "btn btn-primary" %>
|
||||
<%= link_to family_path,
|
||||
class: "btn btn-neutral" do %>
|
||||
<%= t('families.edit.cancel', default: 'Cancel') %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if policy(@family).destroy? %>
|
||||
<%= link_to family_path,
|
||||
method: :delete,
|
||||
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
|
||||
class: "btn btn-outline btn-error" do %>
|
||||
<%= icon 'trash-2', class: "inline-block w-4" %>
|
||||
Delete Family
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
47
app/views/families/index.html.erb
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-base-content mb-4">
|
||||
<%= t('families.index.title', default: 'Family Management') %>
|
||||
</h1>
|
||||
<p class="text-base-content opacity-60">
|
||||
<%= t('families.index.description', default: 'Create or join a family to share your location data with loved ones.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold mb-4 text-base-content">
|
||||
<%= t('families.index.create_family', default: 'Create Your Family') %>
|
||||
</h2>
|
||||
|
||||
<%= form_with model: Family.new, local: true, class: "space-y-4" do |form| %>
|
||||
<div>
|
||||
<%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-1" %>
|
||||
<%= form.text_field :name,
|
||||
placeholder: t('families.form.name_placeholder', default: 'Enter your family name'),
|
||||
class: "input input-bordered w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit t('families.form.create', default: 'Create Family'),
|
||||
class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<h3 class="text-lg font-medium text-base-content mb-4">
|
||||
<%= t('families.index.have_invitation', default: 'Have an invitation?') %>
|
||||
</h3>
|
||||
<p class="text-base-content opacity-60 mb-4">
|
||||
<%= t('families.index.invitation_instructions', default: 'If someone has invited you to join their family, you should have received an email with an invitation link.') %>
|
||||
</p>
|
||||
<div class="text-sm text-base-content opacity-50">
|
||||
<%= t('families.index.invitation_help', default: 'Check your email for an invitation link that looks like: ') %>
|
||||
<code class="bg-base-300 text-base-content px-2 py-1 rounded text-xs">
|
||||
<%= "#{request.base_url}/invitations/..." %>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
66
app/views/families/new.html.erb
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-base-content mb-4">
|
||||
<%= t('families.new.title', default: 'Create Your Family') %>
|
||||
</h1>
|
||||
<p class="text-base-content opacity-60">
|
||||
<%= t('families.new.description', default: 'Create a family to share your location data with your loved ones.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 rounded-lg p-6">
|
||||
<%= form_with url: family_path, model: @family, local: true, class: "space-y-6" do |form| %>
|
||||
<% if @family.errors.any? %>
|
||||
<div class="alert alert-error">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
<%= t('families.new.error_title', default: 'There were problems with your submission:') %>
|
||||
</h3>
|
||||
<div class="mt-2 text-sm">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% @family.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %>
|
||||
<%= form.text_field :name,
|
||||
class: "input input-bordered w-full",
|
||||
placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %>
|
||||
<p class="mt-1 text-sm text-base-content opacity-50">
|
||||
<%= t('families.new.name_help', default: 'Choose a name that all family members will recognize, like "The Smith Family" or "Our Travel Group".') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium mb-2">
|
||||
<%= t('families.new.what_happens_title', default: 'What happens next?') %>
|
||||
</h3>
|
||||
<ul class="text-sm space-y-1">
|
||||
<li>• <%= t('families.new.what_happens_1', default: 'You will become the family owner') %></li>
|
||||
<li>• <%= t('families.new.what_happens_2', default: 'You can invite others to join your family') %></li>
|
||||
<li>• <%= t('families.new.what_happens_3', default: 'Family members can view shared location data') %></li>
|
||||
<li>• <%= t('families.new.what_happens_4', default: 'You can manage family settings and members') %></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<%= form.submit t('families.new.create_family', default: 'Create Family'),
|
||||
class: "btn btn-primary" %>
|
||||
<%= link_to root_path,
|
||||
class: "btn btn-ghost" do %>
|
||||
<%= t('families.new.back', default: '← Back') %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
259
app/views/families/show.html.erb
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Family Header -->
|
||||
<div class="bg-base-200 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-base-content"><%= @family.name %></h1>
|
||||
<p class="text-base-content opacity-60 mt-1">
|
||||
<%= t('families.show.created_by', default: 'Created by') %>
|
||||
<%= @family.creator.email %>
|
||||
<%= t('families.show.on_date', default: 'on') %>
|
||||
<%= @family.created_at.strftime('%B %d, %Y') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<% if policy(@family).update? %>
|
||||
<%= link_to edit_family_path,
|
||||
class: "btn btn-outline btn-info" do %>
|
||||
<%= icon 'square-pen', class: "inline-block w-4" %><%= t('families.show.edit', default: 'Edit') %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if !current_user.family_owner? && current_user.family_membership %>
|
||||
<%= link_to family_member_path(current_user.family_membership),
|
||||
method: :delete,
|
||||
data: { turbo_confirm: 'Are you sure you want to leave this family?' },
|
||||
class: "btn btn-outline btm-sm btn-warning" do %>
|
||||
Leave Family
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if policy(@family).destroy? %>
|
||||
<%= link_to family_path,
|
||||
method: :delete,
|
||||
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
|
||||
class: "btn btn-outline btm-sm btn-error" do %>
|
||||
<%= icon 'trash-2', class: "inline-block w-4" %>
|
||||
Delete
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Family Members -->
|
||||
<div class="bg-base-200 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-base-content">
|
||||
<%= t('families.show.members_title', default: 'Family Members') %>
|
||||
<span class="text-sm font-normal opacity-50">(<%= @members.count %>)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<% @members.each do |member| %>
|
||||
<div class="card bg-base-200 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-grow">
|
||||
<!-- Member Info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-12">
|
||||
<span class="text-lg font-semibold">
|
||||
<%= member.email&.first&.upcase || '?' %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="card-title text-base"><%= member.email %></h3>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<% if member.family_membership.role == 'owner' %>
|
||||
<div class="badge badge-warning badge-sm">
|
||||
<%= t('families.show.owner_badge', default: 'Owner') %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-sm opacity-60">
|
||||
<%= member.family_membership.role.humanize %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-xs opacity-50 mt-1">
|
||||
<%= t('families.show.joined_on', default: 'Joined') %>
|
||||
<%= member.family_membership.created_at.strftime('%b %d, %Y') %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Sharing Controls - More Compact -->
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<% if member == current_user %>
|
||||
<!-- Own toggle - interactive (consolidated controller) -->
|
||||
<div data-controller="location-sharing-toggle"
|
||||
data-location-sharing-toggle-member-id-value="<%= member.id %>"
|
||||
data-location-sharing-toggle-enabled-value="<%= member.family_sharing_enabled? %>"
|
||||
data-location-sharing-toggle-family-id-value="<%= @family.id %>"
|
||||
data-location-sharing-toggle-duration-value="<%= member.family_sharing_duration %>"
|
||||
data-location-sharing-toggle-expires-at-value="<%= member.family_sharing_expires_at&.iso8601 %>"
|
||||
class="flex items-center gap-3">
|
||||
|
||||
<span class="text-sm opacity-60">Location sharing:</span>
|
||||
|
||||
<!-- Toggle Switch -->
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
<%= 'checked' if member.family_sharing_enabled? %>
|
||||
data-location-sharing-toggle-target="checkbox"
|
||||
data-action="change->location-sharing-toggle#toggle">
|
||||
|
||||
<!-- Duration Dropdown (only visible when enabled) -->
|
||||
<div class="<%= 'hidden' unless member.family_sharing_enabled? %>"
|
||||
data-location-sharing-toggle-target="durationContainer">
|
||||
<select class="select select-bordered select-xs w-28 h-full"
|
||||
data-location-sharing-toggle-target="durationSelect"
|
||||
data-action="change->location-sharing-toggle#changeDuration">
|
||||
<option value="permanent" <%= 'selected' if member.family_sharing_duration == 'permanent' %>>Always</option>
|
||||
<option value="1h" <%= 'selected' if member.family_sharing_duration == '1h' %>>1 hour</option>
|
||||
<option value="6h" <%= 'selected' if member.family_sharing_duration == '6h' %>>6 hours</option>
|
||||
<option value="12h" <%= 'selected' if member.family_sharing_duration == '12h' %>>12 hours</option>
|
||||
<option value="24h" <%= 'selected' if member.family_sharing_duration == '24h' %>>24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Expiration Info (inline) -->
|
||||
<% if member.family_sharing_enabled? && member.family_sharing_expires_at.present? %>
|
||||
<div class="text-xs opacity-50"
|
||||
data-location-sharing-toggle-target="expirationInfo">
|
||||
• Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<!-- Other member's status - read-only indicator -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm opacity-60">Location sharing:</span>
|
||||
<% if member.family_sharing_enabled? %>
|
||||
<div class="w-3 h-3 bg-success rounded-full animate-pulse"></div>
|
||||
<span class="text-xs text-success font-medium">
|
||||
<%= member.family_sharing_duration == 'permanent' ? 'Always' : member.family_sharing_duration&.upcase %>
|
||||
</span>
|
||||
<% if member.family_sharing_expires_at.present? %>
|
||||
<span class="text-xs opacity-50">
|
||||
• Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="w-3 h-3 bg-base-300 rounded-full"></div>
|
||||
<span class="text-xs opacity-50">Disabled</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Invitations -->
|
||||
<div class="bg-base-200 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-base-content">
|
||||
<%= t('families.show.invitations_title', default: 'Pending Invitations') %>
|
||||
<span class="text-sm font-normal opacity-50">(<%= @pending_invitations.count %>)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<% if @pending_invitations.any? %>
|
||||
<div class="space-y-3 mb-4">
|
||||
<% @pending_invitations.each do |invitation| %>
|
||||
<div class="flex items-center justify-between p-3 bg-base-100 rounded-lg">
|
||||
<div class="flex-grow">
|
||||
<div class="font-medium text-base-content"><%= invitation.email %></div>
|
||||
<div class="text-sm text-base-content opacity-60">
|
||||
<%= t('families.show.invited_on', default: 'Invited') %>
|
||||
<%= invitation.created_at.strftime('%b %d, %Y') %>
|
||||
</div>
|
||||
<div class="text-xs text-base-content opacity-50">
|
||||
<%= t('families.show.expires_on', default: 'Expires') %>
|
||||
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button data-controller="clipboard"
|
||||
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
|
||||
data-action="click->clipboard#copy"
|
||||
class="btn btn-outline btn-info btn-xs"
|
||||
title="Copy invitation link">
|
||||
<%= icon 'copy', class: "inline-block w-3" %>
|
||||
Copy Invitation Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% if policy(@family).manage_invitations? %>
|
||||
<div class="ml-3">
|
||||
<%= link_to family_invitation_path(invitation.token),
|
||||
method: :delete,
|
||||
data: { turbo_confirm: 'Are you sure you want to cancel this invitation?' },
|
||||
class: "btn btn-outline btn-warning btn-sm" do %>
|
||||
Cancel
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-base-content opacity-50 text-center py-4">
|
||||
<%= t('families.show.no_pending_invitations', default: 'No pending invitations') %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<!-- Invite New Member -->
|
||||
<% if policy(@family).invite? && @family.can_add_members? %>
|
||||
<div class="border-t pt-4">
|
||||
<h3 class="text-lg font-medium text-base-content mb-3">
|
||||
<%= t('families.show.invite_member', default: 'Invite New Member') %>
|
||||
</h3>
|
||||
|
||||
<%= form_with model: [@family, Family::Invitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %>
|
||||
<div>
|
||||
<%= form.label :email, t('families.show.email_label', default: 'Email Address'), class: "label label-text font-medium mb-1" %>
|
||||
<%= form.email_field :email,
|
||||
placeholder: t('families.show.email_placeholder', default: 'Enter email address'),
|
||||
class: "input input-bordered w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit t('families.show.send_invitation', default: 'Send Invitation'),
|
||||
class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif policy(@family).invite? %>
|
||||
<!-- Family at capacity message -->
|
||||
<div class="border-t pt-4">
|
||||
<div class="alert alert-warning">
|
||||
<%= icon 'triangle-alert', class: "inline-block w-6 mr-2 flex-shrink-0" %>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
Family at Capacity
|
||||
</h3>
|
||||
<div class="mt-2 text-sm">
|
||||
<p>
|
||||
Your family has reached the maximum of <%= @family.class::MAX_MEMBERS %> members (including pending invitations).
|
||||
Cancel existing invitations or wait for them to expire to invite new members.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
68
app/views/family/invitations/index.html.erb
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-base-200 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-base-content">
|
||||
<%= t('family_invitations.index.title', default: 'Family Invitations') %>
|
||||
</h1>
|
||||
<%= link_to family_path,
|
||||
class: "btn btn-neutral" do %>
|
||||
<%= t('family_invitations.index.back_to_family', default: 'Back to Family') %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @pending_invitations.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @pending_invitations.each do |invitation| %>
|
||||
<div class="flex items-center justify-between p-4 bg-base-100 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium text-base-content"><%= invitation.email %></div>
|
||||
<div class="text-sm text-base-content opacity-60">
|
||||
<%= t('family_invitations.index.invited_on', default: 'Invited') %>
|
||||
<%= invitation.created_at.strftime('%B %d, %Y') %>
|
||||
</div>
|
||||
<div class="text-xs text-base-content opacity-50">
|
||||
<%= t('family_invitations.index.expires_on', default: 'Expires') %>
|
||||
<%= invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button type="button"
|
||||
data-controller="clipboard"
|
||||
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
|
||||
data-action="click->clipboard#copy"
|
||||
class="btn btn-ghost btn-sm text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<%= t('family_invitations.index.copy_link', default: 'Copy Link') %>
|
||||
</button>
|
||||
|
||||
<%= link_to public_invitation_path(invitation.token),
|
||||
class: "btn btn-ghost btn-sm text-info" do %>
|
||||
<%= t('family_invitations.index.view_invitation', default: 'View') %>
|
||||
<% end %>
|
||||
|
||||
<% if policy(@family).manage_invitations? %>
|
||||
<%= link_to family_invitation_path(invitation.token),
|
||||
method: :delete,
|
||||
confirm: t('family_invitations.index.cancel_confirm', default: 'Are you sure you want to cancel this invitation?'),
|
||||
class: "btn btn-ghost btn-sm text-error" do %>
|
||||
<%= t('family_invitations.index.cancel', default: 'Cancel') %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-8">
|
||||
<p class="text-base-content opacity-50 text-lg">
|
||||
<%= t('family_invitations.index.no_invitations', default: 'No pending invitations') %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
164
app/views/family/invitations/show.html.erb
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<div class="min-h-screen bg-gradient-to-br from-base-100 to-base-200 py-12 px-4 mx-auto">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Hero Section -->
|
||||
<div class="text-center mb-12">
|
||||
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-primary mb-6 shadow-xl">
|
||||
<%= icon 'users', class: "h-12 w-12 text-primary-content" %>
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl font-bold text-base-content mb-4">
|
||||
Join <%= @invitation.family.name %>!
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-base-content opacity-80 mb-2">
|
||||
You've been invited by <strong class="text-base-content"><%= @invitation.invited_by.email %></strong> to join their family. Create your account to accept the invitation and start sharing location data.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-info inline-flex items-center rounded-lg px-4 py-3 mt-4 gap-3">
|
||||
<%= icon 'info', class: "h-5 w-5 shrink-0" %>
|
||||
<span class="text-sm font-medium">
|
||||
Your email (<%= @invitation.email %>) will be used for this account
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benefits Section -->
|
||||
<div class="bg-base-200 shadow-xl rounded-2xl p-8 mb-8">
|
||||
<h2 class="text-2xl font-bold text-base-content mb-6 text-center">
|
||||
What benefits does joining a family bring?
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="flex items-start space-x-4 p-4 bg-info/10 rounded-lg border border-info/20">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-10 w-10 rounded-full bg-info flex items-center justify-center">
|
||||
<%= icon 'map-pin', class: "h-6 w-6 text-info-content" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-base-content mb-1">
|
||||
Share Location Data
|
||||
</h3>
|
||||
<p class="text-sm text-base-content opacity-70">
|
||||
Share your location history with family members and see where they are
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4 p-4 bg-secondary/10 rounded-lg border border-secondary/20">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
|
||||
<%= icon 'chart-column', class: "h-6 w-6 text-secondary-content" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-base-content mb-1">
|
||||
Track your location history
|
||||
</h3>
|
||||
<p class="text-sm text-base-content opacity-70">
|
||||
Access interactive maps and personal travel statistics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4 p-4 bg-success/10 rounded-lg border border-success/20">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-10 w-10 rounded-full bg-success flex items-center justify-center">
|
||||
<%= icon 'heart', class: "h-6 w-6 text-success-content" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-base-content mb-1">
|
||||
Stay Connected
|
||||
</h3>
|
||||
<p class="text-sm text-base-content opacity-70">
|
||||
Keep track of your loved ones' travels and adventures in real-time
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4 p-4 bg-warning/10 rounded-lg border border-warning/20">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-10 w-10 rounded-full bg-warning flex items-center justify-center">
|
||||
<%= icon 'shield-check', class: "h-6 w-6 text-warning-content" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-base-content mb-1">
|
||||
Full Control & Privacy
|
||||
</h3>
|
||||
<p class="text-sm text-base-content opacity-70">
|
||||
You control what and how long you share and can leave the family anytime
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invitation Details -->
|
||||
<div class="bg-base-300 rounded-lg p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-base-content mb-6">Invitation Details</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-base-content opacity-60">Family:</div>
|
||||
<div class="text-base font-semibold text-base-content"><%= @invitation.family.name %></div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-base-content opacity-60">Invited by:</div>
|
||||
<div class="text-base font-semibold text-base-content"><%= @invitation.invited_by.email %></div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-base-content opacity-60">Your email:</div>
|
||||
<div class="text-base font-semibold text-base-content"><%= @invitation.email %></div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-base-content opacity-60">Expires:</div>
|
||||
<div class="text-base font-semibold text-base-content"><%= @invitation.expires_at.strftime('%b %d, %Y') %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-4">
|
||||
<% if user_signed_in? %>
|
||||
<!-- User is logged in, show accept button -->
|
||||
<%= link_to accept_family_invitation_path(token: @invitation.token),
|
||||
method: :post,
|
||||
class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %>
|
||||
✓ Accept Invitation & Join Family
|
||||
<% end %>
|
||||
|
||||
<p class="text-sm text-base-content opacity-60 text-center">
|
||||
Logged in as <%= current_user.email %>
|
||||
·
|
||||
<%= link_to destroy_user_session_path, method: :delete, class: "link link-info" do %>
|
||||
Logout
|
||||
<% end %>
|
||||
</p>
|
||||
<% else %>
|
||||
<!-- User is not logged in, show register button prominently -->
|
||||
<%= link_to new_user_registration_path(invitation_token: @invitation.token),
|
||||
class: "btn btn-primary btn-lg w-full text-lg shadow-lg" do %>
|
||||
Create Account & Join Family →
|
||||
<% end %>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-300 mb-2">
|
||||
Already have an account?
|
||||
</p>
|
||||
<%= link_to new_user_session_path(invitation_token: @invitation.token),
|
||||
class: "link link-info font-medium" do %>
|
||||
Sign in to accept invitation
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Decline Option -->
|
||||
<div class="pt-6 border-t border-base-300 text-center">
|
||||
<p class="text-sm text-base-content opacity-60">
|
||||
Not interested? You can simply close this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
48
app/views/family_mailer/invitation.html.erb
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
|
||||
<div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin-bottom: 20px; text-align: center;">You've been invited to join a family!</h2>
|
||||
|
||||
<p style="color: #374151; line-height: 1.6;">Hi there!</p>
|
||||
|
||||
<p style="color: #374151; line-height: 1.6;">
|
||||
<strong><%= @invited_by.email %></strong> has invited you to join their family
|
||||
"<strong><%= @family.name %></strong>" on Dawarich.
|
||||
</p>
|
||||
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
||||
<h3 style="color: #1f2937; margin-bottom: 15px; font-size: 18px;">By joining this family, you'll be able to:</h3>
|
||||
<ul style="color: #374151; line-height: 1.6; margin: 0; padding-left: 20px;">
|
||||
<li style="margin-bottom: 8px;">Share your current location with family members</li>
|
||||
<li style="margin-bottom: 8px;">See the current location of other family members</li>
|
||||
<li style="margin-bottom: 8px;">Stay connected with your loved ones</li>
|
||||
<li>Control your privacy with full sharing controls</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<%= link_to "Accept Invitation", @accept_url,
|
||||
style: "background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 600;" %>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fef3cd; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0; color: #92400e; font-size: 14px;">
|
||||
<strong>⏰ Important:</strong> This invitation will expire in 7 days.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
|
||||
If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation.
|
||||
</p>
|
||||
|
||||
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||
|
||||
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Best regards,<br>
|
||||
Evgenii from Dawarich
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
22
app/views/family_mailer/invitation.text.erb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
You've been invited to join a family!
|
||||
|
||||
Hi there!
|
||||
|
||||
<%= @invited_by.email %> has invited you to join their family "<%= @family.name %>" on Dawarich.
|
||||
|
||||
By joining this family, you'll be able to:
|
||||
• Share your current location with family members
|
||||
• See the current location of other family members
|
||||
• Stay connected with your loved ones
|
||||
• Control your privacy with full sharing controls
|
||||
|
||||
Accept your invitation here: <%= @accept_url %>
|
||||
|
||||
IMPORTANT: This invitation will expire in 7 days.
|
||||
|
||||
If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation.
|
||||
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
39
app/views/family_mailer/member_joined.html.erb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
|
||||
<div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin-bottom: 20px; text-align: center;">🎉 Great news! Someone joined your family!</h2>
|
||||
|
||||
<p style="color: #374151; line-height: 1.6;">Hi <%= @family.owner.email %>!</p>
|
||||
|
||||
<p style="color: #374151; line-height: 1.6;">
|
||||
We're excited to let you know that <strong><%= @user.email %></strong> has just joined your family
|
||||
"<strong><%= @family.name %></strong>" on Dawarich!
|
||||
</p>
|
||||
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
||||
<h3 style="color: #1f2937; margin-bottom: 15px; font-size: 18px;">Now you can:</h3>
|
||||
<ul style="color: #374151; line-height: 1.6; margin: 0; padding-left: 20px;">
|
||||
<li style="margin-bottom: 8px;">See <%= @user.email %>'s current location (if they've enabled sharing)</li>
|
||||
<li style="margin-bottom: 8px;">Stay connected with your growing family</li>
|
||||
<li style="margin-bottom: 8px;">Share your location with <%= @user.email %></li>
|
||||
<li>Manage family members and settings from your family page</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #dbeafe; border: 1px solid #3b82f6; border-radius: 6px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0; color: #1e40af; font-size: 14px;">
|
||||
<strong>💡 Tip:</strong> You can manage your family members and privacy settings at any time from your family dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #374151; line-height: 1.6;">
|
||||
Your family now has <strong><%= @family.member_count %></strong> member<%= @family.member_count == 1 ? '' : 's' %>.
|
||||
</p>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||
|
||||
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Best regards,<br>
|
||||
Evgenii from Dawarich
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
18
app/views/family_mailer/member_joined.text.erb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
Great news! Someone joined your family!
|
||||
|
||||
Hi <%= @family.owner.email %>!
|
||||
|
||||
We're excited to let you know that <%= @user.email %> has just joined your family "<%= @family.name %>" on Dawarich!
|
||||
|
||||
Now you can:
|
||||
• See <%= @user.email %>'s current location (if they've enabled sharing)
|
||||
• Stay connected with your growing family
|
||||
• Share your location with <%= @user.email %>
|
||||
• Manage family members and settings from your family page
|
||||
|
||||
TIP: You can manage your family members and privacy settings at any time from your family dashboard.
|
||||
|
||||
Your family now has <%= @family.member_count %> member<%= @family.member_count == 1 ? '' : 's' %>.
|
||||
|
||||
Best regards,
|
||||
Evgenii from Dawarich
|
||||
|
|
@ -6,12 +6,12 @@
|
|||
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
|
||||
|
||||
<% if current_user.safe_settings.immich_url && current_user.safe_settings.immich_api_key %>
|
||||
<%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
|
||||
<%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
|
||||
<% else %>
|
||||
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray-500 block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a>
|
||||
<% end %>
|
||||
<% if current_user.safe_settings.photoprism_url && current_user.safe_settings.photoprism_api_key %>
|
||||
<%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
|
||||
<%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
|
||||
<% else %>
|
||||
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray-500 block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Photoprism instance data in the Settings">Import Photoprism data</a>
|
||||
<% end %>
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
<% if import.file.present? %>
|
||||
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %>
|
||||
<% end %>
|
||||
<%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
|
||||
<%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
<%= link_to "Edit this import", edit_import_path(@import), class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content inline-block font-medium" %>
|
||||
<div class="inline-block ml-2">
|
||||
<%= link_to "Destroy this import", import_path(@import), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This action will delete all points imported with this file", turbo_method: :delete }, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content font-medium" %>
|
||||
<%= link_to "Destroy this import", import_path(@import), data: { turbo_confirm: "Are you sure? This action will delete all points imported with this file", turbo_method: :delete }, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-secondary-content font-medium" %>
|
||||
</div>
|
||||
<%= link_to "Back to imports", imports_path, class: "ml-2 rounded-lg py-3 px-5 bg-secondary-content inline-block font-medium" %>
|
||||
</div>
|
||||
|
|
|
|||
52
app/views/layouts/map.html.erb
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!DOCTYPE html>
|
||||
<html data-theme="<%= app_theme %>" data-self-hosted="<%= @self_hosted %>">
|
||||
<head>
|
||||
<title><%= full_title(yield(:title)) %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<%= action_cable_meta_tag %>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
|
||||
|
||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= javascript_include_tag "https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js" %>
|
||||
|
||||
<%= render 'application/favicon' %>
|
||||
<%= Sentry.get_trace_propagation_meta.html_safe if Sentry.initialized? %>
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
<script async src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
|
||||
<% end %>
|
||||
</head>
|
||||
|
||||
<body class='h-screen overflow-hidden relative'>
|
||||
<!-- Fixed Navbar -->
|
||||
<div class='fixed w-full z-40 bg-base-100 shadow-md h-16'>
|
||||
<div class='container mx-auto h-full w-full flex items-center'>
|
||||
<%= render 'shared/navbar' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages - Fixed below navbar -->
|
||||
<div class='fixed top-16 w-full z-50'>
|
||||
<div class='container mx-auto px-5'>
|
||||
<%= render 'shared/flash' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Map Container -->
|
||||
<div class='absolute top-16 left-0 right-0 w-full z-20' style='height: calc(100vh - 4rem);'>
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer (hidden by default) -->
|
||||
<div id='map-footer' class='fixed bottom-0 left-0 right-0 z-30 hidden'>
|
||||
<%= render 'shared/legal_footer' %>
|
||||
</div>
|
||||
|
||||
<%= render 'map/onboarding_modal' %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,85 +1,99 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-4 my-5 w-full">
|
||||
<div class='w-full'>
|
||||
<div class="flex flex-col space-y-4 mb-4 w-full">
|
||||
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
|
||||
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
|
||||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
◀️
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
▶️
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Today",
|
||||
map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
|
||||
class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost" %>
|
||||
</div>
|
||||
<!-- Floating Date Navigation Controls -->
|
||||
<div class="fixed top-20 left-0 right-0 flex justify-center" style="z-index: 9999; margin-left: 80px; margin-right: 80px;">
|
||||
<div style="width: 1500px; max-width: 100%;" data-controller="map-controls">
|
||||
<!-- Mobile: Compact Toggle Button -->
|
||||
<div class="lg:hidden justify-center flex">
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->map-controls#toggle"
|
||||
class="btn btn-primary w-96 shadow-lg">
|
||||
<span data-map-controls-target="toggleIcon">
|
||||
<%= icon 'chevron-down' %>
|
||||
</span>
|
||||
<span class="ml-2"><%= human_date(@start_at) %></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expandable Panel (hidden on mobile by default, always visible on desktop) -->
|
||||
<div
|
||||
data-map-controls-target="panel"
|
||||
class="hidden lg:!block bg-base-100 bg-opacity-95 rounded-lg shadow-lg p-4 mt-2 lg:mt-0 scale-80">
|
||||
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
|
||||
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<%= icon 'chevron-left' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
id='map'
|
||||
class="w-full z-0"
|
||||
data-controller="maps points add-visit"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= (current_user.settings || {}).to_json.html_safe %>'
|
||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
data-features='<%= @features.to_json.html_safe %>'>
|
||||
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen z-0">
|
||||
<div id="fog" class="fog"></div>
|
||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time">
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary w-full", value: @start_at %>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time">
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary w-full", value: @end_at %>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<%= icon 'chevron-right' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "btn btn-primary hover:btn-info w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Today",
|
||||
map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
|
||||
class: "btn border border-base-300 hover:btn-ghost w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Map -->
|
||||
<div
|
||||
id='map'
|
||||
class="absolute inset-0 w-full h-full z-0"
|
||||
data-controller="maps points add-visit family-members"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
|
||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
data-features='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-features-value='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
|
||||
<div data-maps-target="container" class="w-full h-full">
|
||||
<div id="fog" class="fog"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<div class='my-5'>
|
||||
<%= link_to "Back to notifications", notifications_path, class: "btn btn-small" %>
|
||||
<div class="inline-block ml-2">
|
||||
<%= button_to "Destroy this notification", @notification, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-small btn-warning" %>
|
||||
<%= button_to "Destroy this notification", @notification, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-small btn-warning" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
<td><%= human_datetime(place.created_at) %></td>
|
||||
<td><%= "#{place.lat}, #{place.lon}" %></td>
|
||||
<td>
|
||||
<%= link_to 'Delete', place, data: { confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
<%= link_to 'Delete', place, data: { turbo_confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
|
|
|||