feat: implement date validation for itinerary items and add day picker modal for scheduling

This commit is contained in:
Sean Morley
2025-12-27 16:21:44 -05:00
parent 65fcd94898
commit 6f923f0181
11 changed files with 589 additions and 83 deletions

View File

@@ -1,5 +1,6 @@
from typing import List
from django.db import transaction
from django.utils.dateparse import parse_date, parse_datetime
from rest_framework.exceptions import ValidationError, PermissionDenied
from adventures.models import CollectionItineraryItem
@@ -75,6 +76,27 @@ def reorder_itinerary_items(user, items_data: List[dict]):
new_date = item_data.get('date')
new_order = item_data.get('order')
if new_date is not None:
# validate date is within collection bounds (if collection has start/end)
parsed = None
try:
parsed = parse_date(str(new_date))
except Exception:
parsed = None
if parsed is None:
try:
dt = parse_datetime(str(new_date))
if dt:
parsed = dt.date()
except Exception:
parsed = None
collection = item.collection
if parsed and collection:
if collection.start_date and parsed < collection.start_date:
raise ValidationError({"items": f"Item {item_id} date {parsed} is before collection start date {collection.start_date}."})
if collection.end_date and parsed > collection.end_date:
raise ValidationError({"items": f"Item {item_id} date {parsed} is after collection end date {collection.end_date}."})
item.date = new_date
if new_order is not None:
item.order = new_order

View File

