mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-19 20:24:57 -04:00
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.
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -1,35 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { toasts } from '$lib/toasts';
|
||||
import { toasts, removeToast } from '$lib/toasts';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let toastList: any[] = [];
|
||||
|
||||
toasts.subscribe((value) => {
|
||||
toastList = value;
|
||||
console.log(toastList);
|
||||
});
|
||||
|
||||
function getIconSvg(type: string) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path></svg>`;
|
||||
case 'error':
|
||||
return `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"></path></svg>`;
|
||||
case 'warning':
|
||||
return `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>`;
|
||||
case 'info':
|
||||
return `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`;
|
||||
default:
|
||||
return `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toast toast-top mt-14 toast-end z-50 min-w-20">
|
||||
{#each toastList as { type, message, id, duration }}
|
||||
{#if type == 'success'}
|
||||
<div class="alert alert-success">
|
||||
<span>{message}</span>
|
||||
<div class="toast toast-top toast-end z-[9999] mt-16 gap-3 max-w-md px-4">
|
||||
{#each toastList as { type, message, id }}
|
||||
<div
|
||||
class="alert alert-{type} shadow-2xl backdrop-blur-sm rounded-2xl border-0 min-w-80 max-w-md animate-in slide-in-from-right-5 fade-in duration-300"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center gap-4 w-full py-1">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-full bg-base-100/20 flex items-center justify-center"
|
||||
>
|
||||
{@html getIconSvg(type)}
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium leading-relaxed break-words">{message}</p>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle opacity-70 hover:opacity-100 transition-opacity flex-shrink-0 -mr-1"
|
||||
on:click={() => removeToast(id)}
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2.5"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if type == 'error'}
|
||||
<div class="alert alert-error">
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if type == 'info'}
|
||||
<div class="alert alert-info">
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if type == 'warning'}
|
||||
<div class="alert alert-warning">
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slide-in-from-right-5 {
|
||||
from {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation:
|
||||
slide-in-from-right-5 0.3s ease-out,
|
||||
fade-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,803 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Collection, Transportation } from '$lib/types';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
import { addToast } from '$lib/toasts';
|
||||
let modal: HTMLDialogElement;
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import { appVersion } from '$lib/config';
|
||||
import { DefaultMarker, MapLibre } from 'svelte-maplibre';
|
||||
import DateRangeCollapse from './DateRangeCollapse.svelte';
|
||||
import { getBasemapUrl } from '$lib';
|
||||
|
||||
import ImageDropdown from './ImageDropdown.svelte';
|
||||
import AttachmentDropdown from './AttachmentDropdown.svelte';
|
||||
|
||||
export let collection: Collection;
|
||||
export let transportationToEdit: Transportation | null = null;
|
||||
|
||||
let imageDropdownRef: any;
|
||||
let attachmentDropdownRef: any;
|
||||
|
||||
// when this is true the image and attachment sections will create their upload requests
|
||||
let isImagesUploading: boolean = false;
|
||||
let isAttachmentsUploading: boolean = false;
|
||||
|
||||
// Initialize transportation object
|
||||
let transportation: Transportation = {
|
||||
id: transportationToEdit?.id || '',
|
||||
type: transportationToEdit?.type || '',
|
||||
name: transportationToEdit?.name || '',
|
||||
description: transportationToEdit?.description || '',
|
||||
date: transportationToEdit?.date || null,
|
||||
end_date: transportationToEdit?.end_date || null,
|
||||
rating: transportationToEdit?.rating || 0,
|
||||
link: transportationToEdit?.link || '',
|
||||
flight_number: transportationToEdit?.flight_number || '',
|
||||
from_location: transportationToEdit?.from_location || '',
|
||||
to_location: transportationToEdit?.to_location || '',
|
||||
user: transportationToEdit?.user || '',
|
||||
is_public: transportationToEdit?.is_public || false,
|
||||
collection: transportationToEdit?.collection || collection.id,
|
||||
created_at: transportationToEdit?.created_at || '',
|
||||
updated_at: transportationToEdit?.updated_at || '',
|
||||
origin_latitude: transportationToEdit?.origin_latitude || NaN,
|
||||
origin_longitude: transportationToEdit?.origin_longitude || NaN,
|
||||
destination_latitude: transportationToEdit?.destination_latitude || NaN,
|
||||
destination_longitude: transportationToEdit?.destination_longitude || NaN,
|
||||
start_timezone: transportationToEdit?.start_timezone || '',
|
||||
end_timezone: transportationToEdit?.end_timezone || '',
|
||||
distance: null,
|
||||
images: transportationToEdit?.images || [],
|
||||
attachments: transportationToEdit?.attachments || []
|
||||
};
|
||||
|
||||
let startTimezone: string | undefined = transportation.start_timezone ?? undefined;
|
||||
let endTimezone: string | undefined = transportation.end_timezone ?? undefined;
|
||||
|
||||
// Later, you should manually sync these back to `transportation` if needed
|
||||
$: transportation.start_timezone = startTimezone ?? '';
|
||||
$: transportation.end_timezone = endTimezone ?? '';
|
||||
|
||||
let starting_airport: string = '';
|
||||
let ending_airport: string = '';
|
||||
|
||||
$: {
|
||||
if (!transportation.rating) {
|
||||
transportation.rating = NaN;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
async function geocode(e: Event | null) {
|
||||
// Geocoding logic unchanged
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const fetchLocation = async (query: string) => {
|
||||
let res = await fetch(`/api/reverse-geocode/search/?query=${query}`, {
|
||||
headers: {
|
||||
'User-Agent': `AdventureLog / ${appVersion} `
|
||||
}
|
||||
});
|
||||
console.log(query);
|
||||
let data = await res.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
let startingData = null;
|
||||
let endingData = null;
|
||||
|
||||
if (transportation.type == 'plane') {
|
||||
if (!starting_airport || !ending_airport) {
|
||||
alert($t('adventures.no_location'));
|
||||
return;
|
||||
}
|
||||
startingData = await fetchLocation(starting_airport + ' Airport');
|
||||
endingData = await fetchLocation(ending_airport + ' Airport');
|
||||
} else {
|
||||
if (!transportation.from_location || !transportation.to_location) {
|
||||
alert($t('adventures.no_location'));
|
||||
return;
|
||||
}
|
||||
startingData = await fetchLocation(transportation?.from_location || '');
|
||||
endingData = await fetchLocation(transportation?.to_location || '');
|
||||
}
|
||||
|
||||
if (startingData.length === 0 || endingData.length === 0) {
|
||||
alert($t('adventures.no_location_found'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (transportation.type == 'plane') {
|
||||
transportation.from_location =
|
||||
startingData[0].name + ' (' + starting_airport.toUpperCase() + ')';
|
||||
transportation.to_location = endingData[0].name + ' (' + ending_airport.toUpperCase() + ')';
|
||||
} else {
|
||||
transportation.from_location = startingData[0].display_name;
|
||||
transportation.to_location = endingData[0].display_name;
|
||||
}
|
||||
transportation.origin_latitude = startingData[0].lat;
|
||||
transportation.origin_longitude = startingData[0].lon;
|
||||
transportation.destination_latitude = endingData[0].lat;
|
||||
transportation.destination_longitude = endingData[0].lon;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
console.log(transportation);
|
||||
|
||||
// If the user has entered airport codes, but not location names, fetch the location names
|
||||
if (
|
||||
starting_airport &&
|
||||
ending_airport &&
|
||||
(!transportation.from_location || !transportation.to_location)
|
||||
) {
|
||||
transportation.from_location = starting_airport;
|
||||
transportation.to_location = ending_airport;
|
||||
}
|
||||
|
||||
// Round coordinates to 6 decimal places
|
||||
if (transportation.origin_latitude) {
|
||||
transportation.origin_latitude = Math.round(transportation.origin_latitude * 1e6) / 1e6;
|
||||
}
|
||||
if (transportation.origin_longitude) {
|
||||
transportation.origin_longitude = Math.round(transportation.origin_longitude * 1e6) / 1e6;
|
||||
}
|
||||
if (transportation.destination_latitude) {
|
||||
transportation.destination_latitude =
|
||||
Math.round(transportation.destination_latitude * 1e6) / 1e6;
|
||||
}
|
||||
if (transportation.destination_longitude) {
|
||||
transportation.destination_longitude =
|
||||
Math.round(transportation.destination_longitude * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
if (transportation.date && !transportation.end_date) {
|
||||
transportation.end_date = transportation.date;
|
||||
}
|
||||
|
||||
if (!transportation.type) {
|
||||
transportation.type = 'other';
|
||||
}
|
||||
|
||||
// Use the stored UTC dates for submission
|
||||
const submissionData = {
|
||||
...transportation
|
||||
};
|
||||
|
||||
if (transportation.type != 'plane') {
|
||||
submissionData.flight_number = '';
|
||||
}
|
||||
|
||||
if (submissionData.id === '') {
|
||||
let res = await fetch('/api/transportations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(submissionData)
|
||||
});
|
||||
let data = await res.json();
|
||||
if (data.id) {
|
||||
transportation = data as Transportation;
|
||||
|
||||
addToast('success', $t('adventures.location_created'));
|
||||
// Handle image uploads after transportation is created
|
||||
|
||||
// Now handle image uploads if there are any pending
|
||||
if (imageDropdownRef?.hasImagesToUpload()) {
|
||||
console.log('Triggering image upload...');
|
||||
isImagesUploading = true;
|
||||
|
||||
// Wait for image upload to complete
|
||||
await waitForUploadComplete();
|
||||
}
|
||||
|
||||
// Similarly handle attachments if needed
|
||||
if (attachmentDropdownRef?.hasAttachmentsToUpload()) {
|
||||
console.log('Triggering attachment upload...');
|
||||
isAttachmentsUploading = true;
|
||||
|
||||
// Wait for attachment upload to complete
|
||||
await waitForAttachmentUploadComplete();
|
||||
}
|
||||
|
||||
dispatch('save', transportation);
|
||||
} else {
|
||||
console.error(data);
|
||||
addToast('error', $t('adventures.location_create_error'));
|
||||
}
|
||||
} else {
|
||||
let res = await fetch(`/api/transportations/${transportation.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(submissionData)
|
||||
});
|
||||
let data = await res.json();
|
||||
if (data.id) {
|
||||
transportation = data as Transportation;
|
||||
|
||||
addToast('success', $t('adventures.location_updated'));
|
||||
dispatch('save', transportation);
|
||||
} else {
|
||||
addToast('error', $t('adventures.location_update_error'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to wait for image upload completion
|
||||
async function waitForUploadComplete(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkUpload = () => {
|
||||
if (!isImagesUploading) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkUpload, 100);
|
||||
}
|
||||
};
|
||||
checkUpload();
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to wait for attachment upload completion
|
||||
async function waitForAttachmentUploadComplete(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkUpload = () => {
|
||||
if (!isAttachmentsUploading) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkUpload, 100);
|
||||
}
|
||||
};
|
||||
checkUpload();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
|
||||
role="dialog"
|
||||
on:keydown={handleKeydown}
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Header Section - Following adventurelog pattern -->
|
||||
<div
|
||||
class="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-2 bg-primary/10 rounded-xl">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary bg-clip-text">
|
||||
{transportationToEdit
|
||||
? $t('transportation.edit_transportation')
|
||||
: $t('transportation.new_transportation')}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{transportationToEdit
|
||||
? $t('transportation.update_transportation_details')
|
||||
: $t('transportation.create_new_transportation')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="btn btn-ghost btn-square" on:click={close}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-2">
|
||||
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
|
||||
<!-- Basic Information Section -->
|
||||
<div
|
||||
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<input type="checkbox" checked />
|
||||
<div
|
||||
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{$t('adventures.basic_information')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
|
||||
<!-- Dual Column Layout for Large Screens -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Name Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium"
|
||||
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={transportation.name}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_transportation_name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type Selection -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="type">
|
||||
<span class="label-text font-medium"
|
||||
>{$t('transportation.type')}<span class="text-error ml-1">*</span></span
|
||||
>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
name="type"
|
||||
id="type"
|
||||
bind:value={transportation.type}
|
||||
>
|
||||
<option disabled selected>{$t('transportation.select_type')}</option>
|
||||
<option value="car">{$t('transportation.modes.car')}</option>
|
||||
<option value="plane">{$t('transportation.modes.plane')}</option>
|
||||
<option value="train">{$t('transportation.modes.train')}</option>
|
||||
<option value="bus">{$t('transportation.modes.bus')}</option>
|
||||
<option value="boat">{$t('transportation.modes.boat')}</option>
|
||||
<option value="bike">{$t('transportation.modes.bike')}</option>
|
||||
<option value="walking">{$t('transportation.modes.walking')}</option>
|
||||
<option value="other">{$t('transportation.modes.other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Rating Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="rating">
|
||||
<span class="label-text font-medium">{$t('adventures.rating')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
hidden
|
||||
bind:value={transportation.rating}
|
||||
id="rating"
|
||||
name="rating"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
|
||||
>
|
||||
<div class="rating">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(transportation.rating)}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 1)}
|
||||
checked={transportation.rating === 1}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 2)}
|
||||
checked={transportation.rating === 2}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 3)}
|
||||
checked={transportation.rating === 3}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 4)}
|
||||
checked={transportation.rating === 4}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 5)}
|
||||
checked={transportation.rating === 5}
|
||||
/>
|
||||
</div>
|
||||
{#if transportation.rating}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm"
|
||||
on:click={() => (transportation.rating = NaN)}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Link Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="link">
|
||||
<span class="label-text font-medium">{$t('adventures.link')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
name="link"
|
||||
bind:value={transportation.link}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_link')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">{$t('adventures.description')}</span>
|
||||
</label>
|
||||
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
|
||||
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Section -->
|
||||
|
||||
<DateRangeCollapse
|
||||
type="transportation"
|
||||
bind:utcStartDate={transportation.date}
|
||||
bind:utcEndDate={transportation.end_date}
|
||||
bind:selectedStartTimezone={startTimezone}
|
||||
bind:selectedEndTimezone={endTimezone}
|
||||
{collection}
|
||||
/>
|
||||
|
||||
<!-- Location/Flight Information Section -->
|
||||
<div
|
||||
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<input type="checkbox" checked />
|
||||
<div
|
||||
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if transportation?.type == 'plane'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
{#if transportation?.type == 'plane'}
|
||||
{$t('adventures.flight_information')}
|
||||
{:else}
|
||||
{$t('adventures.location_information')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse-content bg-base-100/50 pt-4 p-6">
|
||||
{#if transportation?.type == 'plane'}
|
||||
<!-- Flight-specific fields -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Flight Number -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="flight_number">
|
||||
<span class="label-text font-medium">{$t('transportation.flight_number')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="flight_number"
|
||||
name="flight_number"
|
||||
bind:value={transportation.flight_number}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_flight_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Airport Fields (if locations not set) -->
|
||||
{#if !transportation.from_location || !transportation.to_location}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="form-control">
|
||||
<label class="label" for="starting_airport">
|
||||
<span class="label-text font-medium">{$t('adventures.starting_airport')}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="starting_airport"
|
||||
bind:value={starting_airport}
|
||||
name="starting_airport"
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.starting_airport_desc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="ending_airport">
|
||||
<span class="label-text font-medium">{$t('adventures.ending_airport')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ending_airport"
|
||||
bind:value={ending_airport}
|
||||
name="ending_airport"
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.ending_airport_desc')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start mb-6">
|
||||
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{$t('transportation.fetch_location_information')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Location Fields (for all types or when flight locations are set) -->
|
||||
{#if transportation?.type != 'plane' || (transportation.from_location && transportation.to_location)}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- From Location -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="from_location">
|
||||
<span class="label-text font-medium">{$t('transportation.from_location')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="from_location"
|
||||
name="from_location"
|
||||
bind:value={transportation.from_location}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_from_location')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- To Location -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="to_location">
|
||||
<span class="label-text font-medium">{$t('transportation.to_location')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="to_location"
|
||||
name="to_location"
|
||||
bind:value={transportation.to_location}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_to_location')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if transportation?.type != 'plane'}
|
||||
<div class="flex justify-start mb-6">
|
||||
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{$t('transportation.fetch_location_information')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Map Section -->
|
||||
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4 mb-6">
|
||||
<div class="mb-4">
|
||||
<h4 class="font-semibold text-base-content flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
{$t('adventures.route_map')}
|
||||
</h4>
|
||||
</div>
|
||||
<MapLibre
|
||||
style={getBasemapUrl()}
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
{#if transportation.origin_latitude && transportation.origin_longitude}
|
||||
<DefaultMarker
|
||||
lngLat={[transportation.origin_longitude, transportation.origin_latitude]}
|
||||
/>
|
||||
{/if}
|
||||
{#if transportation.destination_latitude && transportation.destination_longitude}
|
||||
<DefaultMarker
|
||||
lngLat={[
|
||||
transportation.destination_longitude,
|
||||
transportation.destination_latitude
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</MapLibre>
|
||||
</div>
|
||||
|
||||
<!-- Clear Location Button -->
|
||||
{#if transportation.from_location || transportation.to_location}
|
||||
<div class="flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm gap-2"
|
||||
on:click={() => {
|
||||
transportation.from_location = '';
|
||||
transportation.to_location = '';
|
||||
starting_airport = '';
|
||||
ending_airport = '';
|
||||
transportation.origin_latitude = NaN;
|
||||
transportation.origin_longitude = NaN;
|
||||
transportation.destination_latitude = NaN;
|
||||
transportation.destination_longitude = NaN;
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
{$t('adventures.clear_location')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images Section -->
|
||||
<ImageDropdown
|
||||
bind:this={imageDropdownRef}
|
||||
bind:object={transportation}
|
||||
objectType="transportation"
|
||||
bind:isImagesUploading
|
||||
/>
|
||||
|
||||
<!-- Attachments Section -->
|
||||
<AttachmentDropdown
|
||||
bind:this={attachmentDropdownRef}
|
||||
bind:object={transportation}
|
||||
objectType="transportation"
|
||||
bind:isAttachmentsUploading
|
||||
/>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
|
||||
<button type="button" class="btn btn-neutral-200" on:click={close}>
|
||||
{$t('about.close')}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
|
||||
/>
|
||||
</svg>
|
||||
{$t('notes.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -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')}
|
||||
</button>
|
||||
</li>
|
||||
{#if collection.is_public}
|
||||
<li>
|
||||
<button on:click={copyLink} class="flex items-center gap-2">
|
||||
{#if copied}
|
||||
<Check class="w-4 h-4 text-success" />
|
||||
<span>{$t('adventures.link_copied')}</span>
|
||||
{:else}
|
||||
<LinkIcon class="w-4 h-4" />
|
||||
{$t('adventures.copy_link')}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if collection.is_archived}
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
let isCollectionModalOpen: boolean = false;
|
||||
let isWarningModalOpen: boolean = false;
|
||||
let copied = false;
|
||||
let copied: boolean = false;
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
import StarOutline from '~icons/mdi/star-outline';
|
||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||
import CalendarRemove from '~icons/mdi/calendar-remove';
|
||||
import Launch from '~icons/mdi/launch';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { CollectionItineraryItem } from '$lib/types';
|
||||
|
||||
function getTransportationIcon(type: string) {
|
||||
@@ -139,47 +141,62 @@
|
||||
<div class="card-body p-4 space-y-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold line-clamp-2">{transportation.name}</h2>
|
||||
<a
|
||||
href="/transportations/{transportation.id}"
|
||||
class="hover:text-primary transition-colors duration-200 line-clamp-2 text-lg font-semibold"
|
||||
>
|
||||
{transportation.name}
|
||||
</a>
|
||||
|
||||
{#if !readOnly && (transportation.user === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
|
||||
<details class="dropdown dropdown-end relative z-50">
|
||||
<summary class="btn btn-square btn-sm p-1 text-base-content">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</summary>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editTransportation} class="flex items-center gap-2">
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('transportation.edit')}
|
||||
</button>
|
||||
</li>
|
||||
{#if itineraryItem && itineraryItem.id}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-sm p-1 text-base-content"
|
||||
aria-label="open-details"
|
||||
on:click={() => goto(`/transportations/${transportation.id}`)}
|
||||
>
|
||||
<Launch class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{#if !readOnly && (transportation.user === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
|
||||
<details class="dropdown dropdown-end relative z-50">
|
||||
<summary class="btn btn-square btn-sm p-1 text-base-content">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</summary>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editTransportation} class="flex items-center gap-2">
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('transportation.edit')}
|
||||
</button>
|
||||
</li>
|
||||
{#if itineraryItem && itineraryItem.id}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
<TrashCanOutline class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<TrashCanOutline class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Route Info (Compact) -->
|
||||
|
||||
@@ -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<Transportation>) {
|
||||
transportationToEdit = event.detail;
|
||||
isTransportationModalOpen = true;
|
||||
}
|
||||
|
||||
function handleItemDelete(event: CustomEvent<CollectionItineraryItem | string | number>) {
|
||||
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}
|
||||
<TransportationModal
|
||||
on:close={() => (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'}
|
||||
<LodgingCard
|
||||
|
||||
@@ -574,26 +574,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Public Toggle -->
|
||||
{#if !lodgingToEdit || (lodgingToEdit.collection && lodgingToEdit.collection.length === 0)}
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4" for="is_public">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
id="is_public"
|
||||
bind:checked={lodging.is_public}
|
||||
/>
|
||||
<div>
|
||||
<span class="label-text font-medium">{$t('adventures.public_location')}</span>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{$t('adventures.public_location_description')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="description">
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import type { Collection, Location, Lodging, User } from '$lib/types';
|
||||
import type { Collection, Lodging, User } from '$lib/types';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { t } from 'svelte-i18n';
|
||||
import LocationQuickStart from '../locations/LocationQuickStart.svelte';
|
||||
import LocationDetails from '../locations/LocationDetails.svelte';
|
||||
import LocationMedia from '../locations/LocationMedia.svelte';
|
||||
import LocationVisits from '../locations/LocationVisits.svelte';
|
||||
import Bed from '~icons/mdi/bed';
|
||||
import LodgingDetails from './LodgingDetails.svelte';
|
||||
import LodgingMedia from './LodgingMedia.svelte';
|
||||
import MediaStep from '../shared/MediaStep.svelte';
|
||||
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
@@ -254,7 +250,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if steps[1].selected}
|
||||
<LodgingMedia
|
||||
<MediaStep
|
||||
bind:images={lodging.images}
|
||||
bind:attachments={lodging.attachments}
|
||||
itemName={lodging.name}
|
||||
@@ -264,6 +260,7 @@
|
||||
}}
|
||||
on:close={() => close()}
|
||||
itemId={lodging.id}
|
||||
contentType="lodging"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import CheckIcon from '~icons/mdi/check';
|
||||
import ClearIcon from '~icons/mdi/close';
|
||||
import PinIcon from '~icons/mdi/map-marker';
|
||||
import AirplaneIcon from '~icons/mdi/airplane';
|
||||
import SwapIcon from '~icons/mdi/swap-horizontal';
|
||||
|
||||
type GeoSelection = {
|
||||
name: string;
|
||||
@@ -38,6 +40,21 @@
|
||||
export let displayNameLabel = '';
|
||||
export let displayNamePlaceholder = '';
|
||||
export let isReverseGeocoding = false;
|
||||
export let transportationMode = false; // New prop for transportation mode
|
||||
export let airportMode = false; // New prop for airport-specific search
|
||||
// Props for initial transportation locations when editing
|
||||
export let initialStartLocation: {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
location: string;
|
||||
} | null = null;
|
||||
export let initialEndLocation: {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
location: string;
|
||||
} | null = null;
|
||||
|
||||
let isSearching = false;
|
||||
let searchResults: GeoSelection[] = [];
|
||||
@@ -49,6 +66,49 @@
|
||||
let mapComponent: any;
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let initialApplied = false;
|
||||
let initialTransportationApplied = false;
|
||||
|
||||
// track previous airport mode to detect toggles
|
||||
let prevAirportMode = airportMode;
|
||||
|
||||
// Clear inputs/selections when airportMode is toggled
|
||||
$: if (prevAirportMode !== airportMode) {
|
||||
prevAirportMode = airportMode;
|
||||
// clear single-location search state
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
selectedLocation = null;
|
||||
selectedMarker = null;
|
||||
locationData = null;
|
||||
|
||||
// clear transportation-mode search state
|
||||
startSearchQuery = '';
|
||||
endSearchQuery = '';
|
||||
startSearchResults = [];
|
||||
endSearchResults = [];
|
||||
selectedStartLocation = null;
|
||||
selectedEndLocation = null;
|
||||
startMarker = null;
|
||||
endMarker = null;
|
||||
startLocationData = null;
|
||||
endLocationData = null;
|
||||
}
|
||||
|
||||
// Transportation mode variables
|
||||
let startSearchQuery = '';
|
||||
let endSearchQuery = '';
|
||||
let startSearchResults: GeoSelection[] = [];
|
||||
let endSearchResults: GeoSelection[] = [];
|
||||
let selectedStartLocation: GeoSelection | null = null;
|
||||
let selectedEndLocation: GeoSelection | null = null;
|
||||
let startMarker: { lng: number; lat: number } | null = null;
|
||||
let endMarker: { lng: number; lat: number } | null = null;
|
||||
let startLocationData: LocationMeta | null = null;
|
||||
let endLocationData: LocationMeta | null = null;
|
||||
let isSearchingStart = false;
|
||||
let isSearchingEnd = false;
|
||||
let startSearchTimeout: ReturnType<typeof setTimeout>;
|
||||
let endSearchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function applyInitialSelection(selection: GeoSelection) {
|
||||
selectedLocation = selection;
|
||||
@@ -60,6 +120,39 @@
|
||||
await performDetailedReverseGeocode(selection.lat, selection.lng);
|
||||
}
|
||||
|
||||
async function applyInitialTransportationLocations() {
|
||||
if (initialStartLocation) {
|
||||
selectedStartLocation = {
|
||||
name: initialStartLocation.name,
|
||||
lat: initialStartLocation.lat,
|
||||
lng: initialStartLocation.lng,
|
||||
location: initialStartLocation.location
|
||||
};
|
||||
startMarker = { lng: initialStartLocation.lng, lat: initialStartLocation.lat };
|
||||
startSearchQuery = initialStartLocation.name;
|
||||
await performDetailedReverseGeocode(
|
||||
initialStartLocation.lat,
|
||||
initialStartLocation.lng,
|
||||
'start'
|
||||
);
|
||||
}
|
||||
|
||||
if (initialEndLocation) {
|
||||
selectedEndLocation = {
|
||||
name: initialEndLocation.name,
|
||||
lat: initialEndLocation.lat,
|
||||
lng: initialEndLocation.lng,
|
||||
location: initialEndLocation.location
|
||||
};
|
||||
endMarker = { lng: initialEndLocation.lng, lat: initialEndLocation.lat };
|
||||
endSearchQuery = initialEndLocation.name;
|
||||
await performDetailedReverseGeocode(initialEndLocation.lat, initialEndLocation.lng, 'end');
|
||||
}
|
||||
|
||||
updateMapBounds();
|
||||
emitTransportationUpdate();
|
||||
}
|
||||
|
||||
async function searchLocations(query: string) {
|
||||
if (!query.trim() || query.length < 3) {
|
||||
searchResults = [];
|
||||
@@ -68,8 +161,9 @@
|
||||
|
||||
isSearching = true;
|
||||
try {
|
||||
const searchTerm = airportMode ? `${query} Airport` : query;
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(searchTerm)}`
|
||||
);
|
||||
const results = await response.json();
|
||||
|
||||
@@ -92,6 +186,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function searchStartLocation(query: string) {
|
||||
if (!query.trim() || query.length < 3) {
|
||||
startSearchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearchingStart = true;
|
||||
try {
|
||||
const searchTerm = airportMode ? `${query} Airport` : query;
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(searchTerm)}`
|
||||
);
|
||||
const results = await response.json();
|
||||
|
||||
startSearchResults = results.map((result: any) => ({
|
||||
id: result.name + result.lat + result.lon,
|
||||
name: result.name,
|
||||
lat: parseFloat(result.lat),
|
||||
lng: parseFloat(result.lon),
|
||||
type: result.type,
|
||||
category: result.category,
|
||||
location: result.display_name,
|
||||
importance: result.importance,
|
||||
powered_by: result.powered_by
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
startSearchResults = [];
|
||||
} finally {
|
||||
isSearchingStart = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchEndLocation(query: string) {
|
||||
if (!query.trim() || query.length < 3) {
|
||||
endSearchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearchingEnd = true;
|
||||
try {
|
||||
const searchTerm = airportMode ? `${query} Airport` : query;
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/search/?query=${encodeURIComponent(searchTerm)}`
|
||||
);
|
||||
const results = await response.json();
|
||||
|
||||
endSearchResults = results.map((result: any) => ({
|
||||
id: result.name + result.lat + result.lon,
|
||||
name: result.name,
|
||||
lat: parseFloat(result.lat),
|
||||
lng: parseFloat(result.lon),
|
||||
type: result.type,
|
||||
category: result.category,
|
||||
location: result.display_name,
|
||||
importance: result.importance,
|
||||
powered_by: result.powered_by
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
endSearchResults = [];
|
||||
} finally {
|
||||
isSearchingEnd = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
@@ -99,6 +259,20 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleStartSearchInput() {
|
||||
clearTimeout(startSearchTimeout);
|
||||
startSearchTimeout = setTimeout(() => {
|
||||
searchStartLocation(startSearchQuery);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleEndSearchInput() {
|
||||
clearTimeout(endSearchTimeout);
|
||||
endSearchTimeout = setTimeout(() => {
|
||||
searchEndLocation(endSearchQuery);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function emitUpdate(selection: GeoSelection) {
|
||||
dispatch('update', {
|
||||
name: selection.name,
|
||||
@@ -108,6 +282,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
function emitTransportationUpdate() {
|
||||
if (selectedStartLocation && selectedEndLocation) {
|
||||
dispatch('transportationUpdate', {
|
||||
start: {
|
||||
name: selectedStartLocation.name,
|
||||
lat: selectedStartLocation.lat,
|
||||
lng: selectedStartLocation.lng,
|
||||
location: selectedStartLocation.location
|
||||
},
|
||||
end: {
|
||||
name: selectedEndLocation.name,
|
||||
lat: selectedEndLocation.lat,
|
||||
lng: selectedEndLocation.lng,
|
||||
location: selectedEndLocation.location
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSearchResult(searchResult: GeoSelection) {
|
||||
selectedLocation = searchResult;
|
||||
selectedMarker = { lng: searchResult.lng, lat: searchResult.lat };
|
||||
@@ -122,6 +315,59 @@
|
||||
await performDetailedReverseGeocode(searchResult.lat, searchResult.lng);
|
||||
}
|
||||
|
||||
async function selectStartSearchResult(searchResult: GeoSelection) {
|
||||
selectedStartLocation = searchResult;
|
||||
startMarker = { lng: searchResult.lng, lat: searchResult.lat };
|
||||
startSearchResults = [];
|
||||
|
||||
// Extract airport code if in airport mode
|
||||
if (airportMode) {
|
||||
const airportCodeMatch = searchResult.name.match(/\(([A-Z]{3})\)/);
|
||||
startSearchQuery = airportCodeMatch ? airportCodeMatch[1] : searchResult.name;
|
||||
} else {
|
||||
startSearchQuery = searchResult.name;
|
||||
}
|
||||
|
||||
await performDetailedReverseGeocode(searchResult.lat, searchResult.lng, 'start');
|
||||
updateMapBounds();
|
||||
emitTransportationUpdate();
|
||||
}
|
||||
|
||||
async function selectEndSearchResult(searchResult: GeoSelection) {
|
||||
selectedEndLocation = searchResult;
|
||||
endMarker = { lng: searchResult.lng, lat: searchResult.lat };
|
||||
endSearchResults = [];
|
||||
|
||||
// Extract airport code if in airport mode
|
||||
if (airportMode) {
|
||||
const airportCodeMatch = searchResult.name.match(/\(([A-Z]{3})\)/);
|
||||
endSearchQuery = airportCodeMatch ? airportCodeMatch[1] : searchResult.name;
|
||||
} else {
|
||||
endSearchQuery = searchResult.name;
|
||||
}
|
||||
|
||||
await performDetailedReverseGeocode(searchResult.lat, searchResult.lng, 'end');
|
||||
updateMapBounds();
|
||||
emitTransportationUpdate();
|
||||
}
|
||||
|
||||
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;
|
||||
} else if (startMarker) {
|
||||
mapCenter = [startMarker.lng, startMarker.lat];
|
||||
mapZoom = 8;
|
||||
} else if (endMarker) {
|
||||
mapCenter = [endMarker.lng, endMarker.lat];
|
||||
mapZoom = 8;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
|
||||
selectedMarker = {
|
||||
lng: e.detail.lngLat.lng,
|
||||
@@ -183,7 +429,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function performDetailedReverseGeocode(lat: number, lng: number) {
|
||||
async function performDetailedReverseGeocode(
|
||||
lat: number,
|
||||
lng: number,
|
||||
target: 'single' | 'start' | 'end' = 'single'
|
||||
) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
|
||||
@@ -191,7 +441,7 @@
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
locationData = {
|
||||
const metaData = {
|
||||
city: data.city
|
||||
? {
|
||||
name: data.city,
|
||||
@@ -216,13 +466,33 @@
|
||||
display_name: data.display_name,
|
||||
location_name: data.location_name
|
||||
};
|
||||
displayName = data.display_name;
|
||||
|
||||
if (target === 'start') {
|
||||
startLocationData = metaData;
|
||||
} else if (target === 'end') {
|
||||
endLocationData = metaData;
|
||||
} else {
|
||||
locationData = metaData;
|
||||
displayName = data.display_name;
|
||||
}
|
||||
} else {
|
||||
locationData = null;
|
||||
if (target === 'start') {
|
||||
startLocationData = null;
|
||||
} else if (target === 'end') {
|
||||
endLocationData = null;
|
||||
} else {
|
||||
locationData = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Detailed reverse geocoding error:', error);
|
||||
locationData = null;
|
||||
if (target === 'start') {
|
||||
startLocationData = null;
|
||||
} else if (target === 'end') {
|
||||
endLocationData = null;
|
||||
} else {
|
||||
locationData = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,12 +515,25 @@
|
||||
}
|
||||
|
||||
function clearLocationSelection() {
|
||||
selectedLocation = null;
|
||||
selectedMarker = null;
|
||||
locationData = null;
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
displayName = '';
|
||||
if (transportationMode) {
|
||||
selectedStartLocation = null;
|
||||
selectedEndLocation = null;
|
||||
startMarker = null;
|
||||
endMarker = null;
|
||||
startLocationData = null;
|
||||
endLocationData = null;
|
||||
startSearchQuery = '';
|
||||
endSearchQuery = '';
|
||||
startSearchResults = [];
|
||||
endSearchResults = [];
|
||||
} else {
|
||||
selectedLocation = null;
|
||||
selectedMarker = null;
|
||||
locationData = null;
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
displayName = '';
|
||||
}
|
||||
mapCenter = [-74.5, 40];
|
||||
mapZoom = 2;
|
||||
dispatch('clear');
|
||||
@@ -260,11 +543,37 @@
|
||||
initialApplied = true;
|
||||
applyInitialSelection(initialSelection);
|
||||
}
|
||||
|
||||
$: if (
|
||||
!initialTransportationApplied &&
|
||||
transportationMode &&
|
||||
(initialStartLocation || initialEndLocation)
|
||||
) {
|
||||
initialTransportationApplied = true;
|
||||
applyInitialTransportationLocations();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
{#if showDisplayNameInput && displayNamePosition === 'before'}
|
||||
<!-- Transportation Mode Toggle -->
|
||||
{#if transportationMode}
|
||||
<div class="flex items-center gap-3 p-3 bg-primary/10 rounded-lg border border-primary/30">
|
||||
<AirplaneIcon class="w-5 h-5 text-primary" />
|
||||
<div class="flex-1">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="toggle toggle-primary" bind:checked={airportMode} />
|
||||
<span class="label-text font-medium">
|
||||
{airportMode
|
||||
? $t('adventures.airport_search_mode')
|
||||
: $t('adventures.location_search_mode')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDisplayNameInput && displayNamePosition === 'before' && !transportationMode}
|
||||
<div class="form-control">
|
||||
<label class="label" for="location-display">
|
||||
<span class="label-text font-medium">
|
||||
@@ -281,132 +590,312 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="search-location">
|
||||
<span class="label-text font-medium">{$t('adventures.search_location')}</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon class="w-4 h-4 text-base-content/40" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-location"
|
||||
bind:value={searchQuery}
|
||||
on:input={handleSearchInput}
|
||||
placeholder="Enter city, location, or landmark..."
|
||||
class="input input-bordered w-full pl-10 pr-4 bg-base-100/80 focus:bg-base-100"
|
||||
class:input-primary={selectedLocation}
|
||||
/>
|
||||
{#if searchQuery && !selectedLocation}
|
||||
<button
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
on:click={clearLocationSelection}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isSearching}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
|
||||
</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="label">
|
||||
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto space-y-1">
|
||||
{#each searchResults as result}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
|
||||
on:click={() => selectSearchResult(result)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm truncate">{result.name}</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
|
||||
{#if result.category}
|
||||
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="divider divider-horizontal text-xs">{$t('adventures.or')}</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
|
||||
<LocationIcon class="w-4 h-4" />
|
||||
{$t('adventures.use_current_location')}
|
||||
</button>
|
||||
|
||||
{#if showDisplayNameInput && displayNamePosition === 'after'}
|
||||
{#if transportationMode}
|
||||
<!-- Start Location Search -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="location-display-after">
|
||||
<span class="label-text font-medium">
|
||||
{displayNameLabel || $t('adventures.location_display_name')}
|
||||
<label class="label" for="search-start-location">
|
||||
<span class="label-text font-medium flex items-center gap-2">
|
||||
<PinIcon class="w-4 h-4 text-success" />
|
||||
{airportMode ? $t('adventures.departure_airport') : $t('adventures.start_location')}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location-display-after"
|
||||
bind:value={displayName}
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={displayNamePlaceholder || 'Enter location display name'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedLocation && selectedMarker}
|
||||
<div class="card bg-success/10 border border-success/30">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-2 bg-success/20 rounded-lg">
|
||||
<CheckIcon class="w-4 h-4 text-success" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-success mb-1">{$t('adventures.location_selected')}</h4>
|
||||
<p class="text-sm text-base-content/80 truncate">{selectedLocation.name}</p>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
|
||||
</p>
|
||||
|
||||
{#if locationData?.city || locationData?.region || locationData?.country}
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
{#if locationData.city}
|
||||
<div class="badge badge-info badge-sm gap-1">
|
||||
🏙️ {locationData.city.name}
|
||||
</div>
|
||||
{/if}
|
||||
{#if locationData.region}
|
||||
<div class="badge badge-warning badge-sm gap-1">
|
||||
🗺️ {locationData.region.name}
|
||||
</div>
|
||||
{/if}
|
||||
{#if locationData.country}
|
||||
<div class="badge badge-success badge-sm gap-1">
|
||||
🌎 {locationData.country.name}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" on:click={clearLocationSelection}>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon class="w-4 h-4 text-base-content/40" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-start-location"
|
||||
bind:value={startSearchQuery}
|
||||
on:input={handleStartSearchInput}
|
||||
placeholder={airportMode ? 'JFK, LAX, LHR...' : 'Enter start location...'}
|
||||
class="input input-bordered w-full pl-10 pr-4 bg-base-100/80 focus:bg-base-100"
|
||||
class:input-success={selectedStartLocation}
|
||||
/>
|
||||
{#if startSearchQuery && !selectedStartLocation}
|
||||
<button
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
on:click={() => {
|
||||
startSearchQuery = '';
|
||||
startSearchResults = [];
|
||||
}}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isSearchingStart}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
|
||||
</div>
|
||||
{:else if startSearchResults.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="max-h-48 overflow-y-auto space-y-1">
|
||||
{#each startSearchResults as result}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-success/50 transition-colors"
|
||||
on:click={() => selectStartSearchResult(result)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<PinIcon class="w-4 h-4 text-success mt-1 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm truncate">{result.name}</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
|
||||
{#if result.category}
|
||||
<div class="text-xs text-success/70 capitalize">{result.category}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- End Location Search -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="search-end-location">
|
||||
<span class="label-text font-medium flex items-center gap-2">
|
||||
<PinIcon class="w-4 h-4 text-error" />
|
||||
{airportMode ? $t('adventures.arrival_airport') : $t('adventures.end_location')}
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon class="w-4 h-4 text-base-content/40" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-end-location"
|
||||
bind:value={endSearchQuery}
|
||||
on:input={handleEndSearchInput}
|
||||
placeholder={airportMode ? 'JFK, LAX, LHR...' : 'Enter end location...'}
|
||||
class="input input-bordered w-full pl-10 pr-4 bg-base-100/80 focus:bg-base-100"
|
||||
class:input-error={selectedEndLocation}
|
||||
/>
|
||||
{#if endSearchQuery && !selectedEndLocation}
|
||||
<button
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
on:click={() => {
|
||||
endSearchQuery = '';
|
||||
endSearchResults = [];
|
||||
}}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isSearchingEnd}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
|
||||
</div>
|
||||
{:else if endSearchResults.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="max-h-48 overflow-y-auto space-y-1">
|
||||
{#each endSearchResults as result}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-error/50 transition-colors"
|
||||
on:click={() => selectEndSearchResult(result)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<PinIcon class="w-4 h-4 text-error mt-1 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm truncate">{result.name}</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
|
||||
{#if result.category}
|
||||
<div class="text-xs text-error/70 capitalize">{result.category}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Selected Locations Summary for Transportation Mode -->
|
||||
{#if selectedStartLocation && selectedEndLocation}
|
||||
<div class="card bg-success/10 border border-success/30">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-2 bg-success/20 rounded-lg">
|
||||
<SwapIcon class="w-4 h-4 text-success" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<h4 class="font-semibold text-success mb-1">{$t('adventures.route_selected')}</h4>
|
||||
|
||||
<!-- Start Location -->
|
||||
<div class="flex items-start gap-2">
|
||||
<PinIcon class="w-4 h-4 text-success mt-0.5 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-base-content/80 truncate">
|
||||
{selectedStartLocation.name}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{startMarker?.lat.toFixed(6)}, {startMarker?.lng.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-1"></div>
|
||||
|
||||
<!-- End Location -->
|
||||
<div class="flex items-start gap-2">
|
||||
<PinIcon class="w-4 h-4 text-error mt-0.5 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-base-content/80 truncate">
|
||||
{selectedEndLocation.name}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{endMarker?.lat.toFixed(6)}, {endMarker?.lng.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" on:click={clearLocationSelection}>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Single Location Mode (Original) -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="search-location">
|
||||
<span class="label-text font-medium">{$t('adventures.search_location')}</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon class="w-4 h-4 text-base-content/40" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-location"
|
||||
bind:value={searchQuery}
|
||||
on:input={handleSearchInput}
|
||||
placeholder="Enter city, location, or landmark..."
|
||||
class="input input-bordered w-full pl-10 pr-4 bg-base-100/80 focus:bg-base-100"
|
||||
class:input-primary={selectedLocation}
|
||||
/>
|
||||
{#if searchQuery && !selectedLocation}
|
||||
<button
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
on:click={clearLocationSelection}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isSearching}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
|
||||
</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="label">
|
||||
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto space-y-1">
|
||||
{#each searchResults as result}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
|
||||
on:click={() => selectSearchResult(result)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm truncate">{result.name}</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
|
||||
{#if result.category}
|
||||
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="divider divider-horizontal text-xs">{$t('adventures.or')}</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
|
||||
<LocationIcon class="w-4 h-4" />
|
||||
{$t('adventures.use_current_location')}
|
||||
</button>
|
||||
|
||||
{#if showDisplayNameInput && displayNamePosition === 'after'}
|
||||
<div class="form-control">
|
||||
<label class="label" for="location-display-after">
|
||||
<span class="label-text font-medium">
|
||||
{displayNameLabel || $t('adventures.location_display_name')}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location-display-after"
|
||||
bind:value={displayName}
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={displayNamePlaceholder || 'Enter location display name'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedLocation && selectedMarker}
|
||||
<div class="card bg-success/10 border border-success/30">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-2 bg-success/20 rounded-lg">
|
||||
<CheckIcon class="w-4 h-4 text-success" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-success mb-1">
|
||||
{$t('adventures.location_selected')}
|
||||
</h4>
|
||||
<p class="text-sm text-base-content/80 truncate">{selectedLocation.name}</p>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
|
||||
</p>
|
||||
|
||||
{#if locationData?.city || locationData?.region || locationData?.country}
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
{#if locationData.city}
|
||||
<div class="badge badge-info badge-sm gap-1">
|
||||
🏙️ {locationData.city.name}
|
||||
</div>
|
||||
{/if}
|
||||
{#if locationData.region}
|
||||
<div class="badge badge-warning badge-sm gap-1">
|
||||
🗺️ {locationData.region.name}
|
||||
</div>
|
||||
{/if}
|
||||
{#if locationData.country}
|
||||
<div class="badge badge-success badge-sm gap-1">
|
||||
🌎 {locationData.country.name}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" on:click={clearLocationSelection}>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -436,7 +925,24 @@
|
||||
>
|
||||
<MapEvents on:click={handleMapClick} />
|
||||
|
||||
{#if selectedMarker}
|
||||
{#if transportationMode}
|
||||
{#if startMarker}
|
||||
<Marker
|
||||
lngLat={[startMarker.lng, startMarker.lat]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-success shadow-lg cursor-pointer"
|
||||
>
|
||||
<PinIcon class="w-5 h-5 text-success-content" />
|
||||
</Marker>
|
||||
{/if}
|
||||
{#if endMarker}
|
||||
<Marker
|
||||
lngLat={[endMarker.lng, endMarker.lat]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-error shadow-lg cursor-pointer"
|
||||
>
|
||||
<PinIcon class="w-5 h-5 text-error-content" />
|
||||
</Marker>
|
||||
{/if}
|
||||
{:else if selectedMarker}
|
||||
<Marker
|
||||
lngLat={[selectedMarker.lng, selectedMarker.lat]}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
|
||||
@@ -447,7 +953,21 @@
|
||||
</MapLibre>
|
||||
</div>
|
||||
|
||||
{#if !selectedMarker}
|
||||
{#if transportationMode}
|
||||
{#if !startMarker && !endMarker}
|
||||
<p class="text-sm text-base-content/60 text-center">
|
||||
{$t('adventures.search_start_end_locations')}
|
||||
</p>
|
||||
{:else if !startMarker}
|
||||
<p class="text-sm text-base-content/60 text-center">
|
||||
{$t('adventures.search_start_location')}
|
||||
</p>
|
||||
{:else if !endMarker}
|
||||
<p class="text-sm text-base-content/60 text-center">
|
||||
{$t('adventures.search_end_location')}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if !selectedMarker}
|
||||
<p class="text-sm text-base-content/60 text-center">{$t('adventures.click_on_map')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
export let attachments: Attachment[] = [];
|
||||
export let itemName: string = '';
|
||||
export let itemId: string = '';
|
||||
export let contentType: 'location' | 'lodging' | 'transportation' | '' = 'location';
|
||||
// export let measurementSystem: 'metric' | 'imperial' = 'metric';
|
||||
// export let user: User | null = null;
|
||||
|
||||
@@ -72,7 +73,7 @@
|
||||
<ImageManagement
|
||||
bind:images
|
||||
objectId={itemId}
|
||||
contentType="lodging"
|
||||
{contentType}
|
||||
defaultSearchTerm={itemName}
|
||||
{immichIntegration}
|
||||
{copyImmichLocally}
|
||||
@@ -83,7 +84,7 @@
|
||||
<AttachmentManagement
|
||||
bind:attachments
|
||||
{itemId}
|
||||
contentType="lodging"
|
||||
{contentType}
|
||||
on:attachmentsUpdated={handleAttachmentsUpdated}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { updateLocalDate, updateUTCDate, validateDateRange } from '$lib/dateUtils';
|
||||
import type { Collection, Lodging, Transportation } from '$lib/types';
|
||||
import LocationSearchMap from '../shared/LocationSearchMap.svelte';
|
||||
|
||||
// Icons
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
import ClearIcon from '~icons/mdi/close';
|
||||
import InfoIcon from '~icons/mdi/information';
|
||||
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||
import SaveIcon from '~icons/mdi/content-save';
|
||||
import type { Category, User } from '$lib/types';
|
||||
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
|
||||
import MarkdownEditor from '../MarkdownEditor.svelte';
|
||||
import TimezoneSelector from '../TimezoneSelector.svelte';
|
||||
// @ts-ignore
|
||||
import { DateTime } from 'luxon';
|
||||
import { isAllDay } from '$lib';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let isReverseGeocoding = false;
|
||||
let airportMode = false;
|
||||
|
||||
let initialSelection: {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
location: string;
|
||||
category?: any;
|
||||
} | null = null;
|
||||
|
||||
// Props (would be passed in from parent component)
|
||||
export let initialTransportation: any = null;
|
||||
export let currentUser: any = null;
|
||||
export let editingTransportation: any = null;
|
||||
export let collection: Collection | null = null;
|
||||
export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner
|
||||
|
||||
// Form data properties
|
||||
let transportation: any = {
|
||||
name: '',
|
||||
type: '',
|
||||
description: '',
|
||||
rating: NaN,
|
||||
link: '',
|
||||
date: null,
|
||||
end_date: null,
|
||||
start_timezone: null,
|
||||
end_timezone: null,
|
||||
flight_number: null,
|
||||
from_location: null,
|
||||
to_location: null,
|
||||
origin_latitude: null,
|
||||
origin_longitude: null,
|
||||
destination_latitude: null,
|
||||
destination_longitude: null,
|
||||
distance: null,
|
||||
collection: collection?.id,
|
||||
is_public: true
|
||||
};
|
||||
|
||||
let selectedTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
let localStartDate: string = '';
|
||||
let localEndDate: string = '';
|
||||
let allDay: boolean = true;
|
||||
let constrainDates: boolean = true;
|
||||
let fullStartDate: string = '';
|
||||
let fullEndDate: string = '';
|
||||
|
||||
let user: User | null = null;
|
||||
let transportationToEdit: Transportation | null = null;
|
||||
let wikiError = '';
|
||||
let isGeneratingDesc = false;
|
||||
let ownerUser: User | null = null;
|
||||
let dateError = '';
|
||||
|
||||
$: user = currentUser;
|
||||
$: transportationToEdit = editingTransportation;
|
||||
// Only assign timezones when this is a timed transportation. Keep timezones null for all-day entries.
|
||||
$: {
|
||||
transportation.start_timezone = allDay ? null : selectedTimezone;
|
||||
transportation.end_timezone = allDay ? null : selectedTimezone;
|
||||
}
|
||||
|
||||
// Reactive constraints
|
||||
$: constraintStartDate = allDay
|
||||
? fullStartDate && fullStartDate.includes('T')
|
||||
? fullStartDate.split('T')[0]
|
||||
: ''
|
||||
: fullStartDate || '';
|
||||
$: constraintEndDate = allDay
|
||||
? fullEndDate && fullEndDate.includes('T')
|
||||
? fullEndDate.split('T')[0]
|
||||
: ''
|
||||
: fullEndDate || '';
|
||||
|
||||
function handleTransportationUpdate(
|
||||
event: CustomEvent<{
|
||||
start: { name: string; lat: number; lng: number; location: string };
|
||||
end: { name: string; lat: number; lng: number; location: string };
|
||||
}>
|
||||
) {
|
||||
const { start, end } = event.detail;
|
||||
|
||||
// Update from location
|
||||
transportation.from_location = start.location;
|
||||
transportation.origin_latitude = start.lat;
|
||||
transportation.origin_longitude = start.lng;
|
||||
|
||||
// Update to location
|
||||
transportation.to_location = end.location;
|
||||
transportation.destination_latitude = end.lat;
|
||||
transportation.destination_longitude = end.lng;
|
||||
|
||||
// Update name if empty (use route)
|
||||
if (!transportation.name) {
|
||||
transportation.name = `${start.name} → ${end.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLocationClear() {
|
||||
transportation.from_location = null;
|
||||
transportation.to_location = null;
|
||||
transportation.origin_latitude = null;
|
||||
transportation.origin_longitude = null;
|
||||
transportation.destination_latitude = null;
|
||||
transportation.destination_longitude = null;
|
||||
}
|
||||
|
||||
function handleAllDayToggle() {
|
||||
if (allDay) {
|
||||
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
|
||||
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
|
||||
// Clear timezones for all-day transportation
|
||||
transportation.start_timezone = null;
|
||||
transportation.end_timezone = null;
|
||||
} else {
|
||||
localStartDate = localStartDate ? `${localStartDate}T00:00` : '';
|
||||
localEndDate = localEndDate ? `${localEndDate}T23:59` : '';
|
||||
// Restore selected timezones when switching back to timed
|
||||
transportation.start_timezone = selectedTimezone;
|
||||
transportation.end_timezone = selectedTimezone;
|
||||
}
|
||||
|
||||
syncAndValidateDates(false);
|
||||
}
|
||||
|
||||
function handleLocalDateChange() {
|
||||
syncAndValidateDates(false);
|
||||
}
|
||||
|
||||
function syncAndValidateDates(autoFillEnd: boolean): boolean {
|
||||
dateError = '';
|
||||
|
||||
if (localEndDate && !localStartDate) {
|
||||
dateError = 'Start date is required when end date is provided';
|
||||
localEndDate = '';
|
||||
transportation.end_date = null;
|
||||
}
|
||||
|
||||
transportation.date = localStartDate
|
||||
? updateUTCDate({ localDate: localStartDate, timezone: selectedTimezone, allDay }).utcDate
|
||||
: null;
|
||||
transportation.end_date = localEndDate
|
||||
? updateUTCDate({ localDate: localEndDate, timezone: selectedTimezone, allDay }).utcDate
|
||||
: null;
|
||||
|
||||
if (!localEndDate && localStartDate && autoFillEnd) {
|
||||
const start = allDay
|
||||
? DateTime.fromISO(localStartDate, { zone: 'UTC' })
|
||||
: DateTime.fromISO(localStartDate, { zone: selectedTimezone });
|
||||
if (start.isValid) {
|
||||
if (allDay) {
|
||||
const defaultEnd = start.plus({ days: 1 }).toISODate();
|
||||
if (defaultEnd) {
|
||||
localEndDate = defaultEnd;
|
||||
transportation.end_date = updateUTCDate({
|
||||
localDate: defaultEnd,
|
||||
timezone: selectedTimezone,
|
||||
allDay
|
||||
}).utcDate;
|
||||
}
|
||||
} else {
|
||||
const defaultEnd = start
|
||||
.plus({ days: 1 })
|
||||
.set({ hour: 9, minute: 0, second: 0, millisecond: 0 });
|
||||
const defaultEndLocal = defaultEnd.toISO({
|
||||
suppressSeconds: true,
|
||||
suppressMilliseconds: true,
|
||||
includeOffset: false
|
||||
});
|
||||
if (defaultEndLocal) {
|
||||
localEndDate = defaultEndLocal.slice(0, 16);
|
||||
transportation.end_date = updateUTCDate({
|
||||
localDate: localEndDate,
|
||||
timezone: selectedTimezone,
|
||||
allDay
|
||||
}).utcDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transportation.date || transportation.end_date) {
|
||||
// validate start/end dates (constraints are handled elsewhere)
|
||||
const validation = validateDateRange(
|
||||
transportation.date || '',
|
||||
transportation.end_date || ''
|
||||
);
|
||||
if (!validation.valid) {
|
||||
dateError = validation.error || 'Invalid date range';
|
||||
transportation.end_date = null;
|
||||
localEndDate = '';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function generateDesc() {
|
||||
if (!transportation.name) return;
|
||||
|
||||
isGeneratingDesc = true;
|
||||
wikiError = '';
|
||||
|
||||
try {
|
||||
// Mock Wikipedia API call - replace with actual implementation
|
||||
const response = await fetch(
|
||||
`/api/generate/desc/?name=${encodeURIComponent(transportation.name)}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
transportation.description = data.extract || '';
|
||||
} else {
|
||||
wikiError = `${$t('adventures.wikipedia_error') || 'Error fetching description from Wikipedia'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
wikiError = `${$t('adventures.wikipedia_error') || ''}`;
|
||||
} finally {
|
||||
isGeneratingDesc = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!transportation.name || !transportation.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure timezones are only persisted for timed transportation
|
||||
transportation.start_timezone = allDay ? null : selectedTimezone;
|
||||
transportation.end_timezone = allDay ? null : selectedTimezone;
|
||||
|
||||
if (!syncAndValidateDates(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// round origin and destination coordinates to 6 decimal places
|
||||
if (
|
||||
transportation.origin_latitude !== null &&
|
||||
typeof transportation.origin_latitude === 'number'
|
||||
) {
|
||||
transportation.origin_latitude = parseFloat(transportation.origin_latitude.toFixed(6));
|
||||
}
|
||||
if (
|
||||
transportation.origin_longitude !== null &&
|
||||
typeof transportation.origin_longitude === 'number'
|
||||
) {
|
||||
transportation.origin_longitude = parseFloat(transportation.origin_longitude.toFixed(6));
|
||||
}
|
||||
if (
|
||||
transportation.destination_latitude !== null &&
|
||||
typeof transportation.destination_latitude === 'number'
|
||||
) {
|
||||
transportation.destination_latitude = parseFloat(
|
||||
transportation.destination_latitude.toFixed(6)
|
||||
);
|
||||
}
|
||||
if (
|
||||
transportation.destination_longitude !== null &&
|
||||
typeof transportation.destination_longitude === 'number'
|
||||
) {
|
||||
transportation.destination_longitude = parseFloat(
|
||||
transportation.destination_longitude.toFixed(6)
|
||||
);
|
||||
}
|
||||
if (collection && collection.id) {
|
||||
transportation.collection = collection.id;
|
||||
}
|
||||
|
||||
// Build payload and avoid sending an empty `collection` array when editing
|
||||
const payload: any = { ...transportation };
|
||||
|
||||
// Remove empty link to avoid URL validation errors
|
||||
if (!payload.link || payload.link.trim() === '') {
|
||||
delete payload.link;
|
||||
}
|
||||
|
||||
// If we're editing and the original location had collection, but the form's collection
|
||||
// is empty (i.e. user didn't modify collection), omit collection from payload so the
|
||||
// server doesn't clear them unintentionally.
|
||||
if (transportationToEdit && transportationToEdit.id) {
|
||||
if (
|
||||
(!payload.collection || payload.collection.length === 0) &&
|
||||
transportationToEdit.collection
|
||||
) {
|
||||
delete payload.collection;
|
||||
}
|
||||
|
||||
let res = await fetch(`/api/transportations/${transportationToEdit.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
let updatedLocation = await res.json();
|
||||
transportation = updatedLocation;
|
||||
} else {
|
||||
let res = await fetch(`/api/transportations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
let newTransportation = await res.json();
|
||||
transportation = newTransportation;
|
||||
}
|
||||
|
||||
dispatch('save', {
|
||||
...transportation
|
||||
});
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
dispatch('back');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Prefer transportation start_timezone if present, otherwise keep current selection
|
||||
if (initialTransportation?.start_timezone) {
|
||||
selectedTimezone = initialTransportation.start_timezone;
|
||||
}
|
||||
|
||||
// Determine if existing dates are all-day using shared helper
|
||||
if (initialTransportation?.date) {
|
||||
allDay = isAllDay(initialTransportation.date);
|
||||
}
|
||||
|
||||
// Keep transportation timezones null for all-day entries, otherwise use selectedTimezone
|
||||
transportation.start_timezone = allDay ? null : selectedTimezone;
|
||||
transportation.end_timezone = allDay ? null : selectedTimezone;
|
||||
|
||||
// Convert UTC dates to local display, respecting all-day formatting
|
||||
if (initialTransportation?.date) {
|
||||
if (allDay) {
|
||||
localStartDate = initialTransportation.date.split('T')[0];
|
||||
} else {
|
||||
const result = updateLocalDate({
|
||||
utcDate: initialTransportation.date,
|
||||
timezone: selectedTimezone
|
||||
});
|
||||
localStartDate = result.localDate;
|
||||
}
|
||||
}
|
||||
if (initialTransportation?.end_date) {
|
||||
if (allDay) {
|
||||
localEndDate = initialTransportation.end_date.split('T')[0];
|
||||
} else {
|
||||
const result = updateLocalDate({
|
||||
utcDate: initialTransportation.end_date,
|
||||
timezone: selectedTimezone
|
||||
});
|
||||
localEndDate = result.localDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (initialTransportation && typeof initialTransportation === 'object') {
|
||||
// Populate all fields from initialTransportation
|
||||
transportation.name = initialTransportation.name || '';
|
||||
transportation.type = initialTransportation.type || '';
|
||||
transportation.link = initialTransportation.link || '';
|
||||
transportation.description = initialTransportation.description || '';
|
||||
transportation.rating = initialTransportation.rating ?? NaN;
|
||||
transportation.is_public = initialTransportation.is_public ?? true;
|
||||
transportation.flight_number = initialTransportation.flight_number || null;
|
||||
transportation.distance = initialTransportation.distance || null;
|
||||
|
||||
// Populate origin/destination data
|
||||
transportation.from_location = initialTransportation.from_location || null;
|
||||
transportation.to_location = initialTransportation.to_location || null;
|
||||
transportation.origin_latitude = initialTransportation.origin_latitude || null;
|
||||
transportation.origin_longitude = initialTransportation.origin_longitude || null;
|
||||
transportation.destination_latitude = initialTransportation.destination_latitude || null;
|
||||
transportation.destination_longitude = initialTransportation.destination_longitude || null;
|
||||
|
||||
if (initialTransportation.user) {
|
||||
ownerUser = initialTransportation.user;
|
||||
}
|
||||
}
|
||||
|
||||
// If adding from itinerary, pre-fill all-day stay with next-day checkout
|
||||
if (!initialTransportation?.date && initialVisitDate && !localStartDate) {
|
||||
const start = DateTime.fromISO(initialVisitDate, { zone: 'UTC' });
|
||||
if (start.isValid) {
|
||||
allDay = true;
|
||||
localStartDate = start.toISODate() || '';
|
||||
const nextDay = start.plus({ days: 1 }).toISODate();
|
||||
localEndDate = nextDay || '';
|
||||
|
||||
syncAndValidateDates(false);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// no-op
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
|
||||
<div class="max-w-full mx-auto space-y-6">
|
||||
<!-- Location Search & Map Section - FIRST! -->
|
||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-secondary/10 rounded-lg">
|
||||
<MapIcon class="w-5 h-5 text-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{$t('adventures.location_map')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LocationSearchMap
|
||||
bind:isReverseGeocoding
|
||||
transportationMode={true}
|
||||
bind:airportMode
|
||||
showDisplayNameInput={false}
|
||||
initialStartLocation={initialTransportation?.origin_latitude &&
|
||||
initialTransportation?.origin_longitude
|
||||
? {
|
||||
name: initialTransportation.from_location || '',
|
||||
lat: Number(initialTransportation.origin_latitude),
|
||||
lng: Number(initialTransportation.origin_longitude),
|
||||
location: initialTransportation.from_location || ''
|
||||
}
|
||||
: null}
|
||||
initialEndLocation={initialTransportation?.destination_latitude &&
|
||||
initialTransportation?.destination_longitude
|
||||
? {
|
||||
name: initialTransportation.to_location || '',
|
||||
lat: Number(initialTransportation.destination_latitude),
|
||||
lng: Number(initialTransportation.destination_longitude),
|
||||
location: initialTransportation.to_location || ''
|
||||
}
|
||||
: null}
|
||||
on:transportationUpdate={handleTransportationUpdate}
|
||||
on:clear={handleLocationClear}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Information Section -->
|
||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<InfoIcon class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">{$t('adventures.basic_information')}</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Name Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">
|
||||
{$t('adventures.name')} <span class="text-error">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={transportation.name}
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
placeholder="Enter transportation name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="type">
|
||||
<span class="label-text font-medium"
|
||||
>{$t('transportation.type')} <span class="text-error">*</span></span
|
||||
>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
name="type"
|
||||
id="type"
|
||||
required
|
||||
bind:value={transportation.type}
|
||||
>
|
||||
<option disabled value="">{$t('transportation.select_type')}</option>
|
||||
{#each Object.entries(TRANSPORTATION_TYPES_ICONS) as [key, icon]}
|
||||
<option value={key}>{icon} {key.charAt(0).toUpperCase() + key.slice(1)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Flight Number Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="flight_number">
|
||||
<span class="label-text font-medium">{$t('transportation.flight_number')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="flight_number"
|
||||
bind:value={transportation.flight_number}
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
placeholder="Enter flight number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Rating Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="rating">
|
||||
<span class="label-text font-medium">{$t('adventures.rating')}</span>
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
|
||||
>
|
||||
<div class="rating">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
id="rating"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(transportation.rating)}
|
||||
/>
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<input
|
||||
type="radio"
|
||||
name="rating"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = star)}
|
||||
checked={transportation.rating === star}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !Number.isNaN(transportation.rating)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error btn-outline gap-2"
|
||||
on:click={() => (transportation.rating = NaN)}
|
||||
>
|
||||
<ClearIcon class="w-4 h-4" />
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Link Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="link">
|
||||
<span class="label-text font-medium">{$t('adventures.link')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
bind:value={transportation.link}
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">{$t('adventures.description')}</span>
|
||||
</label>
|
||||
<MarkdownEditor bind:text={transportation.description} editor_height="h-32" />
|
||||
|
||||
<div class="flex items-center gap-4 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral btn-sm gap-2"
|
||||
on:click={generateDesc}
|
||||
disabled={!transportation.name || isGeneratingDesc || !transportation.type}
|
||||
>
|
||||
{#if isGeneratingDesc}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<GenerateIcon class="w-4 h-4" />
|
||||
{/if}
|
||||
{$t('adventures.generate_desc')}
|
||||
</button>
|
||||
{#if wikiError}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<InfoIcon class="w-4 h-4" />
|
||||
<span class="text-sm">{wikiError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Departure/Arrival Dates & Timezone Section -->
|
||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-info/10 rounded-lg">
|
||||
<svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold">{$t('adventures.dates')}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- All Day and Constrain Dates Toggles -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
bind:checked={allDay}
|
||||
on:change={handleAllDayToggle}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.all_day')}</span>
|
||||
</label>
|
||||
|
||||
{#if collection}
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-secondary"
|
||||
bind:checked={constrainDates}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.date_constrain')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dateError}
|
||||
<div class="alert alert-error bg-error/10 border border-error/30 text-error">
|
||||
<InfoIcon class="w-4 h-4" />
|
||||
<span class="text-sm">{dateError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Departure Date -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="departure-date">
|
||||
<span class="label-text font-medium">{$t('transportation.departure_date')}</span>
|
||||
</label>
|
||||
{#if allDay}
|
||||
<input
|
||||
id="departure-date"
|
||||
type="date"
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
bind:value={localStartDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : undefined}
|
||||
max={constrainDates ? constraintEndDate : undefined}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
id="departure-date"
|
||||
type="datetime-local"
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
bind:value={localStartDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : undefined}
|
||||
max={constrainDates ? constraintEndDate : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Arrival Date -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="arrival-date">
|
||||
<span class="label-text font-medium">{$t('transportation.arrival_date')}</span>
|
||||
</label>
|
||||
{#if allDay}
|
||||
<input
|
||||
id="arrival-date"
|
||||
type="date"
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
bind:value={localEndDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : undefined}
|
||||
max={constrainDates ? constraintEndDate : undefined}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
id="arrival-date"
|
||||
type="datetime-local"
|
||||
class="input input-bordered bg-base-100/80 focus:bg-base-100"
|
||||
bind:value={localEndDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : undefined}
|
||||
max={constrainDates ? constraintEndDate : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timezone Selector (only for timed transportation) -->
|
||||
{#if !allDay}
|
||||
<TimezoneSelector bind:selectedTimezone />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
class="btn btn-primary gap-2"
|
||||
disabled={!transportation.name || !transportation.type || isReverseGeocoding}
|
||||
on:click={handleSave}
|
||||
>
|
||||
{#if isReverseGeocoding}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{$t('adventures.processing')}...
|
||||
{:else}
|
||||
<SaveIcon class="w-5 h-5" />
|
||||
{$t('adventures.continue')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import type { Collection, Location, Transportation, User } from '$lib/types';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Plane from '~icons/mdi/airplane';
|
||||
import MediaStep from '../shared/MediaStep.svelte';
|
||||
import TransportationDetails from './TransportationDetails.svelte';
|
||||
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Store the initial visit date internally so it persists even if parent clears it
|
||||
let storedInitialVisitDate: string | null = initialVisitDate;
|
||||
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
let steps = [
|
||||
{
|
||||
name: $t('adventures.details'),
|
||||
selected: true,
|
||||
requires_id: false
|
||||
},
|
||||
{
|
||||
name: $t('settings.media'),
|
||||
selected: false,
|
||||
requires_id: true
|
||||
}
|
||||
];
|
||||
|
||||
function createEmptyTransportation(): Transportation {
|
||||
return {
|
||||
id: '',
|
||||
user: '',
|
||||
name: '',
|
||||
type: '',
|
||||
description: null,
|
||||
rating: null,
|
||||
link: null,
|
||||
date: null,
|
||||
end_date: null,
|
||||
start_timezone: null,
|
||||
end_timezone: null,
|
||||
flight_number: null,
|
||||
from_location: null,
|
||||
to_location: null,
|
||||
origin_latitude: null,
|
||||
origin_longitude: null,
|
||||
destination_latitude: null,
|
||||
destination_longitude: null,
|
||||
is_public: false,
|
||||
distance: null,
|
||||
collection: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
images: [],
|
||||
attachments: []
|
||||
};
|
||||
}
|
||||
|
||||
export let transportation: Transportation = createEmptyTransportation();
|
||||
|
||||
export let transportationToEdit: Transportation | null = null;
|
||||
|
||||
// Track which transportation we're currently editing to prevent unnecessary overwrites
|
||||
let previousTransportationId: string | null = null;
|
||||
|
||||
// Reactively update internal state when switching between edit/new.
|
||||
// This prevents stale values when the parent reuses `bind:transportation`.
|
||||
// Only runs when actually switching to a different transportation, not on every reactive update.
|
||||
$: {
|
||||
const currentTransportationId = transportationToEdit?.id || null;
|
||||
|
||||
if (currentTransportationId !== previousTransportationId) {
|
||||
previousTransportationId = currentTransportationId;
|
||||
|
||||
if (transportationToEdit) {
|
||||
transportation = {
|
||||
id: transportationToEdit.id || '',
|
||||
user: transportationToEdit.user || '',
|
||||
name: transportationToEdit.name || '',
|
||||
type: transportationToEdit.type || '',
|
||||
description: transportationToEdit.description || null,
|
||||
rating: transportationToEdit.rating || null,
|
||||
link: transportationToEdit.link || null,
|
||||
date: transportationToEdit.date || null,
|
||||
end_date: transportationToEdit.end_date || null,
|
||||
start_timezone: transportationToEdit.start_timezone || null,
|
||||
end_timezone: transportationToEdit.end_timezone || null,
|
||||
flight_number: transportationToEdit.flight_number || null,
|
||||
from_location: transportationToEdit.from_location || null,
|
||||
to_location: transportationToEdit.to_location || null,
|
||||
origin_latitude: transportationToEdit.origin_latitude || null,
|
||||
origin_longitude: transportationToEdit.origin_longitude || null,
|
||||
destination_latitude: transportationToEdit.destination_latitude || null,
|
||||
destination_longitude: transportationToEdit.destination_longitude || null,
|
||||
is_public: transportationToEdit.is_public || false,
|
||||
distance: transportationToEdit.distance || null,
|
||||
collection: transportationToEdit.collection || null,
|
||||
created_at: transportationToEdit.created_at || '',
|
||||
updated_at: transportationToEdit.updated_at || '',
|
||||
images: transportationToEdit.images || [],
|
||||
attachments: transportationToEdit.attachments || []
|
||||
};
|
||||
} else if (!transportation?.id) {
|
||||
// Only reset to empty if we don't already have a saved transportation with an ID
|
||||
transportation = createEmptyTransportation();
|
||||
storedInitialVisitDate = initialVisitDate;
|
||||
// Reset steps to details when creating a new transportation
|
||||
steps = [
|
||||
{ name: $t('adventures.details'), selected: true, requires_id: false },
|
||||
{ name: $t('settings.media'), selected: false, requires_id: true }
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('transportation_modal') as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
});
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<dialog id="transportation_modal" class="modal backdrop-blur-sm">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
|
||||
role="dialog"
|
||||
on:keydown={handleKeydown}
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Header Section - Following adventurelog pattern -->
|
||||
<div
|
||||
class="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-2 bg-primary/10 rounded-xl">
|
||||
<Plane class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary bg-clip-text">
|
||||
{transportationToEdit
|
||||
? $t('transportation.edit_transportation')
|
||||
: $t('transportation.new_transportation')}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{transportationToEdit
|
||||
? $t('transportation.update_transportation_details')
|
||||
: $t('transportation.create_new_transportation')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="timeline timeline-vertical timeline-compact sm:timeline-horizontal sm:timeline-normal"
|
||||
>
|
||||
{#each steps as step, index}
|
||||
<li>
|
||||
{#if index > 0}
|
||||
<hr class="bg-base-300" />
|
||||
{/if}
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 sm:h-5 sm:w-5 {step.selected
|
||||
? 'text-primary'
|
||||
: 'text-base-content/40'}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-0.089l4-5-5z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
class="timeline-end timeline-box text-xs sm:text-sm px-2 py-1 sm:px-3 sm:py-2 {step.selected
|
||||
? 'bg-primary text-primary-content'
|
||||
: 'bg-base-200'} {step.requires_id && !transportation?.id
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-primary/80 cursor-pointer'} transition-colors"
|
||||
on:click={() => {
|
||||
// Reset all steps
|
||||
steps.forEach((s) => (s.selected = false));
|
||||
// Select clicked step
|
||||
steps[index].selected = true;
|
||||
}}
|
||||
disabled={step.requires_id && !transportation?.id}
|
||||
>
|
||||
<span class="hidden sm:inline">{step.name}</span>
|
||||
<span class="sm:hidden"
|
||||
>{step.name.substring(0, 8)}{step.name.length > 8 ? '...' : ''}</span
|
||||
>
|
||||
</button>
|
||||
{#if index < steps.length - 1}
|
||||
<hr class="bg-base-300" />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="btn btn-ghost btn-square" on:click={close}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if steps[0].selected}
|
||||
<TransportationDetails
|
||||
currentUser={user}
|
||||
initialTransportation={transportation}
|
||||
{collection}
|
||||
bind:editingTransportation={transportation}
|
||||
on:back={() => {
|
||||
steps[1].selected = false;
|
||||
steps[0].selected = true;
|
||||
}}
|
||||
on:save={(e) => {
|
||||
// Update the entire transportation object with all saved data
|
||||
transportation = { ...transportation, ...e.detail };
|
||||
|
||||
// Only allow moving to Media once we have a persisted id.
|
||||
if (!transportation?.id) {
|
||||
addToast('error', $t('adventures.lodging_save_error'));
|
||||
steps[1].selected = false;
|
||||
steps[0].selected = true;
|
||||
return;
|
||||
}
|
||||
|
||||
steps[0].selected = false;
|
||||
steps[1].selected = true;
|
||||
}}
|
||||
initialVisitDate={storedInitialVisitDate}
|
||||
/>
|
||||
{/if}
|
||||
{#if steps[1].selected}
|
||||
<MediaStep
|
||||
bind:images={transportation.images}
|
||||
bind:attachments={transportation.attachments}
|
||||
itemName={transportation.name}
|
||||
on:back={() => {
|
||||
steps[1].selected = false;
|
||||
steps[0].selected = true;
|
||||
}}
|
||||
on:close={() => close()}
|
||||
itemId={transportation.id}
|
||||
contentType="transportation"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -1,4 +1,4 @@
|
||||
export let appVersion = 'v0.12.0-pre-dev-122325';
|
||||
export let appVersion = 'v0.12.0-pre-dev-122625';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2023-2025';
|
||||
|
||||
@@ -28,49 +28,3 @@ export const load = (async (event) => {
|
||||
};
|
||||
}
|
||||
}) satisfies PageServerLoad;
|
||||
|
||||
import { redirect, type Actions } from '@sveltejs/kit';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
|
||||
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const actions: Actions = {
|
||||
delete: async (event) => {
|
||||
const id = event.params as { id: string };
|
||||
const lodgingId = id.id;
|
||||
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
if (!lodgingId) {
|
||||
return {
|
||||
status: 400,
|
||||
error: new Error('Bad request')
|
||||
};
|
||||
}
|
||||
|
||||
let csrfToken = await fetchCSRFToken();
|
||||
|
||||
let res = await fetch(`${serverEndpoint}/api/lodging/${event.params.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Referer: event.url.origin,
|
||||
Cookie: `sessionid=${event.cookies.get('sessionid')};
|
||||
csrftoken=${csrfToken}`,
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
console.log(res);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
error: new Error('Failed to delete lodging')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 204
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import CardAccountDetails from '~icons/mdi/card-account-details';
|
||||
import CardCarousel from '$lib/components/CardCarousel.svelte';
|
||||
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
|
||||
import LodgingModal from '$lib/components/lodging/LodgingModal.svelte';
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
return marked(markdown) as string;
|
||||
@@ -45,6 +46,7 @@
|
||||
let lodging_images: { image: string; lodging: Lodging | null }[] = [];
|
||||
let modalInitialIndex: number = 0;
|
||||
let isImageModalOpen: boolean = false;
|
||||
let isEditModalOpen: boolean = false;
|
||||
|
||||
function getLodgingIcon(type: string) {
|
||||
if (type in LODGING_TYPES_ICONS) {
|
||||
@@ -145,6 +147,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isEditModalOpen}
|
||||
<LodgingModal
|
||||
on:close={() => (isEditModalOpen = false)}
|
||||
user={data.user}
|
||||
lodgingToEdit={lodging}
|
||||
bind:lodging
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isImageModalOpen}
|
||||
<ImageDisplayModal
|
||||
images={lodging.images}
|
||||
@@ -163,6 +174,17 @@
|
||||
{/if}
|
||||
|
||||
{#if lodging}
|
||||
{#if data.user?.uuid && lodging.user && data.user.uuid === lodging.user}
|
||||
<div class="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
class="btn btn-primary btn-circle w-16 h-16 shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-110"
|
||||
on:click={() => (isEditModalOpen = true)}
|
||||
>
|
||||
<ClipboardList class="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="relative">
|
||||
<div
|
||||
|
||||
30
frontend/src/routes/transportations/[id]/+page.server.ts
Normal file
30
frontend/src/routes/transportations/[id]/+page.server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
import type { Transportation } from '$lib/types';
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const load = (async (event) => {
|
||||
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;
|
||||
634
frontend/src/routes/transportations/[id]/+page.svelte
Normal file
634
frontend/src/routes/transportations/[id]/+page.svelte
Normal file
@@ -0,0 +1,634 @@
|
||||
<script lang="ts">
|
||||
import type { Transportation } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import Lost from '$lib/assets/undraw_lost.svg';
|
||||
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
// @ts-ignore
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import ClipboardList from '~icons/mdi/clipboard-list';
|
||||
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
||||
import AttachmentCard from '$lib/components/cards/AttachmentCard.svelte';
|
||||
import { getBasemapUrl, isAllDay, TRANSPORTATION_TYPES_ICONS } from '$lib';
|
||||
import Star from '~icons/mdi/star';
|
||||
import StarOutline from '~icons/mdi/star-outline';
|
||||
import MapMarker from '~icons/mdi/map-marker';
|
||||
import CalendarRange from '~icons/mdi/calendar-range';
|
||||
import Eye from '~icons/mdi/eye';
|
||||
import EyeOff from '~icons/mdi/eye-off';
|
||||
import OpenInNew from '~icons/mdi/open-in-new';
|
||||
import CashMultiple from '~icons/mdi/cash-multiple';
|
||||
import CardAccountDetails from '~icons/mdi/card-account-details';
|
||||
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
|
||||
import TransportationModal from '$lib/components/transportation/TransportationModal.svelte';
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
return marked(markdown) as string;
|
||||
};
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
|
||||
let transportation: Transportation;
|
||||
let currentSlide = 0;
|
||||
|
||||
function goToSlide(index: number) {
|
||||
currentSlide = index;
|
||||
}
|
||||
|
||||
let notFound: boolean = false;
|
||||
let mapCenter: [number, number] | null = null;
|
||||
let modalInitialIndex: number = 0;
|
||||
let isImageModalOpen: boolean = false;
|
||||
let isEditModalOpen: boolean = false;
|
||||
|
||||
function getTransportationIcon(type: string) {
|
||||
if (type in TRANSPORTATION_TYPES_ICONS) {
|
||||
return TRANSPORTATION_TYPES_ICONS[type as keyof typeof TRANSPORTATION_TYPES_ICONS];
|
||||
}
|
||||
return '🚗';
|
||||
}
|
||||
|
||||
function renderStars(rating: number) {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(i <= rating);
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (data.props.transportation) {
|
||||
transportation = data.props.transportation;
|
||||
transportation.images.sort((a, b) => {
|
||||
if (a.is_primary && !b.is_primary) {
|
||||
return -1;
|
||||
} else if (!a.is_primary && b.is_primary) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
notFound = true;
|
||||
}
|
||||
});
|
||||
|
||||
$: mapCenter = transportation ? getMapCenter(transportation) : null;
|
||||
|
||||
function closeImageModal() {
|
||||
isImageModalOpen = false;
|
||||
}
|
||||
|
||||
function openImageModal(imageIndex: number) {
|
||||
modalInitialIndex = imageIndex;
|
||||
isImageModalOpen = true;
|
||||
}
|
||||
|
||||
function getRouteLabel() {
|
||||
if (!transportation) return '';
|
||||
if (transportation.from_location && transportation.to_location) {
|
||||
return `${transportation.from_location} → ${transportation.to_location}`;
|
||||
}
|
||||
return transportation.from_location ?? transportation.to_location ?? '';
|
||||
}
|
||||
|
||||
function formatTravelWindow(
|
||||
start: string | null,
|
||||
end: string | null,
|
||||
startTimezone: string | null,
|
||||
endTimezone: string | null
|
||||
) {
|
||||
if (!start && !end) return null;
|
||||
|
||||
const formatDate = (date: string | null, timezone: string | null) => {
|
||||
if (!date) return '';
|
||||
if (isAllDay(date)) {
|
||||
return formatAllDayDate(date);
|
||||
}
|
||||
return formatDateInTimezone(date, timezone);
|
||||
};
|
||||
|
||||
if (start && end) {
|
||||
return `${formatDate(start, startTimezone)} → ${formatDate(end, endTimezone ?? startTimezone)}`;
|
||||
} else if (start) {
|
||||
return `${$t('adventures.start') ?? 'Start'}: ${formatDate(start, startTimezone)}`;
|
||||
} else if (end) {
|
||||
return `${$t('adventures.end') ?? 'End'}: ${formatDate(end, endTimezone)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function calculateDuration(
|
||||
start: string | null,
|
||||
end: string | null,
|
||||
startTimezone: string | null,
|
||||
endTimezone: string | null
|
||||
): string | null {
|
||||
if (!start || !end) return null;
|
||||
|
||||
const startDT = DateTime.fromISO(start, { zone: startTimezone ?? 'UTC' });
|
||||
const endDT = DateTime.fromISO(end, { zone: endTimezone ?? startTimezone ?? 'UTC' });
|
||||
|
||||
if (!startDT.isValid || !endDT.isValid) return null;
|
||||
|
||||
const totalMinutes = Math.round(endDT.diff(startDT, 'minutes').minutes ?? 0);
|
||||
if (totalMinutes <= 0) return null;
|
||||
|
||||
const days = Math.floor(totalMinutes / (60 * 24));
|
||||
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days) parts.push(`${days}d`);
|
||||
if (hours) parts.push(`${hours}h`);
|
||||
if (minutes) parts.push(`${minutes}m`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function hasOriginCoordinates(item: Transportation) {
|
||||
return item.origin_latitude !== null && item.origin_longitude !== null;
|
||||
}
|
||||
|
||||
function hasDestinationCoordinates(item: Transportation) {
|
||||
return item.destination_latitude !== null && item.destination_longitude !== null;
|
||||
}
|
||||
|
||||
function getMapCenter(item: Transportation): [number, number] | null {
|
||||
if (hasOriginCoordinates(item)) {
|
||||
return [item.origin_longitude as number, item.origin_latitude as number];
|
||||
}
|
||||
if (hasDestinationCoordinates(item)) {
|
||||
return [item.destination_longitude as number, item.destination_latitude as number];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if notFound}
|
||||
<div class="hero min-h-screen bg-gradient-to-br from-base-200 to-base-300 overflow-x-hidden">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<img src={Lost} alt="Lost" class="w-64 mx-auto mb-8 opacity-80" />
|
||||
<h1 class="text-5xl font-bold text-primary mb-4">Transportation not found</h1>
|
||||
<p class="text-lg opacity-70 mb-8">{$t('adventures.location_not_found_desc')}</p>
|
||||
<button class="btn btn-primary btn-lg" on:click={() => goto('/')}>
|
||||
{$t('adventures.homepage')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isEditModalOpen}
|
||||
<TransportationModal
|
||||
on:close={() => (isEditModalOpen = false)}
|
||||
user={data.user}
|
||||
transportationToEdit={transportation}
|
||||
bind:transportation
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isImageModalOpen}
|
||||
<ImageDisplayModal
|
||||
images={transportation.images}
|
||||
initialIndex={modalInitialIndex}
|
||||
location={getRouteLabel()}
|
||||
on:close={closeImageModal}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !transportation && !notFound}
|
||||
<div class="hero min-h-screen overflow-x-hidden">
|
||||
<div class="hero-content">
|
||||
<span class="loading loading-spinner w-24 h-24 text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if transportation}
|
||||
{#if data.user?.uuid && transportation.user && data.user.uuid === transportation.user}
|
||||
<div class="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
class="btn btn-primary btn-circle w-16 h-16 shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-110"
|
||||
on:click={() => (isEditModalOpen = true)}
|
||||
>
|
||||
<ClipboardList class="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="hero min-h-[60vh] relative overflow-hidden"
|
||||
class:min-h-[30vh]={!transportation.images || transportation.images.length === 0}
|
||||
>
|
||||
<!-- Background: Images or Gradient -->
|
||||
{#if transportation.images && transportation.images.length > 0}
|
||||
<div class="hero-overlay bg-gradient-to-t from-black/70 via-black/20 to-transparent"></div>
|
||||
{#each transportation.images as image, i}
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-500"
|
||||
class:opacity-100={i === currentSlide}
|
||||
class:opacity-0={i !== currentSlide}
|
||||
>
|
||||
<button
|
||||
class="w-full h-full p-0 bg-transparent border-0"
|
||||
on:click={() => openImageModal(i)}
|
||||
aria-label={`View full image of ${transportation.name}`}
|
||||
>
|
||||
<img src={image.image} class="w-full h-full object-cover" alt={transportation.name} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 to-secondary/20"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="hero-content relative z-10 text-center"
|
||||
class:text-white={transportation.images?.length > 0}
|
||||
>
|
||||
<div class="max-w-4xl">
|
||||
<div class="flex justify-center items-center gap-3 mb-4">
|
||||
<span class="text-5xl">{getTransportationIcon(transportation.type)}</span>
|
||||
<h1 class="text-6xl font-bold drop-shadow-lg">{transportation.name}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
{#if transportation.rating !== undefined && transportation.rating !== null}
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="rating rating-lg">
|
||||
{#each Array.from({ length: 5 }, (_, i) => i + 1) as star}
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-hero"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
checked={star <= transportation.rating}
|
||||
disabled
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Info Badges -->
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-6">
|
||||
{#if transportation.type}
|
||||
<div class="badge badge-lg badge-primary font-semibold px-4 py-3">
|
||||
{$t(`transportation.modes.${transportation.type}`)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.from_location}
|
||||
<div class="badge badge-lg badge-secondary font-semibold px-4 py-3">
|
||||
🚩 {transportation.from_location}
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.to_location}
|
||||
<div class="badge badge-lg badge-secondary font-semibold px-4 py-3">
|
||||
🏁 {transportation.to_location}
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.is_public}
|
||||
<div class="badge badge-lg badge-accent font-semibold px-4 py-3">
|
||||
👁️ {$t('adventures.public')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-lg badge-ghost font-semibold px-4 py-3">
|
||||
🔒 {$t('adventures.private')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Image Navigation (only shown when multiple images exist) -->
|
||||
{#if transportation.images && transportation.images.length > 1}
|
||||
<div class="w-full max-w-md mx-auto">
|
||||
<!-- Navigation arrows and current position -->
|
||||
<div class="flex items-center justify-center gap-4 mb-3">
|
||||
<button
|
||||
on:click={() =>
|
||||
goToSlide(
|
||||
currentSlide > 0 ? currentSlide - 1 : transportation.images.length - 1
|
||||
)}
|
||||
class="btn btn-circle btn-sm btn-primary"
|
||||
aria-label={$t('adventures.previous_image')}
|
||||
>
|
||||
❮
|
||||
</button>
|
||||
|
||||
<div class="text-sm font-medium bg-black/50 px-3 py-1 rounded-full">
|
||||
{currentSlide + 1} / {transportation.images.length}
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={() =>
|
||||
goToSlide(
|
||||
currentSlide < transportation.images.length - 1 ? currentSlide + 1 : 0
|
||||
)}
|
||||
class="btn btn-circle btn-sm btn-primary"
|
||||
aria-label={$t('adventures.next_image')}
|
||||
>
|
||||
❯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dot navigation -->
|
||||
{#if transportation.images.length <= 12}
|
||||
<div class="flex justify-center gap-2 flex-wrap">
|
||||
{#each transportation.images as _, i}
|
||||
<button
|
||||
on:click={() => goToSlide(i)}
|
||||
class="btn btn-circle btn-xs transition-all duration-200"
|
||||
class:btn-primary={i === currentSlide}
|
||||
class:btn-outline={i !== currentSlide}
|
||||
class:opacity-50={i !== currentSlide}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute left-0 top-0 bottom-2 w-4 bg-gradient-to-r from-black/30 to-transparent pointer-events-none"
|
||||
></div>
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-2 w-4 bg-gradient-to-l from-black/30 to-transparent pointer-events-none"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mx-auto px-2 sm:px-4 py-6 sm:py-8 max-w-7xl">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-8">
|
||||
<!-- Left Column - Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6 sm:space-y-8">
|
||||
<!-- Description Card -->
|
||||
{#if transportation.description}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">📝 {$t('adventures.description')}</h2>
|
||||
<article class="prose max-w-none">
|
||||
{@html DOMPurify.sanitize(renderMarkdown(transportation.description))}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Map Section -->
|
||||
{#if mapCenter}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">🗺️ {$t('adventures.location')}</h2>
|
||||
<div class="rounded-lg overflow-hidden shadow-lg">
|
||||
<MapLibre
|
||||
style={getBasemapUrl()}
|
||||
class="w-full h-96"
|
||||
standardControls
|
||||
center={mapCenter}
|
||||
zoom={13}
|
||||
>
|
||||
{#if hasOriginCoordinates(transportation)}
|
||||
<DefaultMarker
|
||||
lngLat={[
|
||||
Number(transportation.origin_longitude),
|
||||
Number(transportation.origin_latitude)
|
||||
]}
|
||||
>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="p-2">
|
||||
<div class="text-lg font-bold text-black mb-1">{transportation.name}</div>
|
||||
<p class="font-semibold text-black text-sm mb-2">
|
||||
{$t('transportation.from_location')}
|
||||
{getTransportationIcon(transportation.type)}
|
||||
</p>
|
||||
{#if transportation.rating}
|
||||
<div class="flex items-center gap-1 mb-2">
|
||||
{#each renderStars(transportation.rating) as filled}
|
||||
{#if filled}
|
||||
<Star class="w-4 h-4 text-warning fill-current" />
|
||||
{:else}
|
||||
<StarOutline class="w-4 h-4 text-gray-400" />
|
||||
{/if}
|
||||
{/each}
|
||||
<span class="text-xs text-black ml-1">
|
||||
({transportation.rating}/5)
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.from_location}
|
||||
<div class="text-xs text-black">
|
||||
📍 {transportation.from_location}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popup>
|
||||
</DefaultMarker>
|
||||
{/if}
|
||||
|
||||
{#if hasDestinationCoordinates(transportation)}
|
||||
<DefaultMarker
|
||||
lngLat={[
|
||||
Number(transportation.destination_longitude),
|
||||
Number(transportation.destination_latitude)
|
||||
]}
|
||||
>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="p-2">
|
||||
<div class="text-lg font-bold text-black mb-1">{transportation.name}</div>
|
||||
<p class="font-semibold text-black text-sm mb-2">
|
||||
{$t('transportation.to_location')}
|
||||
{getTransportationIcon(transportation.type)}
|
||||
</p>
|
||||
{#if transportation.rating}
|
||||
<div class="flex items-center gap-1 mb-2">
|
||||
{#each renderStars(transportation.rating) as filled}
|
||||
{#if filled}
|
||||
<Star class="w-4 h-4 text-warning fill-current" />
|
||||
{:else}
|
||||
<StarOutline class="w-4 h-4 text-gray-400" />
|
||||
{/if}
|
||||
{/each}
|
||||
<span class="text-xs text-black ml-1">
|
||||
({transportation.rating}/5)
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.to_location}
|
||||
<div class="text-xs text-black">
|
||||
📍 {transportation.to_location}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popup>
|
||||
</DefaultMarker>
|
||||
{/if}
|
||||
</MapLibre>
|
||||
</div>
|
||||
{#if transportation.from_location || transportation.to_location}
|
||||
<p class="mt-4 text-base-content/70 flex items-center gap-2">
|
||||
<MapMarker class="w-5 h-5" />
|
||||
{getRouteLabel()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Sidebar -->
|
||||
<div class="space-y-4 sm:space-y-6">
|
||||
<!-- Quick Info Card -->
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-4">ℹ️ {$t('adventures.details')}</h2>
|
||||
<div class="space-y-4">
|
||||
<!-- Departure/Arrival -->
|
||||
{#if transportation.date || transportation.end_date}
|
||||
<div class="flex items-start gap-3">
|
||||
<CalendarRange class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm opacity-70">{$t('adventures.dates')}</p>
|
||||
<p class="text-base">
|
||||
{formatTravelWindow(
|
||||
transportation.date,
|
||||
transportation.end_date,
|
||||
transportation.start_timezone,
|
||||
transportation.end_timezone
|
||||
)}
|
||||
</p>
|
||||
{#if calculateDuration(transportation.date, transportation.end_date, transportation.start_timezone, transportation.end_timezone)}
|
||||
<p class="text-sm opacity-70 mt-1">
|
||||
{calculateDuration(
|
||||
transportation.date,
|
||||
transportation.end_date,
|
||||
transportation.start_timezone,
|
||||
transportation.end_timezone
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Type -->
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xl mt-1 flex-shrink-0"
|
||||
>{getTransportationIcon(transportation.type)}</span
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-sm opacity-70">{$t('transportation.type')}</p>
|
||||
<p class="text-base">{$t(`transportation.modes.${transportation.type}`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flight Number -->
|
||||
{#if transportation.flight_number}
|
||||
<div class="flex items-start gap-3">
|
||||
<CardAccountDetails class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm opacity-70">
|
||||
{$t('transportation.flight_number')}
|
||||
</p>
|
||||
<p class="text-base font-mono">{transportation.flight_number}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Distance -->
|
||||
{#if transportation.distance}
|
||||
<div class="flex items-start gap-3">
|
||||
<CashMultiple class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm opacity-70">
|
||||
{$t('adventures.distance') ?? 'Distance'}
|
||||
</p>
|
||||
<p class="text-base">{transportation.distance} km</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Link -->
|
||||
{#if transportation.link}
|
||||
<div class="flex items-start gap-3">
|
||||
<OpenInNew class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold text-sm opacity-70 mb-1">{$t('adventures.link')}</p>
|
||||
<a
|
||||
href={transportation.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link link-primary text-base break-all"
|
||||
>
|
||||
{transportation.link}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Images -->
|
||||
{#if transportation.images && transportation.images.length > 0}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-4">🖼️ {$t('adventures.images')}</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each transportation.images as image, i}
|
||||
<button
|
||||
class="aspect-square rounded-lg overflow-hidden hover:opacity-80 transition-opacity"
|
||||
on:click={() => openImageModal(i)}
|
||||
>
|
||||
<img
|
||||
src={image.image}
|
||||
alt={`${transportation.name} - ${i + 1}`}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Attachments -->
|
||||
{#if transportation.attachments && transportation.attachments.length > 0}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-4">📎 {$t('adventures.attachments')}</h2>
|
||||
<div class="space-y-2">
|
||||
{#each transportation.attachments as attachment}
|
||||
<AttachmentCard {attachment} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{data.props.transportation && data.props.transportation.name
|
||||
? `${data.props.transportation.name}`
|
||||
: 'Transportation'}
|
||||
</title>
|
||||
<meta name="description" content="View transportation details" />
|
||||
</svelte:head>
|
||||
Reference in New Issue
Block a user