Merge pull request #1097 from seanmorley15/quickadd

Quickadd
This commit is contained in:
Sean Morley
2026-04-04 21:24:48 -04:00
committed by GitHub
15 changed files with 1854 additions and 602 deletions

View File

@@ -163,7 +163,7 @@ If your changes affect:
please update the documentation in the:
```
/documentation
/docs
```
folder accordingly.

View File

@@ -3,6 +3,7 @@ import time
import socket
import re
import unicodedata
from urllib.parse import quote
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
from django.conf import settings
@@ -20,7 +21,12 @@ def search_google(query):
headers = {
'Content-Type': 'application/json',
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': 'places.displayName.text,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount'
'X-Goog-FieldMask': (
'places.id,places.displayName.text,places.formattedAddress,places.location,'
'places.types,places.rating,places.userRatingCount,places.websiteUri,'
'places.nationalPhoneNumber,places.internationalPhoneNumber,'
'places.editorialSummary.text,places.googleMapsUri,places.photos.name'
)
}
payload = {
@@ -52,6 +58,14 @@ def search_google(query):
if rating is not None and ratings_total:
importance = round(float(rating) * ratings_total / 100, 2)
photos = []
for photo in place.get('photos', [])[:5]:
photo_name = photo.get('name')
if photo_name:
photos.append(
f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}&maxHeightPx=800&maxWidthPx=800"
)
# Extract display name from the new API structure
display_name_obj = place.get("displayName", {})
name = display_name_obj.get("text") if display_name_obj else None
@@ -61,9 +75,18 @@ def search_google(query):
"lon": location.get("longitude"),
"name": name,
"display_name": place.get("formattedAddress"),
"place_id": place.get("id"),
"type": primary_type,
"types": types,
"category": category,
"description": (place.get('editorialSummary') or {}).get('text'),
"website": place.get('websiteUri'),
"phone_number": place.get('internationalPhoneNumber') or place.get('nationalPhoneNumber'),
"google_maps_url": place.get('googleMapsUri'),
"importance": importance,
"rating": rating,
"review_count": ratings_total,
"photos": photos,
"addresstype": addresstype,
"powered_by": "google",
})
@@ -172,6 +195,359 @@ def search(query):
# If Google fails, fallback to OSM
return search_osm(query)
def _fetch_wikipedia_summary(query, language='en'):
normalized_query = (query or '').strip()
if not normalized_query:
return None
candidates = [normalized_query]
if ',' in normalized_query:
head = normalized_query.split(',')[0].strip()
if head and head not in candidates:
candidates.append(head)
for candidate in candidates:
try:
encoded_query = quote(candidate, safe='')
url = f"https://{language}.wikipedia.org/api/rest_v1/page/summary/{encoded_query}"
response = requests.get(
url,
headers={'User-Agent': 'AdventureLog Server'},
timeout=(2, 5),
)
if response.status_code != 200:
continue
data = response.json()
if data.get('type') == 'disambiguation':
continue
extract = (data.get('extract') or '').strip()
if len(extract) >= 120:
return extract
except requests.exceptions.RequestException:
continue
return None
def _compose_place_description(
editorial_summary,
review_snippets,
):
parts = []
summary = (editorial_summary or '').strip()
if summary:
parts.append(f"### About\n\n{summary}")
cleaned_reviews = []
for snippet in review_snippets:
text = (snippet or '').strip()
if len(text) >= 40:
cleaned_reviews.append(text)
if len(cleaned_reviews) >= 2:
break
if cleaned_reviews:
review_block = '### Visitor Highlights\n\n' + '\n'.join(
f"- {text}" for text in cleaned_reviews
)
parts.append(review_block)
return '\n\n'.join(parts).strip() or None
def get_place_details(place_id, fallback_query=None, language='en'):
if not place_id:
return {'error': 'place_id is required'}
details = {
'description': None,
'name': None,
'formatted_address': None,
'types': [],
'rating': None,
'review_count': None,
'website': None,
'phone_number': None,
'google_maps_url': None,
'source': None,
}
api_key = settings.GOOGLE_MAPS_API_KEY
if api_key:
try:
url = f"https://places.googleapis.com/v1/places/{place_id}"
headers = {
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': (
'id,displayName.text,formattedAddress,editorialSummary.text,types,'
'rating,userRatingCount,websiteUri,nationalPhoneNumber,'
'internationalPhoneNumber,googleMapsUri,reviews.text.text'
),
}
response = requests.get(url, headers=headers, timeout=(2, 6))
response.raise_for_status()
place = response.json()
details['name'] = (place.get('displayName') or {}).get('text')
details['formatted_address'] = place.get('formattedAddress')
details['types'] = place.get('types') or []
details['rating'] = place.get('rating')
details['review_count'] = place.get('userRatingCount')
details['website'] = place.get('websiteUri')
details['phone_number'] = (
place.get('internationalPhoneNumber') or place.get('nationalPhoneNumber')
)
details['google_maps_url'] = place.get('googleMapsUri')
editorial_summary = (place.get('editorialSummary') or {}).get('text')
reviews = place.get('reviews') or []
review_snippets = [((review.get('text') or {}).get('text')) for review in reviews]
details['description'] = _compose_place_description(
editorial_summary,
review_snippets,
)
if details['description']:
details['source'] = 'google'
except requests.exceptions.RequestException:
pass
# Google summaries are often short; fallback to Wikipedia for richer context.
description_text = (details.get('description') or '').strip()
if len(description_text) < 220:
wikipedia_summary = _fetch_wikipedia_summary(
fallback_query or details.get('name') or '',
language=language,
)
if wikipedia_summary:
if description_text:
details['description'] = f"{description_text}\n\n### Background\n\n{wikipedia_summary}"
details['source'] = 'google+wikipedia'
else:
details['description'] = f"### Background\n\n{wikipedia_summary}"
details['source'] = 'wikipedia'
if not details.get('description'):
return {'error': 'Unable to enrich place description'}
return details
def _clean_location_candidate(value):
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _looks_like_street_address(value):
candidate = _clean_location_candidate(value)
if not candidate:
return False
lowered = candidate.lower()
if not re.search(r"\d", lowered):
return False
if lowered.count(",") >= 2:
return True
if not re.match(r"^\d{1,6}\s+\S+", lowered):
return False
street_tokens = (
"st",
"street",
"rd",
"road",
"ave",
"avenue",
"blvd",
"boulevard",
"dr",
"drive",
"ln",
"lane",
"ct",
"court",
"pl",
"place",
"pkwy",
"parkway",
"hwy",
"highway",
"trl",
"trail",
)
return any(re.search(rf"\b{token}\b", lowered) for token in street_tokens)
def _first_preferred_location_name(candidates, allow_address_fallback=False):
address_fallback = None
for candidate in candidates:
cleaned = _clean_location_candidate(candidate)
if not cleaned:
continue
if not _looks_like_street_address(cleaned):
return cleaned
if address_fallback is None:
address_fallback = cleaned
return address_fallback if allow_address_fallback else None
def _extract_google_component_name(address_components):
preferred_types = (
"premise",
"point_of_interest",
"establishment",
"subpremise",
"natural_feature",
"airport",
"park",
"tourist_attraction",
"shopping_mall",
"university",
"school",
"hospital",
)
for preferred_type in preferred_types:
for component in address_components or []:
types = component.get("types", [])
if preferred_type in types:
return component.get("long_name") or component.get("short_name")
return None
def _score_google_result_types(types):
priority = (
"point_of_interest",
"establishment",
"premise",
"subpremise",
"tourist_attraction",
"park",
"airport",
"shopping_mall",
"university",
"school",
"hospital",
"street_address",
"route",
)
for idx, type_name in enumerate(priority):
if type_name in types:
return len(priority) - idx
return 0
def _fetch_google_nearby_place_name(lat, lon, api_key):
url = "https://places.googleapis.com/v1/places:searchNearby"
headers = {
'Content-Type': 'application/json',
'X-Goog-Api-Key': api_key,
'X-Goog-FieldMask': 'places.displayName.text,places.formattedAddress,places.types',
}
payload = {
"maxResultCount": 6,
"rankPreference": "DISTANCE",
"locationRestriction": {
"circle": {
"center": {
"latitude": float(lat),
"longitude": float(lon),
},
"radius": 45.0,
}
},
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=(2, 5))
response.raise_for_status()
places = (response.json() or {}).get("places", [])
except requests.exceptions.RequestException:
return None
candidates = [((place.get("displayName") or {}).get("text")) for place in places]
return _first_preferred_location_name(candidates, allow_address_fallback=False)
def _extract_google_location_name(results, nearby_place_name=None):
preferred_nearby = _first_preferred_location_name([nearby_place_name], allow_address_fallback=False)
if preferred_nearby:
return preferred_nearby
scored_candidates = []
for result in results or []:
score = _score_google_result_types(result.get("types", []))
if score <= 0:
continue
component_name = _extract_google_component_name(result.get("address_components", []))
name_candidate = _first_preferred_location_name([component_name], allow_address_fallback=False)
if name_candidate:
scored_candidates.append((score, name_candidate))
if scored_candidates:
scored_candidates.sort(key=lambda item: item[0], reverse=True)
return scored_candidates[0][1]
component_candidates = [
_extract_google_component_name(result.get("address_components", []))
for result in (results or [])
]
component_pick = _first_preferred_location_name(component_candidates, allow_address_fallback=False)
if component_pick:
return component_pick
formatted_candidates = [result.get("formatted_address") for result in (results or [])]
return _first_preferred_location_name(formatted_candidates, allow_address_fallback=True)
def _extract_osm_location_name(data):
address = data.get("address", {}) or {}
namedetails = data.get("namedetails", {}) or {}
extratags = data.get("extratags", {}) or {}
candidates = [
data.get("name"),
namedetails.get("name"),
namedetails.get("official_name"),
namedetails.get("short_name"),
namedetails.get("brand"),
namedetails.get("loc_name"),
address.get("amenity"),
address.get("tourism"),
address.get("attraction"),
address.get("building"),
address.get("shop"),
address.get("leisure"),
address.get("historic"),
address.get("man_made"),
address.get("office"),
address.get("aeroway"),
address.get("railway"),
address.get("public_transport"),
address.get("craft"),
address.get("house_name"),
extratags.get("name"),
extratags.get("official_name"),
extratags.get("brand"),
extratags.get("operator"),
]
preferred = _first_preferred_location_name(candidates, allow_address_fallback=False)
if preferred:
return preferred
return _first_preferred_location_name(
[data.get("name"), data.get("display_name")],
allow_address_fallback=True,
)
# -----------------
# REVERSE GEOCODING
# -----------------
@@ -186,10 +562,7 @@ def extractIsoCode(user, data):
country_code = None
city = None
visited_city = None
location_name = None
if 'name' in data.keys():
location_name = data['name']
location_name = _clean_location_candidate(data.get('location_name') or data.get('name'))
address = data.get('address', {}) or {}
@@ -369,7 +742,10 @@ def reverse_geocode(lat, lon, user):
return reverse_geocode_osm(lat, lon, user)
def reverse_geocode_osm(lat, lon, user):
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
url = (
"https://nominatim.openstreetmap.org/reverse"
f"?format=jsonv2&addressdetails=1&namedetails=1&extratags=1&zoom=18&lat={lat}&lon={lon}"
)
headers = {'User-Agent': 'AdventureLog Server'}
connect_timeout = 1
read_timeout = 5
@@ -381,6 +757,7 @@ def reverse_geocode_osm(lat, lon, user):
response = requests.get(url, headers=headers, timeout=(connect_timeout, read_timeout))
response.raise_for_status()
data = response.json()
data["location_name"] = _extract_osm_location_name(data)
return extractIsoCode(user, data)
except requests.exceptions.Timeout:
return {"error": "Request timed out while contacting OpenStreetMap. Please try again."}
@@ -424,11 +801,23 @@ def reverse_geocode_google(lat, lon, user):
else:
return {"error": "Geocoding failed. Please try again."}
results = data.get("results", [])
if not results:
return {"error": "No location found for the given coordinates."}
nearby_place_name = _fetch_google_nearby_place_name(lat, lon, api_key)
location_name = _extract_google_location_name(results, nearby_place_name=nearby_place_name)
# Convert Google schema to Nominatim-style for extractIsoCode
first_result = data.get("results", [])[0]
first_result = results[0]
address_result = next(
(result for result in results if "plus_code" not in result.get("types", [])),
first_result,
)
result_data = {
"name": first_result.get("formatted_address"),
"address": _parse_google_address_components(first_result.get("address_components", []))
"location_name": location_name,
"address": _parse_google_address_components(address_result.get("address_components", [])),
}
return extractIsoCode(user, result_data)
except requests.exceptions.Timeout:

