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:
Sean Morley
2026-04-24 22:12:10 -04:00
parent 842425371b
commit 97f4a47ffd
12 changed files with 1575 additions and 1045 deletions

View File

@@ -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):

View File

@@ -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}'"
)

View 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"

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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'

View File

@@ -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
/>

View File

@@ -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}

View 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
/>

View 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>

View 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';
}

View File

@@ -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",