From fd463b428b56cbee47bfb4921c4c9d42a0f944ad Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Fri, 26 Dec 2025 19:03:33 -0500 Subject: [PATCH] feat: add Transportation modal component and related routes - Implemented TransportationModal component for creating and editing transportation entries. - Added server-side loading for transportation details in the new route [id]/+page.server.ts. - Created a new Svelte page for displaying transportation details with image and attachment handling. - Integrated modal for editing transportation in the transportation details page. - Updated lodging routes to include a modal for editing lodging entries. - Removed unused delete action from lodging server-side logic. --- .../components/AttachmentManagement.svelte | 11 +- frontend/src/lib/components/Toast.svelte | 101 ++- .../lib/components/TransportationModal.svelte | 803 ------------------ .../components/cards/CollectionCard.svelte | 26 + .../lib/components/cards/LocationCard.svelte | 2 +- .../cards/TransportationCard.svelte | 83 +- .../CollectionItineraryPlanner.svelte | 68 +- .../components/lodging/LodgingDetails.svelte | 20 - .../components/lodging/LodgingModal.svelte | 11 +- .../shared/LocationSearchMap.svelte | 790 ++++++++++++++--- .../MediaStep.svelte} | 5 +- .../TransportationDetails.svelte | 756 +++++++++++++++++ .../transportation/TransportationModal.svelte | 277 ++++++ frontend/src/lib/config.ts | 2 +- .../src/routes/lodging/[id]/+page.server.ts | 46 - frontend/src/routes/lodging/[id]/+page.svelte | 22 + .../transportations/[id]/+page.server.ts | 30 + .../routes/transportations/[id]/+page.svelte | 634 ++++++++++++++ 18 files changed, 2597 insertions(+), 1090 deletions(-) delete mode 100644 frontend/src/lib/components/TransportationModal.svelte rename frontend/src/lib/components/{lodging/LodgingMedia.svelte => shared/MediaStep.svelte} (95%) create mode 100644 frontend/src/lib/components/transportation/TransportationDetails.svelte create mode 100644 frontend/src/lib/components/transportation/TransportationModal.svelte create mode 100644 frontend/src/routes/transportations/[id]/+page.server.ts create mode 100644 frontend/src/routes/transportations/[id]/+page.svelte diff --git a/frontend/src/lib/components/AttachmentManagement.svelte b/frontend/src/lib/components/AttachmentManagement.svelte index 19192af4..827aef03 100644 --- a/frontend/src/lib/components/AttachmentManagement.svelte +++ b/frontend/src/lib/components/AttachmentManagement.svelte @@ -18,7 +18,7 @@ // Props export let attachments: Attachment[] = []; export let itemId: string = ''; - export let contentType: 'location' | 'lodging' = 'location'; + export let contentType: 'location' | 'lodging' | 'transportation' | '' = 'location'; // Component state let attachmentFileInput: HTMLInputElement; @@ -88,13 +88,8 @@ formData.append('file', selectedFile); formData.append('name', attachmentName.trim()); - // Different field names based on content type - if (contentType === 'lodging') { - formData.append('object_id', itemId); - formData.append('content_type', 'lodging'); - } else { - formData.append('location', itemId); - } + formData.append('object_id', itemId); + formData.append('content_type', contentType); try { const res = await fetch('/locations?/attachment', { diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte index 10b563c3..0c4ba7fc 100644 --- a/frontend/src/lib/components/Toast.svelte +++ b/frontend/src/lib/components/Toast.svelte @@ -1,35 +1,90 @@ -
- {#each toastList as { type, message, id, duration }} - {#if type == 'success'} -
- {message} +
+ {#each toastList as { type, message, id }} + {/each}
+ + diff --git a/frontend/src/lib/components/TransportationModal.svelte b/frontend/src/lib/components/TransportationModal.svelte deleted file mode 100644 index 497a639e..00000000 --- a/frontend/src/lib/components/TransportationModal.svelte +++ /dev/null @@ -1,803 +0,0 @@ - - - - - - - diff --git a/frontend/src/lib/components/cards/CollectionCard.svelte b/frontend/src/lib/components/cards/CollectionCard.svelte index a016c283..6529e731 100644 --- a/frontend/src/lib/components/cards/CollectionCard.svelte +++ b/frontend/src/lib/components/cards/CollectionCard.svelte @@ -25,6 +25,7 @@ import EyeOff from '~icons/mdi/eye-off'; import Check from '~icons/mdi/check'; import MapMarker from '~icons/mdi/map-marker-multiple'; + import LinkIcon from '~icons/mdi/link'; const dispatch = createEventDispatcher(); @@ -32,6 +33,18 @@ export let linkedCollectionList: string[] | null = null; export let user: User | null; let isShareModalOpen: boolean = false; + let copied: boolean = false; + + async function copyLink() { + try { + const url = `${location.origin}/collections/${collection.id}`; + await navigator.clipboard.writeText(url); + copied = true; + setTimeout(() => (copied = false), 2000); + } catch (e) { + addToast('error', $t('adventures.copy_failed') || 'Copy failed'); + } + } function editAdventure() { dispatch('edit', collection); @@ -284,6 +297,19 @@ {$t('adventures.share')} + {#if collection.is_public} +
  • + +
  • + {/if} {#if collection.is_archived}
  • -
  • - {#if itineraryItem && itineraryItem.id} +
    + + + {#if !readOnly && (transportation.user === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))} + - {/if} + + + {/if} +
    diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 91f2d338..cc46d77c 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -22,10 +22,11 @@ import ChecklistCard from '$lib/components/cards/ChecklistCard.svelte'; import NewLocationModal from '$lib/components/locations/LocationModal.svelte'; import LodgingModal from '../lodging/LodgingModal.svelte'; - import TransportationModal from '$lib/components/TransportationModal.svelte'; + import TransportationModal from '../transportation/TransportationModal.svelte'; import NoteModal from '$lib/components/NoteModal.svelte'; import ChecklistModal from '$lib/components/ChecklistModal.svelte'; import ItineraryLinkModal from '$lib/components/collections/ItineraryLinkModal.svelte'; + import { t } from 'svelte-i18n'; export let collection: Collection; export let user: any; @@ -126,6 +127,13 @@ isLodgingModalOpen = true; } + let transportationToEdit: Transportation | null = null; + let isTransportationModalOpen: boolean = false; + function handleEditTransportation(event: CustomEvent) { + transportationToEdit = event.detail; + isTransportationModalOpen = true; + } + function handleItemDelete(event: CustomEvent) { const payload = event.detail; @@ -193,8 +201,8 @@ let locationBeingUpdated: Location | null = null; let lodgingBeingUpdated: Lodging | null = null; + let transportationBeingUpdated: Transportation | null = null; - let isTransportationModalOpen = false; let isNoteModalOpen = false; let isChecklistModalOpen = false; let isItineraryLinkModalOpen = false; @@ -286,6 +294,41 @@ addedToItinerary = addedToItinerary; // trigger reactivity } + // Sync the transportationBeingUpdated with the collection.transportations array + $: if (transportationBeingUpdated && transportationBeingUpdated.id && collection) { + // Make a shallow copy of transportations (ensure array exists) + const transports = collection.transportations ? [...collection.transportations] : []; + + const index = transports.findIndex((t) => t.id === transportationBeingUpdated.id); + + if (index !== -1) { + // Replace the item immutably + transports[index] = { + ...transports[index], + ...transportationBeingUpdated + }; + } else { + // Prepend new/updated transportation + transports.unshift({ ...transportationBeingUpdated }); + } + + // Assign back to collection immutably to trigger reactivity + collection = { ...collection, transportations: transports }; + } + + // If a new transportation was just created and we have a pending add-date, + // attach it to that date in the itinerary. + $: if ( + transportationBeingUpdated?.id && + pendingAddDate && + !addedToItinerary.has(transportationBeingUpdated.id) + ) { + addItineraryItemForObject('transportation', transportationBeingUpdated.id, pendingAddDate); + // Mark this transportation as added to prevent duplicates + addedToItinerary.add(transportationBeingUpdated.id); + addedToItinerary = addedToItinerary; // trigger reactivity + } + /** * Get lodging items where the guest is staying overnight on a given date * (i.e., the date is between check_in and check_out, but NOT the check_in date itself) @@ -738,17 +781,19 @@ {#if isTransportationModalOpen} (isTransportationModalOpen = false)} - {collection} - on:save={(e) => { - const transportation = e.detail; - collection.transportations = [...(collection.transportations || []), transportation]; - if (pendingAddDate) { - addItineraryItemForObject('transportation', transportation.id, pendingAddDate); - pendingAddDate = null; - } + on:close={() => { isTransportationModalOpen = false; + transportationToEdit = null; + transportationBeingUpdated = null; + pendingAddDate = null; + addedToItinerary.clear(); + addedToItinerary = addedToItinerary; }} + {user} + {transportationToEdit} + bind:transportation={transportationBeingUpdated} + {collection} + initialVisitDate={pendingAddDate} /> {/if} @@ -1123,6 +1168,7 @@ on:delete={handleItemDelete} itineraryItem={item} on:removeFromItinerary={handleRemoveItineraryItem} + on:edit={handleEditTransportation} /> {:else if objectType === 'lodging'}
    - - {#if !lodgingToEdit || (lodgingToEdit.collection && lodgingToEdit.collection.length === 0)} -
    - -
    - {/if} -
    + {/if} +
    { + const id = event.params as { id: string }; + let request = await fetch(`${endpoint}/api/transportations/${id.id}/`, { + headers: { + Cookie: `sessionid=${event.cookies.get('sessionid')}` + }, + credentials: 'include' + }); + if (!request.ok) { + console.error('Failed to fetch transportation ' + id.id); + return { + props: { + transportation: null + } + }; + } else { + let transportation = (await request.json()) as Transportation; + + return { + props: { + transportation + } + }; + } +}) satisfies PageServerLoad; diff --git a/frontend/src/routes/transportations/[id]/+page.svelte b/frontend/src/routes/transportations/[id]/+page.svelte new file mode 100644 index 00000000..fd029adc --- /dev/null +++ b/frontend/src/routes/transportations/[id]/+page.svelte @@ -0,0 +1,634 @@ + + +{#if notFound} +
    +
    +
    + Lost +

    Transportation not found

    +

    {$t('adventures.location_not_found_desc')}

    + +
    +
    +
    +{/if} + +{#if isEditModalOpen} + (isEditModalOpen = false)} + user={data.user} + transportationToEdit={transportation} + bind:transportation + /> +{/if} + +{#if isImageModalOpen} + +{/if} + +{#if !transportation && !notFound} +
    +
    + +
    +
    +{/if} + +{#if transportation} + {#if data.user?.uuid && transportation.user && data.user.uuid === transportation.user} +
    + +
    + {/if} + + +
    +
    + + {#if transportation.images && transportation.images.length > 0} +
    + {#each transportation.images as image, i} +
    + +
    + {/each} + {:else} +
    + {/if} + + +
    0} + > +
    +
    + {getTransportationIcon(transportation.type)} +

    {transportation.name}

    +
    + + + {#if transportation.rating !== undefined && transportation.rating !== null} +
    +
    + {#each Array.from({ length: 5 }, (_, i) => i + 1) as star} + + {/each} +
    +
    + {/if} + + +
    + {#if transportation.type} +
    + {$t(`transportation.modes.${transportation.type}`)} +
    + {/if} + {#if transportation.from_location} +
    + 🚩 {transportation.from_location} +
    + {/if} + {#if transportation.to_location} +
    + 🏁 {transportation.to_location} +
    + {/if} + {#if transportation.is_public} +
    + đŸ‘ī¸ {$t('adventures.public')} +
    + {:else} +
    + 🔒 {$t('adventures.private')} +
    + {/if} +
    + + + {#if transportation.images && transportation.images.length > 1} +
    + +
    + + +
    + {currentSlide + 1} / {transportation.images.length} +
    + + +
    + + + {#if transportation.images.length <= 12} +
    + {#each transportation.images as _, i} + + {/each} +
    + {:else} +
    +
    +
    +
    + {/if} +
    + {/if} +
    +
    +
    +
    + + +
    +
    + +
    + + {#if transportation.description} +
    +
    +

    📝 {$t('adventures.description')}

    +
    + {@html DOMPurify.sanitize(renderMarkdown(transportation.description))} +
    +
    +
    + {/if} + + + {#if mapCenter} +
    +
    +

    đŸ—ēī¸ {$t('adventures.location')}

    +
    + + {#if hasOriginCoordinates(transportation)} + + +
    +
    {transportation.name}
    +

    + {$t('transportation.from_location')} + {getTransportationIcon(transportation.type)} +

    + {#if transportation.rating} +
    + {#each renderStars(transportation.rating) as filled} + {#if filled} + + {:else} + + {/if} + {/each} + + ({transportation.rating}/5) + +
    + {/if} + {#if transportation.from_location} +
    + 📍 {transportation.from_location} +
    + {/if} +
    +
    +
    + {/if} + + {#if hasDestinationCoordinates(transportation)} + + +
    +
    {transportation.name}
    +

    + {$t('transportation.to_location')} + {getTransportationIcon(transportation.type)} +

    + {#if transportation.rating} +
    + {#each renderStars(transportation.rating) as filled} + {#if filled} + + {:else} + + {/if} + {/each} + + ({transportation.rating}/5) + +
    + {/if} + {#if transportation.to_location} +
    + 📍 {transportation.to_location} +
    + {/if} +
    +
    +
    + {/if} +
    +
    + {#if transportation.from_location || transportation.to_location} +

    + + {getRouteLabel()} +

    + {/if} +
    +
    + {/if} +
    + + +
    + +
    +
    +

    â„šī¸ {$t('adventures.details')}

    +
    + + {#if transportation.date || transportation.end_date} +
    + +
    +

    {$t('adventures.dates')}

    +

    + {formatTravelWindow( + transportation.date, + transportation.end_date, + transportation.start_timezone, + transportation.end_timezone + )} +

    + {#if calculateDuration(transportation.date, transportation.end_date, transportation.start_timezone, transportation.end_timezone)} +

    + {calculateDuration( + transportation.date, + transportation.end_date, + transportation.start_timezone, + transportation.end_timezone + )} +

    + {/if} +
    +
    + {/if} + + +
    + {getTransportationIcon(transportation.type)} +
    +

    {$t('transportation.type')}

    +

    {$t(`transportation.modes.${transportation.type}`)}

    +
    +
    + + + {#if transportation.flight_number} +
    + +
    +

    + {$t('transportation.flight_number')} +

    +

    {transportation.flight_number}

    +
    +
    + {/if} + + + {#if transportation.distance} +
    + +
    +

    + {$t('adventures.distance') ?? 'Distance'} +

    +

    {transportation.distance} km

    +
    +
    + {/if} + + + {#if transportation.link} +
    + +
    +

    {$t('adventures.link')}

    + + {transportation.link} + +
    +
    + {/if} +
    +
    +
    + + + {#if transportation.images && transportation.images.length > 0} +
    +
    +

    đŸ–ŧī¸ {$t('adventures.images')}

    +
    + {#each transportation.images as image, i} + + {/each} +
    +
    +
    + {/if} + + + {#if transportation.attachments && transportation.attachments.length > 0} +
    +
    +

    📎 {$t('adventures.attachments')}

    +
    + {#each transportation.attachments as attachment} + + {/each} +
    +
    +
    + {/if} +
    +
    +
    +{/if} + + + + {data.props.transportation && data.props.transportation.name + ? `${data.props.transportation.name}` + : 'Transportation'} + + +