From 840e97d580359fe96737138f43b9ad9b327c20ac Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 14 Feb 2026 14:09:34 -0500 Subject: [PATCH 1/8] Update README.md supporter list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index feea19e3c..18d2b52a0 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software de ### Top Supporters 💖 -- [Veymax](https://x.com/veymax) +- Veymax - [nebriv](https://github.com/nebriv) - [Miguel Cruz](https://github.com/Tokynet) - [Victor Butler](https://x.com/victor_butler) From 05e89fce53cd82591f0ef1a76a9e11a612e8ade6 Mon Sep 17 00:00:00 2001 From: madmp87 <79420509+madmp87@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:50:48 +0100 Subject: [PATCH 2/8] Fix: Multiple bug fixes and features bundle (#888, #991, #617, #984) (#1007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve location creation failures, broken image uploads, and invalid URL handling - Add missing addToast import in LocationDetails.svelte for proper error feedback - Add objectId check and error response handling in ImageManagement.svelte to prevent ghost images - Add Content-Type check in +page.server.ts image action to handle non-JSON backend responses - Add client-side URL validation in LocationDetails.svelte (invalid URLs → null) - Improve Django field error extraction for user-friendly toast messages - Clean up empty description fields (whitespace → null) - Update BUGFIX_DOCUMENTATION.md with detailed fix descriptions * feat: bug fixes and new features bundle Bug fixes: - fix: resolve PATCH location with visits (#888) - fix: Wikipedia/URL image upload via server-side proxy (#991) - fix: private/public toggle race condition (#617) - fix: location creation feedback (addToast import) - fix: invalid URL handling for locations and collections - fix: world map country highlighting (bg-*-200 -> bg-*-400) - fix: clipboard API polyfill for HTTP contexts - fix: MultipleObjectsReturned for duplicate images - fix: SvelteKit proxy sessionid cookie forwarding Features: - feat: duplicate location button (list + detail view) - feat: duplicate collection button - feat: i18n translations for 19 languages - feat: improved error handling and user feedback Technical: - Backend: fetch_from_url endpoint with SSRF protection - Backend: validate_link() for collections - Backend: file_permissions filter() instead of get() - Frontend: copyToClipboard() helper function - Frontend: clipboard polyfill via server-side injection * chore: switch docker-compose from image to build Use local source code builds instead of upstream :latest images to preserve our custom patches and fixes. * fix: lodging save errors, AI language support, and i18n improvements - Fix Lodging save: add res.ok checks, error toasts, isSaving state (#984) - Fix URL validation: silently set invalid URLs to null (Lodging, Transportation) - Fix AI description language: pass user locale to Wikipedia API - Fix missing i18n keys: Strava toggle buttons (show/hide) - Add CHANGELOG.md - Remove internal documentation from public tracking - Update .gitignore for Cursor IDE and internal docs Co-authored-by: Cursor * feat: update location duplication handling, improve UI feedback, and enhance localization support --------- Co-authored-by: AdventureLog Bugfix Co-authored-by: madmp87 Co-authored-by: Mathias Ponnwitz Co-authored-by: Cursor Co-authored-by: Sean Morley --- .gitignore | 10 + backend/server/adventures/serializers.py | 35 +- .../adventures/utils/file_permissions.py | 108 +- .../adventures/views/collection_view.py | 43 + .../adventures/views/location_image_view.py | 108 + .../server/adventures/views/location_view.py | 83 +- backend/server/requirements.txt | 3 +- docker-compose.yml | 8 +- frontend/src/hooks.server.ts | 15 +- .../src/lib/components/CollectionModal.svelte | 36 +- .../src/lib/components/ImageManagement.svelte | 43 +- frontend/src/lib/components/TOTPModal.svelte | 17 +- .../components/cards/CollectionCard.svelte | 38 +- .../lib/components/cards/LocationCard.svelte | 43 +- .../locations/LocationDetails.svelte | 55 +- .../components/locations/LocationModal.svelte | 7 +- .../components/lodging/LodgingDetails.svelte | 122 +- .../TransportationDetails.svelte | 17 +- frontend/src/lib/index.ts | 24 + frontend/src/lib/types.ts | 1 + frontend/src/locales/ar.json | 15 +- frontend/src/locales/de.json | 17 +- frontend/src/locales/en.json | 15 +- frontend/src/locales/es.json | 15 +- frontend/src/locales/fr.json | 15 +- frontend/src/locales/hu.json | 15 +- frontend/src/locales/it.json | 15 +- frontend/src/locales/ja.json | 15 +- frontend/src/locales/ko.json | 15 +- frontend/src/locales/nl.json | 15 +- frontend/src/locales/no.json | 15 +- frontend/src/locales/pl.json | 15 +- frontend/src/locales/pt-br.json | 15 +- frontend/src/locales/ru.json | 15 +- frontend/src/locales/sk.json | 2271 +++++++++-------- frontend/src/locales/sv.json | 2271 +++++++++-------- frontend/src/locales/tr.json | 15 +- frontend/src/locales/uk.json | 15 +- frontend/src/locales/zh.json | 15 +- frontend/src/routes/api/[...path]/+server.ts | 3 +- frontend/src/routes/auth/[...path]/+server.ts | 3 +- frontend/src/routes/locations/+page.server.ts | 9 + frontend/src/routes/locations/+page.svelte | 6 +- .../src/routes/locations/[id]/+page.svelte | 83 +- 44 files changed, 3249 insertions(+), 2470 deletions(-) mode change 100644 => 100755 frontend/src/lib/components/ImageManagement.svelte mode change 100644 => 100755 frontend/src/lib/components/locations/LocationDetails.svelte mode change 100644 => 100755 frontend/src/routes/locations/+page.server.ts diff --git a/.gitignore b/.gitignore index 090b68131..69919301d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,13 @@ .vscode/settings.json .pnpm-store/ .env + +# Cursor IDE configuration +.cursor/ +.cursorrules + +# Internal development documentation (not for public repo) +docs/cursor-prompts/ +docs/FIX_PACKAGE_PLAN.md +docs/GITHUB_COMMENTS.md +BUGFIX_DOCUMENTATION.md diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 054357397..5b0115e89 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -435,9 +435,8 @@ def create(self, validated_data): return location def update(self, instance, validated_data): - has_visits = 'visits' in validated_data category_data = validated_data.pop('category', None) - + visits_data = validated_data.pop('visits', None) collections_data = validated_data.pop('collections', None) # Update regular fields @@ -452,12 +451,22 @@ def update(self, instance, validated_data): instance.category = category # If not the owner, ignore category changes - # Handle collections - only update if collections were provided + # Save the location first so that user-supplied field values (including + # is_public) are persisted before the m2m_changed signal fires. + instance.save() + + # Handle collections - only update if collections were provided. + # NOTE: .set() triggers the m2m_changed signal which may override + # is_public based on collection publicity. By saving first we ensure + # the user's explicit value reaches the DB before the signal runs. if collections_data is not None: instance.collections.set(collections_data) - # call save on the location to update the updated_at field and trigger any geocoding - instance.save() + # Handle visits - replace all visits if provided + if visits_data is not None: + instance.visits.all().delete() + for visit_data in visits_data: + Visit.objects.create(location=instance, **visit_data) return instance @@ -720,6 +729,9 @@ class CollectionSerializer(CustomModelSerializer): required=False, allow_null=True, ) + # Override link as CharField so DRF's URLField doesn't reject invalid + # values before validate_link() can clean them up. + link = serializers.CharField(required=False, allow_blank=True, allow_null=True) class Meta: model = Collection @@ -749,6 +761,19 @@ class Meta: ] read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start', 'primary_image'] + def validate_link(self, value): + """Convert empty or invalid URLs to None so Django doesn't reject them.""" + if not value or not value.strip(): + return None + from django.core.validators import URLValidator + from django.core.exceptions import ValidationError as DjangoValidationError + validator = URLValidator() + try: + validator(value) + except DjangoValidationError: + return None + return value + def get_collaborators(self, obj): request = self.context.get('request') request_user = getattr(request, 'user', None) if request else None diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py index 1c2f2ba2f..cb3b8daca 100644 --- a/backend/server/adventures/utils/file_permissions.py +++ b/backend/server/adventures/utils/file_permissions.py @@ -4,83 +4,55 @@ protected_paths = ['images/', 'attachments/'] +def _check_content_object_permission(content_object, user): + """Check if user has permission to access a content object.""" + # handle differently when content_object is a Visit, get the location instead + if isinstance(content_object, Visit): + if content_object.location: + content_object = content_object.location + + # Check if content object is public + if hasattr(content_object, 'is_public') and content_object.is_public: + return True + + # Check if user owns the content object + if hasattr(content_object, 'user') and content_object.user == user: + return True + + # Check collection-based permissions + if hasattr(content_object, 'collections') and content_object.collections.exists(): + for collection in content_object.collections.all(): + if collection.user == user or collection.shared_with.filter(id=user.id).exists(): + return True + return False + elif hasattr(content_object, 'collection') and content_object.collection: + if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists(): + return True + return False + else: + return False + def checkFilePermission(fileId, user, mediaType): if mediaType not in protected_paths: return True if mediaType == 'images/': - try: - # Construct the full relative path to match the database field - image_path = f"images/{fileId}" - # Fetch the ContentImage object - content_image = ContentImage.objects.get(image=image_path) - - # Get the content object (could be Location, Transportation, Note, etc.) + image_path = f"images/{fileId}" + # Use filter() instead of get() to handle multiple ContentImage entries + # pointing to the same file (e.g. after location duplication) + content_images = ContentImage.objects.filter(image=image_path) + if not content_images.exists(): + return False + # Grant access if ANY associated content object permits it + for content_image in content_images: content_object = content_image.content_object - - # handle differently when content_object is a Visit, get the location instead - if isinstance(content_object, Visit): - # check visit.location - if content_object.location: - # continue with the location check - content_object = content_object.location - - # Check if content object is public - if hasattr(content_object, 'is_public') and content_object.is_public: - return True - - # Check if user owns the content object - if hasattr(content_object, 'user') and content_object.user == user: + if content_object and _check_content_object_permission(content_object, user): return True - - # Check collection-based permissions - if hasattr(content_object, 'collections') and content_object.collections.exists(): - # For objects with multiple collections (like Location) - for collection in content_object.collections.all(): - if collection.user == user or collection.shared_with.filter(id=user.id).exists(): - return True - return False - elif hasattr(content_object, 'collection') and content_object.collection: - # For objects with single collection (like Transportation, Note, etc.) - if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists(): - return True - return False - else: - return False - - except ContentImage.DoesNotExist: - return False + return False elif mediaType == 'attachments/': try: - # Construct the full relative path to match the database field attachment_path = f"attachments/{fileId}" - # Fetch the ContentAttachment object content_attachment = ContentAttachment.objects.get(file=attachment_path) - - # Get the content object (could be Location, Transportation, Note, etc.) content_object = content_attachment.content_object - - # Check if content object is public - if hasattr(content_object, 'is_public') and content_object.is_public: - return True - - # Check if user owns the content object - if hasattr(content_object, 'user') and content_object.user == user: - return True - - # Check collection-based permissions - if hasattr(content_object, 'collections') and content_object.collections.exists(): - # For objects with multiple collections (like Location) - for collection in content_object.collections.all(): - if collection.user == user or collection.shared_with.filter(id=user.id).exists(): - return True - return False - elif hasattr(content_object, 'collection') and content_object.collection: - # For objects with single collection (like Transportation, Note, etc.) - if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists(): - return True - return False - else: - return False - + return _check_content_object_permission(content_object, user) if content_object else False except ContentAttachment.DoesNotExist: - return False \ No newline at end of file + return False diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index b1917fcab..d6ac0ac49 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -791,6 +791,49 @@ def _coords_close(lat1, lon1, lat2, lon2, threshold=0.02): serializer = self.get_serializer(new_collection) return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['post']) + def duplicate(self, request, pk=None): + """Create a duplicate of an existing collection. + + Copies name (with 'Copy of' prefix), description, and link. + Resets: dates, is_public, is_archived, shared_with, locations, + itinerary, and primary_image. + """ + original = self.get_object() + + # Only the owner can duplicate + if original.user != request.user: + return Response( + {"error": "You do not have permission to duplicate this collection."}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + with transaction.atomic(): + new_collection = Collection.objects.create( + user=request.user, + name=f"Copy of {original.name}", + description=original.description, + link=original.link, + is_public=False, + is_archived=False, + start_date=None, + end_date=None, + ) + # Do NOT copy: locations, shared_with, itinerary, primary_image + + serializer = self.get_serializer(new_collection) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except Exception: + import logging + logging.getLogger(__name__).exception("Failed to duplicate collection %s", pk) + return Response( + {"error": "An error occurred while duplicating the collection."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + def perform_create(self, serializer): # This is ok because you cannot share a collection when creating it serializer.save(user=self.request.user) diff --git a/backend/server/adventures/views/location_image_view.py b/backend/server/adventures/views/location_image_view.py index 0336d1f6f..94ae9f402 100644 --- a/backend/server/adventures/views/location_image_view.py +++ b/backend/server/adventures/views/location_image_view.py @@ -1,6 +1,10 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from django.http import HttpResponse +import ipaddress +from urllib.parse import urlparse from django.db.models import Q from django.core.files.base import ContentFile from django.contrib.contenttypes.models import ContentType @@ -119,6 +123,96 @@ def toggle_primary(self, request, *args, **kwargs): instance.save() return Response({"success": "Image set as primary image"}) + + @action(detail=False, methods=['post'], permission_classes=[AllowAny]) + def fetch_from_url(self, request): + """ + Proxy endpoint to fetch images from external URLs (Wikipedia, etc.). + This avoids CORS issues when the frontend tries to download images + from third-party servers like wikimedia.org. + """ + image_url = request.data.get('url') + if not image_url: + return Response( + {"error": "URL is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validate URL scheme + if not image_url.startswith(('http://', 'https://')): + return Response( + {"error": "Invalid URL scheme. Only http and https are allowed."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # SSRF protection: block private/internal IPs + try: + import socket + hostname = urlparse(image_url).hostname + if hostname: + resolved_ip = socket.getaddrinfo(hostname, None)[0][4][0] + ip = ipaddress.ip_address(resolved_ip) + if ip.is_private or ip.is_loopback or ip.is_reserved: + return Response( + {"error": "Access to internal networks is not allowed"}, + status=status.HTTP_400_BAD_REQUEST + ) + except (socket.gaierror, ValueError): + return Response( + {"error": "Could not resolve hostname"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Download image server-side with a User-Agent to avoid blocks + headers = { + 'User-Agent': 'AdventureLog/1.0 (Image Proxy)' + } + response = requests.get( + image_url, + timeout=30, + headers=headers, + stream=True + ) + response.raise_for_status() + + # Verify content type is an image + 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 + ) + + # Limit size to 20MB to prevent abuse + 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 + ) + + # Read the full content (stream=True means we need to read explicitly) + image_data = response.content + + # Return the raw image bytes with the original content type + return HttpResponse( + image_data, + content_type=content_type, + status=200 + ) + + except requests.exceptions.Timeout: + return Response( + {"error": "Download timeout - image may be too large or server too slow"}, + status=status.HTTP_504_GATEWAY_TIMEOUT + ) + except requests.exceptions.RequestException as e: + return Response( + {"error": f"Failed to fetch image: {str(e)}"}, + status=status.HTTP_502_BAD_GATEWAY + ) + def create(self, request, *args, **kwargs): # Get content type and object ID from request content_type_name = request.data.get('content_type') @@ -163,6 +257,20 @@ def _get_and_validate_content_object(self, content_type_name, object_id): "error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}" }, status=status.HTTP_400_BAD_REQUEST) + # Validate object_id format (must be a valid UUID, not "undefined" or empty) + if not object_id or object_id == 'undefined': + return Response({ + "error": "object_id is required and must be a valid UUID" + }, status=status.HTTP_400_BAD_REQUEST) + + import uuid as uuid_module + try: + uuid_module.UUID(str(object_id)) + except (ValueError, AttributeError): + return Response({ + "error": f"Invalid object_id format: {object_id}" + }, status=status.HTTP_400_BAD_REQUEST) + # Get the content object try: content_object = content_type_map[content_type_name].objects.get(id=object_id) diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index aaf8afa1c..e85c11d2e 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -1,3 +1,4 @@ +import logging from django.utils import timezone from django.db import transaction from django.core.exceptions import PermissionDenied @@ -7,12 +8,14 @@ from rest_framework.decorators import action from rest_framework.response import Response import requests -from adventures.models import Location, Category, CollectionItineraryItem, Visit +from adventures.models import Location, Category, CollectionItineraryItem, ContentImage, Visit from django.contrib.contenttypes.models import ContentType from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.serializers import LocationSerializer, MapPinSerializer, CalendarLocationSerializer from adventures.utils import pagination +logger = logging.getLogger(__name__) + class LocationViewSet(viewsets.ModelViewSet): """ ViewSet for managing Adventure objects with support for filtering, sorting, @@ -254,6 +257,84 @@ def additional_info(self, request, pk=None): return Response(response_data) + @action(detail=True, methods=['post']) + def duplicate(self, request, pk=None): + """Create a duplicate of an existing location. + + Copies all fields except collections and visits. Image references are + duplicated (new ContentImage rows pointing to the same files). The name + is prefixed with "Copy of " and is_public is reset to False. + """ + original = self.get_object() + + # Verify the requesting user owns the location or has access + if not self._has_adventure_access(original, request.user): + return Response( + {"error": "You do not have permission to duplicate this location."}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + with transaction.atomic(): + # Snapshot original images before creating the copy + original_images = list(original.images.all()) + + # Build the new location + new_location = Location( + user=request.user, + name=f"Copy of {original.name}", + description=original.description, + rating=original.rating, + link=original.link, + location=original.location, + tags=list(original.tags) if original.tags else None, + is_public=False, + longitude=original.longitude, + latitude=original.latitude, + city=original.city, + region=original.region, + country=original.country, + price=original.price, + price_currency=original.price_currency, + ) + + # Handle category: reuse the user's own matching category or + # create one if necessary. + if original.category: + category, _ = Category.objects.get_or_create( + user=request.user, + name=original.category.name, + defaults={ + 'display_name': original.category.display_name, + 'icon': original.category.icon, + }, + ) + new_location.category = category + + new_location.save() + + # Duplicate image references (same file, new DB row) + location_ct = ContentType.objects.get_for_model(Location) + for img in original_images: + ContentImage.objects.create( + content_type=location_ct, + object_id=str(new_location.id), + image=img.image.name if img.image else None, + immich_id=img.immich_id, + is_primary=img.is_primary, + user=request.user, + ) + + serializer = self.get_serializer(new_location) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except Exception: + logger.exception("Failed to duplicate location %s", pk) + return Response( + {"error": "An error occurred while duplicating the location."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + # view to return location name and lat/lon for all locations a user owns for the golobal map @action(detail=False, methods=['get'], url_path='pins') def map_locations(self, request): diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index a30234997..cf8df6fd0 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -29,4 +29,5 @@ psutil==6.1.1 geojson==3.2.0 gpxpy==1.6.2 pymemcache==4.0.0 -legacy-cgi==2.6.3 \ No newline at end of file +legacy-cgi==2.6.3 +requests>=2.31.0 diff --git a/docker-compose.yml b/docker-compose.yml index 034ec065e..fa6be82a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: web: - #build: ./frontend/ - image: ghcr.io/seanmorley15/adventurelog-frontend:latest + build: ./frontend/ + #image: ghcr.io/seanmorley15/adventurelog-frontend:latest container_name: adventurelog-frontend restart: unless-stopped env_file: .env @@ -19,8 +19,8 @@ services: - postgres_data:/var/lib/postgresql/data/ server: - #build: ./backend/ - image: ghcr.io/seanmorley15/adventurelog-backend:latest + build: ./backend/ + #image: ghcr.io/seanmorley15/adventurelog-backend:latest container_name: adventurelog-backend restart: unless-stopped env_file: .env diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 12cd0171e..d0b435f11 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -66,16 +66,27 @@ export const authHook: Handle = async ({ event, resolve }) => { return await resolve(event); }; +// Clipboard polyfill for non-secure (HTTP) contexts. +// navigator.clipboard.writeText requires HTTPS or localhost. +// This polyfill injects a `; + export const themeHook: Handle = async ({ event, resolve }) => { let theme = event.url.searchParams.get('theme') || event.cookies.get('colortheme'); if (theme) { return await resolve(event, { - transformPageChunk: ({ html }) => html.replace('data-theme=""', `data-theme="${theme}"`) + transformPageChunk: ({ html }) => + html + .replace('data-theme=""', `data-theme="${theme}"`) + .replace('', clipboardPolyfillScript + '') }); } - return await resolve(event); + return await resolve(event, { + transformPageChunk: ({ html }) => html.replace('', clipboardPolyfillScript + '') + }); }; // hook to get the langauge cookie and set the locale diff --git a/frontend/src/lib/components/CollectionModal.svelte b/frontend/src/lib/components/CollectionModal.svelte index 9328f4ce6..c7df607d7 100644 --- a/frontend/src/lib/components/CollectionModal.svelte +++ b/frontend/src/lib/components/CollectionModal.svelte @@ -4,6 +4,7 @@ import { t } from 'svelte-i18n'; import MarkdownEditor from './MarkdownEditor.svelte'; import { addToast } from '$lib/toasts'; + import { copyToClipboard } from '$lib/index'; import type { Collection, ContentImage, SlimCollection } from '$lib/types'; // Icons @@ -158,7 +159,7 @@ collection.end_date = null; } - const payload = { + const payload: any = { name: collection.name, description: collection.description, start_date: collection.start_date, @@ -168,6 +169,17 @@ primary_image_id: coverImageId }; + // 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 { + payload.link = null; + } + } + if (collection.id === '') { let res = await fetch('/api/collections', { method: 'POST', @@ -186,7 +198,12 @@ dispatch('save', toSlimCollection(collection)); } else { console.error(data); - addToast('error', $t('collection.error_creating_collection')); + // Extract field-level errors from Django response + const fieldErrors = Object.entries(data) + .filter(([_, v]) => Array.isArray(v)) + .map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`) + .join('; '); + addToast('error', fieldErrors || $t('collection.error_creating_collection')); } } else { let res = await fetch(`/api/collections/${collection.id}`, { @@ -205,7 +222,12 @@ addToast('success', $t('collection.collection_edit_success')); dispatch('save', toSlimCollection(collection)); } else { - addToast('error', $t('collection.error_editing_collection')); + // Extract field-level errors from Django response + const fieldErrors = Object.entries(data) + .filter(([_, v]) => Array.isArray(v)) + .map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`) + .join('; '); + addToast('error', fieldErrors || $t('collection.error_editing_collection')); } } } @@ -506,11 +528,15 @@ /> +
  • + +
  • + {#if user?.uuid == adventure.user?.uuid} +
  • + +
  • + {/if} {#if user?.uuid == adventure.user?.uuid}
  • -
    - + {#if isOpen} +
    + -
      - {#each adventure_types as type} -
    • - -
    • - {/each} -
    -
    +
      + {#each adventure_types as type} +
    • + +
    • + {/each} +
    +
    + {/if} diff --git a/frontend/src/lib/components/locations/LocationVisits.svelte b/frontend/src/lib/components/locations/LocationVisits.svelte index 3c09d5b44..2b2d705cd 100644 --- a/frontend/src/lib/components/locations/LocationVisits.svelte +++ b/frontend/src/lib/components/locations/LocationVisits.svelte @@ -8,13 +8,15 @@ TransportationVisit } from '$lib/types'; import TimezoneSelector from '../TimezoneSelector.svelte'; + import MoneyInput from '../shared/MoneyInput.svelte'; import { t } from 'svelte-i18n'; import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate, formatPartialDate, formatPartialDateRange } from '$lib/dateUtils'; - import type { DatePrecision } from '$lib/types'; + import type { DatePrecision, MoneyValue } from '$lib/types'; import { onMount, tick } from 'svelte'; import { isAllDay, SPORT_TYPE_CHOICES } from '$lib'; import { createEventDispatcher } from 'svelte'; import { deserialize } from '$app/forms'; + import { DEFAULT_CURRENCY, toMoneyValue, normalizeMoneyPayload, formatMoney } from '$lib/money'; // Icons import CalendarIcon from '~icons/mdi/calendar'; @@ -63,6 +65,11 @@ let isEditing = false; let visitIdEditing: string | null = null; + // Price state for visit form + let visitPrice: number | null = null; + let visitPriceCurrency: string | null = null; + $: visitMoneyValue = toMoneyValue(visitPrice, visitPriceCurrency, DEFAULT_CURRENCY) as MoneyValue; + // Date input focus state (for placeholder-to-native-picker swap) let startInputActive = false; let endInputActive = false; @@ -306,18 +313,27 @@ async function addVisit(isAuto: boolean = false) { // If editing an existing visit, patch instead of creating new if (visitIdEditing) { + let patchPayload: Record = { + start_date: utcStartDate, + end_date: utcEndDate, + date_precision: datePrecision, + notes: note, + timezone: selectedStartTimezone, + price: visitPrice, + price_currency: visitPriceCurrency + }; + if (visitPrice !== null) { + patchPayload = normalizeMoneyPayload(patchPayload, 'price', 'price_currency', DEFAULT_CURRENCY); + } else { + patchPayload.price = null; + patchPayload.price_currency = null; + } const response = await fetch(`/api/visits/${visitIdEditing}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - start_date: utcStartDate, - end_date: utcEndDate, - date_precision: datePrecision, - notes: note, - timezone: selectedStartTimezone - }) + body: JSON.stringify(patchPayload) }); if (response.ok) { @@ -330,20 +346,29 @@ } } else { // post to /api/visits for new visit + let postPayload: Record = { + object_id: objectId, + start_date: utcStartDate, + end_date: utcEndDate, + date_precision: datePrecision, + notes: note, + timezone: selectedStartTimezone, + location: objectId, + price: visitPrice, + price_currency: visitPriceCurrency + }; + if (visitPrice !== null) { + postPayload = normalizeMoneyPayload(postPayload, 'price', 'price_currency', DEFAULT_CURRENCY); + } else { + delete postPayload.price; + delete postPayload.price_currency; + } const response = await fetch('/api/visits/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - object_id: objectId, - start_date: utcStartDate, - end_date: utcEndDate, - date_precision: datePrecision, - notes: note, - timezone: selectedStartTimezone, - location: objectId - }) + body: JSON.stringify(postPayload) }); if (response.ok) { @@ -366,6 +391,8 @@ datePrecision = 'full'; startInputActive = false; endInputActive = false; + visitPrice = null; + visitPriceCurrency = null; } } @@ -715,6 +742,14 @@ delete showActivityUpload[visit.id]; note = visit.notes; + // Load price data for editing (only available on Visit, not TransportationVisit) + if ('price' in visit) { + visitPrice = (visit as Visit).price; + visitPriceCurrency = (visit as Visit).price_currency; + } else { + visitPrice = null; + visitPriceCurrency = null; + } constrainDates = true; utcStartDate = visit.start_date; utcEndDate = visit.end_date; @@ -1064,6 +1099,18 @@ > + +
    + { + visitPrice = event.detail.amount; + visitPriceCurrency = event.detail.currency; + }} + /> +
    +
    {/if} + {#if visit.price !== null && visit.price !== undefined} + {@const visitPriceLabel = formatMoney(toMoneyValue(visit.price, visit.price_currency, data.user?.default_currency || DEFAULT_CURRENCY))} + {#if visitPriceLabel} +
    + + {visitPriceLabel} +
    + {/if} + {/if} + {#if visit.activities && visit.activities.length > 0}
    From 1ed0a1ca2f2aac2034b45ea4c38e2702919fb51e Mon Sep 17 00:00:00 2001 From: Mathias Ponnwitz Date: Sun, 15 Feb 2026 18:11:50 +0100 Subject: [PATCH 8/8] docs: update CHANGELOG with emoji headers and reorganized issue listing Reorganize CHANGELOG.md for PR #1008: - Add emoji section headers for better readability - Group Part 2 issues (#990, #981, #891, #987, #977) at top - Move Docker Compose config from Technical to Improvements Co-authored-by: Cursor --- CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37441e887..56e5f6c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ## [Unreleased] - 2026-02-15 -### Bug Fixes -- Fix #981: Collection filter UI state resets on navigation — add `afterNavigate` callback to sync filter/sort/data state after every navigation event (same pattern as #990); fix `orderDirection` default mismatch (`asc` → `desc`); add visual indicator for active filters on mobile and sidebar; remove debug `console.log` +### 🐛 Bug Fixes - Fix #990: Category filter not working in v0.12.0 — replace `$:` reactive block with `afterNavigate` to prevent Svelte from resetting `typeString`; replace DaisyUI collapse with Svelte-controlled toggle to fix click interception; auto-apply filter on checkbox change for better UX +- Fix #981: Collection filter UI state resets on navigation — add `afterNavigate` callback to sync filter/sort/data state after every navigation event (same pattern as #990); fix `orderDirection` default mismatch (`asc` → `desc`); add visual indicator for active filters on mobile and sidebar; remove debug `console.log` +- Fix #891: "Adventures" → "Locations" terminology inconsistency — update 18 translation values across all 19 locale files, 2 backend error/response strings, and 1 frontend download filename to use consistent "Locations" terminology - Fix #888: PATCH Location with visits fails — extract `visits_data` before `setattr` loop in `LocationSerializer.update()` - Fix #991: Wikipedia/URL image upload fails — add server-side image proxy (`/api/images/fetch_from_url/`) with SSRF protection - Fix #617: Cannot change adventure from Private to Public — persist `is_public` in serializer update @@ -15,20 +16,19 @@ - Fix: Clipboard API fails in HTTP contexts — add global polyfill for non-secure contexts - Fix: `MultipleObjectsReturned` for duplicate images — change `get()` to `filter().first()` in `file_permissions.py` - Fix: AI description generation ignores user language setting — pass `lang` parameter from frontend locale to Wikipedia API -- Fix #891: "Adventures" → "Locations" terminology inconsistency — update 18 translation values across all 19 locale files, 2 backend error/response strings, and 1 frontend download filename to use consistent "Locations" terminology - Fix: Missing i18n keys for Strava activity toggle buttons — add `show_strava_activities` / `hide_strava_activities` translations -### Features +### ✨ Features - Feat #977: Cost tracking per visit — add `price` (MoneyField) to Visit model so users can record cost per visit instead of only per location; add MoneyInput to visit form with currency selector; display price in visit list and location detail timeline; keep existing Location.price as estimated/reference price - Feat #987: Partial date support for visits — add `date_precision` field to Visit model with three levels: full date (YYYY-MM-DD), month+year (MM/YYYY), year-only (YYYY); dynamic input types, precision selector with visual feedback, `formatPartialDate()` / `formatPartialDateRange()` helpers, badges in location detail view, i18n support for all locale files - Add Duplicate Location button (list and detail view) - Add Duplicate Adventure button (list and detail view) -### Improvements +### 🔧 Improvements - Full i18n support for all 19 languages (new keys for lodging, transportation, and adventure features) - Enhanced error handling and user feedback in Lodging and Transportation forms - Consistent URL validation across Locations, Lodging, and Transportation modules +- Docker Compose: Switch from `image:` to `build:` for local development builds -### Technical -- Switch `docker-compose.yml` from `image:` to `build:` for frontend and backend +### 📦 Technical - Remove internal documentation from public repository tracking