View File

@@ -4,8 +4,11 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.throttling import UserRateThrottle
from django.http import HttpResponse
from concurrent.futures import ThreadPoolExecutor, as_completed
import ipaddress
import mimetypes
import socket
from urllib.parse import urljoin
from urllib.parse import urlparse
from django.db.models import Q
from django.core.files.base import ContentFile
@@ -17,6 +20,7 @@ from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existin
import requests
from adventures.permissions import ContentImagePermission
import logging
import uuid
logger = logging.getLogger(__name__)
@@ -67,6 +71,144 @@ def _is_safe_url(image_url):
return True, parsed
def download_remote_image(image_url):
safe, result = _is_safe_url(image_url)
if not safe:
raise ValueError(result)
headers = {'User-Agent': 'AdventureLog/1.0 (Image Import)'}
max_redirects = 3
current_url = image_url
response = None
for _ in range(max_redirects + 1):
response = requests.get(
current_url,
timeout=10,
headers=headers,
stream=True,
allow_redirects=False,
)
if not response.is_redirect:
break
redirect_url = response.headers.get('Location', '')
if not redirect_url:
raise ValueError('Redirect with missing Location header')
# Handle relative redirects safely.
redirect_url = urljoin(current_url, redirect_url)
safe, result = _is_safe_url(redirect_url)
if not safe:
raise ValueError(f'Redirect blocked: {result}')
current_url = redirect_url
else:
raise ValueError('Too many redirects')
if response is None:
raise ValueError('Failed to fetch image')
response.raise_for_status()
content_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
if not content_type.startswith('image/'):
raise ValueError('URL does not point to an image')
content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > 20 * 1024 * 1024:
raise ValueError('Image too large (max 20MB)')
ext = mimetypes.guess_extension(content_type) or '.jpg'
if ext == '.jpe':
ext = '.jpg'
return {
'filename': f"remote_{uuid.uuid4().hex}{ext}",
'content': response.content,
'content_type': content_type,
'source_url': image_url,
}
def import_remote_images_for_object(content_object, urls, owner=None, max_workers=5):
"""Download remote URLs and attach them as ContentImage records for a content object."""
content_type = ContentType.objects.get_for_model(content_object.__class__)
object_id = str(content_object.id)
image_owner = owner or getattr(content_object, 'user', None)
downloaded_results = []
worker_count = max(1, min(max_workers, len(urls)))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = {
executor.submit(download_remote_image, image_url): (index, image_url)
for index, image_url in enumerate(urls)
}
for future in as_completed(futures):
index, image_url = futures[future]
try:
file_data = future.result()
downloaded_results.append((index, image_url, file_data, None))
except Exception as exc:
downloaded_results.append((index, image_url, None, str(exc)))
downloaded_results.sort(key=lambda item: item[0])
existing_image_count = ContentImage.objects.filter(
content_type=content_type,
object_id=object_id,
).count()
set_primary_next = existing_image_count == 0
created_images = []
results = []
failed = []
for _, image_url, file_data, error_message in downloaded_results:
if error_message:
failure = {
'url': image_url,
'error': error_message,
}
results.append({
**failure,
'status': 'failed',
})
failed.append(failure)
continue
image_file = ContentFile(file_data['content'], name=file_data['filename'])
image = ContentImage.objects.create(
user=image_owner,
image=image_file,
content_type=content_type,
object_id=object_id,
is_primary=set_primary_next,
)
if set_primary_next:
set_primary_next = False
created_images.append(image)
results.append({
'url': image_url,
'status': 'created',
'id': str(image.id),
})
return {
'created_images': created_images,
'results': results,
'created_count': len(created_images),
'requested_count': len(urls),
'failed_count': len(failed),
'failed': failed,
}
class ContentImageViewSet(viewsets.ModelViewSet):
serializer_class = ContentImageSerializer
permission_classes = [ContentImagePermission]
@@ -192,69 +334,12 @@ class ContentImageViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
# Validate the initial URL (scheme, port, SSRF check on all resolved IPs)
safe, result = _is_safe_url(image_url)
if not safe:
return Response({"error": result}, status=status.HTTP_400_BAD_REQUEST)
try:
headers = {'User-Agent': 'AdventureLog/1.0 (Image Proxy)'}
max_redirects = 3
current_url = image_url
image_data = download_remote_image(str(image_url).strip())
return HttpResponse(image_data['content'], content_type=image_data['content_type'], status=200)
for _ in range(max_redirects + 1):
response = requests.get(
current_url,
timeout=10,
headers=headers,
stream=True,
allow_redirects=False,
)
if not response.is_redirect:
break
# Re-validate every redirect destination before following
redirect_url = response.headers.get('Location', '')
if not redirect_url:
return Response(
{"error": "Redirect with missing Location header"},
status=status.HTTP_502_BAD_GATEWAY,
)
safe, result = _is_safe_url(redirect_url)
if not safe:
return Response(
{"error": f"Redirect blocked: {result}"},
status=status.HTTP_400_BAD_REQUEST,
)
current_url = redirect_url
else:
return Response(
{"error": "Too many redirects"},
status=status.HTTP_400_BAD_REQUEST,
)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
if not content_type.startswith('image/'):
return Response(
{"error": "URL does not point to an image"},
status=status.HTTP_400_BAD_REQUEST
)
content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > 20 * 1024 * 1024:
return Response(
{"error": "Image too large (max 20MB)"},
status=status.HTTP_400_BAD_REQUEST
)
image_data = response.content
return HttpResponse(image_data, content_type=content_type, status=200)
except ValueError as exc:
return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except requests.exceptions.Timeout:
logger.error("Timeout fetching image from URL %s", image_url)
@@ -269,6 +354,64 @@ class ContentImageViewSet(viewsets.ModelViewSet):
status=status.HTTP_502_BAD_GATEWAY
)
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
def import_from_urls(self, request):
content_type_name = request.data.get('content_type')
object_id = request.data.get('object_id')
urls = request.data.get('urls')
if not isinstance(urls, list) or not urls:
return Response({"error": "urls must be a non-empty array"}, status=status.HTTP_400_BAD_REQUEST)
urls = [str(url).strip() for url in urls if str(url).strip()]
if not urls:
return Response({"error": "No valid URLs provided"}, status=status.HTTP_400_BAD_REQUEST)
if len(urls) > 10:
return Response({"error": "Maximum 10 URLs per request"}, status=status.HTTP_400_BAD_REQUEST)
content_object = self._get_and_validate_content_object(content_type_name, object_id)
if isinstance(content_object, Response):
return content_object
owner = getattr(content_object, 'user', request.user)
import_summary = import_remote_images_for_object(
content_object,
urls,
owner=owner,
max_workers=min(5, len(urls)),
)
created_images = import_summary['created_images']
results = import_summary['results']
if not created_images:
return Response(
{
'error': 'No images could be imported',
'results': results,
},
status=status.HTTP_400_BAD_REQUEST,
)
serialized = ContentImageSerializer(created_images, many=True, context={'request': request})
response_status = (
status.HTTP_201_CREATED
if import_summary['created_count'] == import_summary['requested_count']
else status.HTTP_200_OK
)
return Response(
{
'created': serialized.data,
'results': results,
'created_count': import_summary['created_count'],
'requested_count': import_summary['requested_count'],
},
status=response_status,
)
def create(self, request, *args, **kwargs):
# Get content type and object ID from request
content_type_name = request.data.get('content_type')

