diff --git a/src/src/app/app.routes.ts b/src/src/app/app.routes.ts index 926f0d7..091c4f8 100644 --- a/src/src/app/app.routes.ts +++ b/src/src/app/app.routes.ts @@ -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], diff --git a/src/src/app/components/shared-trip/shared-trip.component.html b/src/src/app/components/shared-trip/shared-trip.component.html new file mode 100644 index 0000000..a83cd45 --- /dev/null +++ b/src/src/app/components/shared-trip/shared-trip.component.html @@ -0,0 +1,454 @@ +@defer { +@if (trip) { +
+
+
+
+

{{ trip.name }}

+ {{ trip.days.length }} {{ trip.days!.length > 1 ? 'days' : 'day'}} +
+
+ + +
+ {{ + (totalPrice | number:'1.0-2') || '-' }} {{ currency$ | async }} +
+
+
+ +
+
+
+
+

Plans

+ {{ trip.name }} plans +
+ +
+ + +
+ + +
+
+
+ + @defer { + @if (flattenedTripItems.length) { + + + + Day + Time + Text + Place + Comment + LatLng + Price + Status + + + @if (tableExpandableMode) { + + + + + {{ tripitem.td_label }} + + + + + + + + {{ tripitem.td_label }} + {{ tripitem.time }} + +
+ @if (tripitem.status) {
} + {{ tripitem.text }} +
+ + + @if (tripitem.place) { +
+ {{ + tripitem.place.name }} +
+ } @else {-} + + {{ tripitem.comment || '-' }} + +
+ @if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} } + @else {-} +
+ + @if (tripitem.price) {{{ + tripitem.price }} {{ currency$ | async }}} + @if (tripitem.status) {{{ + tripitem.status.label }}} + +
+ } + @else { + + + @if (rowgroup) { + +
{{tripitem.td_label }}
+ + } + {{ tripitem.time }} + +
+ {{ tripitem.text }} + @if (tripitem.status) {
} +
+ + + @if (tripitem.place) { +
+ {{ + tripitem.place.name }} +
+ } @else {-} + + {{ tripitem.comment || '-' }} + +
+ @if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} } + @else {-} +
+ + @if (tripitem.price) {{{ + tripitem.price }} {{ currency$ | async }}} + @if (tripitem.status) {{{ + tripitem.status.label }}} + +
+ } +
+ } @else { +
+
+

+ No Trip. +

+
+
+ + } + } @placeholder (minimum 0.4s) { +
+ +
+ } +
+ +
+ @if (selectedItem) { +
+
+
+ @if (selectedItem.place) { + + } + +
+

{{ selectedItem.text }} +

+ +
+ @if (selectedItem.lat && selectedItem.lng) { + + } +
+
+
+
+ +
+
+

Time

+

{{ selectedItem.time }}

+
+ +
+

Text

+

{{ selectedItem.text }}

+
+ + @if (selectedItem.place) { +
+

Place

+
{{ selectedItem.place.name }}
+
+ } + + @if (selectedItem.comment) { +
+

Comment

+

+
+ } + + @if (selectedItem.lat) { +
+

Latitude, Longitude

+

{{ selectedItem.lat }}, {{ selectedItem.lng }}

+
+ } + + @if (selectedItem.price) { +
+

Price

+

{{ selectedItem.price }} {{ currency$ | async }}

+
+ } + + @if (selectedItem.status) { +
+

Status

+ {{ + selectedItem.status.label }} +
+ } +
+
+ + } +
+
+
+

Map

