✨ Trip sharing
This commit is contained in:
parent
356d9b8a59
commit
1213e27cb9
@ -6,6 +6,7 @@ import { DashboardComponent } from "./components/dashboard/dashboard.component";
|
||||
import { AuthGuard } from "./services/auth.guard";
|
||||
import { TripComponent } from "./components/trip/trip.component";
|
||||
import { TripsComponent } from "./components/trips/trips.component";
|
||||
import { SharedTripComponent } from "./components/shared-trip/shared-trip.component";
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@ -15,6 +16,19 @@ export const routes: Routes = [
|
||||
title: "TRIP - Authentication",
|
||||
},
|
||||
|
||||
{
|
||||
path: "s",
|
||||
children: [
|
||||
{
|
||||
path: "t/:token",
|
||||
component: SharedTripComponent,
|
||||
title: "TRIP - Shared Trip",
|
||||
},
|
||||
|
||||
{ path: "**", redirectTo: "/home", pathMatch: "full" },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "",
|
||||
canActivate: [AuthGuard],
|
||||
|
||||
454
src/src/app/components/shared-trip/shared-trip.component.html
Normal file
454
src/src/app/components/shared-trip/shared-trip.component.html
Normal file
@ -0,0 +1,454 @@
|
||||
@defer {
|
||||
@if (trip) {
|
||||
<section class="mt-4">
|
||||
<div class="p-4 print:p-0 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-col max-w-[55vw] md:max-w-full">
|
||||
<h1 class="font-medium tracking-tight text-2xl truncate">{{ trip.name }}</h1>
|
||||
<span class="text-xs text-gray-500">{{ trip.days.length }} {{ trip.days!.length > 1 ? 'days' : 'day'}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden print:flex flex-col items-center">
|
||||
<img src="favicon.png" class="size-20">
|
||||
<div class="flex gap-2 items-center text-xs text-gray-500"><i class="pi pi-github"></i>itskovacs/trip</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 print:hidden">
|
||||
<span
|
||||
class="bg-gray-100 text-gray-800 text-xs md:text-sm font-medium me-2 px-2.5 py-0.5 rounded min-w-fit dark:bg-gray-400">{{
|
||||
(totalPrice | number:'1.0-2') || '-' }} {{ currency$ | async }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="p-4 print:px-1 grid lg:grid-cols-3 gap-4 print:block">
|
||||
<div class="p-4 shadow self-start rounded-md lg:col-span-2 max-w-screen print:col-span-full">
|
||||
<div [class.sticky]="!isMapFullscreen"
|
||||
class="top-0 z-10 bg-white p-2 mb-2 flex justify-between items-center dark:bg-surface-900">
|
||||
<div>
|
||||
<h1 class="font-semibold tracking-tight text-xl">Plans</h1>
|
||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip.name }} plans</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 print:hidden">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<p-button
|
||||
[icon]="tableExpandableMode ? 'pi pi-arrow-up-right-and-arrow-down-left-from-center' : 'pi pi-arrow-down-left-and-arrow-up-right-to-center'"
|
||||
(click)="tableExpandableMode = !tableExpandableMode" text />
|
||||
<div class="border-l border-solid border-gray-700 h-4"></div>
|
||||
<p-button icon="pi pi-car" (click)="tripToNavigation()" text />
|
||||
<p-button icon="pi pi-directions" [severity]="tripMapAntLayerDayID == -1 ? 'help' : 'primary'"
|
||||
(click)="toggleTripDaysHighlight()" text />
|
||||
<p-button icon="pi pi-print" (click)="printTable()" text />
|
||||
</div>
|
||||
|
||||
<div class="flex md:hidden items-center">
|
||||
<p-button (click)="menuTripTableActions.toggle($event)" severity="secondary" text icon="pi pi-ellipsis-h" />
|
||||
<p-menu #menuTripTableActions [model]="menuTripTableActionsItems" appendTo="body" [popup]="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@defer {
|
||||
@if (flattenedTripItems.length) {
|
||||
<p-table [value]="flattenedTripItems" class="print-striped-rows" styleClass="max-w-[85vw] md:max-w-full"
|
||||
[rowGroupMode]="tableExpandableMode ? 'subheader': 'rowspan'" groupRowsBy="td_label">
|
||||
<ng-template #header>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th class="w-10">Time</th>
|
||||
<th>Text</th>
|
||||
<th class="w-24">Place</th>
|
||||
<th>Comment</th>
|
||||
<th class="w-20">LatLng</th>
|
||||
<th class="w-12">Price</th>
|
||||
<th class="w-12">Status</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
@if (tableExpandableMode) {
|
||||
<ng-template #groupheader let-tripitem let-rowIndex="rowIndex" let-expanded="expanded">
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<button type="button" pButton pRipple [pRowToggler]="tripitem" text rounded plain class="mr-2"
|
||||
[icon]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'">
|
||||
</button>
|
||||
<span class="font-bold ml-2">{{ tripitem.td_label }}</span>
|
||||
<p-button class="ml-2" text icon="pi pi-directions"
|
||||
[severity]="tripMapAntLayerDayID == tripitem.day_id ? 'help' : 'primary'"
|
||||
(click)="toggleTripDayHighlightPathDay(tripitem.day_id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #expandedrow let-tripitem>
|
||||
<tr class="h-12 cursor-pointer" [class.font-bold]="selectedItem?.id === tripitem.id"
|
||||
(click)="onRowClick(tripitem)">
|
||||
<td class="font-mono text-sm max-w-20 truncate">{{ tripitem.td_label }}</td>
|
||||
<td class="font-mono text-sm">{{ tripitem.time }}</td>
|
||||
<td class="relative max-w-60 truncate">
|
||||
<div class="relative">
|
||||
@if (tripitem.status) {<div class="block xl:hidden absolute top-0 -left-1.5 size-1.5 rounded-full"
|
||||
[style.background]="tripitem.status.color"></div>}
|
||||
{{ tripitem.text }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="relative">
|
||||
@if (tripitem.place) {
|
||||
<div class="ml-7 print:ml-0 print:whitespace-normal">
|
||||
<img [src]="tripitem.place.image || tripitem.place.category.image"
|
||||
class="absolute left-0 top-1/2 -translate-y-1/2 w-9 rounded-full object-cover print:hidden" /> {{
|
||||
tripitem.place.name }}
|
||||
</div>
|
||||
} @else {-}
|
||||
</td>
|
||||
<td class="max-w-20 truncate print:whitespace-pre-line">{{ tripitem.comment || '-' }}</td>
|
||||
<td class="font-mono text-sm">
|
||||
<div class="max-w-20 print:max-w-full truncate">
|
||||
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} }
|
||||
@else {-}
|
||||
</div>
|
||||
</td>
|
||||
<td class="truncate">@if (tripitem.price) {<span
|
||||
class="bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||
tripitem.price }} {{ currency$ | async }}</span>}</td>
|
||||
<td class="truncate">@if (tripitem.status) {<span [style.background]="tripitem.status.color+'1A'"
|
||||
[style.color]="tripitem.status.color" class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||
tripitem.status.label }}</span>}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
}
|
||||
@else {
|
||||
<ng-template #body let-tripitem let-rowgroup="rowgroup" let-rowspan="rowspan">
|
||||
<tr class="h-12 cursor-pointer" [class.font-bold]="selectedItem?.id === tripitem.id"
|
||||
(click)="onRowClick(tripitem)">
|
||||
@if (rowgroup) {
|
||||
<td [attr.rowspan]="rowspan" class="font-normal! max-w-20 truncate cursor-pointer"
|
||||
[class.text-blue-500]="tripMapAntLayerDayID == tripitem.day_id"
|
||||
(click)="toggleTripDayHighlightPathDay(tripitem.day_id); $event.stopPropagation()">
|
||||
<div class="truncate">{{tripitem.td_label }}</div>
|
||||
</td>
|
||||
}
|
||||
<td class="font-mono text-sm">{{ tripitem.time }}</td>
|
||||
<td class="relative max-w-60 truncate">
|
||||
<div class="relative">
|
||||
{{ tripitem.text }}
|
||||
@if (tripitem.status) {<div class="block xl:hidden absolute top-0 -left-1.5 size-1.5 rounded-full"
|
||||
[style.background]="tripitem.status.color"></div>}
|
||||
</div>
|
||||
</td>
|
||||
<td class="relative">
|
||||
@if (tripitem.place) {
|
||||
<div class="ml-7 print:ml-0 max-w-24 truncate print:whitespace-normal">
|
||||
<img [src]="tripitem.place.image || tripitem.place.category.image"
|
||||
class="absolute left-0 top-1/2 -translate-y-1/2 w-9 rounded-full object-cover print:hidden" /> {{
|
||||
tripitem.place.name }}
|
||||
</div>
|
||||
} @else {-}
|
||||
</td>
|
||||
<td class="max-w-20 truncate print:whitespace-pre-line">{{ tripitem.comment || '-' }}</td>
|
||||
<td class="font-mono text-sm">
|
||||
<div class="max-w-20 print:max-w-full truncate">
|
||||
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} }
|
||||
@else {-}
|
||||
</div>
|
||||
</td>
|
||||
<td class="truncate">@if (tripitem.price) {<span
|
||||
class="bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||
tripitem.price }} {{ currency$ | async }}</span>}</td>
|
||||
<td class="truncate">@if (tripitem.status) {<span [style.background]="tripitem.status.color+'1A'"
|
||||
[style.color]="tripitem.status.color" class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||
tripitem.status.label }}</span>}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
}
|
||||
</p-table>
|
||||
} @else {
|
||||
<div class="px-4 mx-auto max-w-screen-xl mt-8 col-span-full print:hidden">
|
||||
<div class="py-8 px-4 flex flex-col items-center gap-1">
|
||||
<h2 class="mb-0 text-4xl text-center tracking-tight font-extrabold text-gray-900 dark:text-gray-200">
|
||||
No Trip.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden print:block text-center text-sm text-gray-500 mt-4">
|
||||
No Trip
|
||||
</div>
|
||||
}
|
||||
} @placeholder (minimum 0.4s) {
|
||||
<div class="h-[400px] w-full">
|
||||
<p-skeleton height="100%" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 sticky top-4 self-start max-w-screen print:hidden">
|
||||
@if (selectedItem) {
|
||||
<div class="p-4 w-full max-w-full min-h-20 md:max-h-[600px] rounded-md shadow text-center">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="p-2 flex items-center gap-4 w-full max-w-full">
|
||||
@if (selectedItem.place) {
|
||||
<img [src]="selectedItem.place.image || selectedItem.place.category.image"
|
||||
class="hidden md:flex object-cover rounded-md h-20 w-32" />
|
||||
}
|
||||
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<h1 class="text-start font-semibold mb-0 line-clamp-1 md:text-xl">{{ selectedItem.text }}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-2 flex-none">
|
||||
@if (selectedItem.lat && selectedItem.lng) {
|
||||
<p-button icon="pi pi-car" (click)="itemToNavigation()" text />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 px-2 grid md:grid-cols-3 gap-4 overflow-auto w-full">
|
||||
<div class="rounded-md shadow p-4 w-full">
|
||||
<p class="font-bold mb-1">Time</p>
|
||||
<p class="text-sm text-gray-500">{{ selectedItem.time }}</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 rounded-md shadow p-4">
|
||||
<p class="font-bold mb-1">Text</p>
|
||||
<p class="text-sm text-gray-500">{{ selectedItem.text }}</p>
|
||||
</div>
|
||||
|
||||
@if (selectedItem.place) {
|
||||
<div class="rounded-md shadow p-4">
|
||||
<p class="font-bold mb-1">Place</p>
|
||||
<div class="text-sm text-gray-500 truncate">{{ selectedItem.place.name }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedItem.comment) {
|
||||
<div class="md:col-span-2 rounded-md shadow p-4">
|
||||
<p class="font-bold mb-1">Comment</p>
|
||||
<p class="text-sm text-gray-500 whitespace-pre-line" [innerHTML]="selectedItem.comment | linkify"></p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedItem.lat) {
|
||||
<div class="rounded-md shadow p-4">
|
||||
<p class="font-bold mb-1 truncate">Latitude, Longitude</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ selectedItem.lat }}, {{ selectedItem.lng }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedItem.price) {
|
||||
<div class="rounded-md shadow p-4">
|
||||
<p class="font-bold mb-1">Price</p>
|
||||
<p class="text-sm text-gray-500">{{ selectedItem.price }} {{ currency$ | async }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedItem.status) {
|
||||
<div class="rounded-md shadow p-4">
|
||||
<p class="font-bold mb-1">Status</p>
|
||||
<span [style.background]="selectedItem.status.color+'1A'" [style.color]="selectedItem.status.color"
|
||||
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||
selectedItem.status.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
<div class="z-10 p-4 shadow rounded-md w-full min-h-20 max-h-full overflow-y-auto">
|
||||
<div class="p-2 mb-2 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="font-semibold tracking-tight text-xl">Map</h1>
|
||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip.name }} places</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<p-button icon="pi pi-window-maximize" (click)="toggleMapFullscreen()" text />
|
||||
<p-button icon="pi pi-refresh" [disabled]="!places.length" (click)="resetMapBounds()" text />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map" [class.fullscreen-map]="isMapFullscreen" class="w-full rounded-md min-h-96 h-1/3 max-h-full">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!selectedItem) {
|
||||
<div class="p-4 shadow rounded-md w-full min-h-20">
|
||||
<div class="p-2 mb-2 flex justify-between items-center">
|
||||
<div class="group relative">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Places</h1>
|
||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip.name }} places</span>
|
||||
|
||||
<div
|
||||
class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x dark:bg-surface-900">
|
||||
<p-button [icon]="collapsedTripPlaces ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
|
||||
(click)="collapsedTripPlaces = !collapsedTripPlaces" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
@defer {
|
||||
<span class="bg-blue-100 text-blue-800 text-sm me-2 px-2.5 py-0.5 rounded-md dark:bg-blue-100/85">{{
|
||||
places.length }}</span>
|
||||
} @placeholder (minimum 0.4s) {
|
||||
<p-skeleton height="1.75rem" width="2.5rem" class="mr-1" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!collapsedTripPlaces) {
|
||||
<div class="max-h-[25vh] overflow-y-auto">
|
||||
@defer {
|
||||
@for (p of places; track p.id) {
|
||||
<div class="flex items-center gap-4 py-2 px-4 hover:bg-gray-50 rounded-md overflow-auto dark:hover:bg-gray-800"
|
||||
(mouseenter)="placeHighlightMarker(p.lat, p.lng)" (mouseleave)="resetPlaceHighlightMarker()">
|
||||
<img [src]="p.image || p.category.image" class="w-12 rounded-full object-fit">
|
||||
|
||||
<div class="flex flex-col gap-1 truncate">
|
||||
<h1 class="tracking-tight truncate dark:text-surface-300">{{ p.name }}</h1>
|
||||
<span class="text-xs text-gray-500 truncate">{{ p.place }}</span>
|
||||
|
||||
<div class="flex gap-0.5">
|
||||
<span [style.color]="p.category.color" [style.background-color]="p.category.color + '1A'"
|
||||
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded flex gap-2 items-center truncate"><i
|
||||
class="pi pi-box text-xs"></i>{{ p.category.name }}</span>
|
||||
|
||||
@if (isPlaceUsed(p.id)) {
|
||||
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded dark:bg-green-100/85"><i
|
||||
class="pi pi-check-square text-xs"></i></span>
|
||||
} @else {
|
||||
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded dark:bg-red-100/85"><i
|
||||
class="pi pi-map-marker text-xs"></i></span>
|
||||
}
|
||||
|
||||
<span
|
||||
class="bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded dark:bg-gray-100/85">{{
|
||||
p.price || '-'
|
||||
}} {{ currency$ | async }}</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
No place
|
||||
}
|
||||
} @placeholder (minimum 0.4s) {
|
||||
<div class="flex flex-col gap-4">
|
||||
@for (_ of [1,2,3]; track _) {
|
||||
<div class="h-16">
|
||||
<p-skeleton height="100%" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="p-4 shadow rounded-md w-full min-h-20">
|
||||
<div class="p-2 mb-2 flex justify-between items-center">
|
||||
<div class="group relative">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Days</h1>
|
||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip.name }} days</span>
|
||||
|
||||
<div
|
||||
class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x dark:bg-surface-900">
|
||||
<p-button [icon]="collapsedTripDays ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
|
||||
(click)="collapsedTripDays = !collapsedTripDays" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!collapsedTripDays) {
|
||||
<div class="max-h-[20vh] overflow-y-auto">
|
||||
@defer {
|
||||
@for (d of trip.days; track d.id) {
|
||||
<div class="flex items-center gap-4 rounded-md justify-between h-10 px-4 py-2 w-full max-w-full">
|
||||
<div class="line-clamp-1 dark:text-surface-300">
|
||||
{{ d.label }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-none">
|
||||
<span class="bg-gray-100 text-gray-800 text-sm px-2.5 py-0.5 rounded-md min-w-fit dark:bg-gray-100/85">{{
|
||||
getDayStats(d).price || '-' }} {{ currency$ | async }}</span>
|
||||
<span class="bg-blue-100 text-blue-800 text-sm px-2.5 py-0.5 rounded-md dark:bg-blue-100/85">{{
|
||||
getDayStats(d).places }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
No day
|
||||
}
|
||||
} @placeholder (minimum 0.4s) {
|
||||
<div class="h-16">
|
||||
<p-skeleton height="100%" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="p-4 shadow rounded-md w-full min-h-20">
|
||||
<div class="group relative p-2 mb-2 flex flex-col items-start">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Watchlist</h1>
|
||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip.name }} pending/constraints</span>
|
||||
|
||||
<div
|
||||
class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x dark:bg-surface-900">
|
||||
<p-button [icon]="collapsedTripStatuses ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
|
||||
(click)="collapsedTripStatuses = !collapsedTripStatuses" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!collapsedTripStatuses) {
|
||||
<div class="max-h-[20vh] overflow-y-auto">
|
||||
@defer {
|
||||
@for (item of getWatchlistData; track item.id) {
|
||||
<div class="flex items-center gap-2 h-10 px-4 py-2 w-full max-w-full">
|
||||
<div class="flex flex-none">
|
||||
<span [style.background]="item.status.color+'1A'" [style.color]="item.status.color"
|
||||
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||
item.status.label }}</span>
|
||||
</div>
|
||||
<div class="line-clamp-1">{{ item.text }}</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="p-4 font-light text-gray-500">
|
||||
Nothing there
|
||||
</p>
|
||||
}
|
||||
} @placeholder (minimum 0.4s) {
|
||||
<div class="h-16">
|
||||
<p-skeleton height="100%" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (isMapFullscreen) {
|
||||
<div class="fixed top-2 right-2 p-2 bg-white shadow rounded dark:bg-surface-900">
|
||||
<p-button (click)="toggleMapFullscreen()" severity="secondary" text icon="pi pi-window-minimize" />
|
||||
</div>
|
||||
|
||||
<div class="fixed top-20 right-2 p-2 bg-white shadow rounded dark:bg-surface-900">
|
||||
<p-button (click)="toggleTripDaysHighlight()" text icon="pi pi-directions"
|
||||
[severity]="tripMapAntLayerDayID == -1 ? 'help' : 'secondary'" />
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<section class="min-h-screen my-auto flex flex-col items-center justify-center gap-8">
|
||||
<div class="mx-auto w-full text-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-800 tracking-tight dark:text-white md:text-4xl">Trip not found</h1>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">The requested Trip does not exist</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto">
|
||||
<img class="w-full lg:h-[32rem] h-80 md:h-96 rounded-lg object-cover" src="cover.webp">
|
||||
</div>
|
||||
|
||||
<p-button text label="itskovacs/trip" icon="pi pi-github" (click)="toGithub()" />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
@media print {
|
||||
.print-striped-rows tr:nth-child(even) {
|
||||
background-color: #f9f9f9 !important;
|
||||
}
|
||||
|
||||
.print-striped-rows tr:nth-child(even) td:first-child.truncate {
|
||||
//HACK: The "day" column is truncated
|
||||
background-color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreen-map {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
620
src/src/app/components/shared-trip/shared-trip.component.ts
Normal file
620
src/src/app/components/shared-trip/shared-trip.component.ts
Normal file
@ -0,0 +1,620 @@
|
||||
import { AfterViewInit, Component } from "@angular/core";
|
||||
import { ApiService } from "../../services/api.service";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { SkeletonModule } from "primeng/skeleton";
|
||||
import * as L from "leaflet";
|
||||
import { antPath } from "leaflet-ant-path";
|
||||
import { TableModule } from "primeng/table";
|
||||
import {
|
||||
Trip,
|
||||
FlattenedTripItem,
|
||||
TripDay,
|
||||
TripItem,
|
||||
TripStatus,
|
||||
} from "../../types/trip";
|
||||
import { Place } from "../../types/poi";
|
||||
import {
|
||||
createMap,
|
||||
placeToMarker,
|
||||
createClusterGroup,
|
||||
tripDayMarker,
|
||||
} from "../../shared/map";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Observable, take, tap } from "rxjs";
|
||||
import { UtilsService } from "../../services/utils.service";
|
||||
import { AsyncPipe, DecimalPipe } from "@angular/common";
|
||||
import { MenuItem } from "primeng/api";
|
||||
import { MenuModule } from "primeng/menu";
|
||||
import { LinkifyPipe } from "../../shared/linkify.pipe";
|
||||
|
||||
@Component({
|
||||
selector: "app-shared-trip",
|
||||
standalone: true,
|
||||
imports: [
|
||||
SkeletonModule,
|
||||
MenuModule,
|
||||
LinkifyPipe,
|
||||
TableModule,
|
||||
ButtonModule,
|
||||
DecimalPipe,
|
||||
AsyncPipe,
|
||||
],
|
||||
templateUrl: "./shared-trip.component.html",
|
||||
styleUrls: ["./shared-trip.component.scss"],
|
||||
})
|
||||
export class SharedTripComponent implements AfterViewInit {
|
||||
currency$: Observable<string>;
|
||||
statuses: TripStatus[] = [];
|
||||
trip?: Trip;
|
||||
places: Place[] = [];
|
||||
flattenedTripItems: FlattenedTripItem[] = [];
|
||||
selectedItem?: TripItem & { status?: TripStatus };
|
||||
tableExpandableMode = false;
|
||||
|
||||
isMapFullscreen = false;
|
||||
totalPrice = 0;
|
||||
collapsedTripDays = false;
|
||||
collapsedTripPlaces = false;
|
||||
collapsedTripStatuses = false;
|
||||
|
||||
map?: L.Map;
|
||||
markerClusterGroup?: L.MarkerClusterGroup;
|
||||
tripMapTemporaryMarker?: L.Marker;
|
||||
tripMapHoveredElement?: HTMLElement;
|
||||
tripMapAntLayer?: L.FeatureGroup;
|
||||
tripMapAntLayerDayID?: number;
|
||||
|
||||
readonly menuTripActionsItems: MenuItem[] = [
|
||||
{
|
||||
label: "Actions",
|
||||
items: [
|
||||
{
|
||||
label: "Packing",
|
||||
icon: "pi pi-briefcase",
|
||||
command: () => {
|
||||
// this.toggleArchiveTrip();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reminders",
|
||||
icon: "pi pi-check-square",
|
||||
command: () => {
|
||||
// this.toggleArchiveTrip();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
readonly menuTripTableActionsItems: MenuItem[] = [
|
||||
{
|
||||
label: "Actions",
|
||||
items: [
|
||||
{
|
||||
label: "Directions",
|
||||
icon: "pi pi-directions",
|
||||
command: () => {
|
||||
this.toggleTripDaysHighlight();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Navigation",
|
||||
icon: "pi pi-car",
|
||||
command: () => {
|
||||
this.tripToNavigation();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Expand / Group",
|
||||
icon: "pi pi-arrow-down-left-and-arrow-up-right-to-center",
|
||||
command: () => {
|
||||
this.tableExpandableMode = !this.tableExpandableMode;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Print",
|
||||
icon: "pi pi-print",
|
||||
command: () => {
|
||||
this.printTable();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
dayStatsCache = new Map<number, { price: number; places: number }>();
|
||||
placesUsedInTable = new Set<number>();
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private utilsService: UtilsService,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
this.currency$ = this.utilsService.currency$;
|
||||
this.statuses = this.utilsService.statuses;
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
take(1),
|
||||
tap((params) => {
|
||||
const token = params.get("token");
|
||||
if (token) {
|
||||
this.loadTripData(token);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
loadTripData(token: string): void {
|
||||
this.apiService
|
||||
.getSharedTrip(token)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (trip) => {
|
||||
this.trip = trip;
|
||||
this.flattenTripDayItems();
|
||||
this.updateTotalPrice();
|
||||
this.initMap();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
initMap(): void {
|
||||
const contentMenuItems = [
|
||||
{
|
||||
text: "Copy coordinates",
|
||||
callback: (e: any) => {
|
||||
const latlng = e.latlng;
|
||||
navigator.clipboard.writeText(
|
||||
`${parseFloat(latlng.lat).toFixed(5)}, ${parseFloat(latlng.lng).toFixed(5)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
setTimeout(() => {
|
||||
this.map = createMap(contentMenuItems);
|
||||
this.markerClusterGroup = createClusterGroup().addTo(this.map);
|
||||
this.setPlacesAndMarkers();
|
||||
// this.map.setView([settings.map_lat, settings.map_lng]);
|
||||
this.resetMapBounds();
|
||||
}, 50); // HACK: Prevent map not found due to @if
|
||||
}
|
||||
|
||||
printTable() {
|
||||
this.selectedItem = undefined;
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
sortTripDays() {
|
||||
this.trip?.days.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
toGithub() {
|
||||
this.utilsService.toGithubTRIP();
|
||||
}
|
||||
|
||||
getDayStats(day: TripDay): { price: number; places: number } {
|
||||
if (this.dayStatsCache.has(day.id)) return this.dayStatsCache.get(day.id)!;
|
||||
|
||||
const stats = day.items.reduce(
|
||||
(acc, item) => {
|
||||
acc.price += item.price || 0;
|
||||
if (item.place) acc.places += 1;
|
||||
return acc;
|
||||
},
|
||||
{ price: 0, places: 0 },
|
||||
);
|
||||
this.dayStatsCache.set(day.id, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
get getWatchlistData(): (TripItem & { status: TripStatus })[] {
|
||||
if (!this.trip?.days) return [];
|
||||
|
||||
return this.trip.days
|
||||
.flatMap((day) =>
|
||||
day.items.filter((item) =>
|
||||
["constraint", "pending"].includes(item.status as string),
|
||||
),
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
status: this.statusToTripStatus(item.status as string),
|
||||
})) as (TripItem & { status: TripStatus })[];
|
||||
}
|
||||
|
||||
isPlaceUsed(id: number): boolean {
|
||||
return this.placesUsedInTable.has(id);
|
||||
}
|
||||
|
||||
statusToTripStatus(status?: string): TripStatus | undefined {
|
||||
if (!status) return undefined;
|
||||
return this.statuses.find((s) => s.label == status);
|
||||
}
|
||||
|
||||
flattenTripDayItems() {
|
||||
this.sortTripDays();
|
||||
this.flattenedTripItems = this.trip!.days.flatMap((day) =>
|
||||
[...day.items]
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map((item) => ({
|
||||
td_id: day.id,
|
||||
td_label: day.label,
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
text: item.text,
|
||||
status: this.statusToTripStatus(item.status as string),
|
||||
comment: item.comment,
|
||||
price: item.price || undefined,
|
||||
day_id: item.day_id,
|
||||
place: item.place,
|
||||
lat: item.lat || (item.place ? item.place.lat : undefined),
|
||||
lng: item.lng || (item.place ? item.place.lng : undefined),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
computePlacesUsedInTable() {
|
||||
this.placesUsedInTable.clear();
|
||||
this.flattenedTripItems.forEach((item) => {
|
||||
if (item.place?.id) this.placesUsedInTable.add(item.place.id);
|
||||
});
|
||||
}
|
||||
|
||||
setPlacesAndMarkers() {
|
||||
this.computePlacesUsedInTable();
|
||||
this.places = [...(this.trip?.places ?? [])].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
this.markerClusterGroup?.clearLayers();
|
||||
this.places.forEach((p) => {
|
||||
const marker = placeToMarker(p, false, !this.placesUsedInTable.has(p.id));
|
||||
this.markerClusterGroup?.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
resetMapBounds() {
|
||||
if (!this.places.length) {
|
||||
this.map?.fitBounds(
|
||||
this.flattenedTripItems
|
||||
.filter((i) => i.lat != null && i.lng != null)
|
||||
.map((i) => [i.lat!, i.lng!]),
|
||||
{ padding: [30, 30] },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.map?.fitBounds(
|
||||
this.places.map((p) => [p.lat, p.lng]),
|
||||
{ padding: [30, 30] },
|
||||
);
|
||||
}
|
||||
|
||||
toggleMapFullscreen() {
|
||||
this.isMapFullscreen = !this.isMapFullscreen;
|
||||
document.body.classList.toggle("overflow-hidden");
|
||||
|
||||
setTimeout(() => {
|
||||
this.map?.invalidateSize();
|
||||
if (!this.tripMapAntLayer) this.resetMapBounds();
|
||||
else this.map?.fitBounds(this.tripMapAntLayer.getBounds());
|
||||
}, 10);
|
||||
}
|
||||
|
||||
updateTotalPrice(n?: number) {
|
||||
if (n) {
|
||||
this.totalPrice += n;
|
||||
return;
|
||||
}
|
||||
this.totalPrice =
|
||||
this.trip?.days
|
||||
.flatMap((d) => d.items)
|
||||
.reduce((price, item) => price + (item.price ?? 0), 0) ?? 0;
|
||||
}
|
||||
|
||||
resetPlaceHighlightMarker() {
|
||||
if (this.tripMapHoveredElement) {
|
||||
this.tripMapHoveredElement.classList.remove("listHover");
|
||||
this.tripMapHoveredElement = undefined;
|
||||
}
|
||||
|
||||
if (this.tripMapTemporaryMarker) {
|
||||
this.map?.removeLayer(this.tripMapTemporaryMarker);
|
||||
this.tripMapTemporaryMarker = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
placeHighlightMarker(lat: number, lng: number) {
|
||||
if (this.tripMapHoveredElement || this.tripMapTemporaryMarker)
|
||||
this.resetPlaceHighlightMarker();
|
||||
|
||||
let marker: L.Marker | undefined;
|
||||
this.markerClusterGroup?.eachLayer((layer: any) => {
|
||||
if (layer.getLatLng && layer.getLatLng().equals([lat, lng])) {
|
||||
marker = layer;
|
||||
}
|
||||
});
|
||||
|
||||
if (!marker) {
|
||||
// TripItem without place, but latlng
|
||||
const item = {
|
||||
text: this.selectedItem?.text || "",
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
};
|
||||
this.tripMapTemporaryMarker = tripDayMarker(item).addTo(this.map!);
|
||||
this.map?.fitBounds([[lat, lng]], { padding: [60, 60] });
|
||||
return;
|
||||
}
|
||||
|
||||
let targetLatLng: L.LatLng | null = null;
|
||||
const markerElement = marker.getElement() as HTMLElement; // search for Marker. If 'null', is inside Cluster
|
||||
if (markerElement) {
|
||||
// marker, not clustered
|
||||
markerElement.classList.add("listHover");
|
||||
this.tripMapHoveredElement = markerElement;
|
||||
targetLatLng = marker.getLatLng();
|
||||
} else {
|
||||
// marker is clustered
|
||||
const parentCluster = (this.markerClusterGroup as any).getVisibleParent(
|
||||
marker,
|
||||
);
|
||||
if (parentCluster) {
|
||||
const clusterEl = parentCluster.getElement();
|
||||
if (clusterEl) {
|
||||
clusterEl.classList.add("listHover");
|
||||
this.tripMapHoveredElement = clusterEl;
|
||||
}
|
||||
targetLatLng = parentCluster.getLatLng();
|
||||
}
|
||||
}
|
||||
|
||||
if (targetLatLng && this.map) {
|
||||
const currentBounds = this.map.getBounds();
|
||||
|
||||
// If point is not inside map bounsd, move map w/o touching zoom
|
||||
if (!currentBounds.contains(targetLatLng)) {
|
||||
setTimeout(() => {
|
||||
this.map!.setView(targetLatLng, this.map!.getZoom());
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetDayHighlight() {
|
||||
this.map?.removeLayer(this.tripMapAntLayer!);
|
||||
this.tripMapAntLayerDayID = undefined;
|
||||
this.tripMapAntLayer = undefined;
|
||||
this.resetMapBounds();
|
||||
}
|
||||
|
||||
toggleTripDaysHighlight() {
|
||||
if (this.tripMapAntLayerDayID == -1) {
|
||||
this.resetDayHighlight();
|
||||
return;
|
||||
}
|
||||
if (!this.trip) return;
|
||||
|
||||
const items = this.trip.days
|
||||
.flatMap((day, idx) =>
|
||||
day.items
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map((item) => {
|
||||
let data = {
|
||||
text: item.text,
|
||||
isPlace: !!item.place,
|
||||
idx: idx,
|
||||
};
|
||||
|
||||
if (item.lat && item.lng)
|
||||
return {
|
||||
...data,
|
||||
lat: item.lat,
|
||||
lng: item.lng,
|
||||
};
|
||||
if (item.place)
|
||||
return {
|
||||
...data,
|
||||
lat: item.place.lat,
|
||||
lng: item.place.lng,
|
||||
};
|
||||
return undefined;
|
||||
}),
|
||||
)
|
||||
.filter((n) => n !== undefined);
|
||||
|
||||
if (items.length < 2) {
|
||||
this.utilsService.toast(
|
||||
"info",
|
||||
"Info",
|
||||
"Not enough values to map an itinerary",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dayGroups: { [idx: number]: any } = {};
|
||||
items.forEach((item) => {
|
||||
if (!dayGroups[item.idx]) dayGroups[item.idx] = [];
|
||||
dayGroups[item.idx].push(item);
|
||||
});
|
||||
|
||||
const layGroup = L.featureGroup();
|
||||
const COLORS: string[] = [
|
||||
"#e6194b",
|
||||
"#3cb44b",
|
||||
"#ffe119",
|
||||
"#4363d8",
|
||||
"#9a6324",
|
||||
"#f58231",
|
||||
"#911eb4",
|
||||
"#46f0f0",
|
||||
"#f032e6",
|
||||
"#bcf60c",
|
||||
"#fabebe",
|
||||
"#008080",
|
||||
"#e6beff",
|
||||
"#808000",
|
||||
];
|
||||
let prevPoint: [number, number] | null = null;
|
||||
|
||||
Object.values(dayGroups).forEach((group, idx) => {
|
||||
const coords = group.map((day: any) => [day.lat, day.lng]);
|
||||
const pathOptions = {
|
||||
delay: 600,
|
||||
dashArray: [10, 20],
|
||||
weight: 5,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
pulseColor: "#FFFFFF",
|
||||
paused: false,
|
||||
reverse: false,
|
||||
hardwareAccelerated: true,
|
||||
};
|
||||
|
||||
if (coords.length >= 2) {
|
||||
const path = antPath(coords, pathOptions);
|
||||
layGroup.addLayer(path);
|
||||
prevPoint = coords[coords.length - 1];
|
||||
} else if (coords.length === 1 && prevPoint) {
|
||||
const path = antPath([prevPoint, coords[0]], pathOptions);
|
||||
layGroup.addLayer(path);
|
||||
prevPoint = coords[0];
|
||||
} else if (coords.length === 1) {
|
||||
prevPoint = coords[0];
|
||||
}
|
||||
|
||||
group.forEach((day: any) => {
|
||||
if (!day.isPlace) layGroup.addLayer(tripDayMarker(day));
|
||||
});
|
||||
});
|
||||
|
||||
this.map?.fitBounds(
|
||||
items.map((c) => [c.lat, c.lng]),
|
||||
{ padding: [30, 30] },
|
||||
);
|
||||
|
||||
if (this.tripMapAntLayer) {
|
||||
this.map?.removeLayer(this.tripMapAntLayer);
|
||||
this.tripMapAntLayerDayID = undefined;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
layGroup.addTo(this.map!);
|
||||
}, 200);
|
||||
|
||||
this.tripMapAntLayer = layGroup;
|
||||
this.tripMapAntLayerDayID = -1; //Hardcoded value for global trace
|
||||
}
|
||||
|
||||
toggleTripDayHighlightPathDay(day_id: number) {
|
||||
// Click on the currently displayed day: remove
|
||||
if (this.tripMapAntLayerDayID == day_id) {
|
||||
this.resetDayHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = this.trip?.days.findIndex((d) => d.id === day_id);
|
||||
if (!this.trip || idx === undefined || idx == -1) return;
|
||||
const data = this.trip.days[idx].items.sort((a, b) =>
|
||||
a.time.localeCompare(b.time),
|
||||
);
|
||||
const items = data
|
||||
.map((item) => {
|
||||
if (item.lat && item.lng)
|
||||
return {
|
||||
text: item.text,
|
||||
lat: item.lat,
|
||||
lng: item.lng,
|
||||
isPlace: !!item.place,
|
||||
};
|
||||
if (item.place && item.place)
|
||||
return {
|
||||
text: item.text,
|
||||
lat: item.place.lat,
|
||||
lng: item.place.lng,
|
||||
isPlace: true,
|
||||
};
|
||||
return undefined;
|
||||
})
|
||||
.filter((n) => n !== undefined);
|
||||
|
||||
if (items.length < 2) {
|
||||
this.utilsService.toast(
|
||||
"info",
|
||||
"Info",
|
||||
"Not enough values to map an itinerary",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.map?.fitBounds(
|
||||
items.map((c) => [c.lat, c.lng]),
|
||||
{ padding: [30, 30] },
|
||||
);
|
||||
|
||||
const path = antPath(
|
||||
items.map((c) => [c.lat, c.lng]),
|
||||
{
|
||||
delay: 400,
|
||||
dashArray: [10, 20],
|
||||
weight: 5,
|
||||
color: "#0000FF",
|
||||
pulseColor: "#FFFFFF",
|
||||
paused: false,
|
||||
reverse: false,
|
||||
hardwareAccelerated: true,
|
||||
},
|
||||
);
|
||||
|
||||
const layGroup = L.featureGroup();
|
||||
layGroup.addLayer(path);
|
||||
items.forEach((item) => {
|
||||
if (!item.isPlace) layGroup.addLayer(tripDayMarker(item));
|
||||
});
|
||||
|
||||
if (this.tripMapAntLayer) {
|
||||
this.map?.removeLayer(this.tripMapAntLayer);
|
||||
this.tripMapAntLayerDayID = undefined;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
layGroup.addTo(this.map!);
|
||||
}, 200);
|
||||
|
||||
this.tripMapAntLayer = layGroup;
|
||||
this.tripMapAntLayerDayID = day_id;
|
||||
}
|
||||
|
||||
onRowClick(item: FlattenedTripItem) {
|
||||
if (this.selectedItem && this.selectedItem.id === item.id) {
|
||||
this.selectedItem = undefined;
|
||||
this.resetPlaceHighlightMarker();
|
||||
} else {
|
||||
this.selectedItem = item;
|
||||
if (item.lat && item.lng) this.placeHighlightMarker(item.lat, item.lng);
|
||||
}
|
||||
}
|
||||
|
||||
itemToNavigation() {
|
||||
if (!this.selectedItem) return;
|
||||
// TODO: More services
|
||||
// const url = `http://maps.apple.com/?daddr=${this.selectedItem.lat},${this.selectedItem.lng}`;
|
||||
const url = `https://www.google.com/maps/dir/?api=1&destination=${this.selectedItem.lat},${this.selectedItem.lng}`;
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
tripToNavigation() {
|
||||
// TODO: More services
|
||||
const items = this.flattenedTripItems.filter(
|
||||
(item) => item.lat && item.lng,
|
||||
);
|
||||
if (!items.length) return;
|
||||
|
||||
const waypoints = items.map((item) => `${item.lat},${item.lng}`).join("/");
|
||||
const url = `https://www.google.com/maps/dir/${waypoints}`;
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
}
|
||||
@ -15,10 +15,14 @@
|
||||
<div class="flex items-center gap-2 print:hidden">
|
||||
@if (!trip?.archived) {
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<p-button text (click)="shareDialogVisible = true" icon="pi pi-share-alt" />
|
||||
<p-button text (click)="toggleArchiveTrip()" icon="pi pi-box" severity="warn" />
|
||||
<div class="border-l border-solid border-gray-700 h-4"></div>
|
||||
<p-button text (click)="deleteTrip()" icon="pi pi-trash" severity="danger" />
|
||||
<p-button text (click)="editTrip()" icon="pi pi-pencil" />
|
||||
<div class="border-l border-solid border-gray-700 h-4"></div>
|
||||
<p-button text icon="pi pi-check-square" severity="help" />
|
||||
<p-button text icon="pi pi-briefcase" severity="help" />
|
||||
</div>
|
||||
|
||||
<div class="flex md:hidden">
|
||||
@ -493,3 +497,33 @@
|
||||
[severity]="tripMapAntLayerDayID == -1 ? 'help' : 'secondary'" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<p-dialog header="Share" [draggable]="false" [dismissableMask]="true" [modal]="true" [(visible)]="shareDialogVisible"
|
||||
[style]="{ width: '25rem' }">
|
||||
@if (shareDialogVisible) {
|
||||
<ng-container>
|
||||
@if (trip?.shared) {
|
||||
<div class="flex items-center flex-col gap-2">
|
||||
<span>{{ trip?.name }} is shared on this link:</span>
|
||||
|
||||
<div class="flex items-center w-full rounded-xl border border-gray-300 bg-white p-2 pl-3 pr-2 shadow-sm">
|
||||
<p-button text icon="pi pi-trash" severity="danger" (click)="unshareTrip()" />
|
||||
<span class="text-sm font-semibold text-gray-800 truncate">
|
||||
{{ (tripSharedURL$ | async) || '' }}
|
||||
</span>
|
||||
<p-button text icon="pi pi-copy" [cdkCopyToClipboard]="(tripSharedURL$ | async) || ''" />
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center flex-col gap-2">
|
||||
<span>{{ trip?.name }} is not currently shared.</span>
|
||||
<p-button (click)="shareTrip()" styleClass="mt-4" text label="Share" icon="pi pi-share-alt" />
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<p-button label="Quit" (click)="shareDialogVisible = false" />
|
||||
</div>
|
||||
</p-dialog>
|
||||
@ -45,6 +45,8 @@ import { MenuModule } from "primeng/menu";
|
||||
import { LinkifyPipe } from "../../shared/linkify.pipe";
|
||||
import { PlaceCreateModalComponent } from "../../modals/place-create-modal/place-create-modal.component";
|
||||
import { Settings } from "../../types/settings";
|
||||
import { DialogModule } from "primeng/dialog";
|
||||
import { ClipboardModule } from "@angular/cdk/clipboard";
|
||||
|
||||
@Component({
|
||||
selector: "app-trip",
|
||||
@ -61,12 +63,15 @@ import { Settings } from "../../types/settings";
|
||||
TableModule,
|
||||
ButtonModule,
|
||||
DecimalPipe,
|
||||
DialogModule,
|
||||
ClipboardModule,
|
||||
],
|
||||
templateUrl: "./trip.component.html",
|
||||
styleUrls: ["./trip.component.scss"],
|
||||
})
|
||||
export class TripComponent implements AfterViewInit {
|
||||
currency$: Observable<string>;
|
||||
tripSharedURL$?: Observable<string>;
|
||||
statuses: TripStatus[] = [];
|
||||
trip?: Trip;
|
||||
places: Place[] = [];
|
||||
@ -79,6 +84,7 @@ export class TripComponent implements AfterViewInit {
|
||||
collapsedTripDays = false;
|
||||
collapsedTripPlaces = false;
|
||||
collapsedTripStatuses = false;
|
||||
shareDialogVisible = false;
|
||||
|
||||
map?: L.Map;
|
||||
markerClusterGroup?: L.MarkerClusterGroup;
|
||||
@ -107,6 +113,13 @@ export class TripComponent implements AfterViewInit {
|
||||
this.toggleArchiveTrip();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Share",
|
||||
icon: "pi pi-share-alt",
|
||||
command: () => {
|
||||
this.shareDialogVisible = true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: "pi pi-trash",
|
||||
@ -207,7 +220,10 @@ export class TripComponent implements AfterViewInit {
|
||||
take(1),
|
||||
tap((params) => {
|
||||
const id = params.get("id");
|
||||
if (id) this.loadTripData(+id);
|
||||
if (id) {
|
||||
this.loadTripData(+id);
|
||||
this.tripSharedURL$ = this.apiService.getSharedTripURL(+id);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
@ -1180,4 +1196,51 @@ export class TripComponent implements AfterViewInit {
|
||||
this.selectedItem = undefined;
|
||||
this.resetPlaceHighlightMarker();
|
||||
}
|
||||
|
||||
getSharedTripURL() {
|
||||
if (!this.trip) return;
|
||||
this.apiService.getSharedTripURL(this.trip?.id!).pipe(take(1)).subscribe();
|
||||
}
|
||||
|
||||
shareTrip() {
|
||||
if (!this.trip) return;
|
||||
this.apiService
|
||||
.createSharedTrip(this.trip?.id!)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.trip!.shared = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
unshareTrip() {
|
||||
if (!this.trip) return;
|
||||
|
||||
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||
header: "Confirm deletion",
|
||||
modal: true,
|
||||
closable: true,
|
||||
dismissableMask: true,
|
||||
breakpoints: {
|
||||
"640px": "90vw",
|
||||
},
|
||||
data: `Stop sharing ${this.trip.name} ?`,
|
||||
});
|
||||
|
||||
modal.onClose.pipe(take(1)).subscribe({
|
||||
next: (bool) => {
|
||||
if (!bool) return;
|
||||
this.apiService
|
||||
.deleteSharedTrip(this.trip?.id!)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.trip!.shared = false;
|
||||
this.shareDialogVisible = false;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import { HttpClient, HttpHeaders } from "@angular/common/http";
|
||||
import { Category, Place } from "../types/poi";
|
||||
import { BehaviorSubject, Observable, tap } from "rxjs";
|
||||
import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs";
|
||||
import { Info } from "../types/info";
|
||||
import { ImportResponse, Settings } from "../types/settings";
|
||||
import { Trip, TripBase, TripDay, TripItem } from "../types/trip";
|
||||
import {
|
||||
SharedTripURL,
|
||||
Trip,
|
||||
TripBase,
|
||||
TripDay,
|
||||
TripItem,
|
||||
} from "../types/trip";
|
||||
|
||||
const NO_AUTH_HEADER = {
|
||||
no_auth: "1",
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
@ -194,6 +204,34 @@ export class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
getSharedTrip(token: string): Observable<Trip> {
|
||||
return this.httpClient.get<Trip>(
|
||||
`${this.apiBaseUrl}/trips/shared/${token}`,
|
||||
{ headers: NO_AUTH_HEADER },
|
||||
);
|
||||
}
|
||||
|
||||
getSharedTripURL(trip_id: number): Observable<string> {
|
||||
return this.httpClient
|
||||
.get<SharedTripURL>(`${this.apiBaseUrl}/trips/${trip_id}/share`)
|
||||
.pipe(
|
||||
map((t) => t.url),
|
||||
shareReplay(),
|
||||
);
|
||||
}
|
||||
|
||||
createSharedTrip(trip_id: number): Observable<string> {
|
||||
return this.httpClient
|
||||
.post<SharedTripURL>(`${this.apiBaseUrl}/trips/${trip_id}/share`, {})
|
||||
.pipe(map((t) => t.url));
|
||||
}
|
||||
|
||||
deleteSharedTrip(trip_id: number): Observable<null> {
|
||||
return this.httpClient.delete<null>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/share`,
|
||||
);
|
||||
}
|
||||
|
||||
checkVersion(): Observable<string> {
|
||||
return this.httpClient.get<string>(
|
||||
`${this.apiBaseUrl}/settings/checkversion`,
|
||||
|
||||
@ -40,6 +40,11 @@ export const Interceptor = (
|
||||
return throwError(() => details);
|
||||
}
|
||||
|
||||
if (req.headers.has("no_auth")) {
|
||||
// Shared Trip must be anonymous
|
||||
return next(req);
|
||||
}
|
||||
|
||||
if (!req.headers.has("enctype") && !req.headers.has("Content-Type")) {
|
||||
req = req.clone({
|
||||
setHeaders: {
|
||||
|
||||
@ -16,6 +16,7 @@ export interface Trip {
|
||||
archived?: boolean;
|
||||
user: string;
|
||||
days: TripDay[];
|
||||
shared?: boolean;
|
||||
|
||||
// POST / PUT
|
||||
places: Place[];
|
||||
@ -60,3 +61,7 @@ export interface FlattenedTripItem {
|
||||
day_id: number;
|
||||
status?: TripStatus;
|
||||
}
|
||||
|
||||
export interface SharedTripURL {
|
||||
url: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user