diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 0ee03169..ef241931 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -13,6 +13,32 @@ import logging logger = logging.getLogger(__name__) +def _build_profile_pic_url(user): + """Return absolute-ish profile pic URL using PUBLIC_URL if available.""" + if not getattr(user, 'profile_pic', None): + return None + + public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') + public_url = public_url.replace("'", "") + return f"{public_url}/media/{user.profile_pic.name}" + + +def _serialize_collaborator(user, owner_id=None, request_user=None): + if not user: + return None + + return { + 'uuid': str(user.uuid), + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'profile_pic': _build_profile_pic_url(user), + 'public_profile': bool(getattr(user, 'public_profile', False)), + 'is_owner': owner_id == user.id, + 'is_current_user': bool(request_user and request_user.id == user.id), + } + + class ContentImageSerializer(CustomModelSerializer): class Meta: model = ContentImage @@ -678,6 +704,7 @@ class ChecklistSerializer(CustomModelSerializer): return data class CollectionSerializer(CustomModelSerializer): + collaborators = serializers.SerializerMethodField() locations = serializers.SerializerMethodField() transportations = serializers.SerializerMethodField() notes = serializers.SerializerMethodField() @@ -712,6 +739,7 @@ class CollectionSerializer(CustomModelSerializer): 'checklists', 'is_archived', 'shared_with', + 'collaborators', 'link', 'lodging', 'status', @@ -721,6 +749,30 @@ class CollectionSerializer(CustomModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start', 'primary_image'] + def get_collaborators(self, obj): + request = self.context.get('request') + request_user = getattr(request, 'user', None) if request else None + + users = [] + if obj.user: + users.append(obj.user) + users.extend(list(obj.shared_with.all())) + + collaborators = [] + seen = set() + for user in users: + if not user: + continue + key = str(user.uuid) + if key in seen: + continue + seen.add(key) + serialized = _serialize_collaborator(user, owner_id=obj.user_id, request_user=request_user) + if serialized: + collaborators.append(serialized) + + return collaborators + def get_locations(self, obj): if self.context.get('nested', False): allowed_nested_fields = set(self.context.get('allowed_nested_fields', [])) @@ -856,16 +908,41 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer): status = serializers.SerializerMethodField() days_until_start = serializers.SerializerMethodField() primary_image = ContentImageSerializer(read_only=True) + collaborators = serializers.SerializerMethodField() class Meta: model = Collection fields = [ 'id', 'user', 'name', 'description', 'is_public', 'start_date', 'end_date', 'is_archived', 'link', 'created_at', 'updated_at', 'location_images', - 'location_count', 'shared_with', 'status', 'days_until_start', 'primary_image' + 'location_count', 'shared_with', 'collaborators', 'status', 'days_until_start', 'primary_image' ] read_only_fields = fields # All fields are read-only for listing + def get_collaborators(self, obj): + request = self.context.get('request') + request_user = getattr(request, 'user', None) if request else None + + users = [] + if obj.user: + users.append(obj.user) + users.extend(list(obj.shared_with.all())) + + collaborators = [] + seen = set() + for user in users: + if not user: + continue + key = str(user.uuid) + if key in seen: + continue + seen.add(key) + serialized = _serialize_collaborator(user, owner_id=obj.user_id, request_user=request_user) + if serialized: + collaborators.append(serialized) + + return collaborators + def get_location_images(self, obj): """Get primary images from locations in this collection, optimized with select_related""" # Filter first, then slice (removed slicing) diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index a3998ecd..2c7e07e1 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -110,7 +110,8 @@ class CollectionViewSet(viewsets.ModelViewSet): 'locations__images', queryset=ContentImage.objects.filter(is_primary=True).select_related('user'), to_attr='primary_images' - ) + ), + 'shared_with' ) def get_base_queryset(self): @@ -146,7 +147,7 @@ class CollectionViewSet(viewsets.ModelViewSet): Q(user=self.request.user.id) & Q(is_archived=False) ).distinct() - return queryset.select_related('primary_image') + return queryset.select_related('primary_image').prefetch_related('shared_with') def get_queryset(self): """Get queryset with optimizations for list actions""" diff --git a/frontend/src/lib/components/cards/ChecklistCard.svelte b/frontend/src/lib/components/cards/ChecklistCard.svelte index 2a6ffa1f..8a385902 100644 --- a/frontend/src/lib/components/cards/ChecklistCard.svelte +++ b/frontend/src/lib/components/cards/ChecklistCard.svelte @@ -14,6 +14,7 @@ import CheckCircle from '~icons/mdi/check-circle'; import CheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline'; import CalendarRemove from '~icons/mdi/calendar-remove'; + import Close from '~icons/mdi/close'; import type { CollectionItineraryItem } from '$lib/types'; export let checklist: Checklist; @@ -31,6 +32,18 @@ (checklist.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))); + const normalizeDateForApi = (date: string | Date | null | undefined): string | null => { + if (!date) return null; + if (date instanceof Date && !isNaN(date.getTime())) { + return date.toISOString().slice(0, 10); + } + if (typeof date === 'string') { + const match = date.match(/^\d{4}-\d{2}-\d{2}/); + return match ? match[0] : null; + } + return null; + }; + function editChecklist() { dispatch('edit', checklist); } @@ -68,6 +81,7 @@ const updatedItems = checklist.items.map((item) => item.id === itemId ? { ...item, is_checked: !item.is_checked } : item ); + const dateForApi = normalizeDateForApi(checklist.date); updatingItemId = itemId; checklist = { ...checklist, items: updatedItems }; @@ -80,7 +94,7 @@ }, body: JSON.stringify({ name: checklist.name, - date: checklist.date || null, + date: dateForApi, items: updatedItems, collection: checklist.collection, is_public: checklist.is_public @@ -121,46 +135,83 @@ {#if isDetailsOpen} diff --git a/frontend/src/lib/components/cards/NoteCard.svelte b/frontend/src/lib/components/cards/NoteCard.svelte index 80e371b2..76e8ce4c 100644 --- a/frontend/src/lib/components/cards/NoteCard.svelte +++ b/frontend/src/lib/components/cards/NoteCard.svelte @@ -18,6 +18,8 @@ import FileDocumentEdit from '~icons/mdi/file-document-edit'; import LinkVariant from '~icons/mdi/link-variant'; import CalendarRemove from '~icons/mdi/calendar-remove'; + import Launch from '~icons/mdi/launch'; + import Close from '~icons/mdi/close'; import type { CollectionItineraryItem } from '$lib/types'; export let note: Note; @@ -79,22 +81,35 @@ {#if isDetailsOpen} diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 8a8fbb0e..29a1f8fc 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -17,6 +17,7 @@ import CalendarBlank from '~icons/mdi/calendar-blank'; import Bed from '~icons/mdi/bed'; import Info from '~icons/mdi/information'; + import Plus from '~icons/mdi/plus'; import LocationCard from '$lib/components/cards/LocationCard.svelte'; import TransportationCard from '$lib/components/cards/TransportationCard.svelte'; import LodgingCard from '$lib/components/cards/LodgingCard.svelte'; @@ -1191,7 +1192,7 @@ if (input) input.focus(); }} > - + `{$t('adventures.name')}` + + {$t('adventures.name')} + - + +
+
+ Routes & Activities +
+
+
+ 🗺️ +
+
+ GPX Routes + Transport & activity paths +
+ +
+
{/if} @@ -939,6 +964,19 @@ + {#if showLines && linesGeoJson} + + + + {/if} + {#if newMarker}
{ location.price = event.detail.amount; - location.price_currency = - event.detail.amount === null ? null : event.detail.currency || defaultCurrency; + location.price_currency = event.detail.currency; + + // If an amount exists but no currency is chosen, fall back to the user's default + if (location.price !== null && !location.price_currency) { + location.price_currency = defaultCurrency; + } }} /> diff --git a/frontend/src/lib/components/map/MapStyleSelector.svelte b/frontend/src/lib/components/map/MapStyleSelector.svelte index 7aa54f4b..ec56a042 100644 --- a/frontend/src/lib/components/map/MapStyleSelector.svelte +++ b/frontend/src/lib/components/map/MapStyleSelector.svelte @@ -4,6 +4,26 @@ import MapIcon from '~icons/mdi/map'; export let basemapType: string = 'default'; + + const categoryOrder = [ + 'Standard', + '3D Terrain', + 'Satellite', + 'Topographic', + 'Clean', + 'Specialized' + ]; + + const groupedOptions = basemapOptions.reduce>( + (acc, option) => { + if (!acc[option.category]) { + acc[option.category] = []; + } + acc[option.category].push(option); + return acc; + }, + {} + ); - + {#if basemapOptions?.length} + + {/if}
diff --git a/frontend/src/lib/components/shared/LocationSearchMap.svelte b/frontend/src/lib/components/shared/LocationSearchMap.svelte index 3d7ecad7..9a1b9da6 100644 --- a/frontend/src/lib/components/shared/LocationSearchMap.svelte +++ b/frontend/src/lib/components/shared/LocationSearchMap.svelte @@ -64,7 +64,8 @@ let selectedMarker: { lng: number; lat: number } | null = null; let locationData: LocationMeta | null = null; let mapCenter: [number, number] = [-74.5, 40]; - let mapZoom = 2; + let mapZoom: number | undefined = 2; + let mapBounds: [[number, number], [number, number]] | null = null; let mapComponent: any; let searchTimeout: ReturnType; let initialApplied = false; @@ -96,6 +97,7 @@ selectedEndLocation = null; startMarker = null; endMarker = null; + mapBounds = null; startLocationData = null; startCode = null; endCode = null; @@ -410,18 +412,31 @@ function updateMapBounds() { if (startMarker && endMarker) { - const lngs = [startMarker.lng, endMarker.lng]; - const lats = [startMarker.lat, endMarker.lat]; - const centerLng = (Math.min(...lngs) + Math.max(...lngs)) / 2; - const centerLat = (Math.min(...lats) + Math.max(...lats)) / 2; - mapCenter = [centerLng, centerLat]; - mapZoom = 4; + const minLng = Math.min(startMarker.lng, endMarker.lng); + const maxLng = Math.max(startMarker.lng, endMarker.lng); + const minLat = Math.min(startMarker.lat, endMarker.lat); + const maxLat = Math.max(startMarker.lat, endMarker.lat); + + // Add a small padding so pins are not flush against the edge when fitting + const lonPadding = Math.max((maxLng - minLng) * 0.1, 0.5); + const latPadding = Math.max((maxLat - minLat) * 0.1, 0.5); + + mapBounds = [ + [minLng - lonPadding, minLat - latPadding], + [maxLng + lonPadding, maxLat + latPadding] + ]; + mapCenter = [(minLng + maxLng) / 2, (minLat + maxLat) / 2]; + mapZoom = undefined; } else if (startMarker) { mapCenter = [startMarker.lng, startMarker.lat]; mapZoom = 8; + mapBounds = null; } else if (endMarker) { mapCenter = [endMarker.lng, endMarker.lat]; mapZoom = 8; + mapBounds = null; + } else { + mapBounds = null; } } @@ -595,6 +610,7 @@ } mapCenter = [-74.5, 40]; mapZoom = 2; + mapBounds = null; dispatch('clear'); } @@ -986,6 +1002,7 @@ class="w-full h-80 rounded-lg border border-base-300" center={mapCenter} zoom={mapZoom} + bounds={mapBounds ?? undefined} standardControls > diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index fff9fc97..b3717f4b 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -1,4 +1,4 @@ -export let appVersion = 'v0.12.0-pre-dev-010526'; +export let appVersion = 'v0.12.0-pre-dev-010526-2'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0'; export let appTitle = 'AdventureLog'; export let copyrightYear = '2023-2026'; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index 147d91e9..b093bd07 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -867,12 +867,6 @@ export function getBasemapUrl(type = 'default'): any { '© OpenStreetMap contributors, © CARTO' ); - case 'wikimedia': - return getXYZStyle( - 'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png', - '© OpenStreetMap contributors, Wikimedia Maps' - ); - case 'usgs-imagery': return getXYZStyle( 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}', @@ -994,7 +988,6 @@ export const basemapOptions = [ // Standard & Vector { value: 'osm-standard', label: 'OpenStreetMap', icon: '🌍', category: 'Standard' }, - { value: 'wikimedia', label: 'Wikimedia', icon: '📖', category: 'Standard' }, // Satellite & Imagery { value: 'satellite', label: 'Satellite', icon: '🛰️', category: 'Satellite' }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 92359463..5131a8f6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -21,6 +21,17 @@ export type User = { default_currency: string; }; +export type Collaborator = { + uuid: string; + username: string; + first_name: string | null; + last_name: string | null; + profile_pic: string | null; + public_profile?: boolean; + is_owner: boolean; + is_current_user?: boolean; +}; + export type ContentImage = { id: string; image: string; @@ -142,6 +153,7 @@ export type Collection = { checklists?: Checklist[]; is_archived?: boolean; shared_with: string[] | undefined; + collaborators?: Collaborator[]; link?: string | null; primary_image?: ContentImage | null; primary_image_id?: string | null; @@ -166,6 +178,7 @@ export type SlimCollection = { location_images: ContentImage[]; location_count: number; shared_with: string[]; + collaborators?: Collaborator[]; primary_image?: ContentImage | null; status: 'folder' | 'upcoming' | 'in_progress' | 'completed'; days_until_start: number | null; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 896c05c1..acc6db77 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -787,7 +787,8 @@ "cover": "Cover", "location_primary": "Location cover", "set_cover": "Set cover", - "clear_cover": "Clear cover" + "clear_cover": "Clear cover", + "collaborators": "Collaborators" }, "notes": { "note_deleted": "Note deleted successfully!", diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 137b0031..838e8278 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -1,5 +1,5 @@