mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-14 01:44:12 -04:00
feat: implement date validation for itinerary items and add day picker modal for scheduling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user