View File

@@ -1,4 +1,5 @@
import logging
from urllib.parse import urlparse
from django.utils import timezone
from django.db import transaction
from django.core.exceptions import PermissionDenied
@@ -14,6 +15,9 @@ 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 worldtravel.models import City, Country, Region
from .location_image_view import import_remote_images_for_object
logger = logging.getLogger(__name__)
@@ -158,6 +162,122 @@ class LocationViewSet(viewsets.ModelViewSet):
# ==================== CUSTOM ACTIONS ====================
@action(detail=False, methods=['post'], url_path='quick-add')
@transaction.atomic
def quick_add(self, request):
"""Create a location 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 = self._coerce_coordinate(payload.get('latitude'), -90, 90)
longitude = self._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 = self._resolve_quick_add_collection(payload.get('collection_id'))
if isinstance(collection, Response):
return collection
place_id = str(payload.get('place_id') or '').strip() or None
reverse_data = {}
details = {}
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 = {}
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'))
if rating is None:
rating = self._coerce_float(details.get('rating'))
review_count = self._coerce_int(payload.get('review_count'))
if review_count is None:
review_count = self._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
phone_number = str(details.get('phone_number') or payload.get('phone_number') or '').strip() or None
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
)
description = self._build_quick_add_description(
base_description=payload.get('description'),
detailed_description=details.get('description'),
)
category_payload = self._normalize_quick_add_category(payload.get('category'))
if isinstance(category_payload, Response):
return category_payload
serializer_payload = {
'name': name,
'location': location_label,
'latitude': latitude,
'longitude': longitude,
'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),
}
if category_payload:
serializer_payload['category'] = category_payload
if collection:
serializer_payload['collections'] = [str(collection.id)]
serializer = self.get_serializer(data=serializer_payload)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
location = serializer.instance
self._apply_reverse_geocode_metadata(location, reverse_data, location_label)
photo_urls = self._sanitize_photo_urls(payload.get('photos'))
image_import_summary = None
if photo_urls:
image_import_summary = import_remote_images_for_object(
location,
photo_urls,
owner=location.user,
max_workers=min(5, len(photo_urls)),
)
response_data = self.get_serializer(location).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)
@action(detail=False, methods=['get'])
def filtered(self, request):
"""Filter locations by category types and visit status."""
@@ -460,6 +580,195 @@ class LocationViewSet(viewsets.ModelViewSet):
f"You don't have permission to add location to collection '{collection.name}'"
)
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 as exc:
return Response({"error": str(exc)}, status=status.HTTP_403_FORBIDDEN)
return 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
def _coerce_float(self, value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_int(self, value):
try:
return int(value)
except (TypeError, ValueError):
return None
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
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
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
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
def _normalize_quick_add_category(self, raw_category):
if not raw_category:
return None
if isinstance(raw_category, dict):
category_id = raw_category.get('id')
name = str(raw_category.get('name') or '').strip().lower()
display_name = str(raw_category.get('display_name') or '').strip()
icon = str(raw_category.get('icon') or '').strip() or '🌍'
elif isinstance(raw_category, str):
category_id = raw_category.strip()
name = ''
display_name = ''
icon = '🌍'
else:
return Response(
{"error": "category must be an object or string"},
status=status.HTTP_400_BAD_REQUEST,
)
category = None
if category_id:
category = Category.objects.filter(id=category_id, user=self.request.user).first()
if not category:
return Response(
{"error": "Category not found or inaccessible"},
status=status.HTTP_400_BAD_REQUEST,
)
if category:
return {
'name': category.name,
'display_name': category.display_name,
'icon': category.icon,
}
if not name:
return None
return {
'name': name,
'display_name': display_name or name,
'icon': icon,
}
def _build_quick_add_description(
self,
base_description,
detailed_description,
):
description = str(detailed_description or '').strip() or str(base_description or '').strip()
return description or None
def _apply_reverse_geocode_metadata(self, location, reverse_data, fallback_location):
if not isinstance(reverse_data, dict):
reverse_data = {}
updated_fields = []
region_id = reverse_data.get('region_id')
if region_id:
region = Region.objects.filter(id=region_id).first()
if region and location.region_id != region.id:
location.region = region
updated_fields.append('region')
city_id = reverse_data.get('city_id')
if city_id:
city = City.objects.filter(id=city_id).first()
if city and location.city_id != city.id:
location.city = city
updated_fields.append('city')
country_id = reverse_data.get('country_id')
if country_id:
country = Country.objects.filter(country_code=country_id).first()
if country and location.country_id != country.id:
location.country = country
updated_fields.append('country')
if fallback_location and not location.location:
location.location = fallback_location
updated_fields.append('location')
if updated_fields:
location.save(update_fields=updated_fields, _skip_geocode=True)
def _apply_visit_filtering(self, queryset, request):
"""Apply visit status filtering to queryset."""
is_visited_param = request.query_params.get('is_visited')

View File

@@ -7,7 +7,7 @@ from adventures.models import Location
from adventures.serializers import LocationSerializer
from adventures.geocoding import reverse_geocode
from django.conf import settings
from adventures.geocoding import search_google, search_osm
from adventures.geocoding import search_google, search_osm, get_place_details
class ReverseGeocodeViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@@ -131,4 +131,18 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
"regions": new_regions,
"new_cities": new_city_count,
"cities": new_cities
})
})
@action(detail=False, methods=['get'])
def place_details(self, request):
place_id = request.query_params.get('place_id', '').strip()
if not place_id:
return Response({"error": "place_id parameter is required"}, status=400)
name = request.query_params.get('name', '')
language = request.query_params.get('language', 'en')
details = get_place_details(place_id, fallback_query=name, language=language)
if 'error' in details and not details.get('description'):
return Response(details, status=502)
return Response(details)

View File

@@ -6,7 +6,8 @@
import MoneyInput from '../shared/MoneyInput.svelte';
import MarkdownEditor from '../MarkdownEditor.svelte';
import TagComplete from '../TagComplete.svelte';
import { DEFAULT_CURRENCY, normalizeMoneyPayload, toMoneyValue } from '$lib/money';
import { DEFAULT_CURRENCY, toMoneyValue } from '$lib/money';
import { saveLocation } from '$lib/location-save';
import { addToast } from '$lib/toasts';
import type { Category, Collection, Location, MoneyValue, User } from '$lib/types';
import MapIcon from '~icons/mdi/map';
@@ -67,6 +68,14 @@
let isGeneratingDesc = false;
let ownerUser: User | null = null;
function toFiniteNumber(value: unknown): number | null {
if (value === null || value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
export let initialLocation: any = null;
export let currentUser: any = null;
export let editingLocation: any = null;
@@ -84,21 +93,25 @@
location.price_currency = defaultCurrency;
}
}
$: initialSelection =
initialLocation && initialLocation.latitude && initialLocation.longitude
? {
name: initialLocation.name || '',
lat: Number(initialLocation.latitude),
lng: Number(initialLocation.longitude),
location: initialLocation.location || ''
}
: null;
$: {
const lat = toFiniteNumber(initialLocation?.latitude);
const lng = toFiniteNumber(initialLocation?.longitude);
initialSelection =
initialLocation && lat !== null && lng !== null
? {
name: initialLocation.name || '',
lat,
lng,
location: initialLocation.location || ''
}
: null;
}
function handleLocationUpdate(
event: CustomEvent<{ name?: string; lat: number; lng: number; location: string }>
) {
const { name, lat, lng, location: displayName } = event.detail;
if (!location.name && name) location.name = name;
if (name) location.name = name;
location.latitude = lat;
location.longitude = lng;
location.location = displayName;
@@ -139,83 +152,31 @@
return;
}
if (location.latitude !== null && typeof location.latitude === 'number') {
location.latitude = parseFloat(location.latitude.toFixed(6));
}
if (location.longitude !== null && typeof location.longitude === 'number') {
location.longitude = parseFloat(location.longitude.toFixed(6));
}
if (collection && collection.id) {
location.collections = [collection.id];
}
let payload: any = { ...location };
// Clean up link: empty/whitespace → null, invalid URL → null
if (!payload.link || !payload.link.trim()) {
payload.link = null;
} else {
try {
new URL(payload.link);
} catch {
// Not a valid URL — clear it so Django doesn't reject it
payload.link = null;
}
}
if (!payload.description || !payload.description.trim()) {
payload.description = null;
}
if (location.price === null) {
payload.price = null;
payload.price_currency = null;
} else {
payload = normalizeMoneyPayload(payload, 'price', 'price_currency', defaultCurrency);
}
let res: Response;
if (locationToEdit && locationToEdit.id) {
// Only include collections if explicitly set via a collection context;
// otherwise remove them from the PATCH payload to avoid triggering the
// m2m_changed signal which can override is_public.
if (!collection || !collection.id) {
delete payload.collections;
}
res = await fetch(`/api/locations/${locationToEdit.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
try {
const savedLocation = await saveLocation({
location,
locationToEdit,
collectionId: collection?.id || null,
defaultCurrency
});
} else {
res = await fetch(`/api/locations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
}
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
// Extract error message from Django field errors (e.g. {"link": ["Enter a valid URL."]})
let errorMsg = errorData?.detail || errorData?.name?.[0] || '';
if (!errorMsg) {
const fieldErrors = Object.entries(errorData)
.filter(([_, v]) => Array.isArray(v))
.map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`)
.join('; ');
errorMsg = fieldErrors || 'Failed to save location';
}
addToast('error', String(errorMsg));
location = {
...location,
...savedLocation,
rating:
typeof savedLocation.rating === 'number' && !Number.isNaN(savedLocation.rating)
? savedLocation.rating
: location.rating,
link: savedLocation.link || location.link || '',
description: savedLocation.description || location.description || '',
location: savedLocation.location || location.location || '',
tags: savedLocation.tags || location.tags || [],
collections: savedLocation.collections || location.collections || []
};
} catch (error) {
addToast('error', error instanceof Error ? error.message : 'Failed to save location');
return;
}
location = await res.json();
dispatch('save', {
...location
});
@@ -226,9 +187,11 @@
}
onMount(() => {
if (initialLocation && initialLocation.latitude && initialLocation.longitude) {
location.latitude = initialLocation.latitude;
location.longitude = initialLocation.longitude;
const lat = toFiniteNumber(initialLocation?.latitude);
const lng = toFiniteNumber(initialLocation?.longitude);
if (initialLocation && lat !== null && lng !== null) {
location.latitude = lat;
location.longitude = lng;
if (!location.name) location.name = initialLocation.name || '';
if (initialLocation.location) location.location = initialLocation.location;
}

View File

@@ -19,6 +19,10 @@
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;
@@ -46,6 +50,105 @@
}
];
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 && !location.id) {
return;
}
setStep(stepIndex);
}
function handleDetailsBack() {
if (isEditMode) {
close();
return;
}
setStep(0);
}
function applyQuickStartPrefill(prefill: any) {
if (!prefill) return;
if (prefill.name) location.name = prefill.name;
if (prefill.location) location.location = prefill.location;
if (typeof prefill.latitude === 'number') location.latitude = prefill.latitude;
if (typeof prefill.longitude === 'number') location.longitude = prefill.longitude;
if (typeof prefill.rating === 'number') location.rating = prefill.rating;
if (!location.link && (prefill.website || prefill.google_maps_url)) {
location.link = prefill.website || prefill.google_maps_url;
}
if (!location.description && prefill.description) {
location.description = prefill.description;
}
if ((!location.tags || location.tags.length === 0) && Array.isArray(prefill.types)) {
location.tags = prefill.types.slice(0, 8);
}
if (prefill.selected_category && typeof prefill.selected_category === 'object') {
location.category = prefill.selected_category;
}
pendingGooglePhotoUrls = Array.isArray(prefill.photos)
? prefill.photos.filter((url: unknown) => typeof url === 'string' && url.trim()).slice(0, 5)
: [];
}
async function importPendingGoogleImages(locationId: string) {
if (!locationId || 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: 'location',
object_id: locationId,
urls: pendingGooglePhotoUrls
})
});
if (!res.ok) {
addToast('warning', 'Location 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(location.images) ? location.images : [];
const existingIds = new Set(existingImages.map((img: any) => img.id));
const imported = data.created.filter((img: any) => !existingIds.has(img.id));
location.images = [...existingImages, ...imported];
}
pendingGooglePhotoUrls = [];
} catch {
addToast('warning', 'Location 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;
}
}
export let location: Location = {
id: '',
name: '',
@@ -81,17 +184,17 @@
link: locationToEdit?.link || null,
description: locationToEdit?.description || null,
tags: locationToEdit?.tags || [],
rating: locationToEdit?.rating || NaN,
rating: locationToEdit?.rating ?? NaN,
price: locationToEdit?.price ?? null,
price_currency: locationToEdit?.price_currency ?? null,
is_public: locationToEdit?.is_public || false,
latitude: locationToEdit?.latitude || NaN,
longitude: locationToEdit?.longitude || NaN,
is_public: locationToEdit?.is_public ?? false,
latitude: locationToEdit?.latitude ?? NaN,
longitude: locationToEdit?.longitude ?? NaN,
location: locationToEdit?.location || null,
images: locationToEdit?.images || [],
user: locationToEdit?.user || null,
visits: locationToEdit?.visits || [],
is_visited: locationToEdit?.is_visited || false,
is_visited: locationToEdit?.is_visited ?? false,
collections: locationToEdit?.collections || [],
category: locationToEdit?.category || {
id: '',
@@ -104,23 +207,25 @@
attachments: locationToEdit?.attachments || []
};
onMount(async () => {
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal();
isEditMode = Boolean(locationToEdit?.id);
// Skip the quick start step if editing an existing location
if (!locationToEdit) {
steps[0].selected = true;
steps[1].selected = false;
if (!isEditMode) {
setStep(0);
} else {
steps[0].selected = false;
steps[1].selected = true;
setStep(1);
}
if (initialLatLng) {
location.latitude = initialLatLng.lat;
location.longitude = initialLatLng.lng;
steps[1].selected = true;
steps[0].selected = false;
setStep(1);
}
void loadIntegrations();
});
function close() {
@@ -206,7 +311,7 @@
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-0.089l4-5-5z"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
@@ -216,14 +321,11 @@
? 'bg-primary text-primary-content'
: 'bg-base-200'} {step.requires_id && !location.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 && !location.id}
on:click={() => handleStepSelect(index)}
disabled={(step.requires_id && !location.id) || (index === 0 && isEditMode)}
>
<span class="hidden sm:inline">{step.name}</span>
<span class="sm:hidden"
@@ -276,22 +378,37 @@
</div>
</div>
{#if steps[0].selected}
{#if steps[0].selected && !isEditMode}
<!-- Main Content -->
<LocationQuickStart
on:locationSelected={(e) => {
location.name = e.detail.name;
location.location = e.detail.location;
location.latitude = e.detail.latitude;
location.longitude = e.detail.longitude;
steps[0].selected = false;
steps[1].selected = true;
googleEnabled={googleMapsEnabled}
collectionId={collection?.id || null}
on:addDetails={(e) => {
applyQuickStartPrefill(e.detail.prefill);
setStep(1);
}}
on:manual={() => {
setStep(1);
}}
on:quickAdded={(e) => {
location = e.detail.location;
pendingGooglePhotoUrls = [];
didSave = true;
close();
}}
on:quickAddedEdit={(e) => {
location = e.detail.location;
pendingGooglePhotoUrls = [];
didSave = true;
setStep(1);
}}
on:quickAddedDone={(e) => {
location = e.detail.location;
pendingGooglePhotoUrls = [];
didSave = true;
close();
}}
on:cancel={() => close()}
on:next={() => {
steps[0].selected = false;
steps[1].selected = true;
}}
/>
{/if}
{#if steps[1].selected}
@@ -300,55 +417,49 @@
initialLocation={location}
{collection}
bind:editingLocation={location}
on:back={() => {
steps[1].selected = false;
steps[0].selected = true;
}}
on:save={(e) => {
location.name = e.detail.name;
location.category = e.detail.category;
location.rating = e.detail.rating;
location.is_public = e.detail.is_public;
location.link = e.detail.link;
location.description = e.detail.description;
location.latitude = e.detail.latitude;
location.longitude = e.detail.longitude;
location.location = e.detail.location;
location.tags = e.detail.tags;
location.user = e.detail.user;
location.id = e.detail.id;
location.price = e.detail.price;
location.price_currency = e.detail.price_currency;
on:back={handleDetailsBack}
on:save={async (e) => {
location = {
...location,
...e.detail,
tags: e.detail.tags || location.tags || [],
images: e.detail.images || location.images || [],
attachments: e.detail.attachments || location.attachments || [],
trails: e.detail.trails || location.trails || [],
visits: e.detail.visits || location.visits || []
};
// Mark that a save occurred so close() will notify parent
didSave = true;
steps[1].selected = false;
if (location.id) {
steps[2].selected = true;
setStep(2);
if (pendingGooglePhotoUrls.length > 0) {
void importPendingGoogleImages(location.id);
}
} else {
// Stay on details if save failed (no ID returned)
steps[1].selected = true;
setStep(1);
}
}}
/>
{/if}
{#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}
<LocationMedia
bind:images={location.images}
bind:attachments={location.attachments}
bind:trails={location.trails}
itemName={location.name}
userIsOwner={user?.uuid === location.user?.uuid}
on:back={() => {
steps[2].selected = false;
steps[1].selected = true;
}}
on:back={() => setStep(1)}
itemId={location.id}
on:next={() => {
steps[2].selected = false;
steps[3].selected = true;
}}
on:next={() => setStep(3)}
measurementSystem={user?.measurement_system || 'metric'}
/>
{/if}
@@ -357,10 +468,7 @@
bind:visits={location.visits}
bind:trails={location.trails}
objectId={location.id}
on:back={() => {
steps[3].selected = false;
steps[2].selected = true;
}}
on:back={() => setStep(2)}
on:close={() => close()}
measurementSystem={user?.measurement_system || 'metric'}
{collection}

View File

@@ -3,38 +3,229 @@
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';
// Icons
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';
const dispatch = createEventDispatcher();
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;
};
let searchQuery = '';
let searchResults: any[] = [];
let selectedLocation: any = null;
let mapCenter: [number, number] = [-74.5, 40]; // Default center
let mapZoom = 2;
let isSearching = false;
let isReverseGeocoding = false;
let searchTimeout: ReturnType<typeof setTimeout>;
let mapComponent: any;
let selectedMarker: { lng: number; lat: number } | null = null;
// Enhanced location data from reverse geocoding
let locationData: {
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;
} | null = null;
};
const dispatch = createEventDispatcher();
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;
}
}
// Search for locations using your custom API
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
@@ -47,18 +238,7 @@
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const results = await response.json();
searchResults = results.map((result: any) => ({
id: result.name + result.lat + result.lon, // Create a unique ID
name: result.name,
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
type: result.type,
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
}));
searchResults = Array.isArray(results) ? results.map(toPlaceResult) : [];
} catch (error) {
console.error('Search error:', error);
searchResults = [];
@@ -67,7 +247,6 @@
}
}
// Debounced search
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
@@ -75,70 +254,62 @@
}, 300);
}
// Select a location from search results
async function selectSearchResult(location: any) {
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;
// Perform detailed reverse geocoding
await performDetailedReverseGeocode(location.lat, location.lng);
}
// Handle map click to place marker
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
selectedMarker = {
lng: e.detail.lngLat.lng,
lat: e.detail.lngLat.lat
};
// Reverse geocode to get location name and detailed data
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
}
// Reverse geocode coordinates to get location name using your API
async function reverseGeocode(lng: number, lat: number) {
isReverseGeocoding = true;
try {
// Using a coordinate-based search query for reverse geocoding
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
if (results && results.length > 0) {
const result = results[0];
if (Array.isArray(results) && results.length > 0) {
selectedLocation = {
name: result.name,
lat: lat,
lng: lng,
location: result.display_name,
type: result.type,
category: result.category
...toPlaceResult(results[0]),
lat,
lng
};
searchQuery = result.name;
searchQuery = selectedLocation.name;
} else {
// Fallback if no results from API
selectedLocation = {
id: `manual-${lat}-${lng}`,
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
lat,
lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
types: [],
photos: []
};
searchQuery = selectedLocation.name;
}
// Perform detailed reverse geocoding
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: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
lat,
lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
types: [],
photos: []
};
searchQuery = selectedLocation.name;
locationData = null;
@@ -147,7 +318,6 @@
}
}
// Perform detailed reverse geocoding to get city, region, country data
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
@@ -156,7 +326,6 @@
if (response.ok) {
const data = await response.json();
locationData = {
city: data.city
? {
@@ -176,15 +345,33 @@
? {
name: data.country,
country_code: data.country_id,
visited: false // You might want to check this from your backend
visited: false
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
};
selectedLocation.location = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
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 {
console.warn('Detailed reverse geocoding failed:', response.status);
locationData = null;
}
} catch (error) {
@@ -193,7 +380,18 @@
}
}
// Use current location
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(
@@ -212,39 +410,119 @@
}
}
// Continue with selected location
function continueWithLocation() {
if (selectedLocation && selectedMarker) {
dispatch('locationSelected', {
name: selectedLocation.name,
latitude: selectedMarker.lat,
longitude: selectedMarker.lng,
location: selectedLocation.location,
type: selectedLocation.type,
category: selectedLocation.category,
// Include the enhanced geographical data
city: locationData?.city,
region: locationData?.region,
country: locationData?.country,
display_name: locationData?.display_name,
location_name: locationData?.location_name
});
} else {
dispatch('next');
}
}
// Clear selection
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);
@@ -253,90 +531,119 @@
</script>
<div class="space-y-6">
<!-- Search Section -->
<div class="card bg-base-200/50 border border-base-300">
<div class="card-body p-6">
<div class="space-y-4">
<!-- Search Input -->
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">
{$t('adventures.search_location') || 'Search for a 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"
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}
{#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>
<!-- Search Results -->
{#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">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
</label>
<div class="max-h-48 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.category}
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Current Location Button -->
<div class="flex items-center gap-2">
<div class="divider divider-horizontal text-xs">OR</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>
<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>
{/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>
<!-- Map Section -->
<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">
@@ -347,7 +654,7 @@
{#if selectedMarker}
<button class="btn btn-ghost btn-sm gap-1" on:click={clearSelection}>
<ClearIcon class="w-4 h-4" />
Clear
{$t('adventures.clear') || 'Clear'}
</button>
{/if}
</div>
@@ -362,99 +669,130 @@
<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')}...</span
>
>{$t('adventures.getting_location_details') || 'Getting details...'}
</span>
</div>
{/if}
<div class="relative">
<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} />
<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>
{#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>
<!-- Selected Location Display -->
{#if selectedLocation && selectedMarker}
<div class="card bg-success/10 border border-success/30">
<div class="card-body p-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-success/20 rounded-lg">
<CheckIcon class="w-5 h-5 text-success" />
</div>
<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 text-base-content/80 truncate">{selectedLocation.name}</p>
<p class="text-xs text-base-content/60 mt-1">
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
</p>
{#if selectedLocation.category}
<p class="text-xs text-base-content/50 capitalize">
{selectedLocation.category}{selectedLocation.type || 'location'}
</p>
{/if}
<!-- Geographic Tags -->
{#if locationData?.city || locationData?.region || locationData?.country}
<div class="flex flex-wrap gap-2 mt-3">
{#if locationData.city}
<div class="badge badge-info badge-sm gap-1">
🏙️ {locationData.city.name}
</div>
{/if}
{#if locationData.region}
<div class="badge badge-warning badge-sm gap-1">
🗺️ {locationData.region.name}
</div>
{/if}
{#if locationData.country}
<div class="badge badge-success badge-sm gap-1">
🌎 {locationData.country.name}
</div>
<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 locationData?.display_name}
<p class="text-xs text-base-content/50 mt-2">
{locationData.display_name}
</p>
{#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}
<!-- Action Buttons -->
<div class="flex gap-3 pt-4">
<button class="btn btn-neutral-200 flex-1" on:click={() => dispatch('cancel')}>
<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>
<button class="btn btn-primary flex-1" on:click={continueWithLocation}>
{#if isReverseGeocoding}
<span class="loading loading-spinner loading-xs"></span>
{$t('adventures.getting_location_details') || 'Getting details...'}
{:else}
{$t('adventures.continue')}
{/if}
</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

@@ -72,6 +72,10 @@
let initialTransportationApplied = false;
let isInitializing = false;
function isFiniteCoordinatePair(lat: unknown, lng: unknown): boolean {
return Number.isFinite(Number(lat)) && Number.isFinite(Number(lng));
}
// Track any provided codes (airport / station / etc)
let startCode: string | null = null;
let endCode: string | null = null;
@@ -572,7 +576,25 @@
endLocationData = metaData;
} else {
locationData = metaData;
displayName = data.display_name;
const resolvedLocationName = (data.location_name || '').trim();
const resolvedDisplayName = (data.display_name || '').trim();
if (selectedLocation) {
const isCoordinatePlaceholder = selectedLocation.name.startsWith('Location at ');
selectedLocation = {
...selectedLocation,
name:
resolvedLocationName ||
(isCoordinatePlaceholder && resolvedDisplayName
? resolvedDisplayName
: selectedLocation.name),
location: resolvedDisplayName || selectedLocation.location
};
emitUpdate(selectedLocation);
}
displayName = resolvedDisplayName || resolvedLocationName || displayName;
searchQuery = selectedLocation?.name || searchQuery;
}
} else {
if (target === 'start') {
@@ -641,7 +663,11 @@
dispatch('clear');
}
$: if (!initialApplied && initialSelection) {
$: if (
!initialApplied &&
initialSelection &&
isFiniteCoordinatePair(initialSelection.lat, initialSelection.lng)
) {
initialApplied = true;
applyInitialSelection(initialSelection);
}

View File

@@ -0,0 +1,92 @@
import { DEFAULT_CURRENCY, normalizeMoneyPayload } from '$lib/money';
import type { Location } from '$lib/types';
type SaveLocationInput = {
location: Partial<Location>;
locationToEdit?: { id: string } | null;
collectionId?: string | null;
defaultCurrency?: string;
};
function toFixedCoordinate(value: unknown): number | null {
if (value === null || value === undefined) return null;
const parsed = typeof value === 'string' ? Number(value) : Number(value);
if (Number.isNaN(parsed)) return null;
return Number(parsed.toFixed(6));
}
function sanitizeLink(value: unknown): string | null {
if (!value || typeof value !== 'string' || !value.trim()) {
return null;
}
try {
new URL(value);
return value;
} catch {
return null;
}
}
function parseApiError(errorData: any): string {
let errorMsg = errorData?.detail || errorData?.name?.[0] || '';
if (errorMsg) return String(errorMsg);
const fieldErrors = Object.entries(errorData || {})
.filter(([_, value]) => Array.isArray(value))
.map(([key, value]) => `${key}: ${(value as string[]).join(', ')}`)
.join('; ');
return fieldErrors || 'Failed to save location';
}
export async function saveLocation({
location,
locationToEdit = null,
collectionId = null,
defaultCurrency = DEFAULT_CURRENCY
}: SaveLocationInput): Promise<Location> {
const payload: Record<string, any> = {
...location,
latitude: toFixedCoordinate(location.latitude),
longitude: toFixedCoordinate(location.longitude),
link: sanitizeLink(location.link),
description:
typeof location.description === 'string' && location.description.trim()
? location.description
: null
};
if (collectionId) {
payload.collections = [collectionId];
}
if (location.price === null || location.price === undefined) {
payload.price = null;
payload.price_currency = null;
} else {
const normalized = normalizeMoneyPayload(payload, 'price', 'price_currency', defaultCurrency);
payload.price = normalized.price;
payload.price_currency = normalized.price_currency;
}
const isUpdate = Boolean(locationToEdit?.id);
if (isUpdate && !collectionId) {
delete payload.collections;
}
const res = await fetch(isUpdate ? `/api/locations/${locationToEdit?.id}` : '/api/locations', {
method: isUpdate ? 'PATCH' : 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(parseApiError(errorData));
}
return res.json();
}

View File

@@ -640,8 +640,7 @@
"locations": {
"location": "位置",
"locations": "場所",
"my_locations": "私の場所",
"best_happened_at": "最高の出来事が起きた"
"my_locations": "私の場所"
},
"lodging": {
"apartment": "アパート",
@@ -712,8 +711,7 @@
},
"users": "ユーザー",
"navigation": "ナビゲーション",
"worldtravel": "世界旅行",
"mobile_login": "モバイルログイン"
"worldtravel": "世界旅行"
},
"notes": {
"content": "コンテンツ",
@@ -1141,29 +1139,5 @@
"trip_context_info": "旅行コンテキスト項目は、旅行全体に適用されます。たとえば、目的地そのものである場所、一般的なメモ、旅行全体にとって重要な持ち物リストなどです。",
"unscheduled_items": "予定外の項目",
"unscheduled_items_desc": "これらのアイテムはこの旅行にリンクされていますが、まだ特定の日に追加されていません。"
},
"api_keys": {
"copied": "コピーしました!",
"copy": "キーをコピーする",
"create": "キーの作成",
"create_error": "APIキーの作成に失敗しました。",
"created": "作成されました",
"description": "プログラムによるアクセスのための個人用 API キーを作成します。\nキーは作成時に 1 回だけ表示されます。",
"dismiss": "却下する",
"key_created": "API キーが正常に作成されました。",
"key_name_placeholder": "キー名 (例: ホームアシスタント)",
"key_revoked": "API キーが取り消されました。",
"last_used": "最後に使用した",
"never_used": "一度も使用されていない",
"new_key_title": "新しい API キーを保存します",
"new_key_warning": "このキーは再度表示されません。\nそれをコピーして安全な場所に保管してください。",
"no_keys": "API キーはまだありません。",
"revoke": "取り消す",
"revoke_error": "APIキーの取り消しに失敗しました。",
"title": "APIキー",
"copy_error": "キーのコピー中にエラーが発生しました。",
"usage_middle": "ヘッダーまたはとして",
"usage_prefix": "このキーを",
"delete_confirm": "このモバイル API キーを削除してもよろしいですか?"
}
}

View File

@@ -45,7 +45,7 @@
"copied_to_clipboard": "클립 보드에 복사됨!",
"copy_failed": "복사 실패",
"copy_link": "링크 복사",
"create_new": "새로 만들기",
"create_new": "새로 만들기...",
"date": "일자",
"date_constrain": "컬렉션 일자로 제한",
"date_information": "일자 정보",
@@ -652,8 +652,7 @@
"users": "사용자",
"admin_panel": "관리자 패널",
"navigation": "항해",
"worldtravel": "세계여행",
"mobile_login": "모바일 로그인"
"worldtravel": "세계여행"
},
"notes": {
"content": "콘텐츠",
@@ -1032,8 +1031,7 @@
"locations": {
"location": "위치",
"locations": "위치",
"my_locations": "내 위치",
"best_happened_at": "가장 좋은 일이 일어난 시간은 다음과 같습니다."
"my_locations": "내 위치"
},
"settings_download_backup": "백업을 다운로드하십시오",
"invites": {
@@ -1141,29 +1139,5 @@
"trip_context_info": "여행 컨텍스트 항목은 전체 여행에 적용됩니다. 예를 들어 목적지 자체인 위치, 일반 참고 사항, 전체 여행에 중요한 짐 목록 등이 있습니다.",
"unscheduled_items": "예정되지 않은 품목",
"unscheduled_items_desc": "이 항목은 이 여행에 연결되어 있지만 아직 특정 날짜에 추가되지 않았습니다."
},
"api_keys": {
"copied": "복사되었습니다!",
"copy": "키 복사",
"create": "키 생성",
"create_error": "API 키를 생성하지 못했습니다.",
"created": "생성됨",
"description": "프로그래밍 방식 액세스를 위한 개인 API 키를 만듭니다. \n키는 생성 시 한 번만 표시됩니다.",
"dismiss": "해고하다",
"key_created": "API 키가 생성되었습니다.",
"key_name_placeholder": "키 이름(예: 홈어시스턴트)",
"key_revoked": "API 키가 취소되었습니다.",
"last_used": "마지막으로 사용됨",
"never_used": "한번도 사용하지 않음",
"new_key_title": "새 API 키 저장",
"new_key_warning": "이 키는 다시 표시되지 않습니다. \n복사해서 안전한 곳에 보관하세요.",
"no_keys": "아직 API 키가 없습니다.",
"revoke": "취소",
"revoke_error": "API 키를 취소하지 못했습니다.",
"title": "API 키",
"copy_error": "키를 복사하는 중에 오류가 발생했습니다.",
"usage_middle": "헤더 또는 다음과 같이",
"usage_prefix": "다음에서 이 키를 사용하세요.",
"delete_confirm": "이 모바일 API 키를 삭제하시겠습니까?"
}
}

View File

@@ -492,8 +492,7 @@
"calendar": "Kalender",
"admin_panel": "Admin -paneel",
"navigation": "Navigatie",
"worldtravel": "Wereldreizen",
"mobile_login": "Mobiel inloggen"
"worldtravel": "Wereldreizen"
},
"auth": {
"confirm_password": "Bevestig wachtwoord",
@@ -1032,8 +1031,7 @@
"locations": {
"location": "Locatie",
"locations": "Locaties",
"my_locations": "Mijn locaties",
"best_happened_at": "Het beste gebeurde om"
"my_locations": "Mijn locaties"
},
"settings_download_backup": "Download back -up",
"invites": {
@@ -1141,29 +1139,5 @@
"trip_context_info": "Reiscontextitems zijn van toepassing op de hele reis, bijvoorbeeld locaties die de bestemming zelf vormen, algemene opmerkingen of paklijsten die belangrijk zijn voor de hele reis.",
"unscheduled_items": "Niet-geplande items",
"unscheduled_items_desc": "Deze items zijn gekoppeld aan deze reis, maar nog niet toegevoegd aan een specifieke dag."
},
"api_keys": {
"copied": "Gekopieerd!",
"copy": "Kopieer sleutel",
"create": "Sleutel maken",
"create_error": "Kan API-sleutel niet maken.",
"created": "Gemaakt",
"description": "Maak persoonlijke API-sleutels voor programmatische toegang. \nSleutels worden slechts één keer weergegeven tijdens het maken.",
"dismiss": "Afwijzen",
"key_created": "API-sleutel is succesvol aangemaakt.",
"key_name_placeholder": "Sleutelnaam (bijv. Home Assistant)",
"key_revoked": "API-sleutel ingetrokken.",
"last_used": "Laatst gebruikt",
"never_used": "Nooit gebruikt",
"new_key_title": "Sla uw nieuwe API-sleutel op",
"new_key_warning": "Deze sleutel wordt niet meer getoond. \nKopieer het en bewaar het op een veilige plek.",
"no_keys": "Nog geen API-sleutels.",
"revoke": "Herroepen",
"revoke_error": "Kan de API-sleutel niet intrekken.",
"title": "API-sleutels",
"copy_error": "Fout bij kopiëren van sleutel.",
"usage_middle": "koptekst of als",
"usage_prefix": "Gebruik deze sleutel in de",
"delete_confirm": "Weet u zeker dat u deze mobiele API-sleutel wilt verwijderen?"
}
}

View File

@@ -28,8 +28,7 @@
"northernLights": "Nordlys"
},
"navigation": "Navigasjon",
"worldtravel": "Verdensreise",
"mobile_login": "Mobil pålogging"
"worldtravel": "Verdensreise"
},
"about": {
"about": "Om",
@@ -1032,8 +1031,7 @@
"locations": {
"location": "Sted",
"locations": "Lokasjoner",
"my_locations": "Mine lokasjoner",
"best_happened_at": "Best skjedde kl"
"my_locations": "Mine lokasjoner"
},
"settings_download_backup": "Last ned sikkerhetskopi",
"invites": {
@@ -1141,29 +1139,5 @@
"trip_context_info": "Turkontekstelementer gjelder for hele turen for eksempel steder som er selve destinasjonen, generelle notater eller pakkelister som er viktige for hele turen.",
"unscheduled_items": "Ikke-planlagte elementer",
"unscheduled_items_desc": "Disse elementene er knyttet til denne turen, men har ikke blitt lagt til en bestemt dag ennå."
},
"api_keys": {
"copied": "Kopiert!",
"copy": "Kopier nøkkel",
"create": "Opprett nøkkel",
"create_error": "Kunne ikke opprette API-nøkkel.",
"created": "Opprettet",
"description": "Lag personlige API-nøkler for programmatisk tilgang. \nNøkler vises bare én gang ved opprettelsestidspunktet.",
"dismiss": "Avskjedige",
"key_created": "API-nøkkel opprettet.",
"key_name_placeholder": "Nøkkelnavn (f.eks. Home Assistant)",
"key_revoked": "API-nøkkel er opphevet.",
"last_used": "Sist brukt",
"never_used": "Aldri brukt",
"new_key_title": "Lagre din nye API-nøkkel",
"new_key_warning": "Denne nøkkelen vises ikke igjen. \nKopier den og oppbevar den et trygt sted.",
"no_keys": "Ingen API-nøkler ennå.",
"revoke": "Oppheve",
"revoke_error": "Kunne ikke tilbakekalle API-nøkkel.",
"title": "API-nøkler",
"copy_error": "Feil ved kopiering av nøkkel.",
"usage_middle": "header eller as",
"usage_prefix": "Bruk denne tasten i",
"delete_confirm": "Er du sikker på at du vil slette denne mobile API-nøkkelen?"
}
}

View File

@@ -28,8 +28,7 @@
"calendar": "Kalendarz",
"admin_panel": "Panel administracyjny",
"navigation": "Nawigacja",
"worldtravel": "Światowa podróż",
"mobile_login": "Logowanie mobilne"
"worldtravel": "Światowa podróż"
},
"about": {
"about": "O aplikacji",
@@ -1032,8 +1031,7 @@
"locations": {
"location": "Lokalizacja",
"locations": "Lokalizacje",
"my_locations": "Moje lokalizacje",
"best_happened_at": "Najlepsze wydarzyło się o godz"
"my_locations": "Moje lokalizacje"
},
"settings_download_backup": "Pobierz kopię zapasową",
"invites": {
@@ -1141,29 +1139,5 @@
"trip_context_info": "Elementy kontekstu podróży dotyczą całej podróży — na przykład lokalizacje będące samym celem podróży, uwagi ogólne lub listy rzeczy do spakowania ważne dla całej podróży.",
"unscheduled_items": "Niezaplanowane pozycje",
"unscheduled_items_desc": "Te elementy są powiązane z tą podróżą, ale nie zostały jeszcze dodane do konkretnego dnia."
},
"api_keys": {
"copied": "Skopiowano!",
"copy": "Skopiuj klucz",
"create": "Utwórz klucz",
"create_error": "Nie udało się utworzyć klucza API.",
"created": "Stworzony",
"description": "Twórz osobiste klucze API w celu uzyskania dostępu programowego. \nKlucze są wyświetlane tylko raz w momencie tworzenia.",
"dismiss": "Odrzucać",
"key_created": "Klucz API został utworzony pomyślnie.",
"key_name_placeholder": "Nazwa klucza (np. Asystent domowy)",
"key_revoked": "Klucz API unieważniony.",
"last_used": "Ostatnio używany",
"never_used": "Nigdy nie używany",
"new_key_title": "Zapisz swój nowy klucz API",
"new_key_warning": "Ten klucz nie będzie już więcej wyświetlany. \nSkopiuj go i przechowuj w bezpiecznym miejscu.",
"no_keys": "Nie ma jeszcze kluczy API.",
"revoke": "Unieważnić",
"revoke_error": "Nie udało się unieważnić klucza API.",
"title": "Klucze API",
"copy_error": "Błąd podczas kopiowania klucza.",
"usage_middle": "nagłówek lub jako",
"usage_prefix": "Użyj tego klawisza w",
"delete_confirm": "Czy na pewno chcesz usunąć ten klucz mobilnego API?"
}
}