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'}}
+
+
+
+
+

+
itskovacs/trip
+
+
+ {{
+ (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) {
+
+ }
+
+ @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) {
-}
\ 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;
+}