Enhance API key management, location quick add features, and error handling (#1125)

* Refactor AdventureLog Bot workflow to improve issue validation handling and encapsulate comment and close logic

* feat: add API key management to settings page

- Implemented API key creation, deletion, and display functionality.
- Updated the settings page to fetch and show existing API keys.
- Added UI elements for creating new API keys and copying them to clipboard.
- Enhanced request handling to ensure proper trailing slashes for API endpoints.

* feat: add API Keys documentation and update contributing guidelines

* fix: update appVersion to reflect the latest build

* fix: update @tailwindcss/typography to version 0.5.19

* fix: update @tailwindcss/typography to version 0.5.19

* chore: update dependencies in pnpm-lock.yaml

- dompurify: upgraded from 3.3.1 to 3.3.3
- emoji-picker-element: upgraded from 1.29.0 to 1.29.1
- @sveltejs/adapter-node: updated to use @sveltejs/kit@2.55.0
- @sveltejs/adapter-vercel: updated to use @sveltejs/kit@2.55.0
- @sveltejs/kit: upgraded from 2.53.3 to 2.55.0
- @types/node: upgraded from 22.19.13 to 22.19.15
- autoprefixer: updated postcss version from 8.5.6 to 8.5.8
- baseline-browser-mapping: upgraded from 2.10.0 to 2.10.8
- daisyui: updated postcss version from 8.5.6 to 8.5.8
- prettier-plugin-svelte: upgraded from 3.5.0 to 3.5.1
- svelte-check: updated postcss version from 8.5.6 to 8.5.8
- devalue: upgraded from 5.6.3 to 5.6.4
- electron-to-chromium: upgraded from 1.5.302 to 1.5.313
- caniuse-lite: upgraded from 1.0.30001774 to 1.0.30001780
- mlly: upgraded from 1.8.0 to 1.8.1
- node-releases: upgraded from 2.0.27 to 2.0.36
- tar: upgraded from 7.5.9 to 7.5.11
- tinyexec: upgraded from 1.0.2 to 1.0.4

* fix: update appVersion to include the latest build identifier

* fix: enhance authentication fallback for protected media access

* feat(auth): add 'mobile-qr' to trailing slash list for URL handling

* Translated using Weblate (French)

Currently translated at 99.9% (1091 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1092 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ko/

* Translated using Weblate (German)

Currently translated at 100.0% (1092 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1092 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

* Added translation using Weblate (Catalan)

* Translated using Weblate (Catalan)

Currently translated at 1.2% (14 of 1092 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/ca/

* Docs: Reorder immich API permissions to natural order (#1086)

* Refactor AdventureLog Bot workflow to improve issue validation handling and encapsulate comment and close logic (#1068)

* Reorder immich API permissions to natural order

---------

Co-authored-by: Sean Morley <git@seanmorley.com>

* Translated using Weblate (Turkish)

Currently translated at 100.0% (1093 of 1093 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1093 of 1093 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

* Translated using Weblate (German)

Currently translated at 100.0% (1093 of 1093 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

* Add ENABLE_RATE_LIMITS configuration for backend rate limiting

* Set tabindex to -1 for dropdown menus to improve accessibility

* feat: Enhance LocationQuickStart component with quick add functionality and location enrichment

- Added quick add feature for locations with category selection.
- Implemented location description enrichment using Google Maps API.
- Improved search functionality and result handling.
- Introduced new utility functions for location saving and validation.
- Updated UI to reflect changes in location selection and quick add status.
- Added toast notifications for user feedback on actions.
- Refactored existing code for better readability and maintainability.

fix: Ensure finite coordinates in LocationSearchMap component

- Added validation for initial selection coordinates to prevent errors.

chore: Update app version to v0.12.0-main-033126

- Updated versioning in config file.

feat: Create location-save module for handling location data saving

- Implemented saveLocation function to handle both new and existing location data.
- Added utility functions for coordinate formatting and link sanitization.

* fix: Remove unused API keys section from Norwegian and Polish locale files

* fix: typo in backend success response (#1010)

* feat: enhance API key security with PBKDF2 hashing and configurable iterations

* fix: update PR handling to ignore dependabot in addition to maintainer

* fix: improve error handling for image import and permission validation

* fix: format code for better readability in LocationQuickStart component

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1097 of 1097 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

* Translated using Weblate (German)

Currently translated at 99.7% (1094 of 1097 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

* Translated using Weblate (German)

Currently translated at 99.7% (1094 of 1097 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

* fix: update default value for ENABLE_RATE_LIMITS in Docker configuration

* feat(lodging): implement quick start feature for lodging creation

- Added LodgingQuickStart component to facilitate quick lodging entry.
- Integrated Google Maps support for lodging selection and details enrichment.
- Enhanced LodgingModal to include quick start step and handle prefill from Google Places.
- Introduced utility function to infer lodging type from Google Places data.
- Updated localization files to include new strings for quick start functionality.

* fix: correct appVersion to reflect the development version

* fix: theme selector not working on HTTP environment (#1102)

* fix: remove unnecessary trailing comma in secure cookie setting

* Translated using Weblate (German)

Currently translated at 100.0% (1098 of 1098 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/de/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1098 of 1098 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1098 of 1098 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/sv/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (1098 of 1098 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/tr/

* feat(itinerary): add validation for global and dated itinerary items

* Refactor code structure for improved readability and maintainability

* feat: update serializers and views to handle images and attachments in backup/export processes

* feat(itinerary): implement quick add functionality for locations and lodgings with itinerary date handling

* chore: update Django and Pillow versions in requirements.txt

* fix: update appVersion to reflect the main branch version

* fixes External Mapping Search not using saved GPS coordinate for Loacations
Fixes #1134

---------

Co-authored-by: lesensei <alain-gh@lespeps.eu>
Co-authored-by: Hosted Weblate user 141821 <clearstripe@users.noreply.hosted.weblate.org>
Co-authored-by: Alex <div@alexe.at>
Co-authored-by: AntonPalmqvist <apq@users.noreply.hosted.weblate.org>
Co-authored-by: Marc Llopart <marc@medullar.com>
Co-authored-by: Stephan Zwicknagl <64196842+stephanzwicknagl@users.noreply.github.com>
Co-authored-by: Orhun <orhunavcu@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: Matthias Thym <git@thym.at>
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Johannes Roeßler <adventurelog@joei.de>
Co-authored-by: Gaël <67436391+Pexilo@users.noreply.github.com>
Co-authored-by: MrAsieru <weblate@asier.net>
This commit is contained in:
Sean Morley
2026-05-15 22:25:09 -04:00
committed by GitHub
parent 5291c8ad3a
commit b2ca759358
46 changed files with 3804 additions and 1262 deletions

View File

@@ -61,9 +61,9 @@ jobs:
await safeClosePr();
}
// Ignore specific user
if (context.actor === "seanmorley15") {
console.log("Skipping maintainer PR");
// Ignore PRs created by the maintainer to avoid blocking their work, as well as dependabot
if (context.actor === "seanmorley15" || context.actor === "dependabot") {
console.log("Skipping maintainer or dependabot PR");
return;
}

View File

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

View File

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

View File

@@ -1060,6 +1060,7 @@ class CollectionItineraryDaySerializer(CustomModelSerializer):
return super().update(instance, validated_data)
class CollectionItineraryItemSerializer(CustomModelSerializer):
date = serializers.DateField(required=False, allow_null=True, default=None)
item = serializers.SerializerMethodField()
start_datetime = serializers.ReadOnlyField()
end_datetime = serializers.ReadOnlyField()
@@ -1069,6 +1070,33 @@ class CollectionItineraryItemSerializer(CustomModelSerializer):
model = CollectionItineraryItem
fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'is_global', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name']
read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name']
def validate(self, attrs):
data = super().validate(attrs)
is_global = data.get('is_global')
if is_global is None and self.instance is not None:
is_global = self.instance.is_global
if 'date' in data:
date = data.get('date')
elif self.instance is not None:
date = self.instance.date
else:
date = None
if is_global and date is not None:
raise serializers.ValidationError({
'date': 'Global items must not have a date.',
'is_global': 'Provide either a date or set is_global, not both.',
})
if not is_global and date is None and self.instance is None:
raise serializers.ValidationError({
'date': 'Dated items must include a date. To create a trip-wide item, set is_global=true.',
})
return data
def update(self, instance, validated_data):
# Security: Prevent changing collection, content_type, or object_id after creation

View File

@@ -1,3 +1,57 @@
from django.test import TestCase
from rest_framework.test import APITestCase
# Create your tests here.
from adventures.models import Collection, CollectionItineraryItem, Location
from users.models import CustomUser
class ItineraryAPITestCase(APITestCase):
def setUp(self):
self.user = CustomUser.objects.create_user(
username='itinerary-user',
email='itinerary-user@example.com',
password='testpassword123',
)
self.collection = Collection.objects.create(user=self.user, name='Test Trip')
self.location = Location.objects.create(user=self.user, name='Test Location', is_public=True)
self.client.force_authenticate(user=self.user)
def test_create_global_itinerary_item_without_date(self):
response = self.client.post(
'/api/itineraries/',
{
'collection': str(self.collection.id),
'content_type': 'location',
'object_id': str(self.location.id),
'is_global': True,
'order': 0,
},
format='json',
)
self.assertEqual(response.status_code, 201)
self.assertEqual(CollectionItineraryItem.objects.count(), 1)
item = CollectionItineraryItem.objects.get()
self.assertTrue(item.is_global)
self.assertIsNone(item.date)
self.assertEqual(item.collection, self.collection)
payload = response.json()
self.assertTrue(payload['is_global'])
self.assertIsNone(payload['date'])
def test_create_dated_itinerary_item_without_date_is_rejected(self):
response = self.client.post(
'/api/itineraries/',
{
'collection': str(self.collection.id),
'content_type': 'location',
'object_id': str(self.location.id),
'is_global': False,
'order': 0,
},
format='json',
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()['date'][0], 'Dated items must include a date. To create a trip-wide item, set is_global=true.')

View File

@@ -100,6 +100,103 @@ class BackupViewSet(viewsets.ViewSet):
normalized_currency = default_currency
return amount, normalized_currency
def _serialize_images(self, images_qs):
"""Serialize ContentImage queryset into backup-safe dicts."""
serialized = []
for image in images_qs.all():
entry = {
'immich_id': image.immich_id,
'is_primary': image.is_primary,
'filename': None,
}
if image.image:
entry['filename'] = image.image.name.split('/')[-1]
serialized.append(entry)
return serialized
def _serialize_attachments(self, attachments_qs):
"""Serialize ContentAttachment queryset into backup-safe dicts."""
serialized = []
for attachment in attachments_qs.all():
entry = {
'name': attachment.name,
'filename': None,
}
if attachment.file:
entry['filename'] = attachment.file.name.split('/')[-1]
serialized.append(entry)
return serialized
def _add_storage_file_to_zip(self, zip_file, storage_name, arcname, files_added):
"""Read a Django storage file and add it to the zip once."""
if not storage_name or storage_name in files_added:
return
with default_storage.open(storage_name) as storage_file:
zip_file.writestr(arcname, storage_file.read())
files_added.add(storage_name)
def _import_images(self, images_data, zip_file, user, content_type, object_id, summary):
created = []
for img_data in images_data or []:
immich_id = (img_data or {}).get('immich_id')
if immich_id:
created.append(
ContentImage.objects.create(
user=user,
immich_id=immich_id,
is_primary=(img_data or {}).get('is_primary', False),
content_type=content_type,
object_id=object_id,
)
)
summary['images'] += 1
continue
filename = (img_data or {}).get('filename')
if not filename:
continue
try:
img_content = zip_file.read(f'images/{filename}')
except KeyError:
continue
img_file = ContentFile(img_content, name=filename)
created.append(
ContentImage.objects.create(
user=user,
image=img_file,
is_primary=(img_data or {}).get('is_primary', False),
content_type=content_type,
object_id=object_id,
)
)
summary['images'] += 1
return created
def _import_attachments(self, attachments_data, zip_file, user, content_type, object_id, summary):
for att_data in attachments_data or []:
filename = (att_data or {}).get('filename')
if not filename:
continue
try:
att_content = zip_file.read(f'attachments/{filename}')
except KeyError:
continue
att_file = ContentFile(att_content, name=filename)
ContentAttachment.objects.create(
user=user,
file=att_file,
name=(att_data or {}).get('name'),
content_type=content_type,
object_id=object_id,
)
summary['attachments'] += 1
@action(detail=False, methods=['get'])
def export(self, request):
@@ -148,9 +245,11 @@ class BackupViewSet(viewsets.ViewSet):
# Track images so we can reference them for collection primary images
image_export_map = {}
collection_id_to_export_id = {}
# Export Collections
for idx, collection in enumerate(user.collection_set.all()):
collection_id_to_export_id[collection.id] = idx
export_data['collections'].append({
'export_id': idx, # Add unique identifier for this export
'name': collection.name,
@@ -200,7 +299,9 @@ class BackupViewSet(viewsets.ViewSet):
'end_date': visit.end_date.isoformat() if visit.end_date else None,
'timezone': visit.timezone,
'notes': visit.notes,
'activities': []
'activities': [],
'images': [],
'attachments': [],
}
# Add activities for this visit
@@ -239,6 +340,20 @@ class BackupViewSet(viewsets.ViewSet):
visit_data['activities'].append(activity_data)
location_data['visits'].append(visit_data)
# Add visit images/attachments (generic)
visit_data['images'] = self._serialize_images(visit.images)
visit_data['attachments'] = self._serialize_attachments(visit.attachments)
for image_index, image in enumerate(visit.images.all()):
image_export_map[image.id] = {
'content_type': 'visit',
'location_export_id': idx,
'visit_export_id': visit_idx,
'image_index': image_index,
'immich_id': image.immich_id,
'filename': image.image.name.split('/')[-1] if image.image else None,
}
# Add trails for this location
for trail in location.trails.all():
@@ -251,48 +366,28 @@ class BackupViewSet(viewsets.ViewSet):
location_data['trails'].append(trail_data)
# Add images
location_data['images'] = self._serialize_images(location.images)
for image_index, image in enumerate(location.images.all()):
image_data = {
'immich_id': image.immich_id,
'is_primary': image.is_primary,
'filename': None,
}
if image.image:
image_data['filename'] = image.image.name.split('/')[-1]
location_data['images'].append(image_data)
image_export_map[image.id] = {
'content_type': 'location',
'location_export_id': idx,
'image_index': image_index,
'immich_id': image.immich_id,
'filename': image_data['filename'],
'filename': image.image.name.split('/')[-1] if image.image else None,
}
# Add attachments
for attachment in location.attachments.all():
attachment_data = {
'name': attachment.name,
'filename': None
}
if attachment.file:
attachment_data['filename'] = attachment.file.name.split('/')[-1]
location_data['attachments'].append(attachment_data)
location_data['attachments'] = self._serialize_attachments(location.attachments)
export_data['locations'].append(location_data)
# Attach collection primary image references (if any)
for idx, collection in enumerate(user.collection_set.all()):
primary = collection.primary_image
if primary and primary.id in image_export_map:
export_data['collections'][idx]['primary_image'] = image_export_map[primary.id]
# Export Transportation
for idx, transport in enumerate(user.transportation_set.all()):
collection_export_id = None
if transport.collection:
collection_export_id = collection_name_to_id.get(transport.collection.name)
export_data['transportation'].append({
transport_data = {
'export_id': idx,
'type': transport.type,
'name': transport.name,
@@ -313,8 +408,20 @@ class BackupViewSet(viewsets.ViewSet):
'destination_longitude': str(transport.destination_longitude) if transport.destination_longitude else None,
'to_location': transport.to_location,
'is_public': transport.is_public,
'collection_export_id': collection_export_id
})
'collection_export_id': collection_export_id,
'images': self._serialize_images(transport.images),
'attachments': self._serialize_attachments(transport.attachments),
}
export_data['transportation'].append(transport_data)
for image_index, image in enumerate(transport.images.all()):
image_export_map[image.id] = {
'content_type': 'transportation',
'object_export_id': idx,
'image_index': image_index,
'immich_id': image.immich_id,
'filename': image.image.name.split('/')[-1] if image.image else None,
}
# Export Notes
for idx, note in enumerate(user.note_set.all()):
@@ -322,15 +429,27 @@ class BackupViewSet(viewsets.ViewSet):
if note.collection:
collection_export_id = collection_name_to_id.get(note.collection.name)
export_data['notes'].append({
note_data = {
'export_id': idx,
'name': note.name,
'content': note.content,
'links': note.links,
'date': note.date.isoformat() if note.date else None,
'is_public': note.is_public,
'collection_export_id': collection_export_id
})
'collection_export_id': collection_export_id,
'images': self._serialize_images(note.images),
'attachments': self._serialize_attachments(note.attachments),
}
export_data['notes'].append(note_data)
for image_index, image in enumerate(note.images.all()):
image_export_map[image.id] = {
'content_type': 'note',
'object_export_id': idx,
'image_index': image_index,
'immich_id': image.immich_id,
'filename': image.image.name.split('/')[-1] if image.image else None,
}
# Export Checklists
for idx, checklist in enumerate(user.checklist_set.all()):
@@ -362,7 +481,7 @@ class BackupViewSet(viewsets.ViewSet):
if lodging.collection:
collection_export_id = collection_name_to_id.get(lodging.collection.name)
export_data['lodging'].append({
lodging_data = {
'export_id': idx,
'name': lodging.name,
'type': lodging.type,
@@ -379,8 +498,30 @@ class BackupViewSet(viewsets.ViewSet):
'longitude': str(lodging.longitude) if lodging.longitude else None,
'location': lodging.location,
'is_public': lodging.is_public,
'collection_export_id': collection_export_id
})
'collection_export_id': collection_export_id,
'images': self._serialize_images(lodging.images),
'attachments': self._serialize_attachments(lodging.attachments),
}
export_data['lodging'].append(lodging_data)
for image_index, image in enumerate(lodging.images.all()):
image_export_map[image.id] = {
'content_type': 'lodging',
'object_export_id': idx,
'image_index': image_index,
'immich_id': image.immich_id,
'filename': image.image.name.split('/')[-1] if image.image else None,
}
# Attach collection primary image references (if any)
for collection in user.collection_set.all():
export_id = collection_id_to_export_id.get(collection.id)
if export_id is None:
continue
primary = collection.primary_image
if primary and primary.id in image_export_map:
export_data['collections'][export_id]['primary_image'] = image_export_map[primary.id]
# Export Itinerary Items
# Create export_id mappings for all content types
@@ -431,35 +572,153 @@ class BackupViewSet(viewsets.ViewSet):
for image in location.images.all():
if image.image and image.image.name not in files_added:
try:
image_content = default_storage.open(image.image.name).read()
filename = image.image.name.split('/')[-1]
zip_file.writestr(f'images/{filename}', image_content)
files_added.add(image.image.name)
self._add_storage_file_to_zip(
zip_file,
image.image.name,
f'images/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding image {image.image.name}: {e}")
# Add visit images
for visit in location.visits.all():
for image in visit.images.all():
if image.image and image.image.name not in files_added:
try:
filename = image.image.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
image.image.name,
f'images/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding visit image {image.image.name}: {e}")
# Add attachments
for attachment in location.attachments.all():
if attachment.file and attachment.file.name not in files_added:
try:
file_content = default_storage.open(attachment.file.name).read()
filename = attachment.file.name.split('/')[-1]
zip_file.writestr(f'attachments/{filename}', file_content)
files_added.add(attachment.file.name)
self._add_storage_file_to_zip(
zip_file,
attachment.file.name,
f'attachments/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding attachment {attachment.file.name}: {e}")
# Add visit attachments
for visit in location.visits.all():
for attachment in visit.attachments.all():
if attachment.file and attachment.file.name not in files_added:
try:
filename = attachment.file.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
attachment.file.name,
f'attachments/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding visit attachment {attachment.file.name}: {e}")
# Add GPX files from activities
for visit in location.visits.all():
for activity in visit.activities.all():
if activity.gpx_file and activity.gpx_file.name not in files_added:
try:
gpx_content = default_storage.open(activity.gpx_file.name).read()
filename = activity.gpx_file.name.split('/')[-1]
zip_file.writestr(f'gpx/{filename}', gpx_content)
files_added.add(activity.gpx_file.name)
self._add_storage_file_to_zip(
zip_file,
activity.gpx_file.name,
f'gpx/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding GPX file {activity.gpx_file.name}: {e}")
# Add non-location content images/attachments
for transport in user.transportation_set.all():
for image in transport.images.all():
if image.image and image.image.name not in files_added:
try:
filename = image.image.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
image.image.name,
f'images/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding transportation image {image.image.name}: {e}")
for attachment in transport.attachments.all():
if attachment.file and attachment.file.name not in files_added:
try:
filename = attachment.file.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
attachment.file.name,
f'attachments/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding transportation attachment {attachment.file.name}: {e}")
for note in user.note_set.all():
for image in note.images.all():
if image.image and image.image.name not in files_added:
try:
filename = image.image.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
image.image.name,
f'images/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding note image {image.image.name}: {e}")
for attachment in note.attachments.all():
if attachment.file and attachment.file.name not in files_added:
try:
filename = attachment.file.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
attachment.file.name,
f'attachments/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding note attachment {attachment.file.name}: {e}")
for lodging in user.lodging_set.all():
for image in lodging.images.all():
if image.image and image.image.name not in files_added:
try:
filename = image.image.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
image.image.name,
f'images/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding lodging image {image.image.name}: {e}")
for attachment in lodging.attachments.all():
if attachment.file and attachment.file.name not in files_added:
try:
filename = attachment.file.name.split('/')[-1]
self._add_storage_file_to_zip(
zip_file,
attachment.file.name,
f'attachments/{filename}',
files_added,
)
except Exception as e:
print(f"Error adding lodging attachment {attachment.file.name}: {e}")
# Return ZIP file as response
with open(tmp_file.name, 'rb') as zip_file:
@@ -611,6 +870,16 @@ class BackupViewSet(viewsets.ViewSet):
pending_primary_images = []
location_images_map = {}
visit_images_map = {}
transportation_images_map = {}
note_images_map = {}
lodging_images_map = {}
content_type_location = ContentType.objects.get(model='location')
content_type_visit = ContentType.objects.get(model='visit')
content_type_transportation = ContentType.objects.get(model='transportation')
content_type_note = ContentType.objects.get(model='note')
content_type_lodging = ContentType.objects.get(model='lodging')
# Import Collections
for col_data in backup_data.get('collections', []):
@@ -721,6 +990,10 @@ class BackupViewSet(viewsets.ViewSet):
timezone=visit_data.get('timezone'),
notes=visit_data.get('notes')
)
visit_export_id = visit_data.get('export_id')
if visit_export_id is not None:
visit_images_map.setdefault((adv_data['export_id'], visit_export_id), [])
# Import activities for this visit
for activity_data in visit_data.get('activities', []):
@@ -783,77 +1056,50 @@ class BackupViewSet(viewsets.ViewSet):
activity.save()
summary['activities'] += 1
# Import visit images/attachments (if present)
created_visit_images = self._import_images(
visit_data.get('images', []),
zip_file,
user,
content_type_visit,
visit.id,
summary,
)
if visit_export_id is not None:
visit_images_map[(adv_data['export_id'], visit_export_id)].extend(created_visit_images)
self._import_attachments(
visit_data.get('attachments', []),
zip_file,
user,
content_type_visit,
visit.id,
summary,
)
# Import images
content_type = ContentType.objects.get(model='location')
created_location_images = self._import_images(
adv_data.get('images', []),
zip_file,
user,
content_type_location,
location.id,
summary,
)
location_images_map[adv_data['export_id']].extend(created_location_images)
for img_data in adv_data.get('images', []):
immich_id = img_data.get('immich_id')
if immich_id:
new_img = ContentImage.objects.create(
user=user,
immich_id=immich_id,
is_primary=img_data.get('is_primary', False),
content_type=content_type,
object_id=location.id
)
location_images_map[adv_data['export_id']].append(new_img)
summary['images'] += 1
else:
filename = img_data.get('filename')
if filename:
try:
img_content = zip_file.read(f'images/{filename}')
img_file = ContentFile(img_content, name=filename)
new_img = ContentImage.objects.create(
user=user,
image=img_file,
is_primary=img_data.get('is_primary', False),
content_type=content_type,
object_id=location.id
)
location_images_map[adv_data['export_id']].append(new_img)
summary['images'] += 1
except KeyError:
pass
# Import attachments
for att_data in adv_data.get('attachments', []):
filename = att_data.get('filename')
if filename:
try:
att_content = zip_file.read(f'attachments/{filename}')
att_file = ContentFile(att_content, name=filename)
ContentAttachment.objects.create(
user=user,
file=att_file,
name=att_data.get('name'),
content_type=content_type,
object_id=location.id
)
summary['attachments'] += 1
except KeyError:
pass
self._import_attachments(
adv_data.get('attachments', []),
zip_file,
user,
content_type_location,
location.id,
summary,
)
summary['locations'] += 1
# Apply primary image selections now that images exist
for entry in pending_primary_images:
collection = collection_map.get(entry['collection_export_id'])
data = entry.get('data', {}) or {}
if not collection:
continue
loc_export_id = data.get('location_export_id')
img_index = data.get('image_index')
if loc_export_id is None or img_index is None:
continue
images_for_location = location_images_map.get(loc_export_id, [])
if 0 <= img_index < len(images_for_location):
collection.primary_image = images_for_location[img_index]
collection.save(update_fields=['primary_image'])
# Import Transportation
transportation_map = {} # Map export_id to actual transportation object
for trans_data in backup_data.get('transportation', []):
@@ -889,6 +1135,28 @@ class BackupViewSet(viewsets.ViewSet):
is_public=trans_data.get('is_public', False),
collection=collection
)
export_id = trans_data.get('export_id')
if export_id is not None:
transportation_images_map.setdefault(export_id, [])
transportation_images_map[export_id].extend(
self._import_images(
trans_data.get('images', []),
zip_file,
user,
content_type_transportation,
transportation.id,
summary,
)
)
self._import_attachments(
trans_data.get('attachments', []),
zip_file,
user,
content_type_transportation,
transportation.id,
summary,
)
# Only add to map if export_id exists (for backward compatibility with old backups)
if 'export_id' in trans_data:
transportation_map[trans_data['export_id']] = transportation
@@ -910,6 +1178,28 @@ class BackupViewSet(viewsets.ViewSet):
is_public=note_data.get('is_public', False),
collection=collection
)
export_id = note_data.get('export_id')
if export_id is not None:
note_images_map.setdefault(export_id, [])
note_images_map[export_id].extend(
self._import_images(
note_data.get('images', []),
zip_file,
user,
content_type_note,
note.id,
summary,
)
)
self._import_attachments(
note_data.get('attachments', []),
zip_file,
user,
content_type_note,
note.id,
summary,
)
# Only add to map if export_id exists (for backward compatibility with old backups)
if 'export_id' in note_data:
note_map[note_data['export_id']] = note
@@ -976,10 +1266,77 @@ class BackupViewSet(viewsets.ViewSet):
is_public=lodg_data.get('is_public', False),
collection=collection
)
export_id = lodg_data.get('export_id')
if export_id is not None:
lodging_images_map.setdefault(export_id, [])
lodging_images_map[export_id].extend(
self._import_images(
lodg_data.get('images', []),
zip_file,
user,
content_type_lodging,
lodging.id,
summary,
)
)
self._import_attachments(
lodg_data.get('attachments', []),
zip_file,
user,
content_type_lodging,
lodging.id,
summary,
)
# Only add to map if export_id exists (for backward compatibility with old backups)
if 'export_id' in lodg_data:
lodging_map[lodg_data['export_id']] = lodging
summary['lodging'] += 1
# Apply primary image selections now that images exist
for entry in pending_primary_images:
collection = collection_map.get(entry['collection_export_id'])
data = entry.get('data', {}) or {}
if not collection:
continue
content_type_str = data.get('content_type') or 'location'
img_index = data.get('image_index')
if img_index is None:
continue
if content_type_str == 'location':
loc_export_id = data.get('location_export_id')
if loc_export_id is None:
continue
images_for_object = location_images_map.get(loc_export_id, [])
elif content_type_str == 'visit':
loc_export_id = data.get('location_export_id')
visit_export_id = data.get('visit_export_id')
if loc_export_id is None or visit_export_id is None:
continue
images_for_object = visit_images_map.get((loc_export_id, visit_export_id), [])
elif content_type_str == 'transportation':
obj_export_id = data.get('object_export_id')
if obj_export_id is None:
continue
images_for_object = transportation_images_map.get(obj_export_id, [])
elif content_type_str == 'note':
obj_export_id = data.get('object_export_id')
if obj_export_id is None:
continue
images_for_object = note_images_map.get(obj_export_id, [])
elif content_type_str == 'lodging':
obj_export_id = data.get('object_export_id')
if obj_export_id is None:
continue
images_for_object = lodging_images_map.get(obj_export_id, [])
else:
continue
if 0 <= img_index < len(images_for_object):
collection.primary_image = images_for_object[img_index]
collection.save(update_fields=['primary_image'])
# Import Itinerary Items
# Maps already created during import of each content type

View File

@@ -54,6 +54,8 @@ class ItineraryViewSet(viewsets.ModelViewSet):
if isinstance(is_global, str):
is_global = is_global.lower() in ['1', 'true', 'yes']
data['is_global'] = is_global
if is_global and not target_date:
data['date'] = None
# Support legacy field 'location' -> treat as content_type='location'
if not content_type_val and data.get('location'):

View File

@@ -4,8 +4,11 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.throttling import UserRateThrottle
from django.http import HttpResponse
from concurrent.futures import ThreadPoolExecutor, as_completed
import ipaddress
import mimetypes
import socket
from urllib.parse import urljoin
from urllib.parse import urlparse
from django.db.models import Q
from django.core.files.base import ContentFile
@@ -17,6 +20,7 @@ from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existin
import requests
from adventures.permissions import ContentImagePermission
import logging
import uuid
logger = logging.getLogger(__name__)
@@ -25,6 +29,17 @@ class ImageProxyThrottle(UserRateThrottle):
scope = 'image_proxy'
def _public_import_error_message(exc):
"""Return a safe, user-facing import error without exposing internal details."""
if isinstance(exc, ValueError):
return "Invalid image URL"
if isinstance(exc, requests.exceptions.Timeout):
return "Download timeout"
if isinstance(exc, requests.exceptions.RequestException):
return "Failed to fetch image from the remote server"
return "Image import failed"
def _is_safe_url(image_url):
"""
Validate a URL for safe proxy use.
@@ -67,6 +82,149 @@ 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:
logger.warning(
"Image import failed for URL %s",
image_url,
exc_info=True,
)
downloaded_results.append((index, image_url, None, _public_import_error_message(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 +350,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:
return Response({"error": "Invalid image URL"}, status=status.HTTP_400_BAD_REQUEST)
except requests.exceptions.Timeout:
logger.error("Timeout fetching image from URL %s", image_url)
@@ -269,6 +370,64 @@ class ContentImageViewSet(viewsets.ModelViewSet):
status=status.HTTP_502_BAD_GATEWAY
)
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
def import_from_urls(self, request):
content_type_name = request.data.get('content_type')
object_id = request.data.get('object_id')
urls = request.data.get('urls')
if not isinstance(urls, list) or not urls:
return Response({"error": "urls must be a non-empty array"}, status=status.HTTP_400_BAD_REQUEST)
urls = [str(url).strip() for url in urls if str(url).strip()]
if not urls:
return Response({"error": "No valid URLs provided"}, status=status.HTTP_400_BAD_REQUEST)
if len(urls) > 10:
return Response({"error": "Maximum 10 URLs per request"}, status=status.HTTP_400_BAD_REQUEST)
content_object = self._get_and_validate_content_object(content_type_name, object_id)
if isinstance(content_object, Response):
return content_object
owner = getattr(content_object, 'user', request.user)
import_summary = import_remote_images_for_object(
content_object,
urls,
owner=owner,
max_workers=min(5, len(urls)),
)
created_images = import_summary['created_images']
results = import_summary['results']
if not created_images:
return Response(
{
'error': 'No images could be imported',
'results': results,
},
status=status.HTTP_400_BAD_REQUEST,
)
serialized = ContentImageSerializer(created_images, many=True, context={'request': request})
response_status = (
status.HTTP_201_CREATED
if import_summary['created_count'] == import_summary['requested_count']
else status.HTTP_200_OK
)
return Response(
{
'created': serialized.data,
'results': results,
'created_count': import_summary['created_count'],
'requested_count': import_summary['requested_count'],
},
status=response_status,
)
def create(self, request, *args, **kwargs):
# Get content type and object ID from request
content_type_name = request.data.get('content_type')

View File

@@ -12,8 +12,31 @@ import requests
from adventures.models import Location, Category, Collection, CollectionItineraryItem, ContentImage, Visit
from django.contrib.contenttypes.models import ContentType
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import LocationSerializer, MapPinSerializer, CalendarLocationSerializer
from adventures.serializers import (
CalendarLocationSerializer,
CollectionItineraryItemSerializer,
LocationSerializer,
MapPinSerializer,
)
from adventures.utils import pagination
from adventures.geocoding import reverse_geocode
from worldtravel.models import City, Country, Region
from .location_image_view import import_remote_images_for_object
from .quick_add_utils import (
build_quick_add_description,
clean_url,
coerce_bool,
coerce_coordinate,
coerce_float,
coerce_int,
create_quick_add_itinerary_item,
extract_google_place_details,
parse_itinerary_date,
preferred_link,
resolve_quick_add_collection,
sanitize_photo_urls,
sanitize_tags,
)
logger = logging.getLogger(__name__)
@@ -158,6 +181,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 = coerce_coordinate(payload.get('latitude'), -90, 90)
longitude = coerce_coordinate(payload.get('longitude'), -180, 180)
if latitude is None or longitude is None:
return Response(
{"error": "Valid latitude and longitude are required"},
status=status.HTTP_400_BAD_REQUEST,
)
collection = self._resolve_quick_add_collection(payload.get('collection_id'))
if isinstance(collection, Response):
return collection
reverse_data = {}
_, details = extract_google_place_details(payload, fallback_query=name)
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 = {}
rating = coerce_float(payload.get('rating'))
if rating is None:
rating = coerce_float(details.get('rating'))
review_count = coerce_int(payload.get('review_count'))
if review_count is None:
review_count = coerce_int(details.get('review_count'))
link = preferred_link(payload, details)
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 = 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': sanitize_tags(payload.get('types') or payload.get('tags')),
'is_public': 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)
itinerary_date = parse_itinerary_date(payload.get('itinerary_date'))
itinerary_item = None
if collection and itinerary_date:
itinerary_item = create_quick_add_itinerary_item(collection, location, itinerary_date)
if isinstance(itinerary_item, Response):
return itinerary_item
photo_urls = 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 itinerary_item:
response_data['quick_add_itinerary_item'] = CollectionItineraryItemSerializer(
itinerary_item
).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 +599,122 @@ 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):
return resolve_quick_add_collection(
collection_id,
validate_permissions=self._validate_collection_permissions,
permission_error_message=(
"You do not have permission to add this location to the selected collection."
),
)
def _coerce_coordinate(self, value, min_value, max_value):
return coerce_coordinate(value, min_value, max_value)
def _coerce_float(self, value):
return coerce_float(value)
def _coerce_int(self, value):
return coerce_int(value)
def _coerce_bool(self, value, default=False):
return coerce_bool(value, default=default)
def _clean_url(self, value):
return clean_url(value)
def _sanitize_tags(self, raw_tags):
return sanitize_tags(raw_tags)
def _sanitize_photo_urls(self, raw_urls):
return sanitize_photo_urls(raw_urls)
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,
):
return build_quick_add_description(base_description, detailed_description)
def _apply_reverse_geocode_metadata(self, location, reverse_data, fallback_location):
if not isinstance(reverse_data, dict):
reverse_data = {}
updated_fields = []
region_id = reverse_data.get('region_id')
if region_id:
region = Region.objects.filter(id=region_id).first()
if region and location.region_id != region.id:
location.region = region
updated_fields.append('region')
city_id = reverse_data.get('city_id')
if city_id:
city = City.objects.filter(id=city_id).first()
if city and location.city_id != city.id:
location.city = city
updated_fields.append('city')
country_id = reverse_data.get('country_id')
if country_id:
country = Country.objects.filter(country_code=country_id).first()
if country and location.country_id != country.id:
location.country = country
updated_fields.append('country')
if fallback_location and not location.location:
location.location = fallback_location
updated_fields.append('location')
if updated_fields:
location.save(update_fields=updated_fields, _skip_geocode=True)
def _apply_visit_filtering(self, queryset, request):
"""Apply visit status filtering to queryset."""
is_visited_param = request.query_params.get('is_visited')

View File

@@ -1,12 +1,27 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db import transaction
from django.db.models import Q
from adventures.models import Lodging
from adventures.serializers import LodgingSerializer
from adventures.serializers import CollectionItineraryItemSerializer, LodgingSerializer
from rest_framework.exceptions import PermissionDenied
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from rest_framework.permissions import IsAuthenticated
from adventures.geocoding import reverse_geocode
from .location_image_view import import_remote_images_for_object
from .quick_add_utils import (
build_quick_add_description,
coerce_bool,
coerce_coordinate,
coerce_float,
create_quick_add_itinerary_item,
extract_google_place_details,
infer_lodging_type,
parse_itinerary_date,
preferred_link,
resolve_quick_add_collection,
sanitize_photo_urls,
)
class LodgingViewSet(viewsets.ModelViewSet):
queryset = Lodging.objects.all()
@@ -63,6 +78,114 @@ class LodgingViewSet(viewsets.ModelViewSet):
def perform_update(self, serializer):
serializer.save()
@action(detail=False, methods=['post'], url_path='quick-add')
@transaction.atomic
def quick_add(self, request):
"""Create a lodging from lightweight map/place input in one server-side call."""
payload = request.data if isinstance(request.data, dict) else {}
name = str(payload.get('name') or '').strip()
if not name:
return Response({"error": "name is required"}, status=status.HTTP_400_BAD_REQUEST)
latitude = coerce_coordinate(payload.get('latitude'), -90, 90)
longitude = coerce_coordinate(payload.get('longitude'), -180, 180)
if latitude is None or longitude is None:
return Response(
{"error": "Valid latitude and longitude are required"},
status=status.HTTP_400_BAD_REQUEST,
)
collection = resolve_quick_add_collection(
payload.get('collection_id'),
validate_permissions=self._validate_collection_permissions,
permission_error_message=(
"You do not have permission to add this lodging to the selected collection."
),
)
if isinstance(collection, Response):
return collection
reverse_data = {}
try:
reverse_result = reverse_geocode(latitude, longitude, request.user)
if isinstance(reverse_result, dict) and 'error' not in reverse_result:
reverse_data = reverse_result
except Exception:
reverse_data = {}
_, details = extract_google_place_details(payload, fallback_query=name)
rating = coerce_float(payload.get('rating'))
if rating is None:
rating = coerce_float(details.get('rating'))
location_label = (
str(payload.get('location') or '').strip()
or str(reverse_data.get('display_name') or '').strip()
or str(details.get('formatted_address') or '').strip()
or None
)
place_types = payload.get('types')
if not isinstance(place_types, list) or not place_types:
place_types = details.get('types') if isinstance(details.get('types'), list) else []
serializer_payload = {
'name': name,
'type': infer_lodging_type(payload.get('type'), place_types),
'location': location_label,
'latitude': latitude,
'longitude': longitude,
'rating': rating,
'description': build_quick_add_description(
base_description=payload.get('description'),
detailed_description=details.get('description'),
),
'link': preferred_link(payload, details),
'is_public': coerce_bool(payload.get('is_public'), default=False),
}
if collection:
serializer_payload['collection'] = str(collection.id)
serializer = self.get_serializer(data=serializer_payload)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
lodging = serializer.instance
itinerary_date = parse_itinerary_date(payload.get('itinerary_date'))
itinerary_item = None
if collection and itinerary_date:
itinerary_item = create_quick_add_itinerary_item(collection, lodging, itinerary_date)
if isinstance(itinerary_item, Response):
return itinerary_item
photo_urls = sanitize_photo_urls(payload.get('photos'))
image_import_summary = None
if photo_urls:
image_import_summary = import_remote_images_for_object(
lodging,
photo_urls,
owner=lodging.user,
max_workers=min(5, len(photo_urls)),
)
response_data = self.get_serializer(lodging).data
if itinerary_item:
response_data['quick_add_itinerary_item'] = CollectionItineraryItemSerializer(
itinerary_item
).data
if image_import_summary and image_import_summary.get('failed'):
response_data['quick_add_image_import'] = {
'created_count': image_import_summary['created_count'],
'failed_count': image_import_summary['failed_count'],
'failed': image_import_summary['failed'],
}
return Response(response_data, status=status.HTTP_201_CREATED)
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
def perform_create(self, serializer):
@@ -81,4 +204,13 @@ class LodgingViewSet(viewsets.ModelViewSet):
return
# Save the adventure with the current user as the owner
serializer.save(user=self.request.user)
serializer.save(user=self.request.user)
def _validate_collection_permissions(self, collections):
"""Validate permissions for all collections (used by quick add)."""
for collection in collections:
if collection.user != self.request.user:
if not collection.shared_with.filter(id=self.request.user.id).exists():
raise PermissionDenied(
f"You don't have permission to add lodging to collection '{collection.name}'"
)

View File

@@ -0,0 +1,325 @@
import datetime
from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
from django.db import models
from django.utils.dateparse import parse_date, parse_datetime
from rest_framework import status
from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied
from rest_framework.response import Response
from django.contrib.contenttypes.models import ContentType
from adventures.geocoding import get_place_details
from adventures.models import Collection, CollectionItineraryItem, Visit
def coerce_coordinate(value, min_value, max_value):
try:
number = round(float(value), 6)
except (TypeError, ValueError):
return None
if number < min_value or number > max_value:
return None
return number
def coerce_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def coerce_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def coerce_bool(value, default=False):
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "on"}:
return True
if normalized in {"false", "0", "no", "off"}:
return False
return default
def clean_url(value):
if not isinstance(value, str):
return None
normalized = value.strip()
if not normalized:
return None
parsed = urlparse(normalized)
if parsed.scheme in {"http", "https"} and parsed.netloc:
return normalized
return None
def sanitize_tags(raw_tags, max_tags=8):
if not isinstance(raw_tags, list):
return []
tags = []
for item in raw_tags:
if not isinstance(item, str):
continue
value = item.strip()
if not value or value in tags:
continue
tags.append(value)
if len(tags) >= max_tags:
break
return tags
def sanitize_photo_urls(raw_urls, max_urls=5):
if not isinstance(raw_urls, list):
return []
cleaned = []
for value in raw_urls:
url = clean_url(value)
if not url or url in cleaned:
continue
cleaned.append(url)
if len(cleaned) >= max_urls:
break
return cleaned
def build_quick_add_description(base_description, detailed_description):
description = str(detailed_description or "").strip() or str(base_description or "").strip()
return description or None
def resolve_quick_add_collection(collection_id, validate_permissions, permission_error_message):
if not collection_id:
return None
try:
collection = Collection.objects.get(id=collection_id)
except Collection.DoesNotExist:
return Response(
{"error": "Collection not found."},
status=status.HTTP_404_NOT_FOUND,
)
try:
validate_permissions([collection])
except (DjangoPermissionDenied, DRFPermissionDenied):
return Response(
{"error": permission_error_message},
status=status.HTTP_403_FORBIDDEN,
)
return collection
def extract_google_place_details(payload, fallback_query=""):
place_id = str(payload.get("place_id") or "").strip() or None
details = {}
if not place_id:
return place_id, details
details_result = get_place_details(place_id, fallback_query=fallback_query)
if isinstance(details_result, dict):
if "error" not in details_result or details_result.get("description"):
details = details_result
return place_id, details
def preferred_link(payload, details):
website = clean_url(details.get("website")) or clean_url(payload.get("website"))
maps_url = clean_url(details.get("google_maps_url")) or clean_url(payload.get("google_maps_url"))
return clean_url(payload.get("link")) or website or maps_url
def infer_lodging_type(primary_type, place_types):
valid_types = {
"hotel",
"hostel",
"resort",
"bnb",
"campground",
"cabin",
"apartment",
"house",
"villa",
"motel",
"other",
}
if isinstance(primary_type, str):
normalized = primary_type.strip().lower()
if normalized in valid_types:
return normalized
normalized_types = [
str(type_name).strip().lower()
for type_name in (place_types or [])
if str(type_name).strip()
]
mapping = {
"hotel": "hotel",
"resort_hotel": "resort",
"motel": "motel",
"hostel": "hostel",
"bed_and_breakfast": "bnb",
"guest_house": "bnb",
"campground": "campground",
"rv_park": "campground",
"camping_cabin": "cabin",
"apartment_building": "apartment",
"lodging": "hotel",
"villa": "villa",
}
for type_name in normalized_types:
if type_name in mapping:
return mapping[type_name]
for type_name in normalized_types:
if type_name in valid_types:
return type_name
return "other"
def parse_itinerary_date(value):
if not value:
return None
raw_value = str(value).strip()
if not raw_value:
return None
parsed_date = parse_date(raw_value)
if parsed_date:
return parsed_date
parsed_datetime = parse_datetime(raw_value)
if parsed_datetime:
return parsed_datetime.date()
return None
def validate_itinerary_date(collection, date_value):
if not collection or not date_value:
return None
if collection.start_date and date_value < collection.start_date:
return Response(
{"error": "Itinerary item date is before the collection start_date"},
status=status.HTTP_400_BAD_REQUEST,
)
if collection.end_date and date_value > collection.end_date:
return Response(
{"error": "Itinerary item date is after the collection end_date"},
status=status.HTTP_400_BAD_REQUEST,
)
return None
def apply_quick_add_itinerary_date(content_object, date_value):
if not content_object or not date_value:
return
model_name = content_object._meta.model_name
if model_name == "location":
start_dt = datetime.datetime.combine(date_value, datetime.time.min)
end_dt = datetime.datetime.combine(date_value, datetime.time.max)
exact_match = Visit.objects.filter(
location=content_object, start_date=start_dt, end_date=end_dt
).first()
if exact_match:
return
overlap_q = models.Q(start_date__lte=end_dt) & models.Q(end_date__gte=start_dt)
existing = Visit.objects.filter(location=content_object).filter(overlap_q).first()
if existing:
existing.start_date = start_dt
existing.end_date = end_dt
existing.save(update_fields=["start_date", "end_date"])
return
Visit.objects.create(
location=content_object,
start_date=start_dt,
end_date=end_dt,
notes="Created from quick add",
)
return
if model_name == "lodging":
if content_object.check_in and content_object.check_out:
return
check_in = datetime.datetime.combine(date_value, datetime.time.min)
check_out = check_in + datetime.timedelta(days=1)
content_object.check_in = check_in
content_object.check_out = check_out
content_object.save(update_fields=["check_in", "check_out"])
def create_quick_add_itinerary_item(collection, content_object, date_value):
if not collection or not content_object or not date_value:
return None
existing_error = validate_itinerary_date(collection, date_value)
if isinstance(existing_error, Response):
return existing_error
content_type = ContentType.objects.get_for_model(content_object.__class__)
existing_item = CollectionItineraryItem.objects.filter(
collection=collection,
content_type=content_type,
object_id=content_object.id,
date=date_value,
is_global=False,
).first()
if existing_item:
return existing_item
max_order = (
CollectionItineraryItem.objects.filter(
collection=collection, date=date_value, is_global=False
).aggregate(max_order=models.Max("order"))["max_order"]
or -1
)
apply_quick_add_itinerary_date(content_object, date_value)
return CollectionItineraryItem.objects.create(
collection=collection,
content_type=content_type,
object_id=content_object.id,
date=date_value,
is_global=False,
order=max_order + 1,
)

View File

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

View File

@@ -1,4 +1,4 @@
Django==5.2.12
Django==5.2.13
djangorestframework>=3.15.2,<3.16
django-allauth==0.63.3
django-money==3.5.4
@@ -8,7 +8,7 @@ django-cors-headers==4.4.0
coreapi==2.3.3
python-dotenv==1.1.0
psycopg2-binary==2.9.10
pillow==12.1.1
pillow==12.2.0
whitenoise==6.9.0
django-resized==1.0.3
django-geojson==4.2.0

View File

@@ -175,7 +175,7 @@
`<strong>API Response:</strong> ${data.status} ${data.statusText}<br/><strong>Content:</strong> ${data.responseText}`
);
};
const susccess_response = (data) => {
const success_response = (data) => {
$(".api-response").html(
`<strong>API Response:</strong> OK<br/><strong>Content:</strong> ${JSON.stringify(
data,
@@ -190,7 +190,7 @@
const form = $("form.ajax-post");
$.post(form.attr("action"), form.serialize())
.fail(error_response)
.done(susccess_response);
.done(success_response);
return false;
});
});

View File

@@ -1,6 +1,7 @@
import hashlib
import secrets
import uuid
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models
from django_resized import ResizedImageField
@@ -49,12 +50,15 @@ class APIKey(models.Model):
Security design:
- A 32-byte cryptographically random token is generated with the prefix ``al_``.
- Only a SHA-256 hash of the full token is persisted; the plaintext is returned
exactly once at creation time and never stored.
- Only a PBKDF2-HMAC-SHA256 derived hash of the full token is persisted;
the plaintext is returned exactly once at creation time and never stored.
- The first 12 characters of the token are kept as ``key_prefix`` so users can
identify their keys without revealing the secret.
identify their keys without revealing the secret.
"""
_KEY_HASH_ITERATIONS = 600000
_KEY_HASH_SALT_NAMESPACE = "users.APIKey"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
CustomUser, on_delete=models.CASCADE, related_name='api_keys'
@@ -71,6 +75,17 @@ class APIKey(models.Model):
def __str__(self):
return f"{self.user.username} {self.name} ({self.key_prefix}…)"
@classmethod
def _hash_raw_key(cls, raw_key: str) -> str:
"""Derive a computationally expensive hash for API key persistence."""
salt = f"{cls._KEY_HASH_SALT_NAMESPACE}:{settings.SECRET_KEY}".encode("utf-8")
return hashlib.pbkdf2_hmac(
"sha256",
raw_key.encode("utf-8"),
salt,
cls._KEY_HASH_ITERATIONS,
).hex()
@classmethod
def generate(cls, user, name: str) -> tuple['APIKey', str]:
"""
@@ -80,7 +95,7 @@ class APIKey(models.Model):
user once and must never be stored anywhere after that.
"""
raw_key = f"al_{secrets.token_urlsafe(32)}"
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_hash = cls._hash_raw_key(raw_key)
key_prefix = raw_key[:12]
instance = cls.objects.create(
user=user,
@@ -98,7 +113,7 @@ class APIKey(models.Model):
Returns the matching ``APIKey`` instance (updating ``last_used_at``) or
``None`` if not found.
"""
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_hash = cls._hash_raw_key(raw_key)
try:
api_key = cls.objects.select_related('user').get(key_hash=key_hash)
except cls.DoesNotExist:

View File

@@ -62,7 +62,7 @@ The `.env` file contains all the configuration settings for your AdventureLog in
| `FRONTEND_URL` | Yes | URL to the **frontend**, used for email generation. | `http://localhost:8015` |
| `BACKEND_PORT` | Yes | Port that the backend will run on inside Docker. | `8016` |
| `DEBUG` | No | Should be `False` in production. | `False` |
| `ENABLE_RATE_LIMITS` | No | Enable rate limits on the backend. Should be `True` in production. | `True` |
| `ENABLE_RATE_LIMITS` | No | Enable rate limits on the backend. Should be `True` in production. | `False` |
## Optional Configuration

View File

@@ -20,7 +20,7 @@
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-vercel": "^5.7.0",
"@sveltejs/kit": "^2.49.5",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@sveltejs/vite-plugin-svelte": "3.1.2",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^22.15.2",
"@types/qrcode": "^1.5.5",
@@ -30,7 +30,7 @@
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^4.2.19",
"svelte": "4.2.19",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",

224
frontend/pnpm-lock.yaml generated
View File

@@ -11,8 +11,6 @@ overrides:
brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5'
picomatch@<2.3.2: '>=2.3.2'
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
svelte@<=5.51.4: '>=5.51.5'
svelte@<=5.53.4: '>=5.53.5'
importers:
@@ -20,7 +18,7 @@ importers:
dependencies:
'@lukulent/svelte-umami':
specifier: ^0.0.3
version: 0.0.3(svelte@5.55.1)
version: 0.0.3(svelte@4.2.19)
dompurify:
specifier: ^3.2.5
version: 3.3.3
@@ -44,13 +42,13 @@ importers:
version: 1.5.4
svelte-dnd-action:
specifier: ^0.9.68
version: 0.9.69(svelte@5.55.1)
version: 0.9.69(svelte@4.2.19)
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.55.1)
version: 4.0.1(svelte@4.2.19)
svelte-maplibre:
specifier: ^0.9.14
version: 0.9.14(svelte@5.55.1)
version: 0.9.14(svelte@4.2.19)
devDependencies:
'@event-calendar/core':
specifier: ^3.12.0
@@ -69,16 +67,16 @@ importers:
version: 1.2.3
'@sveltejs/adapter-node':
specifier: ^5.2.12
version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))
version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))
'@sveltejs/adapter-vercel':
specifier: '>=6.3.2'
version: 6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)
version: 6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)
'@sveltejs/kit':
specifier: ^2.49.5
version: 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
version: 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
'@sveltejs/vite-plugin-svelte':
specifier: ^3.1.2
version: 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
specifier: 3.1.2
version: 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.19)
@@ -105,13 +103,13 @@ importers:
version: 3.8.1
prettier-plugin-svelte:
specifier: ^3.3.3
version: 3.5.1(prettier@3.8.1)(svelte@5.55.1)
version: 3.5.1(prettier@3.8.1)(svelte@4.2.19)
svelte:
specifier: '>=5.53.5'
version: 5.55.1
specifier: 4.2.19
version: 4.2.19
svelte-check:
specifier: ^3.8.6
version: 3.8.6(postcss@8.5.8)(svelte@5.55.1)
version: 3.8.6(postcss@8.5.8)(svelte@4.2.19)
tailwindcss:
specifier: ^3.4.17
version: 3.4.19
@@ -134,6 +132,10 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@antfu/install-pkg@0.4.1':
resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==}
@@ -345,9 +347,6 @@ packages:
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -361,7 +360,7 @@ packages:
'@lukulent/svelte-umami@0.0.3':
resolution: {integrity: sha512-4pL0sJapfy14yDj6CyZgewbRDadRoBJtk/dLqCJh7/tQuX7HO4hviBzhrVa4Osxaq2kcGEKdpkhAKAoaNdlNSA==}
peerDependencies:
svelte: '>=5.53.5'
svelte: ^4.0.0
'@mapbox/geojson-rewind@0.5.2':
resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==}
@@ -611,7 +610,7 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.0.0
'@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0
svelte: '>=5.53.5'
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: ^5.3.3
vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0
peerDependenciesMeta:
@@ -625,14 +624,14 @@ packages:
engines: {node: ^18.0.0 || >=20}
peerDependencies:
'@sveltejs/vite-plugin-svelte': ^3.0.0
svelte: '>=5.53.5'
svelte: ^4.0.0 || ^5.0.0-next.0
vite: ^5.0.0
'@sveltejs/vite-plugin-svelte@3.1.2':
resolution: {integrity: sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==}
engines: {node: ^18.0.0 || >=20}
peerDependencies:
svelte: '>=5.53.5'
svelte: ^4.0.0 || ^5.0.0-next.0
vite: ^5.0.0
'@tailwindcss/typography@0.5.19':
@@ -682,10 +681,6 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@typescript-eslint/types@8.58.0':
resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vercel/nft@1.5.0':
resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==}
engines: {node: '>=20'}
@@ -810,9 +805,8 @@ packages:
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
@@ -848,6 +842,10 @@ packages:
css-selector-tokenizer@0.8.0:
resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==}
css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -965,12 +963,12 @@ packages:
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
engines: {node: '>=0.10'}
esrap@2.2.4:
resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
@@ -1218,6 +1216,9 @@ packages:
engines: {node: '>= 18'}
hasBin: true
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
memoizee@0.4.17:
resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==}
engines: {node: '>=0.12'}
@@ -1360,6 +1361,9 @@ packages:
resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==}
hasBin: true
periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1450,7 +1454,7 @@ packages:
resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==}
peerDependencies:
prettier: ^3.0.0
svelte: '>=5.53.5'
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
@@ -1588,25 +1592,25 @@ packages:
resolution: {integrity: sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==}
hasBin: true
peerDependencies:
svelte: '>=5.53.5'
svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0
svelte-dnd-action@0.9.69:
resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==}
peerDependencies:
svelte: '>=5.53.5'
svelte: '>=3.23.0 || ^5.0.0-next.0'
svelte-hmr@0.16.0:
resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==}
engines: {node: ^12.20 || ^14.13.1 || >= 16}
peerDependencies:
svelte: '>=5.53.5'
svelte: ^3.19.0 || ^4.0.0
svelte-i18n@4.0.1:
resolution: {integrity: sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==}
engines: {node: '>= 16'}
hasBin: true
peerDependencies:
svelte: '>=5.53.5'
svelte: ^3 || ^4 || ^5
svelte-maplibre@0.9.14:
resolution: {integrity: sha512-5HBvibzU/Uf3g8eEz4Hty5XAwoBhW9Tp7NQEvb80U/glR/M1IHyzUKss6XMq8Zbci2wtsASeoPc6dA5R4+0e0w==}
@@ -1614,7 +1618,7 @@ packages:
'@deck.gl/core': ^8.8.0
'@deck.gl/layers': ^8.8.0
'@deck.gl/mapbox': ^8.8.0
svelte: '>=5.53.5'
svelte: ^3.54.0 || ^4.0.0 || ^5.0.0
peerDependenciesMeta:
'@deck.gl/core':
optional: true
@@ -1636,7 +1640,7 @@ packages:
sass: ^1.26.8
stylus: ^0.55.0
sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0
svelte: '>=5.53.5'
svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0
typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0'
peerDependenciesMeta:
'@babel/core':
@@ -1660,9 +1664,9 @@ packages:
typescript:
optional: true
svelte@5.55.1:
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
engines: {node: '>=18'}
svelte@4.2.19:
resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==}
engines: {node: '>=16'}
tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
@@ -1846,13 +1850,15 @@ packages:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
snapshots:
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@antfu/install-pkg@0.4.1':
dependencies:
package-manager-detector: 0.2.11
@@ -1947,22 +1953,22 @@ snapshots:
'@event-calendar/core@3.12.0':
dependencies:
svelte: 5.55.1
svelte: 4.2.19
'@event-calendar/day-grid@3.12.0':
dependencies:
'@event-calendar/core': 3.12.0
svelte: 5.55.1
svelte: 4.2.19
'@event-calendar/interaction@3.12.0':
dependencies:
'@event-calendar/core': 3.12.0
svelte: 5.55.1
svelte: 4.2.19
'@event-calendar/time-grid@3.12.0':
dependencies:
'@event-calendar/core': 3.12.0
svelte: 5.55.1
svelte: 4.2.19
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
@@ -2018,11 +2024,6 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
@@ -2032,9 +2033,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lukulent/svelte-umami@0.0.3(svelte@5.55.1)':
'@lukulent/svelte-umami@0.0.3(svelte@4.2.19)':
dependencies:
svelte: 5.55.1
svelte: 4.2.19
'@mapbox/geojson-rewind@0.5.2':
dependencies:
@@ -2209,17 +2210,17 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))':
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))':
dependencies:
'@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0)
'@rollup/plugin-json': 6.1.0(rollup@4.59.0)
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0)
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
rollup: 4.59.0
'@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)':
'@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)':
dependencies:
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))
'@vercel/nft': 1.5.0(rollup@4.59.0)
esbuild: 0.25.12
transitivePeerDependencies:
@@ -2227,11 +2228,11 @@ snapshots:
- rollup
- supports-color
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))':
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
@@ -2242,29 +2243,29 @@ snapshots:
mrmime: 2.0.1
set-cookie-parser: 3.0.1
sirv: 3.0.2
svelte: 5.55.1
svelte: 4.2.19
vite: 5.4.21(@types/node@22.19.15)
optionalDependencies:
typescript: 5.9.3
'@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))':
'@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))':
dependencies:
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
debug: 4.4.3
svelte: 5.55.1
svelte: 4.2.19
vite: 5.4.21(@types/node@22.19.15)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))':
'@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))
'@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))
debug: 4.4.3
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.21
svelte: 5.55.1
svelte-hmr: 0.16.0(svelte@5.55.1)
svelte: 4.2.19
svelte-hmr: 0.16.0(svelte@4.2.19)
vite: 5.4.21(@types/node@22.19.15)
vitefu: 0.2.5(vite@5.4.21(@types/node@22.19.15))
transitivePeerDependencies:
@@ -2315,9 +2316,8 @@ snapshots:
dependencies:
'@types/geojson': 7946.0.16
'@types/trusted-types@2.0.7': {}
'@typescript-eslint/types@8.58.0': {}
'@types/trusted-types@2.0.7':
optional: true
'@vercel/nft@1.5.0(rollup@4.59.0)':
dependencies:
@@ -2447,7 +2447,13 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
code-red@1.0.4:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@types/estree': 1.0.8
acorn: 8.16.0
estree-walker: 3.0.3
periscopic: 3.1.0
color-convert@2.0.1:
dependencies:
@@ -2474,6 +2480,11 @@ snapshots:
cssesc: 3.0.0
fastparse: 1.1.2
css-tree@2.3.1:
dependencies:
mdn-data: 2.0.30
source-map-js: 1.2.1
cssesc@3.0.0: {}
culori@3.3.0: {}
@@ -2603,13 +2614,12 @@ snapshots:
event-emitter: 0.3.5
type: 2.7.3
esrap@2.2.4:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.58.0
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
event-emitter@0.3.5:
dependencies:
d: 1.0.2
@@ -2851,6 +2861,8 @@ snapshots:
marked@15.0.12: {}
mdn-data@2.0.30: {}
memoizee@0.4.17:
dependencies:
d: 1.0.2
@@ -2972,6 +2984,12 @@ snapshots:
ieee754: 1.2.1
resolve-protobuf-schema: 2.1.0
periscopic@3.1.0:
dependencies:
'@types/estree': 1.0.8
estree-walker: 3.0.3
is-reference: 3.0.3
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -3045,10 +3063,10 @@ snapshots:
potpack@2.1.0: {}
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.1):
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@4.2.19):
dependencies:
prettier: 3.8.1
svelte: 5.55.1
svelte: 4.2.19
prettier@3.8.1: {}
@@ -3203,14 +3221,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@3.8.6(postcss@8.5.8)(svelte@5.55.1):
svelte-check@3.8.6(postcss@8.5.8)(svelte@4.2.19):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 3.6.0
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.55.1
svelte-preprocess: 5.1.4(postcss@8.5.8)(svelte@5.55.1)(typescript@5.9.3)
svelte: 4.2.19
svelte-preprocess: 5.1.4(postcss@8.5.8)(svelte@4.2.19)(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- '@babel/core'
@@ -3223,15 +3241,15 @@ snapshots:
- stylus
- sugarss
svelte-dnd-action@0.9.69(svelte@5.55.1):
svelte-dnd-action@0.9.69(svelte@4.2.19):
dependencies:
svelte: 5.55.1
svelte: 4.2.19
svelte-hmr@0.16.0(svelte@5.55.1):
svelte-hmr@0.16.0(svelte@4.2.19):
dependencies:
svelte: 5.55.1
svelte: 4.2.19
svelte-i18n@4.0.1(svelte@5.55.1):
svelte-i18n@4.0.1(svelte@4.2.19):
dependencies:
cli-color: 2.0.4
deepmerge: 4.3.1
@@ -3239,10 +3257,10 @@ snapshots:
estree-walker: 2.0.2
intl-messageformat: 10.7.18
sade: 1.8.1
svelte: 5.55.1
svelte: 4.2.19
tiny-glob: 0.2.9
svelte-maplibre@0.9.14(svelte@5.55.1):
svelte-maplibre@0.9.14(svelte@4.2.19):
dependencies:
d3-geo: 3.1.1
dequal: 2.0.3
@@ -3250,38 +3268,36 @@ snapshots:
just-flush: 2.3.0
maplibre-gl: 4.7.1
pmtiles: 3.2.1
svelte: 5.55.1
svelte: 4.2.19
svelte-preprocess@5.1.4(postcss@8.5.8)(svelte@5.55.1)(typescript@5.9.3):
svelte-preprocess@5.1.4(postcss@8.5.8)(svelte@4.2.19)(typescript@5.9.3):
dependencies:
'@types/pug': 2.0.10
detect-indent: 6.1.0
magic-string: 0.30.21
sorcery: 0.11.1
strip-indent: 3.0.0
svelte: 5.55.1
svelte: 4.2.19
optionalDependencies:
postcss: 8.5.8
typescript: 5.9.3
svelte@5.55.1:
svelte@4.2.19:
dependencies:
'@jridgewell/remapping': 2.3.5
'@ampproject/remapping': 2.3.0
'@jridgewell/sourcemap-codec': 1.5.5
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@jridgewell/trace-mapping': 0.3.31
'@types/estree': 1.0.8
'@types/trusted-types': 2.0.7
acorn: 8.16.0
aria-query: 5.3.1
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.6.4
esm-env: 1.2.2
esrap: 2.2.4
code-red: 1.0.4
css-tree: 2.3.1
estree-walker: 3.0.3
is-reference: 3.0.3
locate-character: 3.0.0
magic-string: 0.30.21
zimmerframe: 1.1.4
periscopic: 3.1.0
tailwindcss@3.4.19:
dependencies:
@@ -3457,5 +3473,3 @@ snapshots:
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
zimmerframe@1.1.4: {}

View File

@@ -5,5 +5,3 @@ overrides:
brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5'
picomatch@<2.3.2: '>=2.3.2'
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
svelte@<=5.51.4: '>=5.51.5'
svelte@<=5.53.4: '>=5.53.5'

View File

@@ -396,6 +396,79 @@
return value.includes('T') ? value.split('T')[0] : value;
}
function upsertItineraryItem(newItem: CollectionItineraryItem) {
if (!newItem) return;
const itinerary = collection.itinerary ? [...collection.itinerary] : [];
const idMatchIndex = itinerary.findIndex((it) => String(it.id) === String(newItem.id));
if (idMatchIndex >= 0) {
itinerary[idMatchIndex] = newItem;
collection = { ...collection, itinerary };
return;
}
const duplicate = itinerary.some(
(it) =>
String(it.object_id) === String(newItem.object_id) &&
String(it.date || '') === String(newItem.date || '') &&
Boolean(it.is_global) === Boolean(newItem.is_global)
);
if (!duplicate) {
collection = { ...collection, itinerary: [...itinerary, newItem] };
}
}
function handleQuickAddCreated(
objectType: 'location' | 'lodging',
event: CustomEvent<{
location: any;
itineraryItem?: CollectionItineraryItem | null;
itineraryDate?: string | null;
}>
) {
const createdItem = event.detail?.location;
if (!createdItem) return;
if (objectType === 'location') {
const locs = collection.locations ? [...collection.locations] : [];
const idx = locs.findIndex((loc) => String(loc.id) === String(createdItem.id));
if (idx >= 0) {
locs[idx] = {
...locs[idx],
...createdItem,
visits: createdItem.visits || locs[idx].visits || []
};
} else {
locs.unshift({ ...createdItem });
}
collection = { ...collection, locations: locs };
} else {
const lodgings = collection.lodging ? [...collection.lodging] : [];
const idx = lodgings.findIndex((l) => String(l.id) === String(createdItem.id));
if (idx >= 0) {
lodgings[idx] = { ...lodgings[idx], ...createdItem };
} else {
lodgings.unshift({ ...createdItem });
}
collection = { ...collection, lodging: lodgings };
}
const itineraryItem = event.detail?.itineraryItem || null;
if (itineraryItem) {
upsertItineraryItem(itineraryItem);
addedToItinerary.add(String(createdItem.id));
addedToItinerary = addedToItinerary;
} else if (event.detail?.itineraryDate) {
void addItineraryItemForObject(
objectType,
String(createdItem.id),
String(event.detail.itineraryDate)
);
addedToItinerary.add(String(createdItem.id));
addedToItinerary = addedToItinerary;
}
pendingAddDate = null;
}
function upsertNote(note: Note) {
const notes = collection.notes ? [...collection.notes] : [];
const idx = notes.findIndex((n) => n.id === note.id);
@@ -543,11 +616,11 @@
$: if (
locationBeingUpdated?.id &&
pendingAddDate &&
!addedToItinerary.has(locationBeingUpdated.id)
!addedToItinerary.has(String(locationBeingUpdated.id))
) {
addItineraryItemForObject('location', locationBeingUpdated.id, pendingAddDate);
// Mark this location as added to prevent duplicates
addedToItinerary.add(locationBeingUpdated.id);
addedToItinerary.add(String(locationBeingUpdated.id));
addedToItinerary = addedToItinerary; // trigger reactivity
}
@@ -578,7 +651,7 @@
$: if (
lodgingBeingUpdated?.id &&
pendingAddDate &&
!addedToItinerary.has(lodgingBeingUpdated.id)
!addedToItinerary.has(String(lodgingBeingUpdated.id))
) {
// Normalize check_in to date-only (YYYY-MM-DD) if present
const lodgingCheckInDate = lodgingBeingUpdated.check_in
@@ -588,7 +661,7 @@
addItineraryItemForObject('lodging', lodgingBeingUpdated.id, targetDate);
// Mark this lodging as added to prevent duplicates
addedToItinerary.add(lodgingBeingUpdated.id);
addedToItinerary.add(String(lodgingBeingUpdated.id));
addedToItinerary = addedToItinerary; // trigger reactivity
}
@@ -619,11 +692,11 @@
$: if (
transportationBeingUpdated?.id &&
pendingAddDate &&
!addedToItinerary.has(transportationBeingUpdated.id)
!addedToItinerary.has(String(transportationBeingUpdated.id))
) {
addItineraryItemForObject('transportation', transportationBeingUpdated.id, pendingAddDate);
// Mark this transportation as added to prevent duplicates
addedToItinerary.add(transportationBeingUpdated.id);
addedToItinerary.add(String(transportationBeingUpdated.id));
addedToItinerary = addedToItinerary; // trigger reactivity
}
@@ -1301,6 +1374,15 @@
dateISO: string,
updateItemDate: boolean = false
) {
const alreadyScheduled = (collection.itinerary || []).some(
(it) =>
String(it.object_id) === String(objectId) &&
String(it.date || '') === String(dateISO) &&
!it.is_global
);
if (alreadyScheduled) {
return;
}
const tempId = `temp-${Date.now()}`;
const day = days.find((d) => d.date === dateISO);
const order = day ? day.items.length : 0;
@@ -1520,6 +1602,7 @@
addedToItinerary.clear();
addedToItinerary = addedToItinerary;
}}
on:quickAddCreated={(e) => handleQuickAddCreated('location', e)}
{user}
{locationToEdit}
bind:location={locationBeingUpdated}
@@ -1538,6 +1621,7 @@
addedToItinerary.clear();
addedToItinerary = addedToItinerary;
}}
on:quickAddCreated={(e) => handleQuickAddCreated('lodging', e)}
{user}
{lodgingToEdit}
bind:lodging={lodgingBeingUpdated}

View File

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

View File

@@ -12,6 +12,7 @@
export let collection: Collection | null = null;
export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal
export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner
export let itineraryDayLabel: string | null = null;
const dispatch = createEventDispatcher();
@@ -19,6 +20,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 +51,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 +185,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 +208,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 +312,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 +322,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 +379,44 @@
</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}
itineraryDate={storedInitialVisitDate}
itineraryLabel={itineraryDayLabel}
on:addDetails={(e) => {
applyQuickStartPrefill(e.detail.prefill);
setStep(1);
}}
on:manual={() => {
setStep(1);
}}
on:quickAdded={(e) => {
location = e.detail.location;
pendingGooglePhotoUrls = [];
didSave = true;
dispatch('quickAddCreated', {
location: e.detail.location,
itineraryItem: e.detail.itineraryItem || null,
itineraryDate: e.detail.itineraryDate || null
});
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 +425,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 +476,7 @@
bind:visits={location.visits}
bind:trails={location.trails}
objectId={location.id}
on:back={() => {
steps[3].selected = false;
steps[2].selected = true;
}}
on:back={() => setStep(2)}
on:close={() => close()}
measurementSystem={user?.measurement_system || 'metric'}
{collection}

View File

@@ -1,460 +1,22 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import { getBasemapUrl } from '$lib';
import PlaceQuickStart from '../shared/PlaceQuickStart.svelte';
// 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';
const dispatch = createEventDispatcher();
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: {
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;
// Search for locations using your custom API
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const results = await response.json();
searchResults = 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
}));
} catch (error) {
console.error('Search error:', error);
searchResults = [];
} finally {
isSearching = false;
}
}
// Debounced search
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchLocations(searchQuery);
}, 300);
}
// Select a location from search results
async function selectSearchResult(location: any) {
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];
selectedLocation = {
name: result.name,
lat: lat,
lng: lng,
location: result.display_name,
type: result.type,
category: result.category
};
searchQuery = result.name;
} else {
// Fallback if no results from API
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
}
// Perform detailed reverse geocoding
await performDetailedReverseGeocode(lat, lng);
} catch (error) {
console.error('Reverse geocoding error:', error);
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
locationData = null;
} finally {
isReverseGeocoding = false;
}
}
// Perform detailed reverse geocoding to get city, region, country data
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
if (response.ok) {
const data = await response.json();
locationData = {
city: data.city
? {
name: data.city,
id: data.city_id,
visited: data.city_visited || false
}
: undefined,
region: data.region
? {
name: data.region,
id: data.region_id,
visited: data.region_visited || false
}
: undefined,
country: data.country
? {
name: data.country,
country_code: data.country_id,
visited: false // You might want to check this from your backend
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
};
selectedLocation.location = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
} else {
console.warn('Detailed reverse geocoding failed:', response.status);
locationData = null;
}
} catch (error) {
console.error('Detailed reverse geocoding error:', error);
locationData = null;
}
}
// Use current location
function useCurrentLocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
async (position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
selectedMarker = { lng, lat };
mapCenter = [lng, lat];
mapZoom = 14;
await reverseGeocode(lng, lat);
},
(error) => {
console.error('Geolocation error:', error);
}
);
}
}
// 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 = [];
mapCenter = [-74.5, 40];
mapZoom = 2;
}
onMount(() => {
return () => {
clearTimeout(searchTimeout);
};
});
export let googleEnabled = false;
export let collectionId: string | null = null;
export let itineraryDate: string | null = null;
export let itineraryLabel: string | null = null;
</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}
</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>
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
<LocationIcon class="w-4 h-4" />
{$t('adventures.use_current_location') || 'Use Current Location'}
</button>
</div>
</div>
</div>
<!-- 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">
<h3 class="font-semibold flex items-center gap-2">
<MapIcon class="w-5 h-5" />
{$t('adventures.select_on_map') || 'Select on Map'}
</h3>
{#if selectedMarker}
<button class="btn btn-ghost btn-sm gap-1" on:click={clearSelection}>
<ClearIcon class="w-4 h-4" />
Clear
</button>
{/if}
</div>
{#if !selectedMarker}
<p class="text-sm text-base-content/60 mb-4">
{$t('adventures.click_map') || 'Click on the map to select a location'}
</p>
{/if}
{#if isReverseGeocoding}
<div class="flex items-center justify-center py-2 mb-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60"
>{$t('adventures.getting_location_details')}...</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} />
{#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>
</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-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>
{/if}
</div>
{/if}
{#if locationData?.display_name}
<p class="text-xs text-base-content/50 mt-2">
{locationData.display_name}
</p>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex gap-3 pt-4">
<button class="btn btn-neutral-200 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>
</div>
</div>
<PlaceQuickStart
mode="location"
{googleEnabled}
{collectionId}
{itineraryDate}
{itineraryLabel}
on:addDetails
on:manual
on:quickAdded
on:quickAddedEdit
on:quickAddedDone
on:cancel
/>

View File

@@ -4,12 +4,15 @@
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
import Bed from '~icons/mdi/bed';
import LodgingQuickStart from './LodgingQuickStart.svelte';
import LodgingDetails from './LodgingDetails.svelte';
import MediaStep from '../shared/MediaStep.svelte';
import { inferLodgingTypeFromPlace } from '$lib/utils/lodgingType';
export let user: User | null = null;
export let collection: Collection | null = null;
export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner
export let itineraryDayLabel: string | null = null;
const dispatch = createEventDispatcher();
@@ -17,16 +20,25 @@
let storedInitialVisitDate: string | null = initialVisitDate;
let modal: HTMLDialogElement;
let googleMapsEnabled = false;
let isEditMode = false;
let pendingGooglePhotoUrls: string[] = [];
let importingGooglePhotos = false;
// Whether a save/create occurred during this modal session
let didSave = false;
let steps = [
{
name: $t('adventures.details'),
name: $t('adventures.quick_start'),
selected: true,
requires_id: false
},
{
name: $t('adventures.details'),
selected: false,
requires_id: false
},
{
name: $t('settings.media'),
selected: false,
@@ -34,6 +46,104 @@
}
];
function setStep(stepIndex: number) {
steps = steps.map((step, index) => ({
...step,
selected: index === stepIndex
}));
}
function handleStepSelect(stepIndex: number) {
if (stepIndex === 0 && isEditMode) {
return;
}
if (steps[stepIndex]?.requires_id && !lodging.id) {
return;
}
setStep(stepIndex);
}
function handleDetailsBack() {
if (isEditMode) {
close();
return;
}
setStep(0);
}
function applyQuickStartPrefill(prefill: any) {
if (!prefill) return;
if (prefill.name) lodging.name = prefill.name;
if (prefill.location) lodging.location = prefill.location;
if (typeof prefill.latitude === 'number') lodging.latitude = prefill.latitude;
if (typeof prefill.longitude === 'number') lodging.longitude = prefill.longitude;
if (typeof prefill.rating === 'number') lodging.rating = prefill.rating;
if (!lodging.link && (prefill.website || prefill.google_maps_url)) {
lodging.link = prefill.website || prefill.google_maps_url;
}
if (!lodging.description && prefill.description) {
lodging.description = prefill.description;
}
if (!lodging.type) {
lodging.type = inferLodgingTypeFromPlace(prefill.type, prefill.types);
}
pendingGooglePhotoUrls = Array.isArray(prefill.photos)
? prefill.photos.filter((url: unknown) => typeof url === 'string' && url.trim()).slice(0, 5)
: [];
}
async function importPendingGoogleImages(lodgingId: string) {
if (!lodgingId || pendingGooglePhotoUrls.length === 0) return;
importingGooglePhotos = true;
try {
const res = await fetch('/api/images/import_from_urls/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
content_type: 'lodging',
object_id: lodgingId,
urls: pendingGooglePhotoUrls
})
});
if (!res.ok) {
addToast('warning', 'Lodging saved, but Google photos could not be imported');
return;
}
const data = await res.json();
if (Array.isArray(data.created) && data.created.length > 0) {
const existingImages = Array.isArray(lodging.images) ? lodging.images : [];
const existingIds = new Set(existingImages.map((img: any) => img.id));
const imported = data.created.filter((img: any) => !existingIds.has(img.id));
lodging.images = [...existingImages, ...imported];
}
pendingGooglePhotoUrls = [];
} catch {
addToast('warning', 'Lodging saved, but Google photos import failed');
} finally {
importingGooglePhotos = false;
}
}
async function loadIntegrations() {
try {
const res = await fetch('/api/integrations/');
if (!res.ok) return;
const integrations = await res.json();
googleMapsEnabled = Boolean(integrations?.google_maps);
} catch {
googleMapsEnabled = false;
}
}
function createEmptyLodging(): Lodging {
return {
id: '',
@@ -106,9 +216,10 @@
// Only reset to empty if we don't already have a saved lodging with an ID
lodging = createEmptyLodging();
storedInitialVisitDate = initialVisitDate;
// Reset steps to details when creating a new lodging
// Reset steps for a fresh lodging creation session
steps = [
{ name: $t('adventures.details'), selected: true, requires_id: false },
{ name: $t('adventures.quick_start'), selected: true, requires_id: false },
{ name: $t('adventures.details'), selected: false, requires_id: false },
{ name: $t('settings.media'), selected: false, requires_id: true }
];
}
@@ -118,6 +229,15 @@
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal();
isEditMode = Boolean(lodgingToEdit?.id);
if (isEditMode) {
setStep(1);
} else {
setStep(0);
}
void loadIntegrations();
});
function close() {
@@ -200,14 +320,11 @@
? 'bg-primary text-primary-content'
: 'bg-base-200'} {step.requires_id && !lodging?.id
? 'opacity-50 cursor-not-allowed'
: ''} {index === 0 && isEditMode
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-primary/80 cursor-pointer'} transition-colors"
on:click={() => {
// Reset all steps
steps.forEach((s) => (s.selected = false));
// Select clicked step
steps[index].selected = true;
}}
disabled={step.requires_id && !lodging?.id}
on:click={() => handleStepSelect(index)}
disabled={(step.requires_id && !lodging?.id) || (index === 0 && isEditMode)}
>
<span class="hidden sm:inline">{step.name}</span>
<span class="sm:hidden"
@@ -241,17 +358,53 @@
</div>
</div>
{#if steps[0].selected}
{#if steps[0].selected && !isEditMode}
<LodgingQuickStart
googleEnabled={googleMapsEnabled}
collectionId={collection?.id || null}
itineraryDate={storedInitialVisitDate}
itineraryLabel={itineraryDayLabel}
on:addDetails={(e) => {
applyQuickStartPrefill(e.detail.prefill);
setStep(1);
}}
on:manual={() => {
setStep(1);
}}
on:quickAdded={(e) => {
lodging = e.detail.location;
pendingGooglePhotoUrls = [];
didSave = true;
dispatch('quickAddCreated', {
location: e.detail.location,
itineraryItem: e.detail.itineraryItem || null,
itineraryDate: e.detail.itineraryDate || null
});
close();
}}
on:quickAddedEdit={(e) => {
lodging = e.detail.location;
pendingGooglePhotoUrls = [];
didSave = true;
setStep(1);
}}
on:quickAddedDone={(e) => {
lodging = e.detail.location;
pendingGooglePhotoUrls = [];
didSave = true;
close();
}}
on:cancel={() => close()}
/>
{/if}
{#if steps[1].selected}
<LodgingDetails
currentUser={user}
initialLodging={lodging}
{collection}
bind:editingLodging={lodging}
on:back={() => {
steps[1].selected = false;
steps[0].selected = true;
}}
on:save={(e) => {
on:back={handleDetailsBack}
on:save={async (e) => {
// Update the entire lodging object with all saved data
const detail = e.detail || {};
const previousImages = lodging.images || [];
@@ -285,25 +438,31 @@
// Only allow moving to Media once we have a persisted id.
if (!lodging?.id) {
addToast('error', $t('adventures.lodging_save_error'));
steps[1].selected = false;
steps[0].selected = true;
setStep(1);
return;
}
steps[0].selected = false;
steps[1].selected = true;
setStep(2);
if (pendingGooglePhotoUrls.length > 0) {
await importPendingGoogleImages(lodging.id);
}
}}
initialVisitDate={storedInitialVisitDate}
/>
{/if}
{#if steps[1].selected}
{#if steps[2].selected}
{#if importingGooglePhotos}
<div class="alert alert-info mb-4">
<span class="loading loading-spinner loading-sm"></span>
<span>Importing Google photos in the background. They will appear here shortly.</span>
</div>
{/if}
<MediaStep
bind:images={lodging.images}
bind:attachments={lodging.attachments}
itemName={lodging.name}
on:back={() => {
steps[1].selected = false;
steps[0].selected = true;
setStep(1);
}}
on:close={() => close()}
itemId={lodging.id}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import PlaceQuickStart from '../shared/PlaceQuickStart.svelte';
export let googleEnabled = false;
export let collectionId: string | null = null;
export let itineraryDate: string | null = null;
export let itineraryLabel: string | null = null;
</script>
<PlaceQuickStart
mode="lodging"
{googleEnabled}
{collectionId}
{itineraryDate}
{itineraryLabel}
on:addDetails
on:manual
on:quickAdded
on:quickAddedEdit
on:quickAddedDone
on:cancel
/>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
export let placeName: string | null = null;
export let latitude: number | null = null;
export let longitude: number | null = null;
export let className = '';
const normalize = (value: string | null | undefined) => value?.trim() || null;
$: hasCoords =
latitude !== null && latitude !== undefined && longitude !== null && longitude !== undefined;
$: coordsLabel = hasCoords ? `${latitude}, ${longitude}` : null;
$: displayName = normalize(placeName) || null;
$: baseQuery =
displayName && coordsLabel ? `${displayName} ${coordsLabel}` : displayName || coordsLabel || '';
$: appleMapsUrl = hasCoords
? `https://maps.apple.com/?q=${encodeURIComponent(displayName ?? coordsLabel ?? '')}&ll=${latitude},${longitude}`
: `https://maps.apple.com/?q=${encodeURIComponent(displayName ?? '')}`;
$: googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
baseQuery
)}`;
$: osmMapsUrl = hasCoords
? `https://www.openstreetmap.org/search?query=${encodeURIComponent(
baseQuery
)}&mlat=${latitude}&mlon=${longitude}`
: `https://www.openstreetmap.org/search?query=${encodeURIComponent(baseQuery)}`;
</script>
{#if displayName || hasCoords}
<div
class={`rounded-lg p-3 bg-gradient-to-br from-primary/10 to-secondary/10 border border-base-300/60 shadow-sm ${className}`}
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2">
<span class="badge badge-primary badge-outline">Open in maps</span>
{#if displayName}
<span class="text-sm font-semibold">{displayName}</span>
{/if}
</div>
{#if coordsLabel}
<span class="badge badge-ghost badge-sm">{coordsLabel}</span>
{/if}
</div>
<div class="grid grid-cols-3 gap-2">
<a
class="btn btn-sm btn-outline hover:btn-neutral"
href={appleMapsUrl}
target="_blank"
rel="noopener noreferrer"
>
🍎 Apple
</a>
<a
class="btn btn-sm btn-outline hover:btn-accent"
href={googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
>
🌍 Google
</a>
<a
class="btn btn-sm btn-outline hover:btn-primary"
href={osmMapsUrl}
target="_blank"
rel="noopener noreferrer"
>
🗺️ OSM
</a>
</div>
</div>
{/if}

View File

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

View File

@@ -0,0 +1,863 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import { getBasemapUrl } from '$lib';
import { addToast } from '$lib/toasts';
import CategoryDropdown from '../CategoryDropdown.svelte';
import type { Category } from '$lib/types';
import SearchIcon from '~icons/mdi/magnify';
import LocationIcon from '~icons/mdi/crosshairs-gps';
import MapIcon from '~icons/mdi/map';
import CheckIcon from '~icons/mdi/check';
import ClearIcon from '~icons/mdi/close';
import PinIcon from '~icons/mdi/map-marker';
import StarIcon from '~icons/mdi/star';
import LightningIcon from '~icons/mdi/lightning-bolt';
import PencilIcon from '~icons/mdi/pencil';
type SelectedPlace = {
id: string;
name: string;
lat: number;
lng: number;
location: string;
type?: string;
category?: string;
types?: string[];
rating?: number | null;
review_count?: number | null;
photos?: string[];
description?: string | null;
website?: string | null;
phone_number?: string | null;
place_id?: string | null;
google_maps_url?: string | null;
powered_by?: string;
};
type LocationData = {
city?: { name: string; id: string; visited: boolean };
region?: { name: string; id: string; visited: boolean };
country?: { name: string; country_code: string; visited: boolean };
display_name?: string;
location_name?: string;
};
const dispatch = createEventDispatcher();
export let mode: 'location' | 'lodging' = 'location';
export let googleEnabled = false;
export let collectionId: string | null = null;
export let itineraryDate: string | null = null;
export let itineraryLabel: string | null = null;
$: supportsCategory = mode === 'location';
$: itemLabel = mode === 'lodging' ? 'lodging' : 'location';
$: quickAddEndpoint =
mode === 'lodging' ? '/api/lodging/quick-add/' : '/api/locations/quick-add/';
$: formattedItineraryLabel = itineraryLabel || formatItineraryDate(itineraryDate);
let searchQuery = '';
let searchResults: SelectedPlace[] = [];
let selectedLocation: SelectedPlace | null = null;
let mapCenter: [number, number] = [-74.5, 40];
let mapZoom = 2;
let isSearching = false;
let isReverseGeocoding = false;
let isEnrichingDescription = false;
let isQuickAdding = false;
let quickAddedLocation: any = null;
let searchTimeout: ReturnType<typeof setTimeout>;
let mapComponent: any;
let selectedMarker: { lng: number; lat: number } | null = null;
let locationData: LocationData | null = null;
let selectedQuickAddCategory: Category | null = null;
const placeDetailsCache = new Map<string, any>();
function toPlaceResult(result: any): SelectedPlace {
return {
id: result.place_id || `${result.name || 'place'}-${result.lat}-${result.lon}`,
name: result.name,
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
location: result.display_name,
type: result.type,
category: result.category,
types: result.types || [],
rating: result.rating ?? null,
review_count: result.review_count ?? null,
photos: result.photos || [],
description: result.description || null,
website: result.website || null,
phone_number: result.phone_number || null,
place_id: result.place_id || null,
google_maps_url: result.google_maps_url || null,
powered_by: result.powered_by
};
}
function pickBestNearbyResult(
results: SelectedPlace[],
lat: number,
lng: number,
preferredName?: string
): SelectedPlace | null {
if (!results.length) {
return null;
}
const normalizedPreferredName = (preferredName || '').trim().toLowerCase();
const scored = results
.filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lng))
.map((item) => {
const dLat = item.lat - lat;
const dLng = item.lng - lng;
const distanceScore = dLat * dLat + dLng * dLng;
const nameScore =
normalizedPreferredName && item.name?.trim().toLowerCase() === normalizedPreferredName
? -1
: 0;
const placeScore = item.place_id ? -0.5 : 0;
return {
item,
score: distanceScore + nameScore + placeScore
};
});
if (!scored.length) {
return results[0];
}
scored.sort((a, b) => a.score - b.score);
return scored[0].item;
}
async function enrichFromResolvedName(lat: number, lng: number, resolvedName: string) {
const query = resolvedName.trim();
if (!query) {
return;
}
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
if (!response.ok) {
return;
}
const rawResults = await response.json();
const mappedResults = Array.isArray(rawResults) ? rawResults.map(toPlaceResult) : [];
const bestMatch = pickBestNearbyResult(mappedResults, lat, lng, query);
if (!bestMatch || !selectedLocation) {
return;
}
selectedLocation = {
...selectedLocation,
...bestMatch,
lat,
lng,
name: bestMatch.name || selectedLocation.name,
location: selectedLocation.location || bestMatch.location
};
searchQuery = selectedLocation.name;
} catch (error) {
console.error('Resolved name enrichment error:', error);
}
}
function needsDescriptionEnrichment(place: SelectedPlace | null) {
if (!place?.place_id) {
return false;
}
const text = (place.description || '').trim();
return text.length < 220;
}
async function fetchPlaceDetails(placeId: string, name: string) {
if (placeDetailsCache.has(placeId)) {
return placeDetailsCache.get(placeId);
}
const response = await fetch(
`/api/reverse-geocode/place_details/?place_id=${encodeURIComponent(placeId)}&name=${encodeURIComponent(name || '')}`
);
if (!response.ok) {
throw new Error('Unable to fetch place details');
}
const details = await response.json();
placeDetailsCache.set(placeId, details);
return details;
}
async function enrichSelectedLocationDescription(force = false) {
if (!selectedLocation?.place_id) {
return;
}
const placeId = selectedLocation.place_id;
if (!placeId || (!force && !needsDescriptionEnrichment(selectedLocation))) {
return;
}
isEnrichingDescription = true;
try {
const details = await fetchPlaceDetails(placeId, selectedLocation.name || '');
if (!selectedLocation || selectedLocation.place_id !== placeId) {
return;
}
selectedLocation = {
...selectedLocation,
name: details.name || selectedLocation.name,
location: details.formatted_address || selectedLocation.location,
types:
Array.isArray(details.types) && details.types.length > 0
? details.types
: selectedLocation.types,
rating: details.rating ?? selectedLocation.rating ?? null,
review_count: details.review_count ?? selectedLocation.review_count ?? null,
description: details.description || selectedLocation.description || null,
website: details.website || selectedLocation.website || null,
phone_number: details.phone_number || selectedLocation.phone_number || null,
google_maps_url: details.google_maps_url || selectedLocation.google_maps_url || null
};
} catch (error) {
console.error('Place details enrichment error:', error);
} finally {
isEnrichingDescription = false;
}
}
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const results = await response.json();
searchResults = Array.isArray(results) ? results.map(toPlaceResult) : [];
} catch (error) {
console.error('Search error:', error);
searchResults = [];
} finally {
isSearching = false;
}
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchLocations(searchQuery);
}, 300);
}
async function selectSearchResult(location: SelectedPlace) {
selectedLocation = location;
selectedMarker = { lng: location.lng, lat: location.lat };
mapCenter = [location.lng, location.lat];
mapZoom = 14;
searchResults = [];
searchQuery = location.name;
await performDetailedReverseGeocode(location.lat, location.lng);
}
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
selectedMarker = {
lng: e.detail.lngLat.lng,
lat: e.detail.lngLat.lat
};
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
}
async function reverseGeocode(lng: number, lat: number) {
isReverseGeocoding = true;
try {
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
if (Array.isArray(results) && results.length > 0) {
selectedLocation = {
...toPlaceResult(results[0]),
lat,
lng
};
searchQuery = selectedLocation.name;
} else {
selectedLocation = {
id: `manual-${lat}-${lng}`,
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat,
lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
types: [],
photos: []
};
searchQuery = selectedLocation.name;
}
await performDetailedReverseGeocode(lat, lng);
} catch (error) {
console.error('Reverse geocoding error:', error);
selectedLocation = {
id: `manual-${lat}-${lng}`,
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat,
lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`,
types: [],
photos: []
};
searchQuery = selectedLocation.name;
locationData = null;
} finally {
isReverseGeocoding = false;
}
}
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
if (response.ok) {
const data = await response.json();
locationData = {
city: data.city
? {
name: data.city,
id: data.city_id,
visited: data.city_visited || false
}
: undefined,
region: data.region
? {
name: data.region,
id: data.region_id,
visited: data.region_visited || false
}
: undefined,
country: data.country
? {
name: data.country,
country_code: data.country_id,
visited: false
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
};
if (selectedLocation) {
const isCoordinatePlaceholder = selectedLocation.name.startsWith('Location at ');
const shouldAutoEnrichQuickAdd = isCoordinatePlaceholder || !selectedLocation.place_id;
const resolvedLocationName = (data.location_name || '').trim();
const resolvedDisplayName = (data.display_name || '').trim();
selectedLocation = {
...selectedLocation,
name:
resolvedLocationName ||
(isCoordinatePlaceholder && resolvedDisplayName
? resolvedDisplayName
: selectedLocation.name),
location: resolvedDisplayName || `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
if (shouldAutoEnrichQuickAdd && resolvedLocationName) {
await enrichFromResolvedName(lat, lng, resolvedLocationName);
}
}
} else {
locationData = null;
}
} catch (error) {
console.error('Detailed reverse geocoding error:', error);
locationData = null;
}
}
async function ensureAdventureLogFormattedLocation() {
if (!selectedMarker) {
return;
}
if (locationData?.display_name?.trim()) {
return;
}
await performDetailedReverseGeocode(selectedMarker.lat, selectedMarker.lng);
}
function useCurrentLocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
async (position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
selectedMarker = { lng, lat };
mapCenter = [lng, lat];
mapZoom = 14;
await reverseGeocode(lng, lat);
},
(error) => {
console.error('Geolocation error:', error);
}
);
}
}
function clearSelection() {
selectedLocation = null;
selectedMarker = null;
locationData = null;
searchQuery = '';
searchResults = [];
quickAddedLocation = null;
selectedQuickAddCategory = null;
mapCenter = [-74.5, 40];
mapZoom = 2;
}
function buildPrefillPayload() {
if (!selectedLocation || !selectedMarker) {
return null;
}
const formattedLocation =
locationData?.display_name?.trim() || selectedLocation.location?.trim() || '';
return {
name: selectedLocation.name,
latitude: selectedMarker.lat,
longitude: selectedMarker.lng,
location: formattedLocation,
type: selectedLocation.type,
category: selectedLocation.category,
city: locationData?.city,
region: locationData?.region,
country: locationData?.country,
display_name: locationData?.display_name,
location_name: locationData?.location_name,
rating: selectedLocation.rating ?? null,
review_count: selectedLocation.review_count ?? null,
photos: selectedLocation.photos || [],
description: selectedLocation.description || null,
website: selectedLocation.website || null,
phone_number: selectedLocation.phone_number || null,
place_id: selectedLocation.place_id || null,
google_maps_url: selectedLocation.google_maps_url || null,
types: selectedLocation.types || [],
selected_category: selectedQuickAddCategory
};
}
function formatItineraryDate(value: string | null) {
if (!value) {
return null;
}
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
async function continueWithDetails() {
await ensureAdventureLogFormattedLocation();
if (selectedLocation?.place_id && needsDescriptionEnrichment(selectedLocation)) {
await enrichSelectedLocationDescription();
}
const prefill = buildPrefillPayload();
if (prefill) {
dispatch('addDetails', { prefill });
return;
}
dispatch('manual');
}
async function quickAdd() {
const prefill = buildPrefillPayload();
if (!prefill) {
addToast('warning', `Please select a place or drop a pin first`);
return;
}
isQuickAdding = true;
try {
const payload: Record<string, any> = {
name: prefill.name,
type: prefill.type,
location: prefill.location,
latitude: prefill.latitude,
longitude: prefill.longitude,
place_id: prefill.place_id,
rating: prefill.rating,
review_count: prefill.review_count,
description: prefill.description,
website: prefill.website,
phone_number: prefill.phone_number,
google_maps_url: prefill.google_maps_url,
types: prefill.types || [],
photos: prefill.photos || [],
collection_id: collectionId,
itinerary_date: itineraryDate,
is_public: false
};
if (supportsCategory && selectedQuickAddCategory) {
payload.category = selectedQuickAddCategory;
}
const res = await fetch(quickAddEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData?.error || errorData?.detail || `Failed to create ${itemLabel}`);
}
quickAddedLocation = await res.json();
const itineraryItem = quickAddedLocation?.quick_add_itinerary_item || null;
addToast(
'success',
`${itemLabel[0].toUpperCase()}${itemLabel.slice(1)} created successfully`
);
dispatch('quickAdded', {
location: quickAddedLocation,
prefill,
itineraryItem,
itineraryDate
});
} catch (error) {
addToast('error', error instanceof Error ? error.message : `Failed to create ${itemLabel}`);
} finally {
isQuickAdding = false;
}
}
onMount(() => {
return () => {
clearTimeout(searchTimeout);
};
});
</script>
<div class="space-y-6">
{#if quickAddedLocation}
<div class="card bg-success/10 border border-success/30">
<div class="card-body p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-success/20 rounded-lg">
<CheckIcon class="w-5 h-5 text-success" />
</div>
<div>
<h4 class="font-semibold text-success">
{mode === 'lodging' ? 'Lodging added' : 'Location added'}
</h4>
<p class="text-sm text-base-content/70">{quickAddedLocation.name}</p>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button
class="btn btn-primary flex-1"
on:click={() => dispatch('quickAddedEdit', { location: quickAddedLocation })}
>
<PencilIcon class="w-4 h-4" />
{$t('adventures.add_details') || 'Add Details'}
</button>
<button
class="btn btn-outline flex-1"
on:click={() => dispatch('quickAddedDone', { location: quickAddedLocation })}
>
{$t('adventures.done') || 'Done'}
</button>
</div>
</div>
</div>
{/if}
<div class="card bg-base-200/50 border border-base-300">
<div class="card-body p-6 space-y-4">
<div class="form-control">
<label class="label" for="quickstart-search-location">
<span class="label-text font-medium">
{#if googleEnabled}
{mode === 'lodging' ? 'Search Google Maps for Lodging' : 'Search Google Maps'}
{:else}
{$t('adventures.search_location')}
{/if}
</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon class="w-5 h-5 text-base-content/40" />
</div>
<input
type="text"
id="quickstart-search-location"
bind:value={searchQuery}
on:input={handleSearchInput}
placeholder={$t('adventures.search_placeholder') ||
'Enter city, location, or landmark...'}
class="input input-bordered w-full pl-10 pr-4"
class:input-primary={selectedLocation}
/>
{#if searchQuery && !selectedLocation}
<button
class="absolute inset-y-0 right-0 pr-3 flex items-center"
on:click={clearSelection}
>
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
</button>
{/if}
</div>
</div>
{#if isSearching}
<div class="flex items-center justify-center py-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
</div>
{:else if searchResults.length > 0}
<div class="space-y-2">
<label class="label" for="quickstart-search-results">
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
</label>
<div id="quickstart-search-results" class="max-h-52 overflow-y-auto space-y-1">
{#each searchResults as result}
<button
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
on:click={() => selectSearchResult(result)}
>
<div class="flex items-start gap-3">
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-medium text-sm truncate">{result.name}</div>
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
{#if result.rating}
<div class="text-xs text-warning mt-1 inline-flex items-center gap-1">
<StarIcon class="w-3 h-3" />
{result.rating}
{#if result.review_count}
<span class="text-base-content/60">({result.review_count})</span>
{/if}
</div>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
<div class="flex items-center gap-2">
<div class="divider divider-horizontal text-xs">{$t('adventures.or') || 'OR'}</div>
</div>
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
<LocationIcon class="w-4 h-4" />
{$t('adventures.use_current_location') || 'Use Current Location'}
</button>
</div>
</div>
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold flex items-center gap-2">
<MapIcon class="w-5 h-5" />
{$t('adventures.select_on_map') || 'Select on Map'}
</h3>
{#if selectedMarker}
<button class="btn btn-ghost btn-sm gap-1" on:click={clearSelection}>
<ClearIcon class="w-4 h-4" />
{$t('adventures.clear') || 'Clear'}
</button>
{/if}
</div>
{#if !selectedMarker}
<p class="text-sm text-base-content/60 mb-4">
{#if mode === 'lodging'}
Click on the map to select a lodging
{:else}
{$t('adventures.click_map') || 'Click on the map to select a location'}
{/if}
</p>
{/if}
{#if isReverseGeocoding}
<div class="flex items-center justify-center py-2 mb-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60"
>{$t('adventures.getting_location_details') || 'Getting details...'}
</span>
</div>
{/if}
<MapLibre
bind:this={mapComponent}
style={getBasemapUrl()}
class="w-full h-80 rounded-lg border border-base-300"
center={mapCenter}
zoom={mapZoom}
standardControls
>
<MapEvents on:click={handleMapClick} />
{#if selectedMarker}
<Marker
lngLat={[selectedMarker.lng, selectedMarker.lat]}
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
>
<PinIcon class="w-5 h-5 text-primary-content" />
</Marker>
{/if}
</MapLibre>
</div>
</div>
{#if selectedLocation && selectedMarker}
<div class="card bg-success/10 border border-success/30">
<div class="card-body p-4">
<div class="flex gap-4 items-start">
{#if selectedLocation.photos && selectedLocation.photos.length > 0}
<img
src={selectedLocation.photos[0]}
alt={selectedLocation.name}
class="w-24 h-24 rounded-lg object-cover border border-base-300"
/>
{/if}
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-success mb-1">
{mode === 'lodging'
? $t('lodging.new_lodging') || 'Lodging selected'
: $t('adventures.location_selected')}
</h4>
<p class="text-sm font-medium text-base-content truncate">{selectedLocation.name}</p>
<p class="text-xs text-base-content/70 truncate">{selectedLocation.location}</p>
{#if selectedLocation.rating}
<div class="text-xs text-warning mt-2 inline-flex items-center gap-1">
<StarIcon class="w-3 h-3" />
{selectedLocation.rating}
{#if selectedLocation.review_count}
<span class="text-base-content/60">({selectedLocation.review_count} reviews)</span
>
{/if}
</div>
{/if}
{#if isEnrichingDescription}
<div class="text-xs text-base-content/60 mt-2 inline-flex items-center gap-1">
<span class="loading loading-spinner loading-xs"></span>
Improving description quality...
</div>
{/if}
<p class="text-xs text-base-content/60 mt-1">
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
</p>
{#if selectedLocation.types && selectedLocation.types.length > 0}
<div class="flex flex-wrap gap-1 mt-2">
{#each selectedLocation.types.slice(0, 5) as typeName}
<span class="badge badge-outline badge-sm capitalize">{typeName}</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if googleEnabled && supportsCategory}
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<div class="form-control gap-2">
<label class="label" for="quick-add-category">
<span class="label-text font-medium">Category for Quick Add</span>
</label>
<div id="quick-add-category">
<CategoryDropdown bind:selected_category={selectedQuickAddCategory} />
</div>
<p class="text-xs text-base-content/60">
Optional. If not selected, backend defaults to General.
</p>
</div>
</div>
</div>
{/if}
{/if}
{#if itineraryDate}
<div class="alert alert-info alert-soft">
<span class="text-sm">
Will be added to {formattedItineraryLabel || itineraryDate}
</span>
</div>
{/if}
<div class="flex flex-col sm:flex-row gap-3 pt-2">
<button class="btn btn-neutral-200 sm:flex-1" on:click={() => dispatch('cancel')}>
{$t('adventures.cancel') || 'Cancel'}
</button>
{#if selectedLocation && selectedMarker && googleEnabled}
<button class="btn btn-outline sm:flex-1" on:click={continueWithDetails}>
<PencilIcon class="w-4 h-4" />
{$t('adventures.add_details') || 'Add Details'}
</button>
<button class="btn btn-primary sm:flex-1" on:click={quickAdd} disabled={isQuickAdding}>
{#if isQuickAdding}
<span class="loading loading-spinner loading-xs"></span>
{$t('adventures.processing') || 'Processing'}...
{:else}
<LightningIcon class="w-4 h-4" />
Quick Add
{/if}
</button>
{:else}
<button
class="btn btn-primary sm:flex-1"
on:click={continueWithDetails}
disabled={isReverseGeocoding}
>
{#if isReverseGeocoding}
<span class="loading loading-spinner loading-xs"></span>
{$t('adventures.getting_location_details') || 'Getting details...'}
{:else}
{$t('adventures.continue')}
{/if}
</button>
{/if}
</div>
</div>

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.12.0-main-040426';
export let appVersion = 'v0.12.0-main-051526';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2026';

View File

@@ -391,7 +391,7 @@ export let LODGING_TYPES_ICONS = {
apartment: '🏢',
house: '🏠',
villa: '🏡',
motel: '🚗🏨',
motel: '🏨',
other: '❓'
};
@@ -506,7 +506,7 @@ export function osmTagToEmoji(tag: string) {
case 'hotel':
return '🏨';
case 'motel':
return '🏩';
return '🏨';
case 'pub':
return '🍺';
case 'restaurant':

View File

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

View File

@@ -0,0 +1,57 @@
const LODGING_TYPE_MAP: Record<string, string> = {
hotel: 'hotel',
resort_hotel: 'resort',
motel: 'motel',
hostel: 'hostel',
bed_and_breakfast: 'bnb',
guest_house: 'bnb',
campground: 'campground',
rv_park: 'campground',
camping_cabin: 'cabin',
apartment_building: 'apartment',
lodging: 'hotel',
villa: 'villa'
};
const VALID_TYPES = new Set([
'hotel',
'hostel',
'resort',
'bnb',
'campground',
'cabin',
'apartment',
'house',
'villa',
'motel',
'other'
]);
export function inferLodgingTypeFromPlace(primaryType: unknown, placeTypes: unknown): string {
if (typeof primaryType === 'string') {
const normalized = primaryType.trim().toLowerCase();
if (VALID_TYPES.has(normalized)) {
return normalized;
}
}
const normalizedTypes = Array.isArray(placeTypes)
? placeTypes
.map((typeName) => (typeof typeName === 'string' ? typeName.trim().toLowerCase() : ''))
.filter(Boolean)
: [];
for (const typeName of normalizedTypes) {
if (LODGING_TYPE_MAP[typeName]) {
return LODGING_TYPE_MAP[typeName];
}
}
for (const typeName of normalizedTypes) {
if (VALID_TYPES.has(typeName)) {
return typeName;
}
}
return 'other';
}

View File

@@ -3,10 +3,10 @@
"about": "Über",
"close": "Schließen",
"license": "Lizenziert unter der GPL-3.0-Lizenz.",
"message": "Hergestellt mit ❤️ in den Vereinigten Staaten.",
"message": "Hergestellt mit ❤️ in den Vereinigten Staaten von Amerika.",
"nominatim_1": "Standortsuche und Geokodierung werden bereitgestellt von",
"other_attributions": "Weitere Hinweise finden Sie in der README-Datei.",
"generic_attributions": "Melden Sie sich bei Adventurelog an, um Zuschreibungen für aktivierte Integrationen und Dienste anzuzeigen.",
"generic_attributions": "Melde dich bei Adventurelog an, um Zuschreibungen für aktivierte Integrationen und Dienste anzuzeigen.",
"attributions": "Zuschreibungen",
"developer": "Entwickler",
"license_info": "Lizenz",
@@ -31,14 +31,14 @@
"clear": "Zurücksetzen",
"collection": "Sammlung",
"date": "Datum",
"dates": "Termine",
"dates": "Daten",
"delete_collection": "Sammlung löschen",
"delete_collection_success": "Sammlung erfolgreich gelöscht!",
"descending": "Absteigend",
"duration": "Dauer",
"edit_collection": "Sammlung bearbeiten",
"filter": "Filter",
"homepage": "Startseite",
"homepage": "Homepage",
"image_upload_error": "Fehler beim Hochladen des Bildes",
"image_upload_success": "Bild erfolgreich hochgeladen!",
"latitude": "Breitengrad",
@@ -55,7 +55,7 @@
"share": "Teilen",
"sort": "Sortieren",
"sources": "Quellen",
"start_before_end_error": "Das Start- muss vor dem Enddatum liegen",
"start_before_end_error": "Das Startdatum muss vor dem Enddatum liegen",
"unarchive": "Dearchivieren",
"unarchived_collection_message": "Sammlung erfolgreich dearchiviert!",
"updated": "Aktualisiert",
@@ -68,14 +68,14 @@
"category": "Kategorie",
"copy_link": "Link kopieren",
"create_new": "Neu erstellen",
"date_constrain": "Beschränke auf Sammlungstermine",
"date_constrain": "Beschränke auf Sammlungsdaten",
"description": "Beschreibung",
"end_date": "Enddatum",
"fetch_image": "Bild abrufen",
"generate_desc": "Beschreibung generieren",
"image_fetch_failed": "Bild konnte nicht abgerufen werden",
"link": "Link",
"location": "Standort",
"location": "Ort",
"no_results": "Keine Ergebnisse gefunden",
"remove": "Entfernen",
"search_for_location": "Nach einem Ort suchen",
@@ -97,8 +97,8 @@
"links": "Links",
"note": "Notiz",
"notes": "Notizen",
"transportation": "Transport",
"transportations": "Transporte",
"transportation": "Transportmittel",
"transportations": "Transportmittel",
"day": "Tag",
"add_a_tag": "Fügen Sie ein Schlagwort hinzu",
"tags": "Schlagworte",
@@ -111,14 +111,14 @@
"date_information": "Datumsinformationen",
"delete_checklist": "Checkliste löschen",
"delete_note": "Notiz löschen",
"delete_transportation": "Transport löschen",
"delete_transportation": "Transportart löschen",
"end": "Ende",
"from": "Von",
"note_delete_confirm": "Sind Sie sicher, dass Sie diese Notiz löschen möchten? \nDies kann nicht rückgängig gemacht werden!",
"out_of_range": "Außerhalb des geplanten Reisezeitraums",
"start": "Start",
"to": "Nach",
"transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDies lässt sich nicht rückgängig machen.",
"transportation_delete_confirm": "Sind Sie sicher, dass Sie diese Transportart löschen möchten? \nDies lässt sich nicht rückgängig machen.",
"cities_updated": "Städte aktualisiert",
"attachment": "Anhang",
"attachment_delete_success": "Anhang erfolgreich gelöscht!",
@@ -137,14 +137,14 @@
"lodging_delete_confirm": "Sind Sie sicher, dass Sie diese Unterkunft löschen möchten? \nDies lässt sich nicht rückgängig machen!",
"price": "Preis",
"open_in_maps": "In Karten öffnen",
"all_day": "Ganztags",
"collection_no_start_end_date": "Durch das Hinzufügen eines Start- und Enddatums zur Sammlung werden Reiseroutenplanungsfunktionen auf der Sammlungsseite freigegeben.",
"all_day": "ganzer Tag",
"collection_no_start_end_date": "Durch das Hinzufügen eines Start- und Enddatums zur Sammlung werden auf der Sammlungsseite Funktionen zur Reiseplanung freigeschaltet.",
"invalid_date_range": "Ungültiger Datumsbereich",
"timezone": "Zeitzone",
"no_visits": "Keine Besuche",
"coordinates": "Koordinaten",
"copy_coordinates": "Koordinaten kopieren",
"sun_times": "Sonnenzeiten",
"sun_times": "Sonnenstunden",
"sunrise": "Sonnenaufgang",
"sunset": "Sonnenuntergang",
"timed": "Zeitlich abgestimmt",
@@ -157,30 +157,30 @@
"filters_and_stats": "Filter & Statistiken",
"no_adventures_message": "Dokumentieren Sie Ihre Abenteuer und planen Sie neue. \nJede Reise hat eine Geschichte, die es wert ist, erzählt zu werden.",
"travel_progress": "Reisefortschritt",
"collections_linked": "Kollektionen verknüpft",
"collections_linked": "Sammlungen verknüpft",
"create_collection_first": "Erstellen Sie zuerst eine Sammlung, um Ihre Abenteuer und Erinnerungen zu organisieren.",
"delete_collection_warning": "Sind Sie sicher, dass Sie diese Sammlung löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
"done": "Erledigt",
"name_location": "Name, Ort",
"check_in": "Einchecken",
"check_out": "Auschecken",
"collection_link_location_error": "Fehler beim Verknüpfen des Standorts mit der Sammlung",
"collection_link_location_success": "Standort mit der Sammlung erfolgreich verknüpft!",
"collection_locations": "Sammelorte einbeziehen",
"collection_remove_location_error": "Fehler bei der Entfernung des Standorts aus der Sammlung",
"collection_remove_location_success": "Standort erfolgreich aus der Sammlung entfernt!",
"create_location": "Standort erstellen",
"delete_location": "Standort löschen",
"edit_location": "Standort bearbeiten",
"collection_link_location_error": "Fehler beim Verknüpfen des Ortes mit der Sammlung",
"collection_link_location_success": "Ort mit der Sammlung erfolgreich verknüpft!",
"collection_locations": "Orte in Sammlung einbeziehen",
"collection_remove_location_error": "Fehler bei der Entfernung des Ortes aus der Sammlung",
"collection_remove_location_success": "Ort erfolgreich aus der Sammlung entfernt!",
"create_location": "Ort erstellen",
"delete_location": "Ort löschen",
"edit_location": "Ort bearbeiten",
"location_delete_confirm": "Sind Sie sicher, dass Sie diesen Ort löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
"location_delete_success": "Standort erfolgreich gelöscht!",
"location_not_found": "Ort nicht gefunden",
"location_not_found_desc": "Der Ort, den Sie gesucht haben, konnte nicht gefunden werden. \nBitte probieren Sie einen anderen Ort aus oder schauen Sie später noch einmal vorbei.",
"new_location": "Neuer Standort",
"no_collections_to_add_location": "Keine Sammlungen gefunden, die dieser Ort hinzugefügt werden kann.",
"public_location": "Öffentliche Lage",
"new_location": "Neuer Ort",
"no_collections_to_add_location": "Keine Sammlungen gefunden, der dieser Ort hinzugefügt werden kann.",
"public_location": "Öffentlicher Ort",
"visit_calendar": "Besuchs-Kalender",
"no_locations_found": "Keine Standorte gefunden",
"no_locations_found": "Keine Orte gefunden",
"image_modal_navigate": "Verwenden Sie Pfeiltasten oder klicken Sie, um zu navigieren",
"details": "Details",
"leave": "Verlassen",
@@ -277,7 +277,7 @@
"rest_time": "Ruhezeit",
"saved_activities": "Gespeicherte Aktivitäten",
"search_location": "Suche nach einem Ort",
"search_placeholder": "Stadt, Standort oder Wahrzeichen eingeben ...",
"search_placeholder": "Stadt, Ort oder Wahrzeichen eingeben ...",
"search_trails_placeholder": "Trails nach Namen suchen",
"searching": "Suche",
"select_on_map": "Wählen Sie auf der Karte",
@@ -364,9 +364,9 @@
"geographic_breakdown": "Geografische Aufteilung",
"gpx_routes": "GPX-Routen",
"hide_filters": "Filter ausblenden",
"images_captured": "Bilder aufgenommen",
"images_captured": "aufgenommene Fotos",
"in": "in",
"in_progress": "Im Gange",
"in_progress": "Laufend",
"items": "Einträge",
"itinerary_link_modal": {
"add_here": "Hier hinzufügen",
@@ -438,12 +438,13 @@
"import_from_file": "Aus Datei importieren",
"import_success": "Erfolg importieren",
"duplicate": "Duplizieren",
"duplicate_location": "Standort duplizieren",
"location_duplicate_success": "Standort erfolgreich dupliziert!",
"location_duplicate_error": "Standort konnte nicht dupliziert werden.",
"duplicate_location": "Ort duplizieren",
"location_duplicate_success": "Ort erfolgreich dupliziert!",
"location_duplicate_error": "Ort konnte nicht dupliziert werden.",
"location_actions": "Standort-Aktionen",
"collection_duplicate_success": "Sammlung erfolgreich dupliziert! Weiterleitung...",
"collection_duplicate_error": "Sammlung konnte nicht dupliziert werden."
"collection_duplicate_error": "Sammlung konnte nicht dupliziert werden.",
"add_details": "Details hinzufügen"
},
"home": {
"desc_1": "Entdecken, planen und erkunden Sie mühelos",
@@ -456,7 +457,7 @@
"go_to": "AdventureLog öffnen",
"hero_1": "Entdecken Sie die aufregendsten Abenteuer der Welt",
"hero_2": "Entdecken und planen Sie Ihr nächstes Abenteuer mit AdventureLog. Erkunden Sie atemberaubende Reiseziele, erstellen Sie individuelle Reisepläne und bleiben Sie unterwegs stets verbunden.",
"key_features": "Hauptmerkmale",
"key_features": "Hauptfunktionen",
"feature_2_desc": "Erstellen Sie mühelos individuelle Reisepläne und erhalten Sie eine detaillierte Tagesübersicht Ihrer Reise.",
"explore_world": "Welt erkunden",
"latest_travel_experiences": "Ihre neuesten Reiseerlebnisse",
@@ -480,7 +481,7 @@
"aestheticLight": "Ästhetisch Hell",
"aqua": "Aqua",
"dark": "Dunkel",
"dim": "Düster",
"dim": "Gedämpft",
"forest": "Wald",
"light": "Hell",
"night": "Nacht",
@@ -493,7 +494,7 @@
"admin_panel": "Administration",
"navigation": "Navigation",
"worldtravel": "Weltreisen",
"mobile_login": "Mobile Anmeldung"
"mobile_login": "Handy Anmeldung"
},
"auth": {
"confirm_password": "Passwort bestätigen",

View File

@@ -494,7 +494,8 @@
"export_success": "Exported collection",
"location_actions": "Location actions",
"collection_duplicate_success": "Collection duplicated successfully! Redirecting...",
"collection_duplicate_error": "Failed to duplicate collection."
"collection_duplicate_error": "Failed to duplicate collection.",
"add_details": "Add details"
},
"worldtravel": {
"country_list": "Country List",

View File

@@ -77,7 +77,7 @@
"longitude": "Longitud",
"visit": "Visita",
"visits": "Visitas",
"create_new": "Crear nuevo...",
"create_new": "Crear nuevo",
"ascending": "Ascendente",
"date": "Fecha",
"descending": "Descendente",
@@ -423,11 +423,11 @@
"already_added": "Ya agregado",
"already_added_on_this_day": "Ya agregado en este día",
"already_added_on_this_day_desc": "Estos artículos ya están programados para este día.",
"already_added_other_days": "Ya agregado en otros días.",
"already_added_other_days_desc": "Estos artículos están programados en diferentes fechas. \nAgregarlos aquí actualizará su fecha o los agregará tal cual.",
"already_added_other_days": "Ya agregado en otros días",
"already_added_other_days_desc": "Estos artículos están programados en diferentes fechas. Agregarlos aquí actualizará su fecha o los agregará tal cual.",
"items_available": "{count} elementos disponibles para vincular",
"items_on_other_days": "Artículos de otros días",
"items_on_other_days_desc": "Estos artículos tienen diferentes fechas. \nPuede agregarlos y, opcionalmente, actualizar su fecha para que coincida.",
"items_on_other_days_desc": "Estos artículos tienen diferentes fechas. Puede agregarlos y, opcionalmente, actualizar su fecha para que coincida.",
"items_on_this_day": "Artículos en este día",
"no_unscheduled_items": "No hay artículos no programados disponibles",
"no_unscheduled_items_desc": "Todos los artículos se han agregado al itinerario o no hay artículos para agregar.",
@@ -493,7 +493,8 @@
"collection_duplicate_success": "Colección duplicada con éxito. Redirigiendo...",
"collection_duplicate_error": "No se pudo duplicar la colección.",
"hide_strava_activities": "Ocultar actividades de Strava",
"show_strava_activities": "Mostrar actividades de Strava"
"show_strava_activities": "Mostrar actividades de Strava",
"add_details": "Añadir detalles"
},
"worldtravel": {
"no_countries_found": "No se encontraron países",
@@ -549,7 +550,7 @@
"in": "en",
"loading_globe_spin": "Cargando giro global",
"no_globe_spin_data": "Sin datos de giro de globo",
"show_globe_spin": "Show Globe Spin",
"show_globe_spin": "Mostrar Giro de Globo",
"spin_again": "Girar de nuevo",
"spinning_globe": "Globo hilado",
"try_again": "Intentar otra vez",
@@ -584,7 +585,7 @@
"enter_username": "Ingrese su nombre de usuario",
"logging_in": "Iniciar sesión",
"totp": "Código de dos factores",
"user_email_verification_required": "Se requiere verificación por correo electrónico. \nPor favor revise su correo electrónico para obtener un enlace de verificación."
"user_email_verification_required": "Se requiere verificación por correo electrónico. Revise su correo electrónico para obtener un enlace de verificación."
},
"users": {
"no_users_found": "No se encontraron usuarios con perfiles públicos."
@@ -606,7 +607,7 @@
"reset_password": "Restablecer contraseña",
"about_this_background": "Sobre este fondo",
"join_discord": "Únete a Discord",
"join_discord_desc": "para compartir tus propias fotos. Publícalos en el canal de #travel-share",
"join_discord_desc": "para compartir tus propias fotos. Publícalos en el canal de #travel-share.",
"photo_by": "Foto por",
"current_password": "Contraseña actual",
"password_change_lopout_warning": "Se cerrará su sesión después de cambiar su contraseña.",
@@ -777,7 +778,7 @@
"available": "Disponible",
"linked": "Vinculado",
"try_different_search": "Pruebe una búsqueda o filtro diferente.",
"changing_date_title": "El cambio de fechas afectará los elementos del itinerario.",
"changing_date_title": "El cambio de fechas afectará los elementos del itinerario",
"changing_date_warning": "Cualquier elemento del itinerario fuera del nuevo rango de fechas se eliminará del itinerario y se volverá a colocar en los elementos sin fecha de la colección.",
"clear_cover": "cubierta transparente",
"collaborators": "Colaboradores",
@@ -1010,7 +1011,7 @@
"enter_reservation_number": "Ingrese el número de reserva",
"update_lodging_details": "Actualizar detalles de alojamiento",
"invalid_link": "Introduzca una URL válida (por ejemplo, https://example.com).",
"save_failed": "No se pudo guardar el alojamiento. \nPor favor inténtalo de nuevo."
"save_failed": "No se pudo guardar el alojamiento. Inténtalo de nuevo."
},
"google_maps": {
"google_maps_integration_desc": "Conecte su cuenta de Google Maps para obtener resultados y recomendaciones de búsqueda de ubicación de alta calidad.",
@@ -1070,7 +1071,7 @@
"currencies": "Monedas",
"currency": "Divisa",
"event_timezone": "Zona horaria del evento",
"event_timezone_desc": "La zona horaria del evento utiliza la zona horaria de la ubicación o del elemento cuando esté disponible. \nMi zona horaria usa",
"event_timezone_desc": "La zona horaria del evento utiliza la zona horaria de la ubicación o del elemento cuando esté disponible. Mi zona horaria usa",
"events": "eventos",
"local_timezone": "mi zona horaria",
"no_calendar_events": "Aún no hay visitas programadas para esta colección.",
@@ -1113,16 +1114,16 @@
"add_description": "Agregar descripción",
"add_to_day": "Añadir al día",
"add_to_trip_context": "Agregar contexto de viaje",
"added_to_trip_context": "Agregado al contexto del viaje.",
"added_to_trip_context": "Agregado al contexto del viaje",
"auto_generate": "Generar automáticamente",
"auto_generate_itinerary": "Itinerario generado automáticamente",
"auto_generate_itinerary_desc": "Esta colección tiene elementos fechados pero aún no tiene un itinerario. \n¿Quieres organizarlos automáticamente por fecha?",
"auto_generate_itinerary_desc": "Esta colección tiene elementos fechados pero aún no tiene un itinerario. ¿Quieres organizarlos automáticamente por fecha?",
"change_day": "Cambiar día",
"drag_to_reorder": "Arrastra para reordenar",
"failed_to_add_to_trip_context": "No se pudo agregar el elemento al contexto del viaje",
"failed_to_move_to_trip_context": "No se pudo pasar al contexto del viaje",
"generating": "generando",
"item_already_in_trip_context": "Elementos que ya están en el contexto del viaje.",
"item_already_in_trip_context": "Elementos que ya están en el contexto del viaje",
"item_not_found": "Artículo no encontrado",
"item_remove_error": "Error al eliminar artículo del itinerario",
"item_remove_success": "Artículo eliminado del itinerario",
@@ -1131,7 +1132,7 @@
"moved_to_trip_context": "Movido al contexto del viaje",
"multi_day": "Varios días",
"no_itinerary_yet": "Aún no hay itinerario",
"no_plans_for_day": "No hay planes para este día.",
"no_plans_for_day": "No hay planes para este día",
"no_trip_context_items": "Aún no hay elementos de contexto de viaje.",
"remove_from_itinerary": "Quitar del día",
"remove_from_trip_context": "Eliminar del contexto",
@@ -1148,7 +1149,7 @@
"create": "Crear clave",
"create_error": "No se pudo crear la clave API.",
"created": "Creado",
"description": "Cree claves API personales para acceso programático. \nLas claves se muestran solo una vez en el momento de la creación.",
"description": "Cree claves API personales para acceso programático. Las claves se muestran solo una vez en el momento de la creación.",
"dismiss": "Despedir",
"key_created": "Clave API creada correctamente.",
"key_name_placeholder": "Nombre clave (por ejemplo, Home Assistant)",
@@ -1156,7 +1157,7 @@
"last_used": "último usado",
"never_used": "Nunca usado",
"new_key_title": "Guarde su nueva clave API",
"new_key_warning": "Esta clave no se volverá a mostrar. \nCópialo y guárdalo en un lugar seguro.",
"new_key_warning": "Esta clave no se volverá a mostrar. Cópialo y guárdalo en un lugar seguro.",
"no_keys": "Aún no hay claves API.",
"revoke": "Revocar",
"revoke_error": "No se pudo revocar la clave API.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -443,7 +443,8 @@
"collection_duplicate_success": "Samlingen har duplicerats! Omdirigerar...",
"collection_duplicate_error": "Misslyckades med att duplicera samlingen.",
"hide_strava_activities": "Göm Strava-aktiviteter",
"show_strava_activities": "Visa Strava-aktiviteter"
"show_strava_activities": "Visa Strava-aktiviteter",
"add_details": "Lägg till detaljer"
},
"home": {
"desc_1": "Upptäck, planera och utforska med lätthet",

View File

@@ -493,7 +493,8 @@
"collection_duplicate_success": "Koleksiyon başarıyla kopyalandı! Yönlendiriliyor...",
"collection_duplicate_error": "Koleksiyon kopyalanamadı.",
"hide_strava_activities": "Strava Faaliyetlerini Gizle",
"show_strava_activities": "Strava Aktivitelerini Göster"
"show_strava_activities": "Strava Aktivitelerini Göster",
"add_details": "Detayları ekle"
},
"worldtravel": {
"country_list": "Ülke Listesi",
@@ -1163,7 +1164,7 @@
"title": "API Anahtarları",
"copy_error": "Anahtar kopyalanırken hata oluştu.",
"usage_middle": "başlık veya olarak",
"usage_prefix": "Bu anahtarı şu şekilde kullanın:",
"usage_prefix": "Bu anahtar içinde kullanın",
"delete_confirm": "Bu mobil API anahtarını silmek istediğinizden emin misiniz?"
}
}

View File

@@ -29,7 +29,8 @@ export const actions: Actions = {
cookies.set('colortheme', theme, {
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: 'lax'
sameSite: 'lax',
secure: url.protocol === 'https:'
});
}
},

View File

@@ -25,6 +25,7 @@
import NewLocationModal from '$lib/components/locations/LocationModal.svelte';
import CashMultiple from '~icons/mdi/cash-multiple';
import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money';
import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte';
let geojson: any;
@@ -675,32 +676,12 @@
{/if}
<!-- External Maps Links -->
<div class="grid grid-cols-3 gap-2 mb-3">
<a
class="btn btn-sm btn-outline hover:btn-neutral"
href={`https://maps.apple.com/?q=${adventure.latitude},${adventure.longitude}`}
target="_blank"
rel="noopener noreferrer"
>
🍎 Apple
</a>
<a
class="btn btn-sm btn-outline hover:btn-accent"
href={`https://maps.google.com/?q=${adventure.latitude},${adventure.longitude}`}
target="_blank"
rel="noopener noreferrer"
>
🌍 Google
</a>
<a
class="btn btn-sm btn-outline hover:btn-primary"
href={`https://www.openstreetmap.org/?mlat=${adventure.latitude}&mlon=${adventure.longitude}`}
target="_blank"
rel="noopener noreferrer"
>
🗺️ OSM
</a>
</div>
<ExternalMapLinks
className="mb-3"
placeName={adventure.name}
latitude={adventure.latitude}
longitude={adventure.longitude}
/>
<!-- Quick Copy Actions -->
<div class="flex gap-2">

View File

@@ -28,6 +28,7 @@
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
import LodgingModal from '$lib/components/lodging/LodgingModal.svelte';
import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money';
import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte';
const renderMarkdown = (markdown: string) => {
return marked(markdown) as string;
@@ -417,38 +418,12 @@
</MapLibre>
</div>
{#if lodging.location}
<div class="rounded-lg p-3 mb-3 bg-gradient-to-br from-primary/10 to-secondary/10">
<p class="flex items-center gap-2 text-sm mb-2">
<MapMarker class="w-4 h-4" />
{lodging.location}
</p>
<div class="grid grid-cols-3 gap-2">
<a
class="btn btn-sm btn-outline hover:btn-neutral"
href={`https://maps.apple.com/?q=${encodeURIComponent(lodging.location)}`}
target="_blank"
rel="noopener noreferrer"
>
🍎 Apple
</a>
<a
class="btn btn-sm btn-outline hover:btn-accent"
href={`https://maps.google.com/?q=${encodeURIComponent(lodging.location)}`}
target="_blank"
rel="noopener noreferrer"
>
🌍 Google
</a>
<a
class="btn btn-sm btn-outline hover:btn-primary"
href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(lodging.location)}`}
target="_blank"
rel="noopener noreferrer"
>
🗺️ OSM
</a>
</div>
</div>
<ExternalMapLinks
className="mb-3"
placeName={lodging.name}
latitude={lodging.latitude}
longitude={lodging.longitude}
/>
{/if}
</div>
</div>

View File

@@ -26,6 +26,7 @@
import TransportationModal from '$lib/components/transportation/TransportationModal.svelte';
import CashMultiple from '~icons/mdi/cash-multiple';
import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money';
import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte';
const renderMarkdown = (markdown: string) => {
return marked(markdown) as string;
@@ -637,32 +638,7 @@
<MapMarker class="w-4 h-4" />
{transportation.from_location}
</p>
<div class="grid grid-cols-3 gap-2">
<a
class="btn btn-sm btn-outline hover:btn-neutral"
href={`https://maps.apple.com/?q=${encodeURIComponent(transportation.from_location)}`}
target="_blank"
rel="noopener noreferrer"
>
🍎 Apple
</a>
<a
class="btn btn-sm btn-outline hover:btn-accent"
href={`https://maps.google.com/?q=${encodeURIComponent(transportation.from_location)}`}
target="_blank"
rel="noopener noreferrer"
>
🌍 Google
</a>
<a
class="btn btn-sm btn-outline hover:btn-primary"
href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(transportation.from_location)}`}
target="_blank"
rel="noopener noreferrer"
>
🗺️ OSM
</a>
</div>
<ExternalMapLinks placeName={transportation.from_location} />
</div>
{/if}
@@ -672,32 +648,7 @@
<MapMarker class="w-4 h-4" />
{transportation.to_location}
</p>
<div class="grid grid-cols-3 gap-2">
<a
class="btn btn-sm btn-outline hover:btn-neutral"
href={`https://maps.apple.com/?q=${encodeURIComponent(transportation.to_location)}`}
target="_blank"
rel="noopener noreferrer"
>
🍎 Apple
</a>
<a
class="btn btn-sm btn-outline hover:btn-accent"
href={`https://maps.google.com/?q=${encodeURIComponent(transportation.to_location)}`}
target="_blank"
rel="noopener noreferrer"
>
🌍 Google
</a>
<a
class="btn btn-sm btn-outline hover:btn-primary"
href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(transportation.to_location)}`}
target="_blank"
rel="noopener noreferrer"
>
🗺️ OSM
</a>
</div>
<ExternalMapLinks placeName={transportation.to_location} />
</div>
{/if}
</div>