mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-08 23:15:11 -04:00
feat(lodging): implement quick start feature for lodging creation
- Added LodgingQuickStart component to facilitate quick lodging entry. - Integrated Google Maps support for lodging selection and details enrichment. - Enhanced LodgingModal to include quick start step and handle prefill from Google Places. - Introduced utility function to infer lodging type from Google Places data. - Updated localization files to include new strings for quick start functionality.
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@@ -15,9 +14,22 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from adventures.serializers import LocationSerializer, MapPinSerializer, CalendarLocationSerializer
|
||||
from adventures.utils import pagination
|
||||
from adventures.geocoding import get_place_details, reverse_geocode
|
||||
from adventures.geocoding import reverse_geocode
|
||||
from worldtravel.models import City, Country, Region
|
||||
from .location_image_view import import_remote_images_for_object
|
||||
from .quick_add_utils import (
|
||||
build_quick_add_description,
|
||||
clean_url,
|
||||
coerce_bool,
|
||||
coerce_coordinate,
|
||||
coerce_float,
|
||||
coerce_int,
|
||||
extract_google_place_details,
|
||||
preferred_link,
|
||||
resolve_quick_add_collection,
|
||||
sanitize_photo_urls,
|
||||
sanitize_tags,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -172,8 +184,8 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
if not name:
|
||||
return Response({"error": "name is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
latitude = self._coerce_coordinate(payload.get('latitude'), -90, 90)
|
||||
longitude = self._coerce_coordinate(payload.get('longitude'), -180, 180)
|
||||
latitude = coerce_coordinate(payload.get('latitude'), -90, 90)
|
||||
longitude = coerce_coordinate(payload.get('longitude'), -180, 180)
|
||||
if latitude is None or longitude is None:
|
||||
return Response(
|
||||
{"error": "Valid latitude and longitude are required"},
|
||||
@@ -184,9 +196,8 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
if isinstance(collection, Response):
|
||||
return collection
|
||||
|
||||
place_id = str(payload.get('place_id') or '').strip() or None
|
||||
reverse_data = {}
|
||||
details = {}
|
||||
_, details = extract_google_place_details(payload, fallback_query=name)
|
||||
|
||||
try:
|
||||
reverse_result = reverse_geocode(latitude, longitude, request.user)
|
||||
@@ -195,25 +206,15 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
except Exception:
|
||||
reverse_data = {}
|
||||
|
||||
if place_id:
|
||||
details_result = get_place_details(place_id, fallback_query=name)
|
||||
if isinstance(details_result, dict):
|
||||
if 'error' not in details_result or details_result.get('description'):
|
||||
details = details_result
|
||||
|
||||
rating = self._coerce_float(payload.get('rating'))
|
||||
rating = coerce_float(payload.get('rating'))
|
||||
if rating is None:
|
||||
rating = self._coerce_float(details.get('rating'))
|
||||
rating = coerce_float(details.get('rating'))
|
||||
|
||||
review_count = self._coerce_int(payload.get('review_count'))
|
||||
review_count = coerce_int(payload.get('review_count'))
|
||||
if review_count is None:
|
||||
review_count = self._coerce_int(details.get('review_count'))
|
||||
review_count = coerce_int(details.get('review_count'))
|
||||
|
||||
website = self._clean_url(details.get('website')) or self._clean_url(payload.get('website'))
|
||||
maps_url = self._clean_url(details.get('google_maps_url')) or self._clean_url(
|
||||
payload.get('google_maps_url')
|
||||
)
|
||||
link = self._clean_url(payload.get('link')) or website or maps_url
|
||||
link = preferred_link(payload, details)
|
||||
|
||||
phone_number = str(details.get('phone_number') or payload.get('phone_number') or '').strip() or None
|
||||
|
||||
@@ -224,7 +225,7 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
or None
|
||||
)
|
||||
|
||||
description = self._build_quick_add_description(
|
||||
description = build_quick_add_description(
|
||||
base_description=payload.get('description'),
|
||||
detailed_description=details.get('description'),
|
||||
)
|
||||
@@ -241,8 +242,8 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
'rating': rating,
|
||||
'description': description,
|
||||
'link': link,
|
||||
'tags': self._sanitize_tags(payload.get('types') or payload.get('tags')),
|
||||
'is_public': self._coerce_bool(payload.get('is_public'), default=False),
|
||||
'tags': sanitize_tags(payload.get('types') or payload.get('tags')),
|
||||
'is_public': coerce_bool(payload.get('is_public'), default=False),
|
||||
}
|
||||
|
||||
if category_payload:
|
||||
@@ -258,7 +259,7 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
location = serializer.instance
|
||||
self._apply_reverse_geocode_metadata(location, reverse_data, location_label)
|
||||
|
||||
photo_urls = self._sanitize_photo_urls(payload.get('photos'))
|
||||
photo_urls = sanitize_photo_urls(payload.get('photos'))
|
||||
image_import_summary = None
|
||||
if photo_urls:
|
||||
image_import_summary = import_remote_images_for_object(
|
||||
@@ -581,108 +582,34 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
def _resolve_quick_add_collection(self, collection_id):
|
||||
if not collection_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
collection = Collection.objects.get(id=collection_id)
|
||||
except Collection.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Collection not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
self._validate_collection_permissions([collection])
|
||||
except PermissionDenied:
|
||||
return Response(
|
||||
{"error": "You do not have permission to add this location to the selected collection."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return collection
|
||||
return resolve_quick_add_collection(
|
||||
collection_id,
|
||||
validate_permissions=self._validate_collection_permissions,
|
||||
permission_error_message=(
|
||||
"You do not have permission to add this location to the selected collection."
|
||||
),
|
||||
)
|
||||
|
||||
def _coerce_coordinate(self, value, min_value, max_value):
|
||||
try:
|
||||
number = round(float(value), 6)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if number < min_value or number > max_value:
|
||||
return None
|
||||
|
||||
return number
|
||||
return coerce_coordinate(value, min_value, max_value)
|
||||
|
||||
def _coerce_float(self, value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return coerce_float(value)
|
||||
|
||||
def _coerce_int(self, value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return coerce_int(value)
|
||||
|
||||
def _coerce_bool(self, value, default=False):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {'true', '1', 'yes', 'on'}:
|
||||
return True
|
||||
if normalized in {'false', '0', 'no', 'off'}:
|
||||
return False
|
||||
return default
|
||||
return coerce_bool(value, default=default)
|
||||
|
||||
def _clean_url(self, value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
parsed = urlparse(normalized)
|
||||
if parsed.scheme in {'http', 'https'} and parsed.netloc:
|
||||
return normalized
|
||||
|
||||
return None
|
||||
return clean_url(value)
|
||||
|
||||
def _sanitize_tags(self, raw_tags):
|
||||
if not isinstance(raw_tags, list):
|
||||
return []
|
||||
|
||||
tags = []
|
||||
for item in raw_tags:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
|
||||
value = item.strip()
|
||||
if not value or value in tags:
|
||||
continue
|
||||
|
||||
tags.append(value)
|
||||
if len(tags) >= 8:
|
||||
break
|
||||
|
||||
return tags
|
||||
return sanitize_tags(raw_tags)
|
||||
|
||||
def _sanitize_photo_urls(self, raw_urls):
|
||||
if not isinstance(raw_urls, list):
|
||||
return []
|
||||
|
||||
cleaned = []
|
||||
for value in raw_urls:
|
||||
url = self._clean_url(value)
|
||||
if not url or url in cleaned:
|
||||
continue
|
||||
cleaned.append(url)
|
||||
if len(cleaned) >= 5:
|
||||
break
|
||||
|
||||
return cleaned
|
||||
return sanitize_photo_urls(raw_urls)
|
||||
|
||||
def _normalize_quick_add_category(self, raw_category):
|
||||
if not raw_category:
|
||||
@@ -734,9 +661,7 @@ class LocationViewSet(viewsets.ModelViewSet):
|
||||
base_description,
|
||||
detailed_description,
|
||||
):
|
||||
description = str(detailed_description or '').strip() or str(base_description or '').strip()
|
||||
|
||||
return description or None
|
||||
return build_quick_add_description(base_description, detailed_description)
|
||||
|
||||
def _apply_reverse_geocode_metadata(self, location, reverse_data, fallback_location):
|
||||
if not isinstance(reverse_data, dict):
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from adventures.models import Lodging
|
||||
from adventures.serializers import LodgingSerializer
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from adventures.geocoding import reverse_geocode
|
||||
from .location_image_view import import_remote_images_for_object
|
||||
from .quick_add_utils import (
|
||||
build_quick_add_description,
|
||||
coerce_bool,
|
||||
coerce_coordinate,
|
||||
coerce_float,
|
||||
extract_google_place_details,
|
||||
infer_lodging_type,
|
||||
preferred_link,
|
||||
resolve_quick_add_collection,
|
||||
sanitize_photo_urls,
|
||||
)
|
||||
|
||||
class LodgingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Lodging.objects.all()
|
||||
@@ -63,6 +76,103 @@ class LodgingViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='quick-add')
|
||||
@transaction.atomic
|
||||
def quick_add(self, request):
|
||||
"""Create a lodging from lightweight map/place input in one server-side call."""
|
||||
payload = request.data if isinstance(request.data, dict) else {}
|
||||
|
||||
name = str(payload.get('name') or '').strip()
|
||||
if not name:
|
||||
return Response({"error": "name is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
latitude = coerce_coordinate(payload.get('latitude'), -90, 90)
|
||||
longitude = coerce_coordinate(payload.get('longitude'), -180, 180)
|
||||
if latitude is None or longitude is None:
|
||||
return Response(
|
||||
{"error": "Valid latitude and longitude are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
collection = resolve_quick_add_collection(
|
||||
payload.get('collection_id'),
|
||||
validate_permissions=self._validate_collection_permissions,
|
||||
permission_error_message=(
|
||||
"You do not have permission to add this lodging to the selected collection."
|
||||
),
|
||||
)
|
||||
if isinstance(collection, Response):
|
||||
return collection
|
||||
|
||||
reverse_data = {}
|
||||
try:
|
||||
reverse_result = reverse_geocode(latitude, longitude, request.user)
|
||||
if isinstance(reverse_result, dict) and 'error' not in reverse_result:
|
||||
reverse_data = reverse_result
|
||||
except Exception:
|
||||
reverse_data = {}
|
||||
|
||||
_, details = extract_google_place_details(payload, fallback_query=name)
|
||||
|
||||
rating = coerce_float(payload.get('rating'))
|
||||
if rating is None:
|
||||
rating = coerce_float(details.get('rating'))
|
||||
|
||||
location_label = (
|
||||
str(payload.get('location') or '').strip()
|
||||
or str(reverse_data.get('display_name') or '').strip()
|
||||
or str(details.get('formatted_address') or '').strip()
|
||||
or None
|
||||
)
|
||||
|
||||
place_types = payload.get('types')
|
||||
if not isinstance(place_types, list) or not place_types:
|
||||
place_types = details.get('types') if isinstance(details.get('types'), list) else []
|
||||
|
||||
serializer_payload = {
|
||||
'name': name,
|
||||
'type': infer_lodging_type(payload.get('type'), place_types),
|
||||
'location': location_label,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'rating': rating,
|
||||
'description': build_quick_add_description(
|
||||
base_description=payload.get('description'),
|
||||
detailed_description=details.get('description'),
|
||||
),
|
||||
'link': preferred_link(payload, details),
|
||||
'is_public': coerce_bool(payload.get('is_public'), default=False),
|
||||
}
|
||||
|
||||
if collection:
|
||||
serializer_payload['collection'] = str(collection.id)
|
||||
|
||||
serializer = self.get_serializer(data=serializer_payload)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
lodging = serializer.instance
|
||||
|
||||
photo_urls = sanitize_photo_urls(payload.get('photos'))
|
||||
image_import_summary = None
|
||||
if photo_urls:
|
||||
image_import_summary = import_remote_images_for_object(
|
||||
lodging,
|
||||
photo_urls,
|
||||
owner=lodging.user,
|
||||
max_workers=min(5, len(photo_urls)),
|
||||
)
|
||||
|
||||
response_data = self.get_serializer(lodging).data
|
||||
if image_import_summary and image_import_summary.get('failed'):
|
||||
response_data['quick_add_image_import'] = {
|
||||
'created_count': image_import_summary['created_count'],
|
||||
'failed_count': image_import_summary['failed_count'],
|
||||
'failed': image_import_summary['failed'],
|
||||
}
|
||||
|
||||
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||
def perform_create(self, serializer):
|
||||
@@ -81,4 +191,13 @@ class LodgingViewSet(viewsets.ModelViewSet):
|
||||
return
|
||||
|
||||
# Save the adventure with the current user as the owner
|
||||
serializer.save(user=self.request.user)
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
def _validate_collection_permissions(self, collections):
|
||||
"""Validate permissions for all collections (used by quick add)."""
|
||||
for collection in collections:
|
||||
if collection.user != self.request.user:
|
||||
if not collection.shared_with.filter(id=self.request.user.id).exists():
|
||||
raise PermissionDenied(
|
||||
f"You don't have permission to add lodging to collection '{collection.name}'"
|
||||
)
|
||||
202
backend/server/adventures/views/quick_add_utils.py
Normal file
202
backend/server/adventures/views/quick_add_utils.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from adventures.geocoding import get_place_details
|
||||
from adventures.models import Collection
|
||||
|
||||
|
||||
def coerce_coordinate(value, min_value, max_value):
|
||||
try:
|
||||
number = round(float(value), 6)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if number < min_value or number > max_value:
|
||||
return None
|
||||
|
||||
return number
|
||||
|
||||
|
||||
def coerce_float(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_int(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_bool(value, default=False):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def clean_url(value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
parsed = urlparse(normalized)
|
||||
if parsed.scheme in {"http", "https"} and parsed.netloc:
|
||||
return normalized
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_tags(raw_tags, max_tags=8):
|
||||
if not isinstance(raw_tags, list):
|
||||
return []
|
||||
|
||||
tags = []
|
||||
for item in raw_tags:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
|
||||
value = item.strip()
|
||||
if not value or value in tags:
|
||||
continue
|
||||
|
||||
tags.append(value)
|
||||
if len(tags) >= max_tags:
|
||||
break
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def sanitize_photo_urls(raw_urls, max_urls=5):
|
||||
if not isinstance(raw_urls, list):
|
||||
return []
|
||||
|
||||
cleaned = []
|
||||
for value in raw_urls:
|
||||
url = clean_url(value)
|
||||
if not url or url in cleaned:
|
||||
continue
|
||||
|
||||
cleaned.append(url)
|
||||
if len(cleaned) >= max_urls:
|
||||
break
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def build_quick_add_description(base_description, detailed_description):
|
||||
description = str(detailed_description or "").strip() or str(base_description or "").strip()
|
||||
return description or None
|
||||
|
||||
|
||||
def resolve_quick_add_collection(collection_id, validate_permissions, permission_error_message):
|
||||
if not collection_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
collection = Collection.objects.get(id=collection_id)
|
||||
except Collection.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Collection not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_permissions([collection])
|
||||
except (DjangoPermissionDenied, DRFPermissionDenied):
|
||||
return Response(
|
||||
{"error": permission_error_message},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
return collection
|
||||
|
||||
|
||||
def extract_google_place_details(payload, fallback_query=""):
|
||||
place_id = str(payload.get("place_id") or "").strip() or None
|
||||
details = {}
|
||||
|
||||
if not place_id:
|
||||
return place_id, details
|
||||
|
||||
details_result = get_place_details(place_id, fallback_query=fallback_query)
|
||||
if isinstance(details_result, dict):
|
||||
if "error" not in details_result or details_result.get("description"):
|
||||
details = details_result
|
||||
|
||||
return place_id, details
|
||||
|
||||
|
||||
def preferred_link(payload, details):
|
||||
website = clean_url(details.get("website")) or clean_url(payload.get("website"))
|
||||
maps_url = clean_url(details.get("google_maps_url")) or clean_url(payload.get("google_maps_url"))
|
||||
return clean_url(payload.get("link")) or website or maps_url
|
||||
|
||||
|
||||
def infer_lodging_type(primary_type, place_types):
|
||||
valid_types = {
|
||||
"hotel",
|
||||
"hostel",
|
||||
"resort",
|
||||
"bnb",
|
||||
"campground",
|
||||
"cabin",
|
||||
"apartment",
|
||||
"house",
|
||||
"villa",
|
||||
"motel",
|
||||
"other",
|
||||
}
|
||||
|
||||
if isinstance(primary_type, str):
|
||||
normalized = primary_type.strip().lower()
|
||||
if normalized in valid_types:
|
||||
return normalized
|
||||
|
||||
normalized_types = [
|
||||
str(type_name).strip().lower()
|
||||
for type_name in (place_types or [])
|
||||
if str(type_name).strip()
|
||||
]
|
||||
|
||||
mapping = {
|
||||
"hotel": "hotel",
|
||||
"resort_hotel": "resort",
|
||||
"motel": "motel",
|
||||
"hostel": "hostel",
|
||||
"bed_and_breakfast": "bnb",
|
||||
"guest_house": "bnb",
|
||||
"campground": "campground",
|
||||
"rv_park": "campground",
|
||||
"camping_cabin": "cabin",
|
||||
"apartment_building": "apartment",
|
||||
"lodging": "hotel",
|
||||
"villa": "villa",
|
||||
}
|
||||
|
||||
for type_name in normalized_types:
|
||||
if type_name in mapping:
|
||||
return mapping[type_name]
|
||||
|
||||
for type_name in normalized_types:
|
||||
if type_name in valid_types:
|
||||
return type_name
|
||||
|
||||
return "other"
|
||||
@@ -20,7 +20,7 @@
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/adapter-vercel": "^5.7.0",
|
||||
"@sveltejs/kit": "^2.49.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@sveltejs/vite-plugin-svelte": "3.1.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^22.15.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -30,7 +30,7 @@
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte": "4.2.19",
|
||||
"svelte-check": "^3.8.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tslib": "^2.8.1",
|
||||
|
||||
224
frontend/pnpm-lock.yaml
generated
224
frontend/pnpm-lock.yaml
generated
@@ -11,8 +11,6 @@ overrides:
|
||||
brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5'
|
||||
picomatch@<2.3.2: '>=2.3.2'
|
||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||
svelte@<=5.51.4: '>=5.51.5'
|
||||
svelte@<=5.53.4: '>=5.53.5'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -20,7 +18,7 @@ importers:
|
||||
dependencies:
|
||||
'@lukulent/svelte-umami':
|
||||
specifier: ^0.0.3
|
||||
version: 0.0.3(svelte@5.55.1)
|
||||
version: 0.0.3(svelte@4.2.19)
|
||||
dompurify:
|
||||
specifier: ^3.2.5
|
||||
version: 3.3.3
|
||||
@@ -44,13 +42,13 @@ importers:
|
||||
version: 1.5.4
|
||||
svelte-dnd-action:
|
||||
specifier: ^0.9.68
|
||||
version: 0.9.69(svelte@5.55.1)
|
||||
version: 0.9.69(svelte@4.2.19)
|
||||
svelte-i18n:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(svelte@5.55.1)
|
||||
version: 4.0.1(svelte@4.2.19)
|
||||
svelte-maplibre:
|
||||
specifier: ^0.9.14
|
||||
version: 0.9.14(svelte@5.55.1)
|
||||
version: 0.9.14(svelte@4.2.19)
|
||||
devDependencies:
|
||||
'@event-calendar/core':
|
||||
specifier: ^3.12.0
|
||||
@@ -69,16 +67,16 @@ importers:
|
||||
version: 1.2.3
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.2.12
|
||||
version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))
|
||||
version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))
|
||||
'@sveltejs/adapter-vercel':
|
||||
specifier: '>=6.3.2'
|
||||
version: 6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)
|
||||
version: 6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.49.5
|
||||
version: 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
|
||||
version: 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
|
||||
specifier: 3.1.2
|
||||
version: 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.19
|
||||
version: 0.5.19(tailwindcss@3.4.19)
|
||||
@@ -105,13 +103,13 @@ importers:
|
||||
version: 3.8.1
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.3.3
|
||||
version: 3.5.1(prettier@3.8.1)(svelte@5.55.1)
|
||||
version: 3.5.1(prettier@3.8.1)(svelte@4.2.19)
|
||||
svelte:
|
||||
specifier: '>=5.53.5'
|
||||
version: 5.55.1
|
||||
specifier: 4.2.19
|
||||
version: 4.2.19
|
||||
svelte-check:
|
||||
specifier: ^3.8.6
|
||||
version: 3.8.6(postcss@8.5.8)(svelte@5.55.1)
|
||||
version: 3.8.6(postcss@8.5.8)(svelte@4.2.19)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.19
|
||||
@@ -134,6 +132,10 @@ packages:
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@antfu/install-pkg@0.4.1':
|
||||
resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==}
|
||||
|
||||
@@ -345,9 +347,6 @@ packages:
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -361,7 +360,7 @@ packages:
|
||||
'@lukulent/svelte-umami@0.0.3':
|
||||
resolution: {integrity: sha512-4pL0sJapfy14yDj6CyZgewbRDadRoBJtk/dLqCJh7/tQuX7HO4hviBzhrVa4Osxaq2kcGEKdpkhAKAoaNdlNSA==}
|
||||
peerDependencies:
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^4.0.0
|
||||
|
||||
'@mapbox/geojson-rewind@0.5.2':
|
||||
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
|
||||
@@ -611,7 +610,7 @@ packages:
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.0.0
|
||||
'@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
typescript: ^5.3.3
|
||||
vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
@@ -625,14 +624,14 @@ packages:
|
||||
engines: {node: ^18.0.0 || >=20}
|
||||
peerDependencies:
|
||||
'@sveltejs/vite-plugin-svelte': ^3.0.0
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
vite: ^5.0.0
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@3.1.2':
|
||||
resolution: {integrity: sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==}
|
||||
engines: {node: ^18.0.0 || >=20}
|
||||
peerDependencies:
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
vite: ^5.0.0
|
||||
|
||||
'@tailwindcss/typography@0.5.19':
|
||||
@@ -682,10 +681,6 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@typescript-eslint/types@8.58.0':
|
||||
resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vercel/nft@1.5.0':
|
||||
resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -810,9 +805,8 @@ packages:
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
code-red@1.0.4:
|
||||
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
@@ -848,6 +842,10 @@ packages:
|
||||
css-selector-tokenizer@0.8.0:
|
||||
resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==}
|
||||
|
||||
css-tree@2.3.1:
|
||||
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -965,12 +963,12 @@ packages:
|
||||
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
esrap@2.2.4:
|
||||
resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
event-emitter@0.3.5:
|
||||
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
|
||||
|
||||
@@ -1218,6 +1216,9 @@ packages:
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
mdn-data@2.0.30:
|
||||
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
|
||||
|
||||
memoizee@0.4.17:
|
||||
resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -1360,6 +1361,9 @@ packages:
|
||||
resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==}
|
||||
hasBin: true
|
||||
|
||||
periscopic@3.1.0:
|
||||
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1450,7 +1454,7 @@ packages:
|
||||
resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==}
|
||||
peerDependencies:
|
||||
prettier: ^3.0.0
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
||||
|
||||
prettier@3.8.1:
|
||||
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
|
||||
@@ -1588,25 +1592,25 @@ packages:
|
||||
resolution: {integrity: sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0
|
||||
|
||||
svelte-dnd-action@0.9.69:
|
||||
resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==}
|
||||
peerDependencies:
|
||||
svelte: '>=5.53.5'
|
||||
svelte: '>=3.23.0 || ^5.0.0-next.0'
|
||||
|
||||
svelte-hmr@0.16.0:
|
||||
resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==}
|
||||
engines: {node: ^12.20 || ^14.13.1 || >= 16}
|
||||
peerDependencies:
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^3.19.0 || ^4.0.0
|
||||
|
||||
svelte-i18n@4.0.1:
|
||||
resolution: {integrity: sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^3 || ^4 || ^5
|
||||
|
||||
svelte-maplibre@0.9.14:
|
||||
resolution: {integrity: sha512-5HBvibzU/Uf3g8eEz4Hty5XAwoBhW9Tp7NQEvb80U/glR/M1IHyzUKss6XMq8Zbci2wtsASeoPc6dA5R4+0e0w==}
|
||||
@@ -1614,7 +1618,7 @@ packages:
|
||||
'@deck.gl/core': ^8.8.0
|
||||
'@deck.gl/layers': ^8.8.0
|
||||
'@deck.gl/mapbox': ^8.8.0
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^3.54.0 || ^4.0.0 || ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@deck.gl/core':
|
||||
optional: true
|
||||
@@ -1636,7 +1640,7 @@ packages:
|
||||
sass: ^1.26.8
|
||||
stylus: ^0.55.0
|
||||
sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||
svelte: '>=5.53.5'
|
||||
svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0
|
||||
typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
@@ -1660,9 +1664,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
svelte@5.55.1:
|
||||
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
|
||||
engines: {node: '>=18'}
|
||||
svelte@4.2.19:
|
||||
resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tailwindcss@3.4.19:
|
||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||
@@ -1846,13 +1850,15 @@ packages:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
zimmerframe@1.1.4:
|
||||
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@antfu/install-pkg@0.4.1':
|
||||
dependencies:
|
||||
package-manager-detector: 0.2.11
|
||||
@@ -1947,22 +1953,22 @@ snapshots:
|
||||
|
||||
'@event-calendar/core@3.12.0':
|
||||
dependencies:
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
'@event-calendar/day-grid@3.12.0':
|
||||
dependencies:
|
||||
'@event-calendar/core': 3.12.0
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
'@event-calendar/interaction@3.12.0':
|
||||
dependencies:
|
||||
'@event-calendar/core': 3.12.0
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
'@event-calendar/time-grid@3.12.0':
|
||||
dependencies:
|
||||
'@event-calendar/core': 3.12.0
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
'@formatjs/ecma402-abstract@2.3.6':
|
||||
dependencies:
|
||||
@@ -2018,11 +2024,6 @@ snapshots:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
@@ -2032,9 +2033,9 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lukulent/svelte-umami@0.0.3(svelte@5.55.1)':
|
||||
'@lukulent/svelte-umami@0.0.3(svelte@4.2.19)':
|
||||
dependencies:
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
'@mapbox/geojson-rewind@0.5.2':
|
||||
dependencies:
|
||||
@@ -2209,17 +2210,17 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))':
|
||||
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))':
|
||||
dependencies:
|
||||
'@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0)
|
||||
'@rollup/plugin-json': 6.1.0(rollup@4.59.0)
|
||||
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0)
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
|
||||
rollup: 4.59.0
|
||||
|
||||
'@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)':
|
||||
'@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)':
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@vercel/nft': 1.5.0(rollup@4.59.0)
|
||||
esbuild: 0.25.12
|
||||
transitivePeerDependencies:
|
||||
@@ -2227,11 +2228,11 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))':
|
||||
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
||||
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@types/cookie': 0.6.0
|
||||
acorn: 8.16.0
|
||||
cookie: 0.6.0
|
||||
@@ -2242,29 +2243,29 @@ snapshots:
|
||||
mrmime: 2.0.1
|
||||
set-cookie-parser: 3.0.1
|
||||
sirv: 3.0.2
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
vite: 5.4.21(@types/node@22.19.15)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))':
|
||||
'@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
|
||||
debug: 4.4.3
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
vite: 5.4.21(@types/node@22.19.15)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))':
|
||||
'@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
|
||||
debug: 4.4.3
|
||||
deepmerge: 4.3.1
|
||||
kleur: 4.1.5
|
||||
magic-string: 0.30.21
|
||||
svelte: 5.55.1
|
||||
svelte-hmr: 0.16.0(svelte@5.55.1)
|
||||
svelte: 4.2.19
|
||||
svelte-hmr: 0.16.0(svelte@4.2.19)
|
||||
vite: 5.4.21(@types/node@22.19.15)
|
||||
vitefu: 0.2.5(vite@5.4.21(@types/node@22.19.15))
|
||||
transitivePeerDependencies:
|
||||
@@ -2315,9 +2316,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@typescript-eslint/types@8.58.0': {}
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@vercel/nft@1.5.0(rollup@4.59.0)':
|
||||
dependencies:
|
||||
@@ -2447,7 +2447,13 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
code-red@1.0.4:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@types/estree': 1.0.8
|
||||
acorn: 8.16.0
|
||||
estree-walker: 3.0.3
|
||||
periscopic: 3.1.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
@@ -2474,6 +2480,11 @@ snapshots:
|
||||
cssesc: 3.0.0
|
||||
fastparse: 1.1.2
|
||||
|
||||
css-tree@2.3.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.30
|
||||
source-map-js: 1.2.1
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
culori@3.3.0: {}
|
||||
@@ -2603,13 +2614,12 @@ snapshots:
|
||||
event-emitter: 0.3.5
|
||||
type: 2.7.3
|
||||
|
||||
esrap@2.2.4:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
event-emitter@0.3.5:
|
||||
dependencies:
|
||||
d: 1.0.2
|
||||
@@ -2851,6 +2861,8 @@ snapshots:
|
||||
|
||||
marked@15.0.12: {}
|
||||
|
||||
mdn-data@2.0.30: {}
|
||||
|
||||
memoizee@0.4.17:
|
||||
dependencies:
|
||||
d: 1.0.2
|
||||
@@ -2972,6 +2984,12 @@ snapshots:
|
||||
ieee754: 1.2.1
|
||||
resolve-protobuf-schema: 2.1.0
|
||||
|
||||
periscopic@3.1.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
estree-walker: 3.0.3
|
||||
is-reference: 3.0.3
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@@ -3045,10 +3063,10 @@ snapshots:
|
||||
|
||||
potpack@2.1.0: {}
|
||||
|
||||
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.1):
|
||||
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@4.2.19):
|
||||
dependencies:
|
||||
prettier: 3.8.1
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
prettier@3.8.1: {}
|
||||
|
||||
@@ -3203,14 +3221,14 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svelte-check@3.8.6(postcss@8.5.8)(svelte@5.55.1):
|
||||
svelte-check@3.8.6(postcss@8.5.8)(svelte@4.2.19):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
chokidar: 3.6.0
|
||||
picocolors: 1.1.1
|
||||
sade: 1.8.1
|
||||
svelte: 5.55.1
|
||||
svelte-preprocess: 5.1.4(postcss@8.5.8)(svelte@5.55.1)(typescript@5.9.3)
|
||||
svelte: 4.2.19
|
||||
svelte-preprocess: 5.1.4(postcss@8.5.8)(svelte@4.2.19)(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -3223,15 +3241,15 @@ snapshots:
|
||||
- stylus
|
||||
- sugarss
|
||||
|
||||
svelte-dnd-action@0.9.69(svelte@5.55.1):
|
||||
svelte-dnd-action@0.9.69(svelte@4.2.19):
|
||||
dependencies:
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
svelte-hmr@0.16.0(svelte@5.55.1):
|
||||
svelte-hmr@0.16.0(svelte@4.2.19):
|
||||
dependencies:
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
svelte-i18n@4.0.1(svelte@5.55.1):
|
||||
svelte-i18n@4.0.1(svelte@4.2.19):
|
||||
dependencies:
|
||||
cli-color: 2.0.4
|
||||
deepmerge: 4.3.1
|
||||
@@ -3239,10 +3257,10 @@ snapshots:
|
||||
estree-walker: 2.0.2
|
||||
intl-messageformat: 10.7.18
|
||||
sade: 1.8.1
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
tiny-glob: 0.2.9
|
||||
|
||||
svelte-maplibre@0.9.14(svelte@5.55.1):
|
||||
svelte-maplibre@0.9.14(svelte@4.2.19):
|
||||
dependencies:
|
||||
d3-geo: 3.1.1
|
||||
dequal: 2.0.3
|
||||
@@ -3250,38 +3268,36 @@ snapshots:
|
||||
just-flush: 2.3.0
|
||||
maplibre-gl: 4.7.1
|
||||
pmtiles: 3.2.1
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
|
||||
svelte-preprocess@5.1.4(postcss@8.5.8)(svelte@5.55.1)(typescript@5.9.3):
|
||||
svelte-preprocess@5.1.4(postcss@8.5.8)(svelte@4.2.19)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@types/pug': 2.0.10
|
||||
detect-indent: 6.1.0
|
||||
magic-string: 0.30.21
|
||||
sorcery: 0.11.1
|
||||
strip-indent: 3.0.0
|
||||
svelte: 5.55.1
|
||||
svelte: 4.2.19
|
||||
optionalDependencies:
|
||||
postcss: 8.5.8
|
||||
typescript: 5.9.3
|
||||
|
||||
svelte@5.55.1:
|
||||
svelte@4.2.19:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@types/estree': 1.0.8
|
||||
'@types/trusted-types': 2.0.7
|
||||
acorn: 8.16.0
|
||||
aria-query: 5.3.1
|
||||
axobject-query: 4.1.0
|
||||
clsx: 2.1.1
|
||||
devalue: 5.6.4
|
||||
esm-env: 1.2.2
|
||||
esrap: 2.2.4
|
||||
code-red: 1.0.4
|
||||
css-tree: 2.3.1
|
||||
estree-walker: 3.0.3
|
||||
is-reference: 3.0.3
|
||||
locate-character: 3.0.0
|
||||
magic-string: 0.30.21
|
||||
zimmerframe: 1.1.4
|
||||
periscopic: 3.1.0
|
||||
|
||||
tailwindcss@3.4.19:
|
||||
dependencies:
|
||||
@@ -3457,5 +3473,3 @@ snapshots:
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
zimmerframe@1.1.4: {}
|
||||
|
||||
@@ -5,5 +5,3 @@ overrides:
|
||||
brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5'
|
||||
picomatch@<2.3.2: '>=2.3.2'
|
||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||
svelte@<=5.51.4: '>=5.51.5'
|
||||
svelte@<=5.53.4: '>=5.53.5'
|
||||
|
||||
@@ -1,800 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getBasemapUrl } from '$lib';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import CategoryDropdown from '../CategoryDropdown.svelte';
|
||||
import type { Category } from '$lib/types';
|
||||
|
||||
import SearchIcon from '~icons/mdi/magnify';
|
||||
import LocationIcon from '~icons/mdi/crosshairs-gps';
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
import CheckIcon from '~icons/mdi/check';
|
||||
import ClearIcon from '~icons/mdi/close';
|
||||
import PinIcon from '~icons/mdi/map-marker';
|
||||
import StarIcon from '~icons/mdi/star';
|
||||
import LightningIcon from '~icons/mdi/lightning-bolt';
|
||||
import PencilIcon from '~icons/mdi/pencil';
|
||||
|
||||
type SelectedPlace = {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
location: string;
|
||||
type?: string;
|
||||
category?: string;
|
||||
types?: string[];
|
||||
rating?: number | null;
|
||||
review_count?: number | null;
|
||||
photos?: string[];
|
||||
description?: string | null;
|
||||
website?: string | null;
|
||||
phone_number?: string | null;
|
||||
place_id?: string | null;
|
||||
google_maps_url?: string | null;
|
||||
powered_by?: string;
|
||||
};
|
||||
|
||||
type LocationData = {
|
||||
city?: { name: string; id: string; visited: boolean };
|
||||
region?: { name: string; id: string; visited: boolean };
|
||||
country?: { name: string; country_code: string; visited: boolean };
|
||||
display_name?: string;
|
||||
location_name?: string;
|
||||
};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
import PlaceQuickStart from '../shared/PlaceQuickStart.svelte';
|
||||
|
||||
export let googleEnabled = false;
|
||||
export let collectionId: string | null = null;
|
||||
|
||||
let searchQuery = '';
|
||||
let searchResults: SelectedPlace[] = [];
|
||||
let selectedLocation: SelectedPlace | null = null;
|
||||
let mapCenter: [number, number] = [-74.5, 40];
|
||||
let mapZoom = 2;
|
||||
let isSearching = false;
|
||||
let isReverseGeocoding = false;
|
||||
let isEnrichingDescription = false;
|
||||
let isQuickAdding = false;
|
||||
let quickAddedLocation: any = null;
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let mapComponent: any;
|
||||
let selectedMarker: { lng: number; lat: number } | null = null;
|
||||
let locationData: LocationData | null = null;
|
||||
let selectedQuickAddCategory: Category | null = null;
|
||||
const placeDetailsCache = new Map<string, any>();
|
||||
|
||||
function toPlaceResult(result: any): SelectedPlace {
|
||||
return {
|
||||
id: result.place_id || `${result.name || 'place'}-${result.lat}-${result.lon}`,
|
||||
name: result.name,
|
||||
lat: parseFloat(result.lat),
|
||||
lng: parseFloat(result.lon),
|
||||
location: result.display_name,
|
||||
type: result.type,
|
||||
category: result.category,
|
||||
types: result.types || [],
|
||||
rating: result.rating ?? null,
|
||||
review_count: result.review_count ?? null,
|
||||
photos: result.photos || [],
|
||||
description: result.description || null,
|
||||
website: result.website || null,
|
||||
phone_number: result.phone_number || null,
|
||||
place_id: result.place_id || null,
|
||||
google_maps_url: result.google_maps_url || null,
|
||||
powered_by: result.powered_by
|
||||
};
|
||||
}
|
||||
|
||||
function pickBestNearbyResult(
|
||||
results: SelectedPlace[],
|
||||
lat: number,
|
||||
lng: number,
|
||||
preferredName?: string
|
||||
): SelectedPlace | null {
|
||||
if (!results.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPreferredName = (preferredName || '').trim().toLowerCase();
|
||||
const scored = results
|
||||
.filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lng))
|
||||
.map((item) => {
|
||||
const dLat = item.lat - lat;
|
||||
const dLng = item.lng - lng;
|
||||
const distanceScore = dLat * dLat + dLng * dLng;
|
||||
const nameScore =
|
||||
normalizedPreferredName && item.name?.trim().toLowerCase() === normalizedPreferredName
|
||||
? -1
|
||||
: 0;
|
||||
const placeScore = item.place_id ? -0.5 : 0;
|
||||
return {
|
||||
item,
|
||||
score: distanceScore + nameScore + placeScore
|
||||
};
|
||||
});
|
||||
|
||||
if (!scored.length) {
|
||||
return results[0];
|
||||
}
|
||||
|
||||
scored.sort((a, b) => a.score - b.score);
|
||||
return scored[0].item;
|
||||
}
|
||||
|
||||
async function enrichFromResolvedName(lat: number, lng: number, resolvedName: string) {
|
||||
const query = resolvedName.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawResults = await response.json();
|
||||
const mappedResults = Array.isArray(rawResults) ? rawResults.map(toPlaceResult) : [];
|
||||
const bestMatch = pickBestNearbyResult(mappedResults, lat, lng, query);
|
||||
if (!bestMatch || !selectedLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLocation = {
|
||||
...selectedLocation,
|
||||
...bestMatch,
|
||||
lat,
|
||||
lng,
|
||||
name: bestMatch.name || selectedLocation.name,
|
||||
location: selectedLocation.location || bestMatch.location
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
} catch (error) {
|
||||
console.error('Resolved name enrichment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function needsDescriptionEnrichment(place: SelectedPlace | null) {
|
||||
if (!place?.place_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = (place.description || '').trim();
|
||||
return text.length < 220;
|
||||
}
|
||||
|
||||
async function fetchPlaceDetails(placeId: string, name: string) {
|
||||
if (placeDetailsCache.has(placeId)) {
|
||||
return placeDetailsCache.get(placeId);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/place_details/?place_id=${encodeURIComponent(placeId)}&name=${encodeURIComponent(name || '')}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to fetch place details');
|
||||
}
|
||||
|
||||
const details = await response.json();
|
||||
placeDetailsCache.set(placeId, details);
|
||||
return details;
|
||||
}
|
||||
|
||||
async function enrichSelectedLocationDescription(force = false) {
|
||||
if (!selectedLocation?.place_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placeId = selectedLocation.place_id;
|
||||
if (!placeId || (!force && !needsDescriptionEnrichment(selectedLocation))) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEnrichingDescription = true;
|
||||
try {
|
||||
const details = await fetchPlaceDetails(placeId, selectedLocation.name || '');
|
||||
|
||||
if (!selectedLocation || selectedLocation.place_id !== placeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLocation = {
|
||||
...selectedLocation,
|
||||
name: details.name || selectedLocation.name,
|
||||
location: details.formatted_address || selectedLocation.location,
|
||||
types:
|
||||
Array.isArray(details.types) && details.types.length > 0
|
||||
? details.types
|
||||
: selectedLocation.types,
|
||||
rating: details.rating ?? selectedLocation.rating ?? null,
|
||||
review_count: details.review_count ?? selectedLocation.review_count ?? null,
|
||||
description: details.description || selectedLocation.description || null,
|
||||
website: details.website || selectedLocation.website || null,
|
||||
phone_number: details.phone_number || selectedLocation.phone_number || null,
|
||||
google_maps_url: details.google_maps_url || selectedLocation.google_maps_url || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Place details enrichment error:', error);
|
||||
} finally {
|
||||
isEnrichingDescription = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchLocations(query: string) {
|
||||
if (!query.trim() || query.length < 3) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
const results = await response.json();
|
||||
searchResults = Array.isArray(results) ? results.map(toPlaceResult) : [];
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
searchResults = [];
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchLocations(searchQuery);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function selectSearchResult(location: SelectedPlace) {
|
||||
selectedLocation = location;
|
||||
selectedMarker = { lng: location.lng, lat: location.lat };
|
||||
mapCenter = [location.lng, location.lat];
|
||||
mapZoom = 14;
|
||||
searchResults = [];
|
||||
searchQuery = location.name;
|
||||
await performDetailedReverseGeocode(location.lat, location.lng);
|
||||
}
|
||||
|
||||
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
|
||||
selectedMarker = {
|
||||
lng: e.detail.lngLat.lng,
|
||||
lat: e.detail.lngLat.lat
|
||||
};
|
||||
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
|
||||
}
|
||||
|
||||
async function reverseGeocode(lng: number, lat: number) {
|
||||
isReverseGeocoding = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
|
||||
const results = await response.json();
|
||||
|
||||
if (Array.isArray(results) && results.length > 0) {
|
||||
selectedLocation = {
|
||||
...toPlaceResult(results[0]),
|
||||
lat,
|
||||
lng
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
} else {
|
||||
selectedLocation = {
|
||||
id: `manual-${lat}-${lng}`,
|
||||
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
lat,
|
||||
lng,
|
||||
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
types: [],
|
||||
photos: []
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
}
|
||||
|
||||
await performDetailedReverseGeocode(lat, lng);
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding error:', error);
|
||||
selectedLocation = {
|
||||
id: `manual-${lat}-${lng}`,
|
||||
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
lat,
|
||||
lng,
|
||||
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
types: [],
|
||||
photos: []
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
locationData = null;
|
||||
} finally {
|
||||
isReverseGeocoding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performDetailedReverseGeocode(lat: number, lng: number) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
locationData = {
|
||||
city: data.city
|
||||
? {
|
||||
name: data.city,
|
||||
id: data.city_id,
|
||||
visited: data.city_visited || false
|
||||
}
|
||||
: undefined,
|
||||
region: data.region
|
||||
? {
|
||||
name: data.region,
|
||||
id: data.region_id,
|
||||
visited: data.region_visited || false
|
||||
}
|
||||
: undefined,
|
||||
country: data.country
|
||||
? {
|
||||
name: data.country,
|
||||
country_code: data.country_id,
|
||||
visited: false
|
||||
}
|
||||
: undefined,
|
||||
display_name: data.display_name,
|
||||
location_name: data.location_name
|
||||
};
|
||||
|
||||
if (selectedLocation) {
|
||||
const isCoordinatePlaceholder = selectedLocation.name.startsWith('Location at ');
|
||||
const shouldAutoEnrichQuickAdd = isCoordinatePlaceholder || !selectedLocation.place_id;
|
||||
const resolvedLocationName = (data.location_name || '').trim();
|
||||
const resolvedDisplayName = (data.display_name || '').trim();
|
||||
|
||||
selectedLocation = {
|
||||
...selectedLocation,
|
||||
name:
|
||||
resolvedLocationName ||
|
||||
(isCoordinatePlaceholder && resolvedDisplayName
|
||||
? resolvedDisplayName
|
||||
: selectedLocation.name),
|
||||
location: resolvedDisplayName || `${lat.toFixed(4)}, ${lng.toFixed(4)}`
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
|
||||
if (shouldAutoEnrichQuickAdd && resolvedLocationName) {
|
||||
await enrichFromResolvedName(lat, lng, resolvedLocationName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
locationData = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Detailed reverse geocoding error:', error);
|
||||
locationData = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdventureLogFormattedLocation() {
|
||||
if (!selectedMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (locationData?.display_name?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await performDetailedReverseGeocode(selectedMarker.lat, selectedMarker.lng);
|
||||
}
|
||||
|
||||
function useCurrentLocation() {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lng = position.coords.longitude;
|
||||
selectedMarker = { lng, lat };
|
||||
mapCenter = [lng, lat];
|
||||
mapZoom = 14;
|
||||
await reverseGeocode(lng, lat);
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedLocation = null;
|
||||
selectedMarker = null;
|
||||
locationData = null;
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
quickAddedLocation = null;
|
||||
selectedQuickAddCategory = null;
|
||||
mapCenter = [-74.5, 40];
|
||||
mapZoom = 2;
|
||||
}
|
||||
|
||||
function buildPrefillPayload() {
|
||||
if (!selectedLocation || !selectedMarker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedLocation =
|
||||
locationData?.display_name?.trim() || selectedLocation.location?.trim() || '';
|
||||
|
||||
return {
|
||||
name: selectedLocation.name,
|
||||
latitude: selectedMarker.lat,
|
||||
longitude: selectedMarker.lng,
|
||||
location: formattedLocation,
|
||||
type: selectedLocation.type,
|
||||
category: selectedLocation.category,
|
||||
city: locationData?.city,
|
||||
region: locationData?.region,
|
||||
country: locationData?.country,
|
||||
display_name: locationData?.display_name,
|
||||
location_name: locationData?.location_name,
|
||||
rating: selectedLocation.rating ?? null,
|
||||
review_count: selectedLocation.review_count ?? null,
|
||||
photos: selectedLocation.photos || [],
|
||||
description: selectedLocation.description || null,
|
||||
website: selectedLocation.website || null,
|
||||
phone_number: selectedLocation.phone_number || null,
|
||||
place_id: selectedLocation.place_id || null,
|
||||
google_maps_url: selectedLocation.google_maps_url || null,
|
||||
types: selectedLocation.types || [],
|
||||
selected_category: selectedQuickAddCategory
|
||||
};
|
||||
}
|
||||
|
||||
async function continueWithDetails() {
|
||||
await ensureAdventureLogFormattedLocation();
|
||||
|
||||
if (selectedLocation?.place_id && needsDescriptionEnrichment(selectedLocation)) {
|
||||
await enrichSelectedLocationDescription();
|
||||
}
|
||||
|
||||
const prefill = buildPrefillPayload();
|
||||
if (prefill) {
|
||||
dispatch('addDetails', { prefill });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch('manual');
|
||||
}
|
||||
|
||||
async function quickAdd() {
|
||||
const prefill = buildPrefillPayload();
|
||||
if (!prefill) {
|
||||
addToast('warning', 'Please select a place or drop a pin first');
|
||||
return;
|
||||
}
|
||||
|
||||
isQuickAdding = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
name: prefill.name,
|
||||
location: prefill.location,
|
||||
latitude: prefill.latitude,
|
||||
longitude: prefill.longitude,
|
||||
place_id: prefill.place_id,
|
||||
rating: prefill.rating,
|
||||
review_count: prefill.review_count,
|
||||
description: prefill.description,
|
||||
website: prefill.website,
|
||||
phone_number: prefill.phone_number,
|
||||
google_maps_url: prefill.google_maps_url,
|
||||
types: prefill.types || [],
|
||||
photos: prefill.photos || [],
|
||||
collection_id: collectionId,
|
||||
...(selectedQuickAddCategory ? { category: selectedQuickAddCategory } : {}),
|
||||
is_public: false
|
||||
};
|
||||
|
||||
const res = await fetch('/api/locations/quick-add/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData?.error || errorData?.detail || 'Failed to create location');
|
||||
}
|
||||
|
||||
quickAddedLocation = await res.json();
|
||||
|
||||
addToast('success', 'Location created successfully');
|
||||
dispatch('quickAdded', { location: quickAddedLocation, prefill });
|
||||
} catch (error) {
|
||||
addToast('error', error instanceof Error ? error.message : 'Failed to create location');
|
||||
} finally {
|
||||
isQuickAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
clearTimeout(searchTimeout);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if quickAddedLocation}
|
||||
<div class="card bg-success/10 border border-success/30">
|
||||
<div class="card-body p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-success/20 rounded-lg">
|
||||
<CheckIcon class="w-5 h-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-success">Location added</h4>
|
||||
<p class="text-sm text-base-content/70">{quickAddedLocation.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
class="btn btn-primary flex-1"
|
||||
on:click={() => dispatch('quickAddedEdit', { location: quickAddedLocation })}
|
||||
>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
{$t('adventures.add_details') || 'Add Details'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline flex-1"
|
||||
on:click={() => dispatch('quickAddedDone', { location: quickAddedLocation })}
|
||||
>
|
||||
{$t('adventures.done') || 'Done'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card bg-base-200/50 border border-base-300">
|
||||
<div class="card-body p-6 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="quickstart-search-location">
|
||||
<span class="label-text font-medium">
|
||||
{googleEnabled ? 'Search Google Maps' : $t('adventures.search_location')}
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon class="w-5 h-5 text-base-content/40" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="quickstart-search-location"
|
||||
bind:value={searchQuery}
|
||||
on:input={handleSearchInput}
|
||||
placeholder={$t('adventures.search_placeholder') ||
|
||||
'Enter city, location, or landmark...'}
|
||||
class="input input-bordered w-full pl-10 pr-4"
|
||||
class:input-primary={selectedLocation}
|
||||
/>
|
||||
{#if searchQuery && !selectedLocation}
|
||||
<button
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
on:click={clearSelection}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isSearching}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
|
||||
</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<div class="space-y-2">
|
||||
<label class="label" for="quickstart-search-results">
|
||||
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
|
||||
</label>
|
||||
<div id="quickstart-search-results" class="max-h-52 overflow-y-auto space-y-1">
|
||||
{#each searchResults as result}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
|
||||
on:click={() => selectSearchResult(result)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm truncate">{result.name}</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
|
||||
{#if result.rating}
|
||||
<div class="text-xs text-warning mt-1 inline-flex items-center gap-1">
|
||||
<StarIcon class="w-3 h-3" />
|
||||
{result.rating}
|
||||
{#if result.review_count}
|
||||
<span class="text-base-content/60">({result.review_count})</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="divider divider-horizontal text-xs">{$t('adventures.or') || 'OR'}</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
|
||||
<LocationIcon class="w-4 h-4" />
|
||||
{$t('adventures.use_current_location') || 'Use Current Location'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold flex items-center gap-2">
|
||||
<MapIcon class="w-5 h-5" />
|
||||
{$t('adventures.select_on_map') || 'Select on Map'}
|
||||
</h3>
|
||||
{#if selectedMarker}
|
||||
<button class="btn btn-ghost btn-sm gap-1" on:click={clearSelection}>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
{$t('adventures.clear') || 'Clear'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedMarker}
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
{$t('adventures.click_map') || 'Click on the map to select a location'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if isReverseGeocoding}
|
||||
<div class="flex items-center justify-center py-2 mb-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60"
|
||||
>{$t('adventures.getting_location_details') || 'Getting details...'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<MapLibre
|
||||
bind:this={mapComponent}
|
||||
style={getBasemapUrl()}
|
||||
class="w-full h-80 rounded-lg border border-base-300"
|
||||
center={mapCenter}
|
||||
zoom={mapZoom}
|
||||
standardControls
|
||||
>
|
||||
<MapEvents on:click={handleMapClick} />
|
||||
|
||||
{#if selectedMarker}
|
||||
<Marker
|
||||
lngLat={[selectedMarker.lng, selectedMarker.lat]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
|
||||
>
|
||||
<PinIcon class="w-5 h-5 text-primary-content" />
|
||||
</Marker>
|
||||
{/if}
|
||||
</MapLibre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedLocation && selectedMarker}
|
||||
<div class="card bg-success/10 border border-success/30">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex gap-4 items-start">
|
||||
{#if selectedLocation.photos && selectedLocation.photos.length > 0}
|
||||
<img
|
||||
src={selectedLocation.photos[0]}
|
||||
alt={selectedLocation.name}
|
||||
class="w-24 h-24 rounded-lg object-cover border border-base-300"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-success mb-1">{$t('adventures.location_selected')}</h4>
|
||||
<p class="text-sm font-medium text-base-content truncate">{selectedLocation.name}</p>
|
||||
<p class="text-xs text-base-content/70 truncate">{selectedLocation.location}</p>
|
||||
{#if selectedLocation.rating}
|
||||
<div class="text-xs text-warning mt-2 inline-flex items-center gap-1">
|
||||
<StarIcon class="w-3 h-3" />
|
||||
{selectedLocation.rating}
|
||||
{#if selectedLocation.review_count}
|
||||
<span class="text-base-content/60">({selectedLocation.review_count} reviews)</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isEnrichingDescription}
|
||||
<div class="text-xs text-base-content/60 mt-2 inline-flex items-center gap-1">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Improving description quality...
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
|
||||
</p>
|
||||
{#if selectedLocation.types && selectedLocation.types.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
{#each selectedLocation.types.slice(0, 5) as typeName}
|
||||
<span class="badge badge-outline badge-sm capitalize">{typeName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if googleEnabled}
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="form-control gap-2">
|
||||
<label class="label" for="quick-add-category">
|
||||
<span class="label-text font-medium">Category for Quick Add</span>
|
||||
</label>
|
||||
<div id="quick-add-category">
|
||||
<CategoryDropdown bind:selected_category={selectedQuickAddCategory} />
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Optional. If not selected, backend defaults to General.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<button class="btn btn-neutral-200 sm:flex-1" on:click={() => dispatch('cancel')}>
|
||||
{$t('adventures.cancel') || 'Cancel'}
|
||||
</button>
|
||||
|
||||
{#if selectedLocation && selectedMarker && googleEnabled}
|
||||
<button class="btn btn-outline sm:flex-1" on:click={continueWithDetails}>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
{$t('adventures.add_details') || 'Add Details'}
|
||||
</button>
|
||||
<button class="btn btn-primary sm:flex-1" on:click={quickAdd} disabled={isQuickAdding}>
|
||||
{#if isQuickAdding}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{$t('adventures.processing') || 'Processing'}...
|
||||
{:else}
|
||||
<LightningIcon class="w-4 h-4" />
|
||||
Quick Add
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-primary sm:flex-1"
|
||||
on:click={continueWithDetails}
|
||||
disabled={isReverseGeocoding}
|
||||
>
|
||||
{#if isReverseGeocoding}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{$t('adventures.getting_location_details') || 'Getting details...'}
|
||||
{:else}
|
||||
{$t('adventures.continue')}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<PlaceQuickStart
|
||||
mode="location"
|
||||
{googleEnabled}
|
||||
{collectionId}
|
||||
on:addDetails
|
||||
on:manual
|
||||
on:quickAdded
|
||||
on:quickAddedEdit
|
||||
on:quickAddedDone
|
||||
on:cancel
|
||||
/>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Bed from '~icons/mdi/bed';
|
||||
import LodgingQuickStart from './LodgingQuickStart.svelte';
|
||||
import LodgingDetails from './LodgingDetails.svelte';
|
||||
import MediaStep from '../shared/MediaStep.svelte';
|
||||
import { inferLodgingTypeFromPlace } from '$lib/utils/lodgingType';
|
||||
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
@@ -17,16 +19,25 @@
|
||||
let storedInitialVisitDate: string | null = initialVisitDate;
|
||||
|
||||
let modal: HTMLDialogElement;
|
||||
let googleMapsEnabled = false;
|
||||
let isEditMode = false;
|
||||
let pendingGooglePhotoUrls: string[] = [];
|
||||
let importingGooglePhotos = false;
|
||||
|
||||
// Whether a save/create occurred during this modal session
|
||||
let didSave = false;
|
||||
|
||||
let steps = [
|
||||
{
|
||||
name: $t('adventures.details'),
|
||||
name: $t('adventures.quick_start'),
|
||||
selected: true,
|
||||
requires_id: false
|
||||
},
|
||||
{
|
||||
name: $t('adventures.details'),
|
||||
selected: false,
|
||||
requires_id: false
|
||||
},
|
||||
{
|
||||
name: $t('settings.media'),
|
||||
selected: false,
|
||||
@@ -34,6 +45,104 @@
|
||||
}
|
||||
];
|
||||
|
||||
function setStep(stepIndex: number) {
|
||||
steps = steps.map((step, index) => ({
|
||||
...step,
|
||||
selected: index === stepIndex
|
||||
}));
|
||||
}
|
||||
|
||||
function handleStepSelect(stepIndex: number) {
|
||||
if (stepIndex === 0 && isEditMode) {
|
||||
return;
|
||||
}
|
||||
if (steps[stepIndex]?.requires_id && !lodging.id) {
|
||||
return;
|
||||
}
|
||||
setStep(stepIndex);
|
||||
}
|
||||
|
||||
function handleDetailsBack() {
|
||||
if (isEditMode) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
setStep(0);
|
||||
}
|
||||
|
||||
function applyQuickStartPrefill(prefill: any) {
|
||||
if (!prefill) return;
|
||||
|
||||
if (prefill.name) lodging.name = prefill.name;
|
||||
if (prefill.location) lodging.location = prefill.location;
|
||||
if (typeof prefill.latitude === 'number') lodging.latitude = prefill.latitude;
|
||||
if (typeof prefill.longitude === 'number') lodging.longitude = prefill.longitude;
|
||||
if (typeof prefill.rating === 'number') lodging.rating = prefill.rating;
|
||||
if (!lodging.link && (prefill.website || prefill.google_maps_url)) {
|
||||
lodging.link = prefill.website || prefill.google_maps_url;
|
||||
}
|
||||
if (!lodging.description && prefill.description) {
|
||||
lodging.description = prefill.description;
|
||||
}
|
||||
|
||||
if (!lodging.type) {
|
||||
lodging.type = inferLodgingTypeFromPlace(prefill.type, prefill.types);
|
||||
}
|
||||
|
||||
pendingGooglePhotoUrls = Array.isArray(prefill.photos)
|
||||
? prefill.photos.filter((url: unknown) => typeof url === 'string' && url.trim()).slice(0, 5)
|
||||
: [];
|
||||
}
|
||||
|
||||
async function importPendingGoogleImages(lodgingId: string) {
|
||||
if (!lodgingId || pendingGooglePhotoUrls.length === 0) return;
|
||||
importingGooglePhotos = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/images/import_from_urls/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content_type: 'lodging',
|
||||
object_id: lodgingId,
|
||||
urls: pendingGooglePhotoUrls
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
addToast('warning', 'Lodging saved, but Google photos could not be imported');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.created) && data.created.length > 0) {
|
||||
const existingImages = Array.isArray(lodging.images) ? lodging.images : [];
|
||||
const existingIds = new Set(existingImages.map((img: any) => img.id));
|
||||
const imported = data.created.filter((img: any) => !existingIds.has(img.id));
|
||||
lodging.images = [...existingImages, ...imported];
|
||||
}
|
||||
|
||||
pendingGooglePhotoUrls = [];
|
||||
} catch {
|
||||
addToast('warning', 'Lodging saved, but Google photos import failed');
|
||||
} finally {
|
||||
importingGooglePhotos = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIntegrations() {
|
||||
try {
|
||||
const res = await fetch('/api/integrations/');
|
||||
if (!res.ok) return;
|
||||
const integrations = await res.json();
|
||||
googleMapsEnabled = Boolean(integrations?.google_maps);
|
||||
} catch {
|
||||
googleMapsEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyLodging(): Lodging {
|
||||
return {
|
||||
id: '',
|
||||
@@ -106,9 +215,10 @@
|
||||
// Only reset to empty if we don't already have a saved lodging with an ID
|
||||
lodging = createEmptyLodging();
|
||||
storedInitialVisitDate = initialVisitDate;
|
||||
// Reset steps to details when creating a new lodging
|
||||
// Reset steps for a fresh lodging creation session
|
||||
steps = [
|
||||
{ name: $t('adventures.details'), selected: true, requires_id: false },
|
||||
{ name: $t('adventures.quick_start'), selected: true, requires_id: false },
|
||||
{ name: $t('adventures.details'), selected: false, requires_id: false },
|
||||
{ name: $t('settings.media'), selected: false, requires_id: true }
|
||||
];
|
||||
}
|
||||
@@ -118,6 +228,15 @@
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
isEditMode = Boolean(lodgingToEdit?.id);
|
||||
|
||||
if (isEditMode) {
|
||||
setStep(1);
|
||||
} else {
|
||||
setStep(0);
|
||||
}
|
||||
|
||||
void loadIntegrations();
|
||||
});
|
||||
|
||||
function close() {
|
||||
@@ -200,14 +319,11 @@
|
||||
? 'bg-primary text-primary-content'
|
||||
: 'bg-base-200'} {step.requires_id && !lodging?.id
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''} {index === 0 && isEditMode
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-primary/80 cursor-pointer'} transition-colors"
|
||||
on:click={() => {
|
||||
// Reset all steps
|
||||
steps.forEach((s) => (s.selected = false));
|
||||
// Select clicked step
|
||||
steps[index].selected = true;
|
||||
}}
|
||||
disabled={step.requires_id && !lodging?.id}
|
||||
on:click={() => handleStepSelect(index)}
|
||||
disabled={(step.requires_id && !lodging?.id) || (index === 0 && isEditMode)}
|
||||
>
|
||||
<span class="hidden sm:inline">{step.name}</span>
|
||||
<span class="sm:hidden"
|
||||
@@ -241,17 +357,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if steps[0].selected}
|
||||
{#if steps[0].selected && !isEditMode}
|
||||
<LodgingQuickStart
|
||||
googleEnabled={googleMapsEnabled}
|
||||
collectionId={collection?.id || null}
|
||||
on:addDetails={(e) => {
|
||||
applyQuickStartPrefill(e.detail.prefill);
|
||||
setStep(1);
|
||||
}}
|
||||
on:manual={() => {
|
||||
setStep(1);
|
||||
}}
|
||||
on:quickAdded={(e) => {
|
||||
lodging = e.detail.location;
|
||||
pendingGooglePhotoUrls = [];
|
||||
didSave = true;
|
||||
close();
|
||||
}}
|
||||
on:quickAddedEdit={(e) => {
|
||||
lodging = e.detail.location;
|
||||
pendingGooglePhotoUrls = [];
|
||||
didSave = true;
|
||||
setStep(1);
|
||||
}}
|
||||
on:quickAddedDone={(e) => {
|
||||
lodging = e.detail.location;
|
||||
pendingGooglePhotoUrls = [];
|
||||
didSave = true;
|
||||
close();
|
||||
}}
|
||||
on:cancel={() => close()}
|
||||
/>
|
||||
{/if}
|
||||
{#if steps[1].selected}
|
||||
<LodgingDetails
|
||||
currentUser={user}
|
||||
initialLodging={lodging}
|
||||
{collection}
|
||||
bind:editingLodging={lodging}
|
||||
on:back={() => {
|
||||
steps[1].selected = false;
|
||||
steps[0].selected = true;
|
||||
}}
|
||||
on:save={(e) => {
|
||||
on:back={handleDetailsBack}
|
||||
on:save={async (e) => {
|
||||
// Update the entire lodging object with all saved data
|
||||
const detail = e.detail || {};
|
||||
const previousImages = lodging.images || [];
|
||||
@@ -285,25 +430,31 @@
|
||||
// Only allow moving to Media once we have a persisted id.
|
||||
if (!lodging?.id) {
|
||||
addToast('error', $t('adventures.lodging_save_error'));
|
||||
steps[1].selected = false;
|
||||
steps[0].selected = true;
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
steps[0].selected = false;
|
||||
steps[1].selected = true;
|
||||
setStep(2);
|
||||
if (pendingGooglePhotoUrls.length > 0) {
|
||||
await importPendingGoogleImages(lodging.id);
|
||||
}
|
||||
}}
|
||||
initialVisitDate={storedInitialVisitDate}
|
||||
/>
|
||||
{/if}
|
||||
{#if steps[1].selected}
|
||||
{#if steps[2].selected}
|
||||
{#if importingGooglePhotos}
|
||||
<div class="alert alert-info mb-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Importing Google photos in the background. They will appear here shortly.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<MediaStep
|
||||
bind:images={lodging.images}
|
||||
bind:attachments={lodging.attachments}
|
||||
itemName={lodging.name}
|
||||
on:back={() => {
|
||||
steps[1].selected = false;
|
||||
steps[0].selected = true;
|
||||
setStep(1);
|
||||
}}
|
||||
on:close={() => close()}
|
||||
itemId={lodging.id}
|
||||
|
||||
18
frontend/src/lib/components/lodging/LodgingQuickStart.svelte
Normal file
18
frontend/src/lib/components/lodging/LodgingQuickStart.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import PlaceQuickStart from '../shared/PlaceQuickStart.svelte';
|
||||
|
||||
export let googleEnabled = false;
|
||||
export let collectionId: string | null = null;
|
||||
</script>
|
||||
|
||||
<PlaceQuickStart
|
||||
mode="lodging"
|
||||
{googleEnabled}
|
||||
{collectionId}
|
||||
on:addDetails
|
||||
on:manual
|
||||
on:quickAdded
|
||||
on:quickAddedEdit
|
||||
on:quickAddedDone
|
||||
on:cancel
|
||||
/>
|
||||
827
frontend/src/lib/components/shared/PlaceQuickStart.svelte
Normal file
827
frontend/src/lib/components/shared/PlaceQuickStart.svelte
Normal file
@@ -0,0 +1,827 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getBasemapUrl } from '$lib';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import CategoryDropdown from '../CategoryDropdown.svelte';
|
||||
import type { Category } from '$lib/types';
|
||||
|
||||
import SearchIcon from '~icons/mdi/magnify';
|
||||
import LocationIcon from '~icons/mdi/crosshairs-gps';
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
import CheckIcon from '~icons/mdi/check';
|
||||
import ClearIcon from '~icons/mdi/close';
|
||||
import PinIcon from '~icons/mdi/map-marker';
|
||||
import StarIcon from '~icons/mdi/star';
|
||||
import LightningIcon from '~icons/mdi/lightning-bolt';
|
||||
import PencilIcon from '~icons/mdi/pencil';
|
||||
|
||||
type SelectedPlace = {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
location: string;
|
||||
type?: string;
|
||||
category?: string;
|
||||
types?: string[];
|
||||
rating?: number | null;
|
||||
review_count?: number | null;
|
||||
photos?: string[];
|
||||
description?: string | null;
|
||||
website?: string | null;
|
||||
phone_number?: string | null;
|
||||
place_id?: string | null;
|
||||
google_maps_url?: string | null;
|
||||
powered_by?: string;
|
||||
};
|
||||
|
||||
type LocationData = {
|
||||
city?: { name: string; id: string; visited: boolean };
|
||||
region?: { name: string; id: string; visited: boolean };
|
||||
country?: { name: string; country_code: string; visited: boolean };
|
||||
display_name?: string;
|
||||
location_name?: string;
|
||||
};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let mode: 'location' | 'lodging' = 'location';
|
||||
export let googleEnabled = false;
|
||||
export let collectionId: string | null = null;
|
||||
|
||||
$: supportsCategory = mode === 'location';
|
||||
$: itemLabel = mode === 'lodging' ? 'lodging' : 'location';
|
||||
$: quickAddEndpoint =
|
||||
mode === 'lodging' ? '/api/lodging/quick-add/' : '/api/locations/quick-add/';
|
||||
|
||||
let searchQuery = '';
|
||||
let searchResults: SelectedPlace[] = [];
|
||||
let selectedLocation: SelectedPlace | null = null;
|
||||
let mapCenter: [number, number] = [-74.5, 40];
|
||||
let mapZoom = 2;
|
||||
let isSearching = false;
|
||||
let isReverseGeocoding = false;
|
||||
let isEnrichingDescription = false;
|
||||
let isQuickAdding = false;
|
||||
let quickAddedLocation: any = null;
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let mapComponent: any;
|
||||
let selectedMarker: { lng: number; lat: number } | null = null;
|
||||
let locationData: LocationData | null = null;
|
||||
let selectedQuickAddCategory: Category | null = null;
|
||||
const placeDetailsCache = new Map<string, any>();
|
||||
|
||||
function toPlaceResult(result: any): SelectedPlace {
|
||||
return {
|
||||
id: result.place_id || `${result.name || 'place'}-${result.lat}-${result.lon}`,
|
||||
name: result.name,
|
||||
lat: parseFloat(result.lat),
|
||||
lng: parseFloat(result.lon),
|
||||
location: result.display_name,
|
||||
type: result.type,
|
||||
category: result.category,
|
||||
types: result.types || [],
|
||||
rating: result.rating ?? null,
|
||||
review_count: result.review_count ?? null,
|
||||
photos: result.photos || [],
|
||||
description: result.description || null,
|
||||
website: result.website || null,
|
||||
phone_number: result.phone_number || null,
|
||||
place_id: result.place_id || null,
|
||||
google_maps_url: result.google_maps_url || null,
|
||||
powered_by: result.powered_by
|
||||
};
|
||||
}
|
||||
|
||||
function pickBestNearbyResult(
|
||||
results: SelectedPlace[],
|
||||
lat: number,
|
||||
lng: number,
|
||||
preferredName?: string
|
||||
): SelectedPlace | null {
|
||||
if (!results.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPreferredName = (preferredName || '').trim().toLowerCase();
|
||||
const scored = results
|
||||
.filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lng))
|
||||
.map((item) => {
|
||||
const dLat = item.lat - lat;
|
||||
const dLng = item.lng - lng;
|
||||
const distanceScore = dLat * dLat + dLng * dLng;
|
||||
const nameScore =
|
||||
normalizedPreferredName && item.name?.trim().toLowerCase() === normalizedPreferredName
|
||||
? -1
|
||||
: 0;
|
||||
const placeScore = item.place_id ? -0.5 : 0;
|
||||
return {
|
||||
item,
|
||||
score: distanceScore + nameScore + placeScore
|
||||
};
|
||||
});
|
||||
|
||||
if (!scored.length) {
|
||||
return results[0];
|
||||
}
|
||||
|
||||
scored.sort((a, b) => a.score - b.score);
|
||||
return scored[0].item;
|
||||
}
|
||||
|
||||
async function enrichFromResolvedName(lat: number, lng: number, resolvedName: string) {
|
||||
const query = resolvedName.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawResults = await response.json();
|
||||
const mappedResults = Array.isArray(rawResults) ? rawResults.map(toPlaceResult) : [];
|
||||
const bestMatch = pickBestNearbyResult(mappedResults, lat, lng, query);
|
||||
if (!bestMatch || !selectedLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLocation = {
|
||||
...selectedLocation,
|
||||
...bestMatch,
|
||||
lat,
|
||||
lng,
|
||||
name: bestMatch.name || selectedLocation.name,
|
||||
location: selectedLocation.location || bestMatch.location
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
} catch (error) {
|
||||
console.error('Resolved name enrichment error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function needsDescriptionEnrichment(place: SelectedPlace | null) {
|
||||
if (!place?.place_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = (place.description || '').trim();
|
||||
return text.length < 220;
|
||||
}
|
||||
|
||||
async function fetchPlaceDetails(placeId: string, name: string) {
|
||||
if (placeDetailsCache.has(placeId)) {
|
||||
return placeDetailsCache.get(placeId);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/place_details/?place_id=${encodeURIComponent(placeId)}&name=${encodeURIComponent(name || '')}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to fetch place details');
|
||||
}
|
||||
|
||||
const details = await response.json();
|
||||
placeDetailsCache.set(placeId, details);
|
||||
return details;
|
||||
}
|
||||
|
||||
async function enrichSelectedLocationDescription(force = false) {
|
||||
if (!selectedLocation?.place_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placeId = selectedLocation.place_id;
|
||||
if (!placeId || (!force && !needsDescriptionEnrichment(selectedLocation))) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEnrichingDescription = true;
|
||||
try {
|
||||
const details = await fetchPlaceDetails(placeId, selectedLocation.name || '');
|
||||
|
||||
if (!selectedLocation || selectedLocation.place_id !== placeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLocation = {
|
||||
...selectedLocation,
|
||||
name: details.name || selectedLocation.name,
|
||||
location: details.formatted_address || selectedLocation.location,
|
||||
types:
|
||||
Array.isArray(details.types) && details.types.length > 0
|
||||
? details.types
|
||||
: selectedLocation.types,
|
||||
rating: details.rating ?? selectedLocation.rating ?? null,
|
||||
review_count: details.review_count ?? selectedLocation.review_count ?? null,
|
||||
description: details.description || selectedLocation.description || null,
|
||||
website: details.website || selectedLocation.website || null,
|
||||
phone_number: details.phone_number || selectedLocation.phone_number || null,
|
||||
google_maps_url: details.google_maps_url || selectedLocation.google_maps_url || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Place details enrichment error:', error);
|
||||
} finally {
|
||||
isEnrichingDescription = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchLocations(query: string) {
|
||||
if (!query.trim() || query.length < 3) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
const results = await response.json();
|
||||
searchResults = Array.isArray(results) ? results.map(toPlaceResult) : [];
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
searchResults = [];
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchLocations(searchQuery);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function selectSearchResult(location: SelectedPlace) {
|
||||
selectedLocation = location;
|
||||
selectedMarker = { lng: location.lng, lat: location.lat };
|
||||
mapCenter = [location.lng, location.lat];
|
||||
mapZoom = 14;
|
||||
searchResults = [];
|
||||
searchQuery = location.name;
|
||||
await performDetailedReverseGeocode(location.lat, location.lng);
|
||||
}
|
||||
|
||||
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
|
||||
selectedMarker = {
|
||||
lng: e.detail.lngLat.lng,
|
||||
lat: e.detail.lngLat.lat
|
||||
};
|
||||
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
|
||||
}
|
||||
|
||||
async function reverseGeocode(lng: number, lat: number) {
|
||||
isReverseGeocoding = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
|
||||
const results = await response.json();
|
||||
|
||||
if (Array.isArray(results) && results.length > 0) {
|
||||
selectedLocation = {
|
||||
...toPlaceResult(results[0]),
|
||||
lat,
|
||||
lng
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
} else {
|
||||
selectedLocation = {
|
||||
id: `manual-${lat}-${lng}`,
|
||||
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
lat,
|
||||
lng,
|
||||
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
types: [],
|
||||
photos: []
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
}
|
||||
|
||||
await performDetailedReverseGeocode(lat, lng);
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding error:', error);
|
||||
selectedLocation = {
|
||||
id: `manual-${lat}-${lng}`,
|
||||
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
lat,
|
||||
lng,
|
||||
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
|
||||
types: [],
|
||||
photos: []
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
locationData = null;
|
||||
} finally {
|
||||
isReverseGeocoding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performDetailedReverseGeocode(lat: number, lng: number) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
locationData = {
|
||||
city: data.city
|
||||
? {
|
||||
name: data.city,
|
||||
id: data.city_id,
|
||||
visited: data.city_visited || false
|
||||
}
|
||||
: undefined,
|
||||
region: data.region
|
||||
? {
|
||||
name: data.region,
|
||||
id: data.region_id,
|
||||
visited: data.region_visited || false
|
||||
}
|
||||
: undefined,
|
||||
country: data.country
|
||||
? {
|
||||
name: data.country,
|
||||
country_code: data.country_id,
|
||||
visited: false
|
||||
}
|
||||
: undefined,
|
||||
display_name: data.display_name,
|
||||
location_name: data.location_name
|
||||
};
|
||||
|
||||
if (selectedLocation) {
|
||||
const isCoordinatePlaceholder = selectedLocation.name.startsWith('Location at ');
|
||||
const shouldAutoEnrichQuickAdd = isCoordinatePlaceholder || !selectedLocation.place_id;
|
||||
const resolvedLocationName = (data.location_name || '').trim();
|
||||
const resolvedDisplayName = (data.display_name || '').trim();
|
||||
|
||||
selectedLocation = {
|
||||
...selectedLocation,
|
||||
name:
|
||||
resolvedLocationName ||
|
||||
(isCoordinatePlaceholder && resolvedDisplayName
|
||||
? resolvedDisplayName
|
||||
: selectedLocation.name),
|
||||
location: resolvedDisplayName || `${lat.toFixed(4)}, ${lng.toFixed(4)}`
|
||||
};
|
||||
searchQuery = selectedLocation.name;
|
||||
|
||||
if (shouldAutoEnrichQuickAdd && resolvedLocationName) {
|
||||
await enrichFromResolvedName(lat, lng, resolvedLocationName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
locationData = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Detailed reverse geocoding error:', error);
|
||||
locationData = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdventureLogFormattedLocation() {
|
||||
if (!selectedMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (locationData?.display_name?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await performDetailedReverseGeocode(selectedMarker.lat, selectedMarker.lng);
|
||||
}
|
||||
|
||||
function useCurrentLocation() {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lng = position.coords.longitude;
|
||||
selectedMarker = { lng, lat };
|
||||
mapCenter = [lng, lat];
|
||||
mapZoom = 14;
|
||||
await reverseGeocode(lng, lat);
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedLocation = null;
|
||||
selectedMarker = null;
|
||||
locationData = null;
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
quickAddedLocation = null;
|
||||
selectedQuickAddCategory = null;
|
||||
mapCenter = [-74.5, 40];
|
||||
mapZoom = 2;
|
||||
}
|
||||
|
||||
function buildPrefillPayload() {
|
||||
if (!selectedLocation || !selectedMarker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedLocation =
|
||||
locationData?.display_name?.trim() || selectedLocation.location?.trim() || '';
|
||||
|
||||
return {
|
||||
name: selectedLocation.name,
|
||||
latitude: selectedMarker.lat,
|
||||
longitude: selectedMarker.lng,
|
||||
location: formattedLocation,
|
||||
type: selectedLocation.type,
|
||||
category: selectedLocation.category,
|
||||
city: locationData?.city,
|
||||
region: locationData?.region,
|
||||
country: locationData?.country,
|
||||
display_name: locationData?.display_name,
|
||||
location_name: locationData?.location_name,
|
||||
rating: selectedLocation.rating ?? null,
|
||||
review_count: selectedLocation.review_count ?? null,
|
||||
photos: selectedLocation.photos || [],
|
||||
description: selectedLocation.description || null,
|
||||
website: selectedLocation.website || null,
|
||||
phone_number: selectedLocation.phone_number || null,
|
||||
place_id: selectedLocation.place_id || null,
|
||||
google_maps_url: selectedLocation.google_maps_url || null,
|
||||
types: selectedLocation.types || [],
|
||||
selected_category: selectedQuickAddCategory
|
||||
};
|
||||
}
|
||||
|
||||
async function continueWithDetails() {
|
||||
await ensureAdventureLogFormattedLocation();
|
||||
|
||||
if (selectedLocation?.place_id && needsDescriptionEnrichment(selectedLocation)) {
|
||||
await enrichSelectedLocationDescription();
|
||||
}
|
||||
|
||||
const prefill = buildPrefillPayload();
|
||||
if (prefill) {
|
||||
dispatch('addDetails', { prefill });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch('manual');
|
||||
}
|
||||
|
||||
async function quickAdd() {
|
||||
const prefill = buildPrefillPayload();
|
||||
if (!prefill) {
|
||||
addToast('warning', `Please select a place or drop a pin first`);
|
||||
return;
|
||||
}
|
||||
|
||||
isQuickAdding = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
name: prefill.name,
|
||||
type: prefill.type,
|
||||
location: prefill.location,
|
||||
latitude: prefill.latitude,
|
||||
longitude: prefill.longitude,
|
||||
place_id: prefill.place_id,
|
||||
rating: prefill.rating,
|
||||
review_count: prefill.review_count,
|
||||
description: prefill.description,
|
||||
website: prefill.website,
|
||||
phone_number: prefill.phone_number,
|
||||
google_maps_url: prefill.google_maps_url,
|
||||
types: prefill.types || [],
|
||||
photos: prefill.photos || [],
|
||||
collection_id: collectionId,
|
||||
is_public: false
|
||||
};
|
||||
|
||||
if (supportsCategory && selectedQuickAddCategory) {
|
||||
payload.category = selectedQuickAddCategory;
|
||||
}
|
||||
|
||||
const res = await fetch(quickAddEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData?.error || errorData?.detail || `Failed to create ${itemLabel}`);
|
||||
}
|
||||
|
||||
quickAddedLocation = await res.json();
|
||||
|
||||
addToast(
|
||||
'success',
|
||||
`${itemLabel[0].toUpperCase()}${itemLabel.slice(1)} created successfully`
|
||||
);
|
||||
dispatch('quickAdded', { location: quickAddedLocation, prefill });
|
||||
} catch (error) {
|
||||
addToast('error', error instanceof Error ? error.message : `Failed to create ${itemLabel}`);
|
||||
} finally {
|
||||
isQuickAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
clearTimeout(searchTimeout);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if quickAddedLocation}
|
||||
<div class="card bg-success/10 border border-success/30">
|
||||
<div class="card-body p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-success/20 rounded-lg">
|
||||
<CheckIcon class="w-5 h-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-success">
|
||||
{mode === 'lodging' ? 'Lodging added' : 'Location added'}
|
||||
</h4>
|
||||
<p class="text-sm text-base-content/70">{quickAddedLocation.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
class="btn btn-primary flex-1"
|
||||
on:click={() => dispatch('quickAddedEdit', { location: quickAddedLocation })}
|
||||
>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
{$t('adventures.add_details') || 'Add Details'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline flex-1"
|
||||
on:click={() => dispatch('quickAddedDone', { location: quickAddedLocation })}
|
||||
>
|
||||
{$t('adventures.done') || 'Done'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card bg-base-200/50 border border-base-300">
|
||||
<div class="card-body p-6 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="quickstart-search-location">
|
||||
<span class="label-text font-medium">
|
||||
{#if googleEnabled}
|
||||
{mode === 'lodging' ? 'Search Google Maps for Lodging' : 'Search Google Maps'}
|
||||
{:else}
|
||||
{$t('adventures.search_location')}
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon class="w-5 h-5 text-base-content/40" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="quickstart-search-location"
|
||||
bind:value={searchQuery}
|
||||
on:input={handleSearchInput}
|
||||
placeholder={$t('adventures.search_placeholder') ||
|
||||
'Enter city, location, or landmark...'}
|
||||
class="input input-bordered w-full pl-10 pr-4"
|
||||
class:input-primary={selectedLocation}
|
||||
/>
|
||||
{#if searchQuery && !selectedLocation}
|
||||
<button
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
on:click={clearSelection}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isSearching}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
|
||||
</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<div class="space-y-2">
|
||||
<label class="label" for="quickstart-search-results">
|
||||
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
|
||||
</label>
|
||||
<div id="quickstart-search-results" class="max-h-52 overflow-y-auto space-y-1">
|
||||
{#each searchResults as result}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
|
||||
on:click={() => selectSearchResult(result)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm truncate">{result.name}</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
|
||||
{#if result.rating}
|
||||
<div class="text-xs text-warning mt-1 inline-flex items-center gap-1">
|
||||
<StarIcon class="w-3 h-3" />
|
||||
{result.rating}
|
||||
{#if result.review_count}
|
||||
<span class="text-base-content/60">({result.review_count})</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="divider divider-horizontal text-xs">{$t('adventures.or') || 'OR'}</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
|
||||
<LocationIcon class="w-4 h-4" />
|
||||
{$t('adventures.use_current_location') || 'Use Current Location'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold flex items-center gap-2">
|
||||
<MapIcon class="w-5 h-5" />
|
||||
{$t('adventures.select_on_map') || 'Select on Map'}
|
||||
</h3>
|
||||
{#if selectedMarker}
|
||||
<button class="btn btn-ghost btn-sm gap-1" on:click={clearSelection}>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
{$t('adventures.clear') || 'Clear'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedMarker}
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
{#if mode === 'lodging'}
|
||||
Click on the map to select a lodging
|
||||
{:else}
|
||||
{$t('adventures.click_map') || 'Click on the map to select a location'}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if isReverseGeocoding}
|
||||
<div class="flex items-center justify-center py-2 mb-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60"
|
||||
>{$t('adventures.getting_location_details') || 'Getting details...'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<MapLibre
|
||||
bind:this={mapComponent}
|
||||
style={getBasemapUrl()}
|
||||
class="w-full h-80 rounded-lg border border-base-300"
|
||||
center={mapCenter}
|
||||
zoom={mapZoom}
|
||||
standardControls
|
||||
>
|
||||
<MapEvents on:click={handleMapClick} />
|
||||
|
||||
{#if selectedMarker}
|
||||
<Marker
|
||||
lngLat={[selectedMarker.lng, selectedMarker.lat]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
|
||||
>
|
||||
<PinIcon class="w-5 h-5 text-primary-content" />
|
||||
</Marker>
|
||||
{/if}
|
||||
</MapLibre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedLocation && selectedMarker}
|
||||
<div class="card bg-success/10 border border-success/30">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex gap-4 items-start">
|
||||
{#if selectedLocation.photos && selectedLocation.photos.length > 0}
|
||||
<img
|
||||
src={selectedLocation.photos[0]}
|
||||
alt={selectedLocation.name}
|
||||
class="w-24 h-24 rounded-lg object-cover border border-base-300"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-success mb-1">
|
||||
{mode === 'lodging'
|
||||
? $t('lodging.new_lodging') || 'Lodging selected'
|
||||
: $t('adventures.location_selected')}
|
||||
</h4>
|
||||
<p class="text-sm font-medium text-base-content truncate">{selectedLocation.name}</p>
|
||||
<p class="text-xs text-base-content/70 truncate">{selectedLocation.location}</p>
|
||||
{#if selectedLocation.rating}
|
||||
<div class="text-xs text-warning mt-2 inline-flex items-center gap-1">
|
||||
<StarIcon class="w-3 h-3" />
|
||||
{selectedLocation.rating}
|
||||
{#if selectedLocation.review_count}
|
||||
<span class="text-base-content/60">({selectedLocation.review_count} reviews)</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isEnrichingDescription}
|
||||
<div class="text-xs text-base-content/60 mt-2 inline-flex items-center gap-1">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Improving description quality...
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
|
||||
</p>
|
||||
{#if selectedLocation.types && selectedLocation.types.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
{#each selectedLocation.types.slice(0, 5) as typeName}
|
||||
<span class="badge badge-outline badge-sm capitalize">{typeName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if googleEnabled && supportsCategory}
|
||||
<div class="card bg-base-100 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="form-control gap-2">
|
||||
<label class="label" for="quick-add-category">
|
||||
<span class="label-text font-medium">Category for Quick Add</span>
|
||||
</label>
|
||||
<div id="quick-add-category">
|
||||
<CategoryDropdown bind:selected_category={selectedQuickAddCategory} />
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Optional. If not selected, backend defaults to General.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<button class="btn btn-neutral-200 sm:flex-1" on:click={() => dispatch('cancel')}>
|
||||
{$t('adventures.cancel') || 'Cancel'}
|
||||
</button>
|
||||
|
||||
{#if selectedLocation && selectedMarker && googleEnabled}
|
||||
<button class="btn btn-outline sm:flex-1" on:click={continueWithDetails}>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
{$t('adventures.add_details') || 'Add Details'}
|
||||
</button>
|
||||
<button class="btn btn-primary sm:flex-1" on:click={quickAdd} disabled={isQuickAdding}>
|
||||
{#if isQuickAdding}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{$t('adventures.processing') || 'Processing'}...
|
||||
{:else}
|
||||
<LightningIcon class="w-4 h-4" />
|
||||
Quick Add
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-primary sm:flex-1"
|
||||
on:click={continueWithDetails}
|
||||
disabled={isReverseGeocoding}
|
||||
>
|
||||
{#if isReverseGeocoding}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{$t('adventures.getting_location_details') || 'Getting details...'}
|
||||
{:else}
|
||||
{$t('adventures.continue')}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
57
frontend/src/lib/utils/lodgingType.ts
Normal file
57
frontend/src/lib/utils/lodgingType.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const LODGING_TYPE_MAP: Record<string, string> = {
|
||||
hotel: 'hotel',
|
||||
resort_hotel: 'resort',
|
||||
motel: 'motel',
|
||||
hostel: 'hostel',
|
||||
bed_and_breakfast: 'bnb',
|
||||
guest_house: 'bnb',
|
||||
campground: 'campground',
|
||||
rv_park: 'campground',
|
||||
camping_cabin: 'cabin',
|
||||
apartment_building: 'apartment',
|
||||
lodging: 'hotel',
|
||||
villa: 'villa'
|
||||
};
|
||||
|
||||
const VALID_TYPES = new Set([
|
||||
'hotel',
|
||||
'hostel',
|
||||
'resort',
|
||||
'bnb',
|
||||
'campground',
|
||||
'cabin',
|
||||
'apartment',
|
||||
'house',
|
||||
'villa',
|
||||
'motel',
|
||||
'other'
|
||||
]);
|
||||
|
||||
export function inferLodgingTypeFromPlace(primaryType: unknown, placeTypes: unknown): string {
|
||||
if (typeof primaryType === 'string') {
|
||||
const normalized = primaryType.trim().toLowerCase();
|
||||
if (VALID_TYPES.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedTypes = Array.isArray(placeTypes)
|
||||
? placeTypes
|
||||
.map((typeName) => (typeof typeName === 'string' ? typeName.trim().toLowerCase() : ''))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
for (const typeName of normalizedTypes) {
|
||||
if (LODGING_TYPE_MAP[typeName]) {
|
||||
return LODGING_TYPE_MAP[typeName];
|
||||
}
|
||||
}
|
||||
|
||||
for (const typeName of normalizedTypes) {
|
||||
if (VALID_TYPES.has(typeName)) {
|
||||
return typeName;
|
||||
}
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
@@ -494,7 +494,8 @@
|
||||
"export_success": "Exported collection",
|
||||
"location_actions": "Location actions",
|
||||
"collection_duplicate_success": "Collection duplicated successfully! Redirecting...",
|
||||
"collection_duplicate_error": "Failed to duplicate collection."
|
||||
"collection_duplicate_error": "Failed to duplicate collection.",
|
||||
"add_details": "Add details"
|
||||
},
|
||||
"worldtravel": {
|
||||
"country_list": "Country List",
|
||||
|
||||
Reference in New Issue
Block a user