feat(frontend): apply itinerary UI and docs refinements
This commit is contained in:
@@ -47,7 +47,7 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
|
|||||||
<img src="./brand/screenshots/adventures.png" alt="Locations" />
|
<img src="./brand/screenshots/adventures.png" alt="Locations" />
|
||||||
<p>Displays the locations you have visited and the ones you plan to embark on. You can also filter and sort the locations.</p>
|
<p>Displays the locations you have visited and the ones you plan to embark on. You can also filter and sort the locations.</p>
|
||||||
<img src="./brand/screenshots/details.png" alt="Location Details" />
|
<img src="./brand/screenshots/details.png" alt="Location Details" />
|
||||||
<p>Shows specific details about a location, including the name, date, location, description, and rating.</p>
|
<p>Shows specific details about a location, including the name, date, location, and description.</p>
|
||||||
<img src="./brand/screenshots/edit.png" alt="Edit Modal" />
|
<img src="./brand/screenshots/edit.png" alt="Edit Modal" />
|
||||||
<img src="./brand/screenshots/map.png" alt="Location Details" />
|
<img src="./brand/screenshots/map.png" alt="Location Details" />
|
||||||
<p>View all of your locations on a map, with the ability to filter by visit status and add new ones by click on the map</p>
|
<p>View all of your locations on a map, with the ability to filter by visit status and add new ones by click on the map</p>
|
||||||
@@ -99,7 +99,7 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
|
|||||||
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
||||||
- Itineraries can be created for any number of days and can include multiple destinations.
|
- Itineraries can be created for any number of days and can include multiple destinations.
|
||||||
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging placement follows directional rules: on check-in day lodging appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Lodging cards use the same compact style (no image header) as location cards within the itinerary.
|
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging placement follows directional rules: on check-in day lodging appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Lodging cards use the same compact style (no image header) as location cards within the itinerary.
|
||||||
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance. Boundary transitions between lodging and adjacent stops are also shown as connector rows.
|
- Connector rows between adjacent items display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as compact cards (same style as location cards — no image banners) showing mode, duration, and distance; connector routing uses the transportation's origin coordinates when approaching and destination coordinates when departing. Boundary transitions between lodging and adjacent stops are also shown as connector rows.
|
||||||
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day.
|
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day.
|
||||||
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
||||||
- Itineraries can be shared with friends and family for collaborative planning.
|
- Itineraries can be shared with friends and family for collaborative planning.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu
|
|||||||
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
||||||
- Itineraries can be created for any number of days and can include multiple destinations.
|
- Itineraries can be created for any number of days and can include multiple destinations.
|
||||||
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging placement follows directional rules: on check-in day lodging appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Lodging cards use the same compact style (no image header) as location cards within the itinerary.
|
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging placement follows directional rules: on check-in day lodging appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Lodging cards use the same compact style (no image header) as location cards within the itinerary.
|
||||||
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance. Boundary transitions between lodging and adjacent stops are also shown as connector rows.
|
- Connector rows between adjacent items display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as compact cards (same style as location cards — no image banners) showing mode, duration, and distance; connector routing uses the transportation's origin coordinates when approaching and destination coordinates when departing. Boundary transitions between lodging and adjacent stops are also shown as connector rows.
|
||||||
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day.
|
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day.
|
||||||
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
||||||
- Itineraries can be shared with friends and family for collaborative planning.
|
- Itineraries can be shared with friends and family for collaborative planning.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ The term "Location" is now used instead of "Adventure" - the usage remains the s
|
|||||||
|
|
||||||
#### Collections
|
#### Collections
|
||||||
|
|
||||||
- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between consecutive locations showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Lodging placement follows directional rules: on check-in day it appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Connector rows link lodging to adjacent locations. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and an Optimize placeholder for future route optimization. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try.
|
- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between adjacent items showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Lodging placement follows directional rules: on check-in day it appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single lodging card is shown (or two cards when a checkout and checkin are different lodgings). Connector rows link lodging to adjacent locations. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and an Optimize placeholder for future route optimization. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try.
|
||||||
- **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. In the itinerary timeline view, transportation items appear as compact connector rows between stops — showing the travel mode, duration, and distance. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time.
|
- **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. In the itinerary timeline view, transportation items appear as compact cards (same style as location cards — no image banners) showing the travel mode, duration, and distance. Connector rows adjacent to transportation use directional coordinates: the row before a transportation segment measures distance to the transportation's origin, while the row after measures distance from its destination. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time.
|
||||||
- **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time. In the itinerary timeline view, lodging is displayed as compact cards (without image headers) using directional placement: on check-in day the lodging card appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single card is shown unless the checkout and checkin are different lodgings (in which case both appear). Lodging added from within a specific day is automatically scheduled to that day. Connector rows show boundary transitions between lodging and adjacent locations.
|
- **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time. In the itinerary timeline view, lodging is displayed as compact cards (without image headers) using directional placement: on check-in day the lodging card appears after the last stop, on check-out day it appears before the first stop, and on days with no locations a single card is shown unless the checkout and checkin are different lodgings (in which case both appear). Lodging added from within a specific day is automatically scheduled to that day. Connector rows show boundary transitions between lodging and adjacent locations.
|
||||||
- **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information.
|
- **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information.
|
||||||
- **Checklist**: a checklist is a collection exclusive feature that allows you to add a checklist to your trip. This can be used to create a list of things to do during your trip or for planning purposes like packing lists. Checklists can be assigned to a specific day of the trip to help organize the information.
|
- **Checklist**: a checklist is a collection exclusive feature that allows you to add a checklist to your trip. This can be used to create a list of things to do during your trip or for planning purposes like packing lists. Checklists can be assigned to a specific day of the trip to help organize the information.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Manage your full list of locations with ease. View upcoming and past trips, filt
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
::: details 📋 **Detailed Adventure Logs**
|
::: details 📋 **Detailed Adventure Logs**
|
||||||
Capture rich details for every location: name, dates, precise locations, vivid descriptions, personal ratings, photos, and customizable categories. Your memories deserve to be more than just map pins — keep them alive with full, organized logs.
|
Capture rich details for every location: name, dates, precise locations, vivid descriptions, photos, and customizable categories. Your memories deserve to be more than just map pins — keep them alive with full, organized logs.
|
||||||
<img src="https://raw.githubusercontent.com/Alex-Wiesner/voyage/refs/heads/main/brand/screenshots/details.png" alt="Detailed Adventure Logs" style="max-width:100%; margin-top:10px;" />
|
<img src="https://raw.githubusercontent.com/Alex-Wiesner/voyage/refs/heads/main/brand/screenshots/details.png" alt="Detailed Adventure Logs" style="max-width:100%; margin-top:10px;" />
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
import DeleteWarning from '../DeleteWarning.svelte';
|
import DeleteWarning from '../DeleteWarning.svelte';
|
||||||
import CardCarousel from '../CardCarousel.svelte';
|
import CardCarousel from '../CardCarousel.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import Star from '~icons/mdi/star';
|
|
||||||
import StarOutline from '~icons/mdi/star-outline';
|
|
||||||
import Eye from '~icons/mdi/eye';
|
import Eye from '~icons/mdi/eye';
|
||||||
import EyeOff from '~icons/mdi/eye-off';
|
import EyeOff from '~icons/mdi/eye-off';
|
||||||
import CollectionItineraryPlanner from '../collections/CollectionItineraryPlanner.svelte';
|
import CollectionItineraryPlanner from '../collections/CollectionItineraryPlanner.svelte';
|
||||||
@@ -112,16 +110,6 @@
|
|||||||
? `${adventure.user.first_name} ${adventure.user.last_name || ''}`.trim()
|
? `${adventure.user.first_name} ${adventure.user.last_name || ''}`.trim()
|
||||||
: adventure.user?.username || 'Unknown User';
|
: adventure.user?.username || 'Unknown User';
|
||||||
|
|
||||||
// Helper functions for display
|
|
||||||
|
|
||||||
function renderStars(rating: number) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
stars.push(i <= rating);
|
|
||||||
}
|
|
||||||
return stars;
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeDay() {
|
function changeDay() {
|
||||||
dispatch('changeDay', { type: 'location', item: adventure, forcePicker: true });
|
dispatch('changeDay', { type: 'location', item: adventure, forcePicker: true });
|
||||||
}
|
}
|
||||||
@@ -577,7 +565,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inline stats: location, rating, visits -->
|
<!-- Inline stats: location and price -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center text-base-content/70 min-w-0"
|
class="flex flex-wrap items-center text-base-content/70 min-w-0"
|
||||||
class:gap-2={compact}
|
class:gap-2={compact}
|
||||||
@@ -592,21 +580,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if adventure.rating}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<div class="flex -ml-1">
|
|
||||||
{#each renderStars(adventure.rating) as filled}
|
|
||||||
{#if filled}
|
|
||||||
<Star class="w-4 h-4 text-warning fill-current" />
|
|
||||||
{:else}
|
|
||||||
<StarOutline class="w-4 h-4 text-base-content/30" />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-base-content/60">({adventure.rating}/5)</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if adventurePriceLabel}
|
{#if adventurePriceLabel}
|
||||||
<span class="badge badge-ghost badge-sm whitespace-nowrap">💰 {adventurePriceLabel}</span>
|
<span class="badge badge-ghost badge-sm whitespace-nowrap">💰 {adventurePriceLabel}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
import CardCarousel from '../CardCarousel.svelte';
|
import CardCarousel from '../CardCarousel.svelte';
|
||||||
import Eye from '~icons/mdi/eye';
|
import Eye from '~icons/mdi/eye';
|
||||||
import EyeOff from '~icons/mdi/eye-off';
|
import EyeOff from '~icons/mdi/eye-off';
|
||||||
import Star from '~icons/mdi/star';
|
|
||||||
import StarOutline from '~icons/mdi/star-outline';
|
|
||||||
import MapMarker from '~icons/mdi/map-marker';
|
import MapMarker from '~icons/mdi/map-marker';
|
||||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||||
import CalendarRemove from '~icons/mdi/calendar-remove';
|
import CalendarRemove from '~icons/mdi/calendar-remove';
|
||||||
@@ -61,14 +59,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStars(rating: number) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
stars.push(i <= rating);
|
|
||||||
}
|
|
||||||
return stars;
|
|
||||||
}
|
|
||||||
|
|
||||||
export let lodging: Lodging;
|
export let lodging: Lodging;
|
||||||
export let user: User | null = null;
|
export let user: User | null = null;
|
||||||
export let collection: Collection | null = null;
|
export let collection: Collection | null = null;
|
||||||
@@ -532,23 +522,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Rating & Info Badges -->
|
<!-- Info Badges -->
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{#if lodging.rating}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<div class="flex -ml-1">
|
|
||||||
{#each renderStars(lodging.rating) as filled}
|
|
||||||
{#if filled}
|
|
||||||
<Star class="w-4 h-4 text-warning fill-current" />
|
|
||||||
{:else}
|
|
||||||
<StarOutline class="w-4 h-4 text-base-content/30" />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-base-content/60">({lodging.rating}/5)</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
|
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
|
||||||
{#if lodging.reservation_number}
|
{#if lodging.reservation_number}
|
||||||
<span class="badge badge-primary badge-sm font-medium">
|
<span class="badge badge-primary badge-sm font-medium">
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
|
|
||||||
import Eye from '~icons/mdi/eye';
|
import Eye from '~icons/mdi/eye';
|
||||||
import EyeOff from '~icons/mdi/eye-off';
|
import EyeOff from '~icons/mdi/eye-off';
|
||||||
import Star from '~icons/mdi/star';
|
|
||||||
import StarOutline from '~icons/mdi/star-outline';
|
|
||||||
import Calendar from '~icons/mdi/calendar';
|
import Calendar from '~icons/mdi/calendar';
|
||||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||||
import CalendarRemove from '~icons/mdi/calendar-remove';
|
import CalendarRemove from '~icons/mdi/calendar-remove';
|
||||||
@@ -59,14 +57,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStars(rating: number) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
stars.push(i <= rating);
|
|
||||||
}
|
|
||||||
return stars;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
||||||
@@ -86,6 +76,8 @@
|
|||||||
export let collection: Collection | null = null;
|
export let collection: Collection | null = null;
|
||||||
export let readOnly: boolean = false;
|
export let readOnly: boolean = false;
|
||||||
export let itineraryItem: CollectionItineraryItem | null = null;
|
export let itineraryItem: CollectionItineraryItem | null = null;
|
||||||
|
export let compact: boolean = false;
|
||||||
|
export let showImage: boolean = true;
|
||||||
|
|
||||||
const toMiles = (km: any) => (Number(km) * 0.621371).toFixed(1);
|
const toMiles = (km: any) => (Number(km) * 0.621371).toFixed(1);
|
||||||
|
|
||||||
@@ -173,9 +165,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
|
class="card w-full bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
|
||||||
|
class:max-w-md={!compact}
|
||||||
aria-label="transportation-card"
|
aria-label="transportation-card"
|
||||||
>
|
>
|
||||||
|
{#if showImage}
|
||||||
<!-- Image Section with Overlay -->
|
<!-- Image Section with Overlay -->
|
||||||
<div class="relative overflow-hidden rounded-t-2xl">
|
<div class="relative overflow-hidden rounded-t-2xl">
|
||||||
{#if routeGeojson}
|
{#if routeGeojson}
|
||||||
@@ -201,7 +195,9 @@
|
|||||||
<div
|
<div
|
||||||
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
aria-label={transportation.is_public
|
||||||
|
? $t('adventures.public')
|
||||||
|
: $t('adventures.private')}
|
||||||
>
|
>
|
||||||
{#if transportation.is_public}
|
{#if transportation.is_public}
|
||||||
<Eye class="w-4 h-4" />
|
<Eye class="w-4 h-4" />
|
||||||
@@ -222,13 +218,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="card-body p-4 space-y-3 min-w-0">
|
<div
|
||||||
|
class="card-body min-w-0"
|
||||||
|
class:p-3={compact}
|
||||||
|
class:p-4={!compact}
|
||||||
|
class:space-y-2={compact}
|
||||||
|
class:space-y-3={!compact}
|
||||||
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<a
|
<a
|
||||||
href="/transportations/{transportation.id}"
|
href="/transportations/{transportation.id}"
|
||||||
class="hover:text-primary transition-colors duration-200 line-clamp-2 text-lg font-semibold"
|
class="hover:text-primary transition-colors duration-200 line-clamp-2"
|
||||||
|
class:text-base={compact}
|
||||||
|
class:text-lg={!compact}
|
||||||
|
class:font-medium={compact}
|
||||||
|
class:font-semibold={!compact}
|
||||||
>
|
>
|
||||||
{transportation.name}
|
{transportation.name}
|
||||||
</a>
|
</a>
|
||||||
@@ -343,6 +350,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !showImage}
|
||||||
|
<div class="flex items-center gap-2 text-xs text-base-content/70 min-w-0">
|
||||||
|
<div
|
||||||
|
class="tooltip tooltip-left"
|
||||||
|
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
|
||||||
|
role="img"
|
||||||
|
aria-label={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||||
|
>
|
||||||
|
{#if transportation.is_public}
|
||||||
|
<Eye class="w-4 h-4" />
|
||||||
|
{:else}
|
||||||
|
<EyeOff class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if transportation.type}
|
||||||
|
<div class="badge badge-primary badge-sm font-medium">
|
||||||
|
{$t(`transportation.modes.${transportation.type}`)}
|
||||||
|
{getTransportationIcon(transportation.type)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Route & Flight Info -->
|
<!-- Route & Flight Info -->
|
||||||
{#if routeFromLabel || routeToLabel}
|
{#if routeFromLabel || routeToLabel}
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
@@ -466,7 +500,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Stats & Rating -->
|
<!-- Stats -->
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{#if transportationPriceLabel}
|
{#if transportationPriceLabel}
|
||||||
<span class="badge badge-ghost badge-sm">💰 {transportationPriceLabel}</span>
|
<span class="badge badge-ghost badge-sm">💰 {transportationPriceLabel}</span>
|
||||||
@@ -483,20 +517,6 @@
|
|||||||
<span class="badge badge-ghost badge-sm">⏱️ {travelDurationLabel}</span>
|
<span class="badge badge-ghost badge-sm">⏱️ {travelDurationLabel}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if transportation.rating}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<div class="flex -ml-1">
|
|
||||||
{#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-base-content/30" />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-base-content/60">({transportation.rating}/5)</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -408,17 +408,6 @@
|
|||||||
return '🚗';
|
return '🚗';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTransportationDuration(minutes: number | null | undefined): string | null {
|
|
||||||
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return null;
|
|
||||||
const safeMinutes = Math.max(0, Math.floor(minutes));
|
|
||||||
const hours = Math.floor(safeMinutes / 60);
|
|
||||||
const mins = safeMinutes % 60;
|
|
||||||
const parts = [] as string[];
|
|
||||||
if (hours) parts.push(`${hours}h`);
|
|
||||||
parts.push(`${mins}m`);
|
|
||||||
return parts.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTransportationDistance(distanceKm: number | null | undefined): string | null {
|
function formatTransportationDistance(distanceKm: number | null | undefined): string | null {
|
||||||
if (distanceKm === null || distanceKm === undefined || Number.isNaN(distanceKm)) return null;
|
if (distanceKm === null || distanceKm === undefined || Number.isNaN(distanceKm)) return null;
|
||||||
if (distanceKm < 10) return `${distanceKm.toFixed(1)} km`;
|
if (distanceKm < 10) return `${distanceKm.toFixed(1)} km`;
|
||||||
@@ -509,7 +498,8 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConnectableItemType = 'location' | 'lodging';
|
type ConnectableItemType = 'location' | 'lodging' | 'transportation';
|
||||||
|
type TransportationCoordinateRole = 'origin' | 'destination';
|
||||||
|
|
||||||
type RouteMetricResult = {
|
type RouteMetricResult = {
|
||||||
distance_label?: string;
|
distance_label?: string;
|
||||||
@@ -523,17 +513,37 @@
|
|||||||
let activeConnectorFetchVersion = 0;
|
let activeConnectorFetchVersion = 0;
|
||||||
|
|
||||||
function isConnectableItemType(type: string): type is ConnectableItemType {
|
function isConnectableItemType(type: string): type is ConnectableItemType {
|
||||||
return type === 'location' || type === 'lodging';
|
return type === 'location' || type === 'lodging' || type === 'transportation';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCoordinatesFromItineraryItem(
|
function getCoordinatesFromItineraryItem(
|
||||||
item: ResolvedItineraryItem | null
|
item: ResolvedItineraryItem | null,
|
||||||
|
transportationRole: TransportationCoordinateRole = 'origin'
|
||||||
): { latitude: number; longitude: number } | null {
|
): { latitude: number; longitude: number } | null {
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
const itemType = item.item?.type || '';
|
const itemType = item.item?.type || '';
|
||||||
if (!isConnectableItemType(itemType)) return null;
|
if (!isConnectableItemType(itemType)) return null;
|
||||||
|
|
||||||
|
if (itemType === 'transportation') {
|
||||||
|
const transportation = item.resolvedObject as Transportation | null;
|
||||||
|
if (!transportation) return null;
|
||||||
|
|
||||||
|
const latitude = normalizeCoordinate(
|
||||||
|
transportationRole === 'origin'
|
||||||
|
? transportation.origin_latitude
|
||||||
|
: transportation.destination_latitude
|
||||||
|
);
|
||||||
|
const longitude = normalizeCoordinate(
|
||||||
|
transportationRole === 'origin'
|
||||||
|
? transportation.origin_longitude
|
||||||
|
: transportation.destination_longitude
|
||||||
|
);
|
||||||
|
if (latitude === null || longitude === null) return null;
|
||||||
|
|
||||||
|
return { latitude, longitude };
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedObj = item.resolvedObject as Location | Lodging | null;
|
const resolvedObj = item.resolvedObject as Location | Lodging | null;
|
||||||
if (!resolvedObj) return null;
|
if (!resolvedObj) return null;
|
||||||
|
|
||||||
@@ -544,20 +554,20 @@
|
|||||||
return { latitude, longitude };
|
return { latitude, longitude };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
function getFirstConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
|
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
|
||||||
if ((item.item?.type || '') === 'location') return item;
|
if (isConnectableItemType(item.item?.type || '')) return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
function getLastConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
|
||||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
|
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
|
||||||
if ((item.item?.type || '') === 'location') return item;
|
if (isConnectableItemType(item.item?.type || '')) return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -691,8 +701,14 @@
|
|||||||
const nextType = nextItem.item?.type || '';
|
const nextType = nextItem.item?.type || '';
|
||||||
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
|
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
|
||||||
|
|
||||||
const fromCoordinates = getCoordinatesFromItineraryItem(currentItem);
|
const fromCoordinates = getCoordinatesFromItineraryItem(
|
||||||
const toCoordinates = getCoordinatesFromItineraryItem(nextItem);
|
currentItem,
|
||||||
|
currentType === 'transportation' ? 'destination' : 'origin'
|
||||||
|
);
|
||||||
|
const toCoordinates = getCoordinatesFromItineraryItem(
|
||||||
|
nextItem,
|
||||||
|
'origin'
|
||||||
|
);
|
||||||
if (!fromCoordinates || !toCoordinates) return null;
|
if (!fromCoordinates || !toCoordinates) return null;
|
||||||
|
|
||||||
const key = getLocationConnectorKey(currentItem, nextItem);
|
const key = getLocationConnectorKey(currentItem, nextItem);
|
||||||
@@ -705,7 +721,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNextLocationItem(
|
function findNextConnectableItem(
|
||||||
items: ResolvedItineraryItem[],
|
items: ResolvedItineraryItem[],
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
): ResolvedItineraryItem | null {
|
): ResolvedItineraryItem | null {
|
||||||
@@ -714,7 +730,7 @@
|
|||||||
if (candidate?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
|
if (candidate?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((candidate?.item?.type || '') === 'location') {
|
if (isConnectableItemType(candidate?.item?.type || '')) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,11 +750,11 @@
|
|||||||
|
|
||||||
for (const dayGroup of dayGroups) {
|
for (const dayGroup of dayGroups) {
|
||||||
const dayTimelineItems = getDayTimelineItems(dayGroup);
|
const dayTimelineItems = getDayTimelineItems(dayGroup);
|
||||||
const firstLocationItem = getFirstLocationItem(dayGroup.items);
|
const firstConnectableItem = getFirstConnectableItem(dayGroup.items);
|
||||||
const lastLocationItem = getLastLocationItem(dayGroup.items);
|
const lastConnectableItem = getLastConnectableItem(dayGroup.items);
|
||||||
|
|
||||||
if (dayGroup.preTimelineLodging && firstLocationItem) {
|
if (dayGroup.preTimelineLodging && firstConnectableItem) {
|
||||||
pushPair(getConnectorPair(dayGroup.preTimelineLodging, firstLocationItem));
|
pushPair(getConnectorPair(dayGroup.preTimelineLodging, firstConnectableItem));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let index = 0; index < dayTimelineItems.length - 1; index += 1) {
|
for (let index = 0; index < dayTimelineItems.length - 1; index += 1) {
|
||||||
@@ -746,12 +762,12 @@
|
|||||||
if (currentItem?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
|
if (currentItem?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const nextLocationItem = findNextLocationItem(dayTimelineItems, index);
|
const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index);
|
||||||
pushPair(getConnectorPair(currentItem, nextLocationItem));
|
pushPair(getConnectorPair(currentItem, nextConnectableItem));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dayGroup.postTimelineLodging && lastLocationItem) {
|
if (dayGroup.postTimelineLodging && lastConnectableItem) {
|
||||||
pushPair(getConnectorPair(lastLocationItem, dayGroup.postTimelineLodging));
|
pushPair(getConnectorPair(lastConnectableItem, dayGroup.postTimelineLodging));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,11 +901,20 @@
|
|||||||
unavailable: true
|
unavailable: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
|
const fromCoordinates = getCoordinatesFromItineraryItem(
|
||||||
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
|
currentItem,
|
||||||
if (!currentLocation || !nextLocation) return unavailableConnector;
|
currentType === 'transportation' ? 'destination' : 'origin'
|
||||||
|
);
|
||||||
|
const toCoordinates = getCoordinatesFromItineraryItem(
|
||||||
|
nextItem,
|
||||||
|
'origin'
|
||||||
|
);
|
||||||
|
if (!fromCoordinates || !toCoordinates) return unavailableConnector;
|
||||||
|
|
||||||
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
|
const distanceKm = haversineDistanceKm(
|
||||||
|
{ latitude: fromCoordinates.latitude, longitude: fromCoordinates.longitude } as Location,
|
||||||
|
{ latitude: toCoordinates.latitude, longitude: toCoordinates.longitude } as Location
|
||||||
|
);
|
||||||
if (distanceKm === null) return unavailableConnector;
|
if (distanceKm === null) return unavailableConnector;
|
||||||
|
|
||||||
const walkingMinutes = (distanceKm / WALKING_SPEED_KMH) * 60;
|
const walkingMinutes = (distanceKm / WALKING_SPEED_KMH) * 60;
|
||||||
@@ -926,14 +951,20 @@
|
|||||||
const nextType = nextItem.item?.type || '';
|
const nextType = nextItem.item?.type || '';
|
||||||
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
|
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
|
||||||
|
|
||||||
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
|
const fromCoordinates = getCoordinatesFromItineraryItem(
|
||||||
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
|
currentItem,
|
||||||
if (!currentLocation || !nextLocation) return null;
|
currentType === 'transportation' ? 'destination' : 'origin'
|
||||||
|
);
|
||||||
|
const toCoordinates = getCoordinatesFromItineraryItem(
|
||||||
|
nextItem,
|
||||||
|
'origin'
|
||||||
|
);
|
||||||
|
if (!fromCoordinates || !toCoordinates) return null;
|
||||||
|
|
||||||
const fromLatitude = normalizeCoordinate(currentLocation.latitude);
|
const fromLatitude = fromCoordinates.latitude;
|
||||||
const fromLongitude = normalizeCoordinate(currentLocation.longitude);
|
const fromLongitude = fromCoordinates.longitude;
|
||||||
const toLatitude = normalizeCoordinate(nextLocation.latitude);
|
const toLatitude = toCoordinates.latitude;
|
||||||
const toLongitude = normalizeCoordinate(nextLocation.longitude);
|
const toLongitude = toCoordinates.longitude;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
fromLatitude === null ||
|
fromLatitude === null ||
|
||||||
@@ -955,50 +986,6 @@
|
|||||||
return translated && translated !== key ? translated : fallback;
|
return translated && translated !== key ? translated : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function editTransportationInline(transportation: Transportation) {
|
|
||||||
handleEditTransportation({ detail: transportation } as CustomEvent<Transportation>);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeItineraryEntry(item: CollectionItineraryItem) {
|
|
||||||
if (!item?.id) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/itineraries/${item.id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to remove itinerary item');
|
|
||||||
handleRemoveItineraryItem(new CustomEvent('removeFromItinerary', { detail: item }) as any);
|
|
||||||
addToast('info', $t('itinerary.item_remove_success'));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error removing itinerary item:', error);
|
|
||||||
addToast('error', $t('itinerary.item_remove_error'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTransportationFromItinerary(
|
|
||||||
item: CollectionItineraryItem,
|
|
||||||
transportation: Transportation
|
|
||||||
) {
|
|
||||||
const confirmed = window.confirm($t('adventures.transportation_delete_confirm'));
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/transportations/${transportation.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Failed to delete transportation');
|
|
||||||
|
|
||||||
addToast('info', $t('transportation.transportation_deleted'));
|
|
||||||
handleItemDelete(new CustomEvent('delete', { detail: transportation.id }) as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete transportation:', error);
|
|
||||||
addToast('error', $t('transportation.transportation_delete_error'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function upsertNote(note: Note) {
|
function upsertNote(note: Note) {
|
||||||
const notes = collection.notes ? [...collection.notes] : [];
|
const notes = collection.notes ? [...collection.notes] : [];
|
||||||
const idx = notes.findIndex((n) => n.id === note.id);
|
const idx = notes.findIndex((n) => n.id === note.id);
|
||||||
@@ -2399,6 +2386,9 @@
|
|||||||
transportation={resolvedObj}
|
transportation={resolvedObj}
|
||||||
{user}
|
{user}
|
||||||
{collection}
|
{collection}
|
||||||
|
readOnly={!canModify}
|
||||||
|
compact={true}
|
||||||
|
showImage={false}
|
||||||
on:delete={handleItemDelete}
|
on:delete={handleItemDelete}
|
||||||
itineraryItem={item}
|
itineraryItem={item}
|
||||||
on:removeFromItinerary={handleRemoveItineraryItem}
|
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||||
@@ -2463,34 +2453,34 @@
|
|||||||
{@const preTimelineLodging = day.preTimelineLodging}
|
{@const preTimelineLodging = day.preTimelineLodging}
|
||||||
{@const postTimelineLodging = day.postTimelineLodging}
|
{@const postTimelineLodging = day.postTimelineLodging}
|
||||||
{@const dayTimelineItems = getDayTimelineItems(day)}
|
{@const dayTimelineItems = getDayTimelineItems(day)}
|
||||||
{@const firstLocationItem = getFirstLocationItem(day.items)}
|
{@const firstConnectableItem = getFirstConnectableItem(day.items)}
|
||||||
{@const lastLocationItem = getLastLocationItem(day.items)}
|
{@const lastConnectableItem = getLastConnectableItem(day.items)}
|
||||||
{@const noLocationsInDay = !firstLocationItem && !lastLocationItem}
|
{@const noLocationsInDay = !firstConnectableItem && !lastConnectableItem}
|
||||||
{@const shouldCollapseBoundaryLodging =
|
{@const shouldCollapseBoundaryLodging =
|
||||||
noLocationsInDay &&
|
noLocationsInDay &&
|
||||||
preTimelineLodging?.id &&
|
preTimelineLodging?.id &&
|
||||||
postTimelineLodging?.id &&
|
postTimelineLodging?.id &&
|
||||||
preTimelineLodging.id === postTimelineLodging.id}
|
preTimelineLodging.id === postTimelineLodging.id}
|
||||||
{@const startBoundaryConnector =
|
{@const startBoundaryConnector =
|
||||||
preTimelineLodging && firstLocationItem
|
preTimelineLodging && firstConnectableItem
|
||||||
? getLocationConnector(preTimelineLodging, firstLocationItem)
|
? getLocationConnector(preTimelineLodging, firstConnectableItem)
|
||||||
: null}
|
: null}
|
||||||
{@const startBoundaryDirectionsUrl =
|
{@const startBoundaryDirectionsUrl =
|
||||||
preTimelineLodging && firstLocationItem
|
preTimelineLodging && firstConnectableItem
|
||||||
? buildDirectionsUrl(
|
? buildDirectionsUrl(
|
||||||
preTimelineLodging,
|
preTimelineLodging,
|
||||||
firstLocationItem,
|
firstConnectableItem,
|
||||||
startBoundaryConnector?.mode || 'walking'
|
startBoundaryConnector?.mode || 'walking'
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
{@const endBoundaryConnector =
|
{@const endBoundaryConnector =
|
||||||
postTimelineLodging && lastLocationItem
|
postTimelineLodging && lastConnectableItem
|
||||||
? getLocationConnector(lastLocationItem, postTimelineLodging)
|
? getLocationConnector(lastConnectableItem, postTimelineLodging)
|
||||||
: null}
|
: null}
|
||||||
{@const endBoundaryDirectionsUrl =
|
{@const endBoundaryDirectionsUrl =
|
||||||
postTimelineLodging && lastLocationItem
|
postTimelineLodging && lastConnectableItem
|
||||||
? buildDirectionsUrl(
|
? buildDirectionsUrl(
|
||||||
lastLocationItem,
|
lastConnectableItem,
|
||||||
postTimelineLodging,
|
postTimelineLodging,
|
||||||
endBoundaryConnector?.mode || 'walking'
|
endBoundaryConnector?.mode || 'walking'
|
||||||
)
|
)
|
||||||
@@ -2737,11 +2727,11 @@
|
|||||||
{@const objectType = item.item?.type || ''}
|
{@const objectType = item.item?.type || ''}
|
||||||
{@const resolvedObj = item.resolvedObject}
|
{@const resolvedObj = item.resolvedObject}
|
||||||
{@const multiDay = isMultiDay(item)}
|
{@const multiDay = isMultiDay(item)}
|
||||||
{@const nextLocationItem = findNextLocationItem(dayTimelineItems, index)}
|
{@const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index)}
|
||||||
{@const locationConnector = getLocationConnector(item, nextLocationItem)}
|
{@const locationConnector = getLocationConnector(item, nextConnectableItem)}
|
||||||
{@const directionsUrl = buildDirectionsUrl(
|
{@const directionsUrl = buildDirectionsUrl(
|
||||||
item,
|
item,
|
||||||
nextLocationItem,
|
nextConnectableItem,
|
||||||
locationConnector?.mode || 'walking'
|
locationConnector?.mode || 'walking'
|
||||||
)}
|
)}
|
||||||
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
|
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
|
||||||
@@ -2797,84 +2787,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if objectType === 'transportation'}
|
{#if objectType === 'transportation'}
|
||||||
<div class="rounded-xl border border-base-300 bg-base-100 px-4 py-3">
|
<TransportationCard
|
||||||
<div class="flex items-center justify-between gap-3 mb-2">
|
transportation={resolvedObj}
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
{user}
|
||||||
<span class="text-lg"
|
{collection}
|
||||||
>{getTransportationIcon(resolvedObj.type)}</span
|
itineraryItem={item}
|
||||||
>
|
compact={true}
|
||||||
<p class="font-semibold truncate">{resolvedObj.name}</p>
|
showImage={false}
|
||||||
<span class="badge badge-outline badge-sm truncate">
|
on:delete={handleItemDelete}
|
||||||
{$t(`transportation.modes.${resolvedObj.type}`) ||
|
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||||
resolvedObj.type}
|
on:edit={handleEditTransportation}
|
||||||
</span>
|
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
|
||||||
</div>
|
on:changeDay={(e) =>
|
||||||
<div class="text-xs opacity-70 flex items-center gap-2 shrink-0">
|
|
||||||
{#if formatTransportationDuration(resolvedObj.travel_duration_minutes)}
|
|
||||||
<span
|
|
||||||
>{formatTransportationDuration(
|
|
||||||
resolvedObj.travel_duration_minutes
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if formatTransportationDistance(resolvedObj.distance)}
|
|
||||||
<span>{formatTransportationDistance(resolvedObj.distance)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm opacity-80 truncate">
|
|
||||||
{resolvedObj.from_location || '—'} → {resolvedObj.to_location ||
|
|
||||||
'—'}
|
|
||||||
</div>
|
|
||||||
{#if canModify}
|
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-xs btn-ghost"
|
|
||||||
on:click={() => editTransportationInline(resolvedObj)}
|
|
||||||
>
|
|
||||||
{$t('transportation.edit')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-xs btn-ghost"
|
|
||||||
on:click={() =>
|
|
||||||
handleOpenDayPickerForItem(
|
handleOpenDayPickerForItem(
|
||||||
'transportation',
|
e.detail.type,
|
||||||
resolvedObj,
|
e.detail.item,
|
||||||
true,
|
e.detail.forcePicker,
|
||||||
day.date
|
day.date
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
{$t('itinerary.change_day')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-xs btn-ghost"
|
|
||||||
on:click={() =>
|
|
||||||
moveItemToGlobal('transportation', resolvedObj.id)}
|
|
||||||
>
|
|
||||||
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-xs btn-ghost"
|
|
||||||
on:click={() => removeItineraryEntry(item)}
|
|
||||||
>
|
|
||||||
{$t('itinerary.remove_from_itinerary')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-xs btn-error btn-outline"
|
|
||||||
on:click={() =>
|
|
||||||
deleteTransportationFromItinerary(item, resolvedObj)}
|
|
||||||
>
|
|
||||||
{$t('adventures.delete')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#if multiDay && objectType === 'lodging'}
|
{#if multiDay && objectType === 'lodging'}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -3443,6 +3374,9 @@
|
|||||||
transportation={item}
|
transportation={item}
|
||||||
{user}
|
{user}
|
||||||
{collection}
|
{collection}
|
||||||
|
readOnly={!canModify}
|
||||||
|
compact={true}
|
||||||
|
showImage={false}
|
||||||
on:delete={handleItemDelete}
|
on:delete={handleItemDelete}
|
||||||
on:edit={handleEditTransportation}
|
on:edit={handleEditTransportation}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -355,12 +355,6 @@
|
|||||||
return Array.from(types.values()).sort((a, b) => b.distance - a.distance);
|
return Array.from(types.values()).sort((a, b) => b.distance - a.distance);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: averageLocationRating = (() => {
|
|
||||||
const rated = visitedLocations.filter((loc) => loc.rating !== null && loc.rating !== undefined);
|
|
||||||
if (rated.length === 0) return 0;
|
|
||||||
return rated.reduce((sum, loc) => sum + (loc.rating || 0), 0) / rated.length;
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: checklistStats = (() => {
|
$: checklistStats = (() => {
|
||||||
let totalItems = 0;
|
let totalItems = 0;
|
||||||
let checkedItems = 0;
|
let checkedItems = 0;
|
||||||
@@ -743,18 +737,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Stats Row -->
|
<!-- Additional Stats Row -->
|
||||||
{#if averageLocationRating > 0 || checklistStats.total > 0 || lodgingTypeBreakdown.length > 0}
|
{#if checklistStats.total > 0 || lodgingTypeBreakdown.length > 0}
|
||||||
<div class="divider">{$t('adventures.more_details')}</div>
|
<div class="divider">{$t('adventures.more_details')}</div>
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{#if averageLocationRating > 0}
|
|
||||||
<div class="stat bg-base-300 rounded-lg p-4">
|
|
||||||
<div class="stat-figure text-2xl">⭐</div>
|
|
||||||
<div class="stat-title text-xs">{$t('adventures.avg_rating')}</div>
|
|
||||||
<div class="stat-value text-lg">{averageLocationRating.toFixed(1)}</div>
|
|
||||||
<div class="stat-desc text-xs">{$t('adventures.of_locations')}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if checklistStats.total > 0}
|
{#if checklistStats.total > 0}
|
||||||
<div class="stat bg-base-300 rounded-lg p-4">
|
<div class="stat bg-base-300 rounded-lg p-4">
|
||||||
<div class="stat-figure text-2xl">✓</div>
|
<div class="stat-figure text-2xl">✓</div>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
||||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||||
import SaveIcon from '~icons/mdi/content-save';
|
import SaveIcon from '~icons/mdi/content-save';
|
||||||
import ClearIcon from '~icons/mdi/close-circle';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
let location: {
|
let location: {
|
||||||
name: string;
|
name: string;
|
||||||
category: Category | null;
|
category: Category | null;
|
||||||
rating: number;
|
|
||||||
price: number | null;
|
price: number | null;
|
||||||
price_currency: string | null;
|
price_currency: string | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
@@ -48,7 +46,6 @@
|
|||||||
} = {
|
} = {
|
||||||
name: '',
|
name: '',
|
||||||
category: null,
|
category: null,
|
||||||
rating: NaN,
|
|
||||||
price: null,
|
price: null,
|
||||||
price_currency: DEFAULT_CURRENCY,
|
price_currency: DEFAULT_CURRENCY,
|
||||||
is_public: false,
|
is_public: false,
|
||||||
@@ -239,7 +236,6 @@
|
|||||||
if (!location.name) location.name = initialLocation.name || '';
|
if (!location.name) location.name = initialLocation.name || '';
|
||||||
if (!location.link) location.link = initialLocation.link || '';
|
if (!location.link) location.link = initialLocation.link || '';
|
||||||
if (!location.description) location.description = initialLocation.description || '';
|
if (!location.description) location.description = initialLocation.description || '';
|
||||||
if (Number.isNaN(location.rating)) location.rating = initialLocation.rating || NaN;
|
|
||||||
if (location.price === null || location.price === undefined) {
|
if (location.price === null || location.price === undefined) {
|
||||||
const money = toMoneyValue(
|
const money = toMoneyValue(
|
||||||
initialLocation.price,
|
initialLocation.price,
|
||||||
@@ -359,44 +355,6 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 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(location.rating)}
|
|
||||||
/>
|
|
||||||
{#each [1, 2, 3, 4, 5] as star}
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="rating"
|
|
||||||
class="mask mask-star-2 bg-warning"
|
|
||||||
on:click={() => (location.rating = star)}
|
|
||||||
checked={location.rating === star}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if !Number.isNaN(location.rating)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-error btn-outline gap-2"
|
|
||||||
on:click={() => (location.rating = NaN)}
|
|
||||||
>
|
|
||||||
<ClearIcon class="w-4 h-4" />
|
|
||||||
{$t('adventures.remove')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
link: null,
|
link: null,
|
||||||
description: null,
|
description: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
rating: NaN,
|
|
||||||
price: null,
|
price: null,
|
||||||
price_currency: null,
|
price_currency: null,
|
||||||
is_public: false,
|
is_public: false,
|
||||||
@@ -81,7 +80,6 @@
|
|||||||
link: locationToEdit?.link || null,
|
link: locationToEdit?.link || null,
|
||||||
description: locationToEdit?.description || null,
|
description: locationToEdit?.description || null,
|
||||||
tags: locationToEdit?.tags || [],
|
tags: locationToEdit?.tags || [],
|
||||||
rating: locationToEdit?.rating || NaN,
|
|
||||||
price: locationToEdit?.price ?? null,
|
price: locationToEdit?.price ?? null,
|
||||||
price_currency: locationToEdit?.price_currency ?? null,
|
price_currency: locationToEdit?.price_currency ?? null,
|
||||||
is_public: locationToEdit?.is_public || false,
|
is_public: locationToEdit?.is_public || false,
|
||||||
@@ -295,7 +293,6 @@
|
|||||||
on:save={(e) => {
|
on:save={(e) => {
|
||||||
location.name = e.detail.name;
|
location.name = e.detail.name;
|
||||||
location.category = e.detail.category;
|
location.category = e.detail.category;
|
||||||
location.rating = e.detail.rating;
|
|
||||||
location.is_public = e.detail.is_public;
|
location.is_public = e.detail.is_public;
|
||||||
location.link = e.detail.link;
|
location.link = e.detail.link;
|
||||||
location.description = e.detail.description;
|
location.description = e.detail.description;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import MapIcon from '~icons/mdi/map';
|
import MapIcon from '~icons/mdi/map';
|
||||||
import ClearIcon from '~icons/mdi/close';
|
|
||||||
import InfoIcon from '~icons/mdi/information';
|
import InfoIcon from '~icons/mdi/information';
|
||||||
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
||||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||||
@@ -49,7 +48,6 @@
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
description: string;
|
description: string;
|
||||||
rating: number;
|
|
||||||
link: string;
|
link: string;
|
||||||
check_in: string | null;
|
check_in: string | null;
|
||||||
check_out: string | null;
|
check_out: string | null;
|
||||||
@@ -67,7 +65,6 @@
|
|||||||
name: '',
|
name: '',
|
||||||
type: '',
|
type: '',
|
||||||
description: '',
|
description: '',
|
||||||
rating: NaN,
|
|
||||||
link: '',
|
link: '',
|
||||||
check_in: null,
|
check_in: null,
|
||||||
check_out: null,
|
check_out: null,
|
||||||
@@ -442,7 +439,6 @@
|
|||||||
lodging.type = initialLodging.type || '';
|
lodging.type = initialLodging.type || '';
|
||||||
lodging.link = initialLodging.link || '';
|
lodging.link = initialLodging.link || '';
|
||||||
lodging.description = initialLodging.description || '';
|
lodging.description = initialLodging.description || '';
|
||||||
lodging.rating = initialLodging.rating ?? NaN;
|
|
||||||
lodging.is_public = initialLodging.is_public ?? true;
|
lodging.is_public = initialLodging.is_public ?? true;
|
||||||
lodging.reservation_number = initialLodging.reservation_number || null;
|
lodging.reservation_number = initialLodging.reservation_number || null;
|
||||||
const money = toMoneyValue(
|
const money = toMoneyValue(
|
||||||
@@ -565,45 +561,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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(lodging.rating)}
|
|
||||||
/>
|
|
||||||
{#each [1, 2, 3, 4, 5] as star}
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="rating"
|
|
||||||
class="mask mask-star-2 bg-warning"
|
|
||||||
on:click={() => (lodging.rating = star)}
|
|
||||||
checked={lodging.rating === star}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if !Number.isNaN(lodging.rating)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-error btn-outline gap-2"
|
|
||||||
on:click={() => (lodging.rating = NaN)}
|
|
||||||
>
|
|
||||||
<ClearIcon class="w-4 h-4" />
|
|
||||||
{$t('adventures.remove')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reservation Number -->
|
<!-- Reservation Number -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="reservation">
|
<label class="label" for="reservation">
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
name: '',
|
name: '',
|
||||||
type: '',
|
type: '',
|
||||||
description: null,
|
description: null,
|
||||||
rating: null,
|
|
||||||
link: null,
|
link: null,
|
||||||
check_in: null,
|
check_in: null,
|
||||||
check_out: null,
|
check_out: null,
|
||||||
@@ -84,7 +83,6 @@
|
|||||||
name: lodgingToEdit.name || '',
|
name: lodgingToEdit.name || '',
|
||||||
type: lodgingToEdit.type || '',
|
type: lodgingToEdit.type || '',
|
||||||
description: lodgingToEdit.description || null,
|
description: lodgingToEdit.description || null,
|
||||||
rating: lodgingToEdit.rating || null,
|
|
||||||
link: lodgingToEdit.link || null,
|
link: lodgingToEdit.link || null,
|
||||||
check_in: lodgingToEdit.check_in || null,
|
check_in: lodgingToEdit.check_in || null,
|
||||||
check_out: lodgingToEdit.check_out || null,
|
check_out: lodgingToEdit.check_out || null,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import MapIcon from '~icons/mdi/map';
|
import MapIcon from '~icons/mdi/map';
|
||||||
import ClearIcon from '~icons/mdi/close';
|
|
||||||
import InfoIcon from '~icons/mdi/information';
|
import InfoIcon from '~icons/mdi/information';
|
||||||
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
import GenerateIcon from '~icons/mdi/lightning-bolt';
|
||||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||||
@@ -50,7 +49,6 @@
|
|||||||
name: '',
|
name: '',
|
||||||
type: '',
|
type: '',
|
||||||
description: '',
|
description: '',
|
||||||
rating: NaN,
|
|
||||||
link: '',
|
link: '',
|
||||||
date: null,
|
date: null,
|
||||||
end_date: null,
|
end_date: null,
|
||||||
@@ -516,7 +514,6 @@
|
|||||||
transportation.type = initialTransportation.type || '';
|
transportation.type = initialTransportation.type || '';
|
||||||
transportation.link = initialTransportation.link || '';
|
transportation.link = initialTransportation.link || '';
|
||||||
transportation.description = initialTransportation.description || '';
|
transportation.description = initialTransportation.description || '';
|
||||||
transportation.rating = initialTransportation.rating ?? NaN;
|
|
||||||
transportation.is_public = initialTransportation.is_public ?? true;
|
transportation.is_public = initialTransportation.is_public ?? true;
|
||||||
transportation.flight_number = initialTransportation.flight_number || null;
|
transportation.flight_number = initialTransportation.flight_number || null;
|
||||||
transportation.start_code = initialTransportation.start_code || null;
|
transportation.start_code = initialTransportation.start_code || null;
|
||||||
@@ -714,44 +711,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
name: '',
|
name: '',
|
||||||
type: '',
|
type: '',
|
||||||
description: null,
|
description: null,
|
||||||
rating: null,
|
|
||||||
link: null,
|
link: null,
|
||||||
date: null,
|
date: null,
|
||||||
end_date: null,
|
end_date: null,
|
||||||
@@ -91,7 +90,6 @@
|
|||||||
name: transportationToEdit.name || '',
|
name: transportationToEdit.name || '',
|
||||||
type: transportationToEdit.type || '',
|
type: transportationToEdit.type || '',
|
||||||
description: transportationToEdit.description || null,
|
description: transportationToEdit.description || null,
|
||||||
rating: transportationToEdit.rating || null,
|
|
||||||
link: transportationToEdit.link || null,
|
link: transportationToEdit.link || null,
|
||||||
date: transportationToEdit.date || null,
|
date: transportationToEdit.date || null,
|
||||||
end_date: transportationToEdit.end_date || null,
|
end_date: transportationToEdit.end_date || null,
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export type Location = {
|
|||||||
location?: string | null;
|
location?: string | null;
|
||||||
tags?: string[] | null;
|
tags?: string[] | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
rating?: number | null;
|
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
price_currency?: string | null;
|
price_currency?: string | null;
|
||||||
link?: string | null;
|
link?: string | null;
|
||||||
@@ -202,7 +201,6 @@ export type Transportation = {
|
|||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
rating: number | null;
|
|
||||||
price: number | null;
|
price: number | null;
|
||||||
price_currency: string | null;
|
price_currency: string | null;
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -341,7 +339,6 @@ export type Lodging = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
rating: number | null;
|
|
||||||
link: string | null;
|
link: string | null;
|
||||||
check_in: string | null; // ISO 8601 date string
|
check_in: string | null; // ISO 8601 date string
|
||||||
check_out: string | null; // ISO 8601 date string
|
check_out: string | null; // ISO 8601 date string
|
||||||
|
|||||||
@@ -405,11 +405,11 @@
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="order_by"
|
name="order_by"
|
||||||
value="rating"
|
value="price"
|
||||||
class="radio radio-primary radio-sm"
|
class="radio radio-primary radio-sm"
|
||||||
checked={currentSort.order_by === 'rating'}
|
checked={currentSort.order_by === 'price'}
|
||||||
/>
|
/>
|
||||||
<span class="label-text text-sm">{$t('adventures.rating')}</span>
|
<span class="label-text text-sm">{$t('adventures.price')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -285,23 +285,6 @@
|
|||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<h1 class="text-6xl font-bold mb-4 drop-shadow-lg">{adventure.name}</h1>
|
<h1 class="text-6xl font-bold mb-4 drop-shadow-lg">{adventure.name}</h1>
|
||||||
|
|
||||||
<!-- Rating -->
|
|
||||||
{#if adventure.rating !== undefined && adventure.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 <= adventure.rating}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Quick Info Badges -->
|
<!-- Quick Info Badges -->
|
||||||
<div class="flex flex-wrap justify-center gap-4 mb-6">
|
<div class="flex flex-wrap justify-center gap-4 mb-6">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
||||||
import AttachmentCard from '$lib/components/cards/AttachmentCard.svelte';
|
import AttachmentCard from '$lib/components/cards/AttachmentCard.svelte';
|
||||||
import { getBasemapUrl, isAllDay, LODGING_TYPES_ICONS } from '$lib';
|
import { getBasemapUrl, isAllDay, LODGING_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 MapMarker from '~icons/mdi/map-marker';
|
||||||
import CalendarRange from '~icons/mdi/calendar-range';
|
import CalendarRange from '~icons/mdi/calendar-range';
|
||||||
import Eye from '~icons/mdi/eye';
|
import Eye from '~icons/mdi/eye';
|
||||||
@@ -69,14 +67,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStars(rating: number) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
stars.push(i <= rating);
|
|
||||||
}
|
|
||||||
return stars;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
||||||
const getTimezoneLabel = (zone?: string | null) => zone ?? localTimeZone;
|
const getTimezoneLabel = (zone?: string | null) => zone ?? localTimeZone;
|
||||||
const getTimezoneTip = (zone?: string | null) => {
|
const getTimezoneTip = (zone?: string | null) => {
|
||||||
@@ -258,23 +248,6 @@
|
|||||||
<h1 class="text-6xl font-bold drop-shadow-lg">{lodging.name}</h1>
|
<h1 class="text-6xl font-bold drop-shadow-lg">{lodging.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rating -->
|
|
||||||
{#if lodging.rating !== undefined && lodging.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 <= lodging.rating}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Quick Info Badges -->
|
<!-- Quick Info Badges -->
|
||||||
<div class="flex flex-wrap justify-center gap-4 mb-6">
|
<div class="flex flex-wrap justify-center gap-4 mb-6">
|
||||||
{#if lodging.type}
|
{#if lodging.type}
|
||||||
@@ -396,18 +369,6 @@
|
|||||||
{$t(`lodging.${lodging.type}`)}
|
{$t(`lodging.${lodging.type}`)}
|
||||||
{getLodgingIcon(lodging.type)}
|
{getLodgingIcon(lodging.type)}
|
||||||
</p>
|
</p>
|
||||||
{#if lodging.rating}
|
|
||||||
<div class="flex items-center gap-1 mb-2">
|
|
||||||
{#each renderStars(lodging.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">({lodging.rating}/5)</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if lodging.location}
|
{#if lodging.location}
|
||||||
<div class="text-xs text-black">
|
<div class="text-xs text-black">
|
||||||
📍 {lodging.location}
|
📍 {lodging.location}
|
||||||
|
|||||||
@@ -747,11 +747,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if hoveredLocation.rating !== null && hoveredLocation.rating !== undefined}
|
|
||||||
<div class="badge badge-neutral badge-sm">
|
|
||||||
★ {hoveredLocation.rating}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="badge badge-ghost badge-sm">
|
<div class="badge badge-ghost badge-sm">
|
||||||
Visits: {hoveredLocation.visits?.length ?? 0}
|
Visits: {hoveredLocation.visits?.length ?? 0}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
||||||
import AttachmentCard from '$lib/components/cards/AttachmentCard.svelte';
|
import AttachmentCard from '$lib/components/cards/AttachmentCard.svelte';
|
||||||
import { getBasemapUrl, isAllDay, TRANSPORTATION_TYPES_ICONS } from '$lib';
|
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 MapMarker from '~icons/mdi/map-marker';
|
||||||
import CalendarRange from '~icons/mdi/calendar-range';
|
import CalendarRange from '~icons/mdi/calendar-range';
|
||||||
import OpenInNew from '~icons/mdi/open-in-new';
|
import OpenInNew from '~icons/mdi/open-in-new';
|
||||||
@@ -67,14 +65,6 @@
|
|||||||
return '🚗';
|
return '🚗';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStars(rating: number) {
|
|
||||||
const stars = [];
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
stars.push(i <= rating);
|
|
||||||
}
|
|
||||||
return stars;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (data.props.transportation) {
|
if (data.props.transportation) {
|
||||||
transportation = data.props.transportation;
|
transportation = data.props.transportation;
|
||||||
@@ -395,23 +385,6 @@
|
|||||||
<h1 class="text-6xl font-bold drop-shadow-lg">{transportation.name}</h1>
|
<h1 class="text-6xl font-bold drop-shadow-lg">{transportation.name}</h1>
|
||||||
</div>
|
</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 -->
|
<!-- Quick Info Badges -->
|
||||||
<div class="flex flex-wrap justify-center gap-4 mb-6">
|
<div class="flex flex-wrap justify-center gap-4 mb-6">
|
||||||
{#if transportation.type}
|
{#if transportation.type}
|
||||||
@@ -553,20 +526,6 @@
|
|||||||
{$t('transportation.from_location')}
|
{$t('transportation.from_location')}
|
||||||
{getTransportationIcon(transportation.type)}
|
{getTransportationIcon(transportation.type)}
|
||||||
</p>
|
</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}
|
{#if transportation.from_location}
|
||||||
<div class="text-xs text-black">
|
<div class="text-xs text-black">
|
||||||
📍 {transportation.from_location}
|
📍 {transportation.from_location}
|
||||||
@@ -591,20 +550,6 @@
|
|||||||
{$t('transportation.to_location')}
|
{$t('transportation.to_location')}
|
||||||
{getTransportationIcon(transportation.type)}
|
{getTransportationIcon(transportation.type)}
|
||||||
</p>
|
</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}
|
{#if transportation.to_location}
|
||||||
<div class="text-xs text-black">
|
<div class="text-xs text-black">
|
||||||
📍 {transportation.to_location}
|
📍 {transportation.to_location}
|
||||||
|
|||||||
Reference in New Issue
Block a user