@@ -179,6 +179,34 @@ class ItineraryViewSet(viewsets.ModelViewSet):
item_date = data.get('date')
item_order = data.get('order', 0)
# Validate that the itinerary date (if provided) falls within the
# collection's start_date/end_date range (if those bounds are set).
if collection_id and item_date:
# Try parse date or datetime-like values
parsed_date = None
try:
parsed_date = parse_date(str(item_date))
except Exception:
parsed_date = None
if parsed_date is None:
try:
dt = parse_datetime(str(item_date))
if dt:
parsed_date = dt.date()
except Exception:
parsed_date = None
if parsed_date is not None:
try:
collection_obj = Collection.objects.get(id=collection_id)
except Collection.DoesNotExist:
return Response({'error': 'Collection not found'}, status=status.HTTP_404_NOT_FOUND)
if collection_obj.start_date and parsed_date < collection_obj.start_date:
return Response({'error': 'Itinerary item date is before the collection start_date'}, status=status.HTTP_400_BAD_REQUEST)
if collection_obj.end_date and parsed_date > collection_obj.end_date:
return Response({'error': 'Itinerary item date is after the collection end_date'}, status=status.HTTP_400_BAD_REQUEST)
if collection_id and item_date:
# Find the maximum order for this collection+date
existing_max = CollectionItineraryItem.objects.filter(

View File

@@ -7,7 +7,8 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
import requests
from adventures.models import Location, Category
from adventures.models import Location, Category, CollectionItineraryItem
from django.contrib.contenttypes.models import ContentType
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import LocationSerializer, MapPinSerializer
from adventures.utils import pagination
@@ -277,6 +278,25 @@ class LocationViewSet(viewsets.ModelViewSet):
raise PermissionDenied(
f"You don't have permission to remove this location from one of the collections it's linked to.'"
)
else:
# If the removal is permitted, also remove any itinerary items
# in this collection that reference this Location instance.
try:
ct = ContentType.objects.get_for_model(instance.__class__)
# Try deleting by native PK type first, then by string.
qs = CollectionItineraryItem.objects.filter(
collection=collection, content_type=ct, object_id=instance.pk
)
if qs.exists():
qs.delete()
else:
CollectionItineraryItem.objects.filter(
collection=collection, content_type=ct, object_id=str(instance.pk)
).delete()
except Exception:
# Don't raise on cleanup failures; deletion of itinerary items
# is best-effort and shouldn't block the update operation.
pass
def _validate_collection_permissions(self, collections):
"""Validate permissions for all collections (used in create)."""

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Location, User } from '$lib/types';
import type { Location, User, Pin } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { t } from 'svelte-i18n';
@@ -16,9 +16,15 @@
import Cancel from '~icons/mdi/cancel';
import Public from '~icons/mdi/earth';
import Private from '~icons/mdi/lock';
import Loading from '~icons/mdi/loading';
let adventures: Location[] = [];
let filteredAdventures: Location[] = [];
let pins: Pin[] = [];
let locationCache: Map<string, Location> = new Map();
let loadingLocationIds: Set<string> = new Set();
let locationRequests: Map<string, Promise<Location | null>> = new Map();
let filteredPins: Pin[] = [];
let filteredLocations: Map<string, Location | null> = new Map();
let searchQuery: string = '';
let filterOption: string = 'all';
let isLoading: boolean = true;
@@ -26,42 +32,124 @@
export let user: User | null;
export let collectionId: string;
// Search and filter functionality following worldtravel pattern
// Search and filter functionality - works with pins and cached locations
$: {
let filtered = adventures;
let filtered = pins;
// Apply search filter - include name and location
// Apply search filter - include name and location (using pin data + cached location data)
if (searchQuery !== '') {
filtered = filtered.filter((adventure) => {
const nameMatch = adventure.name.toLowerCase().includes(searchQuery.toLowerCase());
filtered = filtered.filter((pin) => {
const nameMatch = pin.name.toLowerCase().includes(searchQuery.toLowerCase());
const cachedLocation = locationCache.get(pin.id);
const locationMatch =
adventure.location?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
cachedLocation?.location?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
const descriptionMatch =
adventure.description?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
cachedLocation?.description?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
return nameMatch || locationMatch || descriptionMatch;
});
}
// Apply status filter
// Apply status filter (using pin data + cached location data)
if (filterOption === 'public') {
filtered = filtered.filter((adventure) => adventure.is_public);
filtered = filtered.filter((pin) => {
const cachedLocation = locationCache.get(pin.id);
return cachedLocation?.is_public ?? false;
});
} else if (filterOption === 'private') {
filtered = filtered.filter((adventure) => !adventure.is_public);
filtered = filtered.filter((pin) => {
const cachedLocation = locationCache.get(pin.id);
return !(cachedLocation?.is_public ?? true);
});
} else if (filterOption === 'visited') {
filtered = filtered.filter((adventure) => adventure.visits && adventure.visits.length > 0);
filtered = filtered.filter((pin) => pin.is_visited === true);
} else if (filterOption === 'not_visited') {
filtered = filtered.filter((adventure) => !adventure.visits || adventure.visits.length === 0);
filtered = filtered.filter((pin) => pin.is_visited !== true);
}
filteredAdventures = filtered;
filteredPins = filtered;
}
// Statistics following worldtravel pattern
$: totalAdventures = adventures.length;
$: publicAdventures = adventures.filter((a) => a.is_public).length;
$: privateAdventures = adventures.filter((a) => !a.is_public).length;
$: visitedAdventures = adventures.filter((a) => a.visits && a.visits.length > 0).length;
$: notVisitedAdventures = adventures.filter((a) => !a.visits || a.visits.length === 0).length;
$: totalAdventures = pins.length;
$: visitedAdventures = pins.filter((p) => p.is_visited === true).length;
$: plannedAdventures = pins.filter((p) => p.is_visited !== true).length;
// Fetch location details lazily (like the map page)
async function fetchLocationDetails(locationId: string): Promise<Location | null> {
// Check cache first
if (locationCache.has(locationId)) {
return locationCache.get(locationId)!;
}
// Reuse in-flight requests
const existing = locationRequests.get(locationId);
if (existing) return existing;
const request = (async () => {
try {
loadingLocationIds.add(locationId);
const res = await fetch(`/api/locations/${locationId}/?include_collections=true`);
if (!res.ok) {
console.error(`Failed to fetch location ${locationId}`);
return null;
}
const location = (await res.json()) as Location;
locationCache.set(locationId, location);
// Trigger reactivity
locationCache = locationCache;
return location;
} catch (error) {
console.error('Error fetching location details:', error);
return null;
} finally {
loadingLocationIds.delete(locationId);
locationRequests.delete(locationId);
}
})();
locationRequests.set(locationId, request);
loadingLocationIds.add(locationId);
return request;
}
// Intersection Observer for lazy loading
let observer: IntersectionObserver | null = null;
function setupLazyLoading(element: HTMLElement) {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const pinId = entry.target.getAttribute('data-pin-id');
if (pinId && !locationCache.has(pinId) && !loadingLocationIds.has(pinId)) {
console.log('Lazy loading location:', pinId);
fetchLocationDetails(pinId);
}
}
});
},
{ rootMargin: '200px' } // Start loading 200px before it comes into view
);
return {
destroy: () => {
if (observer) {
observer.disconnect();
}
}
};
}
// Re-observe elements when pins change
$: if (observer && filteredPins.length > 0) {
// Disconnect and reconnect to observe all pin cards
observer.disconnect();
setTimeout(() => {
const pinCards = document.querySelectorAll('[data-pin-id]');
console.log('Observing pin cards:', pinCards.length);
pinCards.forEach((el) => observer?.observe(el));
}, 0);
}
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
@@ -69,22 +157,35 @@
modal.showModal();
}
let res = await fetch(`/api/locations/all/?include_collections=true`, {
method: 'GET'
});
const newAdventures = await res.json();
// Filter out adventures that are already linked to the collections
if (collectionId) {
adventures = newAdventures.filter((adventure: Location) => {
return !(adventure.collections ?? []).includes(collectionId);
try {
// Fetch minimal pin data first
const res = await fetch(`/api/locations/pins/`, {
method: 'GET'
});
} else {
adventures = newAdventures;
}
isLoading = false;
if (!res.ok) {
console.error('Failed to fetch pins:', res.status, res.statusText);
isLoading = false;
return;
}
const newPins = (await res.json()) as Pin[];
console.log('Fetched pins:', newPins);
// Filter out pins that are already linked to the collection
if (collectionId) {
// For now, show all pins - we'll check collections when fetching full data
pins = newPins;
} else {
pins = newPins;
}
console.log('Set pins to:', pins);
isLoading = false;
} catch (error) {
console.error('Error fetching pins:', error);
isLoading = false;
}
});
function close() {
@@ -92,7 +193,7 @@
}
function add(event: CustomEvent<Location>) {
adventures = adventures.filter((a) => a.id !== event.detail.id);
pins = pins.filter((p) => p.id !== event.detail.id);
dispatch('add', event.detail);
}
@@ -131,7 +232,7 @@
{$t('adventures.my_adventures')}
</h1>
<p class="text-sm text-base-content/60">
{filteredAdventures.length}
{filteredPins.length}
{$t('worldtravel.of')}
{totalAdventures}
{$t('locations.locations')}
@@ -244,8 +345,8 @@
</div>
{:else}
<!-- Main Content -->
<div class="px-2">
{#if filteredAdventures.length === 0}
<div class="px-2" use:setupLazyLoading>
{#if filteredPins.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
<Adventures class="w-16 h-16 text-base-content/30" />
@@ -271,10 +372,27 @@
{/if}
</div>
{:else}
<!-- Adventures Grid -->
<!-- Locations Grid with Lazy Loading -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6 p-4">
{#each filteredAdventures as adventure}
<LocationCard {user} type="link" {adventure} on:link={add} />
{#each filteredPins as pin (pin.id)}
<div data-pin-id={pin.id} class="h-full">
{#if locationCache.has(pin.id)}
{@const location = locationCache.get(pin.id)}
{#if location}
<LocationCard {user} type="link" adventure={location} on:link={add} />
{/if}
{:else}
<!-- Skeleton Loading Card -->
<div class="card w-full bg-base-300 shadow animate-pulse">
<div class="h-48 bg-base-200"></div>
<div class="card-body gap-3">
<div class="h-6 bg-base-200 rounded w-3/4"></div>
<div class="h-4 bg-base-200 rounded w-full"></div>
<div class="h-4 bg-base-200 rounded w-2/3"></div>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -287,7 +405,7 @@
>
<div class="flex items-center justify-between">
<div class="text-sm text-base-content/60">
{filteredAdventures.length}
{filteredPins.length}
{$t('adventures.adventures_available')}
</div>
<button class="btn btn-primary gap-2" on:click={close}>

View File

@@ -199,36 +199,37 @@
</div>
</div>
<!-- Route Info (Compact) -->
<div class="space-y-2">
{#if transportation.from_location && transportation.to_location}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.route')}:</span>
<span class="truncate">{transportation.from_location} → {transportation.to_location}</span
>
</div>
{:else if transportation.from_location}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.from')}:</span>
<span class="truncate">{transportation.from_location}</span>
</div>
{:else if transportation.to_location}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.to')}:</span>
<span class="truncate">{transportation.to_location}</span>
</div>
{/if}
{#if transportation.type === 'plane' && transportation.flight_number}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.flight')}:</span>
<span>{transportation.flight_number}</span>
</div>
{/if}
</div>
<!-- Route Info -->
{#if (transportation.start_code && transportation.end_code) || transportation.from_location || transportation.to_location}
<div class="text-sm text-base-content/70">
{#if transportation.start_code && transportation.end_code}
<div class="flex items-center gap-2">
<span class="font-semibold text-base-content">{transportation.start_code}</span>
<span class="text-base-content/40">→</span>
<span class="font-semibold text-base-content">{transportation.end_code}</span>
</div>
{:else if transportation.from_location && transportation.to_location}
<div class="flex items-start gap-2">
<span class="flex-1">{transportation.from_location}</span>
<span class="text-base-content/40 mt-0.5">→</span>
<span class="flex-1">{transportation.to_location}</span>
</div>
{:else if transportation.from_location}
<span>{transportation.from_location}</span>
{:else}
<span>{transportation.to_location}</span>
{/if}
</div>
{/if}
<!-- Inline Stats -->
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
{#if transportation.type === 'plane' && transportation.flight_number}
<div class="badge badge-ghost badge-sm">
{transportation.flight_number}
</div>
{/if}
{#if transportation.date}
<div class="flex items-center gap-1">
<span class="font-medium">

View File

@@ -26,6 +26,7 @@
import NoteModal from '$lib/components/NoteModal.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import ItineraryLinkModal from '$lib/components/collections/ItineraryLinkModal.svelte';
import ItineraryDayPickModal from '$lib/components/collections/ItineraryDayPickModal.svelte';
import { t } from 'svelte-i18n';
export let collection: Collection;
@@ -211,6 +212,10 @@
let linkModalTargetDate: string = '';
let linkModalDisplayDate: string = '';
// Day picker modal state for unscheduled items
let isDayPickModalOpen = false;
let dayPickItemToAdd: { type: string; item: any } | null = null;
// When opening a "create new item" modal we store the target date here
let pendingAddDate: string | null = null;
// Track if we've already added this location to the itinerary
@@ -585,6 +590,89 @@
}
}
// Handle opening the day picker modal for an unscheduled item
function handleOpenDayPickerForItem(type: string, item: any) {
// Check if the item already has a date, and if so, add it directly
let itemDate: string | null = null;
if (type === 'location') {
// For locations, check if there's a visit with a start_date
const firstVisit = item.visits?.[0];
if (firstVisit?.start_date) {
itemDate = firstVisit.start_date.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
} else if (type === 'transportation') {
if (item.date) {
itemDate = item.date.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
} else if (type === 'lodging') {
if (item.check_in) {
itemDate = item.check_in.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
} else if (type === 'note') {
if (item.date) {
itemDate = item.date.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
} else if (type === 'checklist') {
if (item.date) {
itemDate = item.date.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
}
// If we found a date, add it directly to that date
// Helper: check if a date is within collection start/end bounds (if set)
function isDateWithinCollectionRange(dateISO: string | null) {
if (!dateISO) return false;
if (!collection) return true; // no collection context -> allow
try {
const d = DateTime.fromISO(dateISO).startOf('day');
if (collection.start_date) {
const s = DateTime.fromISO(collection.start_date).startOf('day');
if (d < s) return false;
}
if (collection.end_date) {
const e = DateTime.fromISO(collection.end_date).startOf('day');
if (d > e) return false;
}
return true;
} catch (err) {
return false;
}
}
if (itemDate) {
// If the item's date is outside the collection range, prompt the day picker
if (!isDateWithinCollectionRange(itemDate)) {
dayPickItemToAdd = { type, item };
isDayPickModalOpen = true;
return;
}
addItineraryItemForObject(type, item.id, itemDate, false);
} else {
// Otherwise, show the day picker modal
dayPickItemToAdd = { type, item };
isDayPickModalOpen = true;
}
}
// Handle day selection from the day picker modal
async function handleDaySelected(event: CustomEvent<{ date: string; updateDate: boolean }>) {
const { date: selectedDate, updateDate } = event.detail;
if (!dayPickItemToAdd) return;
const { type, item } = dayPickItemToAdd;
const objectType = type; // 'location', 'transportation', 'lodging', 'note', 'checklist'
const objectId = item.id;
// Add the item to the selected day
await addItineraryItemForObject(objectType, objectId, selectedDate, updateDate);
// Reset state
dayPickItemToAdd = null;
isDayPickModalOpen = false;
}
// Add an itinerary item locally and attempt to persist to backend
async function addItineraryItemForObject(
objectType: string,
@@ -843,6 +931,19 @@
/>
{/if}
{#if isDayPickModalOpen}
<ItineraryDayPickModal
isOpen={isDayPickModalOpen}
{days}
itemName={dayPickItemToAdd?.item?.name || 'Item'}
on:daySelected={handleDaySelected}
on:close={() => {
isDayPickModalOpen = false;
dayPickItemToAdd = null;
}}
/>
{/if}
{#if canAutoGenerate}
<div class="alert alert-info shadow-lg mb-6">
<div class="flex-1 flex items-center gap-3 min-w-0">
@@ -1284,7 +1385,11 @@
<!-- "Add to itinerary" indicator -->
{#if canModify}
<div class="absolute -right-2 top-2 z-10">
<button class="btn btn-circle btn-sm btn-primary" title="Add to itinerary">
<button
class="btn btn-circle btn-sm btn-primary"
title="Add to itinerary"
on:click={() => handleOpenDayPickerForItem(type, item)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
// @ts-ignore
import { DateTime } from 'luxon';
import CalendarBlank from '~icons/mdi/calendar-blank';
import { t } from 'svelte-i18n';
export let isOpen: boolean = false;
export let days: Array<{ date: string; displayDate: string; items: any[] }> = [];
export let itemName: string = 'Item';
const dispatch = createEventDispatcher();
function handleDaySelect(dayDate: string, updateDate: boolean) {
dispatch('daySelected', { date: dayDate, updateDate });
isOpen = false;
}
function handleClose() {
dispatch('close');
isOpen = false;
}
</script>
{#if isOpen}
<dialog class="modal modal-open backdrop-blur-sm">
<div
class="modal-box w-11/12 max-w-4xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
>
<div
class="sticky top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-1 bg-primary/10 rounded-xl">
<CalendarBlank class="w-6 h-6 text-primary" />
</div>
<div>
<h1 class="text-xl font-bold text-primary">
Add "{itemName}" to itinerary
</h1>
<p class="text-xs text-base-content/60">
{days.length}
{days.length === 1 ? 'day available' : 'days available'}
</p>
</div>
</div>
<button class="btn btn-ghost btn-square" on:click={handleClose}>
<span class="text-lg"></span>
</button>
</div>
</div>
<div class="px-2 max-h-[28rem] overflow-y-auto space-y-3">
{#if days.length === 0}
<div class="card bg-base-200 border border-base-300">
<div class="card-body text-center py-10">
<CalendarBlank class="w-10 h-10 mx-auto mb-3 opacity-40" />
<p class="font-semibold opacity-80">No days available</p>
<p class="text-sm opacity-60">Create itinerary dates to add this item.</p>
</div>
</div>
{/if}
{#each days as day, index}
{@const dayNumber = index + 1}
{@const totalDays = days.length}
{@const weekday = DateTime.fromISO(day.date).toFormat('ccc')}
{@const dayOfMonth = DateTime.fromISO(day.date).toFormat('d')}
{@const monthAbbrev = DateTime.fromISO(day.date).toFormat('LLL')}
<div
class="card bg-base-100 border border-base-300 shadow-sm hover:border-primary/60 hover:shadow-md transition-all"
>
<div class="card-body p-4">
<div class="flex flex-row items-center gap-4 mb-3">
<div class="flex-none">
<div class="text-center bg-base-300 rounded-xl px-3 py-2 w-16">
<div class="text-xs opacity-70">{weekday}</div>
<div class="text-2xl font-bold -mt-1">{dayOfMonth}</div>
<div class="text-xs opacity-70">{monthAbbrev}</div>
</div>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="badge badge-primary badge-sm">Day {dayNumber}</span>
<span class="text-xs opacity-60">of {totalDays}</span>
</div>
<div class="font-semibold text-base mt-1">{day.displayDate}</div>
<div class="text-sm opacity-70 flex items-center gap-2 mt-1">
<span class="badge badge-outline badge-sm"
>{day.items.length} {day.items.length === 1 ? 'item' : 'items'}</span
>
</div>
</div>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-outline btn-sm flex-1"
on:click={() => handleDaySelect(day.date, false)}
>
Add as is
</button>
<button
type="button"
class="btn btn-primary btn-sm flex-1"
on:click={() => handleDaySelect(day.date, true)}
>
Add & Update Date
</button>
</div>
</div>
</div>
{/each}
</div>
<div
class="sticky bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-4 py-3 mt-6 flex items-center justify-between"
>
<div class="text-xs text-base-content/60">
{days.length}
{days.length === 1 ? 'day available' : 'days available'}
</div>
<button type="button" class="btn" on:click={handleClose}>Cancel</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" on:click={handleClose}>close</button>
</form>
</dialog>
{/if}

View File

@@ -595,10 +595,6 @@
maxlength="5"
placeholder={airportMode ? 'JFK' : 'Code'}
/>
<p class="text-xs text-base-content/60 mt-1">
{$t('transportation.autofill_code_hint') ||
'Auto-filled from airport search; you can override'}
</p>
</div>
<div class="form-control">
<label class="label" for="end_code">
@@ -615,10 +611,6 @@
maxlength="5"
placeholder={airportMode ? 'LHR' : 'Code'}
/>
<p class="text-xs text-base-content/60 mt-1">
{$t('transportation.autofill_code_hint_arrival') ||
'Auto-filled from arrival search; you can override'}
</p>
</div>
</div>

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.12.0-pre-dev-122625';
export let appVersion = 'v0.12.0-pre-dev-122725';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025';

View File

@@ -842,7 +842,9 @@
"enter_link": "Enter link",
"enter_flight_number": "Enter flight number",
"enter_from_location": "Enter from location",
"enter_to_location": "Enter to location"
"enter_to_location": "Enter to location",
"arrival_code": "Arrival Code",
"departure_code": "Departure Code"
},
"lodging": {
"new_lodging": "New Lodging",

View File

@@ -2,7 +2,7 @@
import type { Collection, ContentImage, Location } from '$lib/types';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import Lost from '$lib/assets/undraw_lost.svg';
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
@@ -16,12 +16,15 @@
import CollectionAllItems from '$lib/components/collections/CollectionAllItems.svelte';
import CollectionItineraryPlanner from '$lib/components/collections/CollectionItineraryPlanner.svelte';
import CollectionRecommendationView from '$lib/components/CollectionRecommendationView.svelte';
import LocationLink from '$lib/components/LocationLink.svelte';
import { getBasemapUrl } from '$lib';
import FolderMultiple from '~icons/mdi/folder-multiple';
import FormatListBulleted from '~icons/mdi/format-list-bulleted';
import Timeline from '~icons/mdi/timeline';
import Map from '~icons/mdi/map';
import Lightbulb from '~icons/mdi/lightbulb';
import Plus from '~icons/mdi/plus';
import { addToast } from '$lib/toasts';
const renderMarkdown = (markdown: string) => {
return marked(markdown) as string;
@@ -37,6 +40,7 @@
let heroImages: ContentImage[] = [];
let modalInitialIndex: number = 0;
let isImageModalOpen: boolean = false;
let isLocationLinkModalOpen: boolean = false;
// View state from URL params
type ViewType = 'all' | 'itinerary' | 'map' | 'recommendations';
@@ -141,6 +145,64 @@
url.searchParams.set('view', view);
goto(url.toString(), { replaceState: true, noScroll: true });
}
function openLocationLinkModal() {
isLocationLinkModalOpen = true;
}
function closeLocationLinkModal() {
isLocationLinkModalOpen = false;
}
async function handleLocationAdded(event: CustomEvent<Location>) {
// Link the location to this collection
const location = event.detail;
try {
const response = await fetch(`/api/locations/${location.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
collections: [...(location.collections || []), collection.id]
})
});
if (response.ok) {
// Keep modal open so user can link more locations.
// Update local collection state so UI reflects the new link immediately.
try {
if (!collection.locations) collection.locations = [];
// Avoid duplicates
const exists = collection.locations.some((l) => String(l.id) === String(location.id));
if (!exists) {
collection.locations = [...collection.locations, location];
}
} catch (e) {
// if collection shape is unexpected, ignore and continue
console.warn('Unable to update local collection.locations', e);
}
// Show success message but do NOT close the modal or reload the page
addToast(
'success',
$t('adventures.collection_link_location_success') || 'Location added successfully'
);
} else {
addToast(
'error',
$t('adventures.collection_link_location_error') || 'Failed to add location'
);
}
} catch (error) {
console.error('Error linking location:', error);
addToast(
'error',
$t('adventures.collection_link_location_error') || 'Failed to add location'
);
}
}
</script>
{#if notFound}
@@ -167,6 +229,15 @@
/>
{/if}
{#if isLocationLinkModalOpen && collection}
<LocationLink
user={data.user}
collectionId={collection.id}
on:close={closeLocationLinkModal}
on:add={handleLocationAdded}
/>
{/if}
{#if !collection && !notFound}
<div class="hero min-h-screen overflow-x-hidden">
<div class="hero-content">
@@ -606,6 +677,19 @@
</div>
{/if}
<!-- Floating Action Button (FAB) - Only shown if user can modify collection -->
{#if collection && canModifyCollection}
<div class="fixed bottom-6 right-6 z-40">
<button
class="btn btn-primary btn-circle w-16 h-16 shadow-2xl hover:shadow-primary/25 transition-all duration-200"
on:click={openLocationLinkModal}
aria-label="Add locations to collection"
>
<Plus class="w-8 h-8" />
</button>
</div>
{/if}
<svelte:head>
<title>
{collection && collection.name ? `${collection.name}` : 'Collection'}