diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index e8c6fa67..85ca04b4 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -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): diff --git a/backend/server/adventures/views/lodging_view.py b/backend/server/adventures/views/lodging_view.py index 159c127a..c2ec8f36 100644 --- a/backend/server/adventures/views/lodging_view.py +++ b/backend/server/adventures/views/lodging_view.py @@ -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) \ No newline at end of file + 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}'" + ) \ No newline at end of file diff --git a/backend/server/adventures/views/quick_add_utils.py b/backend/server/adventures/views/quick_add_utils.py new file mode 100644 index 00000000..a7195704 --- /dev/null +++ b/backend/server/adventures/views/quick_add_utils.py @@ -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" diff --git a/frontend/package.json b/frontend/package.json index a54c7b95..537caf02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2ba80d43..624a073d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index d66e1150..c3322a78 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -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' diff --git a/frontend/src/lib/components/locations/LocationQuickStart.svelte b/frontend/src/lib/components/locations/LocationQuickStart.svelte index cc54b4b4..60d34efc 100644 --- a/frontend/src/lib/components/locations/LocationQuickStart.svelte +++ b/frontend/src/lib/components/locations/LocationQuickStart.svelte @@ -1,800 +1,18 @@ -
- {#if quickAddedLocation} -
-
-
-
- -
-
-

Location added

-

{quickAddedLocation.name}

-
-
-
- - -
-
-
- {/if} - -
-
-
- -
-
- -
- - {#if searchQuery && !selectedLocation} - - {/if} -
-
- - {#if isSearching} -
- - {$t('adventures.searching')}... -
- {:else if searchResults.length > 0} -
- -
- {#each searchResults as result} - - {/each} -
-
- {/if} - -
-
{$t('adventures.or') || 'OR'}
-
- - -
-
- -
-
-
-

- - {$t('adventures.select_on_map') || 'Select on Map'} -

- {#if selectedMarker} - - {/if} -
- - {#if !selectedMarker} -

- {$t('adventures.click_map') || 'Click on the map to select a location'} -

- {/if} - - {#if isReverseGeocoding} -
- - {$t('adventures.getting_location_details') || 'Getting details...'} - -
- {/if} - - - - - {#if selectedMarker} - - - - {/if} - -
-
- - {#if selectedLocation && selectedMarker} -
-
-
- {#if selectedLocation.photos && selectedLocation.photos.length > 0} - {selectedLocation.name} - {/if} -
-

{$t('adventures.location_selected')}

-

{selectedLocation.name}

-

{selectedLocation.location}

- {#if selectedLocation.rating} -
- - {selectedLocation.rating} - {#if selectedLocation.review_count} - ({selectedLocation.review_count} reviews) - {/if} -
- {/if} - {#if isEnrichingDescription} -
- - Improving description quality... -
- {/if} -

- {selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)} -

- {#if selectedLocation.types && selectedLocation.types.length > 0} -
- {#each selectedLocation.types.slice(0, 5) as typeName} - {typeName} - {/each} -
- {/if} -
-
-
-
- - {#if googleEnabled} -
-
-
- -
- -
-

- Optional. If not selected, backend defaults to General. -

-
-
-
- {/if} - {/if} - -
- - - {#if selectedLocation && selectedMarker && googleEnabled} - - - {:else} - - {/if} -
-
+ diff --git a/frontend/src/lib/components/lodging/LodgingModal.svelte b/frontend/src/lib/components/lodging/LodgingModal.svelte index d4e9d8df..2034ef65 100644 --- a/frontend/src/lib/components/lodging/LodgingModal.svelte +++ b/frontend/src/lib/components/lodging/LodgingModal.svelte @@ -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)} > - {#if steps[0].selected} + {#if steps[0].selected && !isEditMode} + { + 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} { - 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} +
+ + Importing Google photos in the background. They will appear here shortly. +
+ {/if} { - steps[1].selected = false; - steps[0].selected = true; + setStep(1); }} on:close={() => close()} itemId={lodging.id} diff --git a/frontend/src/lib/components/lodging/LodgingQuickStart.svelte b/frontend/src/lib/components/lodging/LodgingQuickStart.svelte new file mode 100644 index 00000000..df134bd7 --- /dev/null +++ b/frontend/src/lib/components/lodging/LodgingQuickStart.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/shared/PlaceQuickStart.svelte b/frontend/src/lib/components/shared/PlaceQuickStart.svelte new file mode 100644 index 00000000..e7efe592 --- /dev/null +++ b/frontend/src/lib/components/shared/PlaceQuickStart.svelte @@ -0,0 +1,827 @@ + + +
+ {#if quickAddedLocation} +
+
+
+
+ +
+
+

+ {mode === 'lodging' ? 'Lodging added' : 'Location added'} +

+

{quickAddedLocation.name}

+
+
+
+ + +
+
+
+ {/if} + +
+
+
+ +
+
+ +
+ + {#if searchQuery && !selectedLocation} + + {/if} +
+
+ + {#if isSearching} +
+ + {$t('adventures.searching')}... +
+ {:else if searchResults.length > 0} +
+ +
+ {#each searchResults as result} + + {/each} +
+
+ {/if} + +
+
{$t('adventures.or') || 'OR'}
+
+ + +
+
+ +
+
+
+

+ + {$t('adventures.select_on_map') || 'Select on Map'} +

+ {#if selectedMarker} + + {/if} +
+ + {#if !selectedMarker} +

+ {#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} +

+ {/if} + + {#if isReverseGeocoding} +
+ + {$t('adventures.getting_location_details') || 'Getting details...'} + +
+ {/if} + + + + + {#if selectedMarker} + + + + {/if} + +
+
+ + {#if selectedLocation && selectedMarker} +
+
+
+ {#if selectedLocation.photos && selectedLocation.photos.length > 0} + {selectedLocation.name} + {/if} +
+

+ {mode === 'lodging' + ? $t('lodging.new_lodging') || 'Lodging selected' + : $t('adventures.location_selected')} +

+

{selectedLocation.name}

+

{selectedLocation.location}

+ {#if selectedLocation.rating} +
+ + {selectedLocation.rating} + {#if selectedLocation.review_count} + ({selectedLocation.review_count} reviews) + {/if} +
+ {/if} + {#if isEnrichingDescription} +
+ + Improving description quality... +
+ {/if} +

+ {selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)} +

+ {#if selectedLocation.types && selectedLocation.types.length > 0} +
+ {#each selectedLocation.types.slice(0, 5) as typeName} + {typeName} + {/each} +
+ {/if} +
+
+
+
+ + {#if googleEnabled && supportsCategory} +
+
+
+ +
+ +
+

+ Optional. If not selected, backend defaults to General. +

+
+
+
+ {/if} + {/if} + +
+ + + {#if selectedLocation && selectedMarker && googleEnabled} + + + {:else} + + {/if} +
+
diff --git a/frontend/src/lib/utils/lodgingType.ts b/frontend/src/lib/utils/lodgingType.ts new file mode 100644 index 00000000..6da6c500 --- /dev/null +++ b/frontend/src/lib/utils/lodgingType.ts @@ -0,0 +1,57 @@ +const LODGING_TYPE_MAP: Record = { + 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'; +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a3d37482..2ce342a6 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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",