mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-08 23:15:11 -04:00
@@ -163,7 +163,7 @@ If your changes affect:
|
||||
please update the documentation in the:
|
||||
|
||||
```
|
||||
/documentation
|
||||
/docs
|
||||
```
|
||||
|
||||
folder accordingly.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
92
frontend/src/lib/location-save.ts
Normal file
92
frontend/src/lib/location-save.ts
Normal 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();
|
||||
}
|
||||
@@ -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 キーを削除してもよろしいですか?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 키를 삭제하시겠습니까?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user