+ {{ trip.name }} places +
+ +
+ + +
+
+ +
+
+
+ + @if (!selectedItem) { +
+
+
+

Places

+ {{ trip.name }} places + + +
+ +
+ @defer { + {{ + places.length }} + } @placeholder (minimum 0.4s) { + + } +
+
+ + @if (!collapsedTripPlaces) { +
+ @defer { + @for (p of places; track p.id) { +
+ + +
+

{{ p.name }}

+ {{ p.place }} + +
+ {{ p.category.name }} + + @if (isPlaceUsed(p.id)) { + + } @else { + + } + + {{ + p.price || '-' + }} {{ currency$ | async }} + +
+
+
+ } @empty { + No place + } + } @placeholder (minimum 0.4s) { +
+ @for (_ of [1,2,3]; track _) { +
+ +
+ } +
+ } +
+ } +
+ +
+
+
+

Days

+ {{ trip.name }} days + + +
+
+ + @if (!collapsedTripDays) { +
+ @defer { + @for (d of trip.days; track d.id) { +
+
+ {{ d.label }} +
+
+ {{ + getDayStats(d).price || '-' }} {{ currency$ | async }} + {{ + getDayStats(d).places }} +
+
+ } @empty { + No day + } + } @placeholder (minimum 0.4s) { +
+ +
+ } +
+ } +
+ +
+
+

Watchlist

+ {{ trip.name }} pending/constraints + + +
+ + @if (!collapsedTripStatuses) { +
+ @defer { + @for (item of getWatchlistData; track item.id) { +
+
+ {{ + item.status.label }} +
+
{{ item.text }}
+
+ } @empty { +

+ Nothing there +

+ } + } @placeholder (minimum 0.4s) { +
+ +
+ } +
+ } +
+ } +
+
+ +@if (isMapFullscreen) { +
+ +
+ +
+ +
+} +} @else { +
+
+

Trip not found

+

The requested Trip does not exist

+
+ +
+ +
+ + +
+} +} \ No newline at end of file diff --git a/src/src/app/components/shared-trip/shared-trip.component.scss b/src/src/app/components/shared-trip/shared-trip.component.scss new file mode 100644 index 0000000..f22b193 --- /dev/null +++ b/src/src/app/components/shared-trip/shared-trip.component.scss @@ -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; +} diff --git a/src/src/app/components/shared-trip/shared-trip.component.ts b/src/src/app/components/shared-trip/shared-trip.component.ts new file mode 100644 index 0000000..762daeb --- /dev/null +++ b/src/src/app/components/shared-trip/shared-trip.component.ts @@ -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; + 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(); + placesUsedInTable = new Set(); + + 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"); + } +} diff --git a/src/src/app/components/trip/trip.component.html b/src/src/app/components/trip/trip.component.html index 708a125..e44c4f7 100644 --- a/src/src/app/components/trip/trip.component.html +++ b/src/src/app/components/trip/trip.component.html @@ -15,10 +15,14 @@
@if (!trip?.archived) {
@@ -492,4 +496,34 @@
-} \ No newline at end of file +} + + + @if (shareDialogVisible) { + + @if (trip?.shared) { +
+ {{ trip?.name }} is shared on this link: + +
+ + + {{ (tripSharedURL$ | async) || '' }} + + +
+
+ } @else { +
+ {{ trip?.name }} is not currently shared. + +
+ } +
+ } + +
+ +
+
\ No newline at end of file diff --git a/src/src/app/components/trip/trip.component.ts b/src/src/app/components/trip/trip.component.ts index d6067bf..557423e 100644 --- a/src/src/app/components/trip/trip.component.ts +++ b/src/src/app/components/trip/trip.component.ts @@ -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; + tripSharedURL$?: Observable; 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; + }, + }); + }, + }); + } } diff --git a/src/src/app/services/api.service.ts b/src/src/app/services/api.service.ts index 49998e6..0a5ff99 100644 --- a/src/src/app/services/api.service.ts +++ b/src/src/app/services/api.service.ts @@ -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 { + return this.httpClient.get( + `${this.apiBaseUrl}/trips/shared/${token}`, + { headers: NO_AUTH_HEADER }, + ); + } + + getSharedTripURL(trip_id: number): Observable { + return this.httpClient + .get(`${this.apiBaseUrl}/trips/${trip_id}/share`) + .pipe( + map((t) => t.url), + shareReplay(), + ); + } + + createSharedTrip(trip_id: number): Observable { + return this.httpClient + .post(`${this.apiBaseUrl}/trips/${trip_id}/share`, {}) + .pipe(map((t) => t.url)); + } + + deleteSharedTrip(trip_id: number): Observable { + return this.httpClient.delete( + `${this.apiBaseUrl}/trips/${trip_id}/share`, + ); + } + checkVersion(): Observable { return this.httpClient.get( `${this.apiBaseUrl}/settings/checkversion`, diff --git a/src/src/app/services/interceptor.service.ts b/src/src/app/services/interceptor.service.ts index 57fa475..b4aaa92 100644 --- a/src/src/app/services/interceptor.service.ts +++ b/src/src/app/services/interceptor.service.ts @@ -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: { diff --git a/src/src/app/types/trip.ts b/src/src/app/types/trip.ts index 5564c98..539ce77 100644 --- a/src/src/app/types/trip.ts +++ b/src/src/app/types/trip.ts @@ -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; +}