🎨 Prettier setup

This commit is contained in:
itskovacs 2025-10-10 18:02:26 +02:00
parent aa17b78a40
commit 42654fb9a1
41 changed files with 1551 additions and 2103 deletions

View File

@ -1 +1,9 @@
{} {
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all",
"bracketSameLine": true,
"arrowParens": "always",
"semi": true,
"endOfLine": "lf"
}

View File

@ -1,18 +1,14 @@
import { import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core';
ApplicationConfig, import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
provideZoneChangeDetection, import { provideRouter } from '@angular/router';
isDevMode, import { routes } from './app.routes';
} from "@angular/core"; import { providePrimeNG } from 'primeng/config';
import { provideAnimationsAsync } from "@angular/platform-browser/animations/async"; import { TripThemePreset } from '../mytheme';
import { provideRouter } from "@angular/router"; import { MessageService } from 'primeng/api';
import { routes } from "./app.routes"; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { providePrimeNG } from "primeng/config"; import { Interceptor } from './services/interceptor.service';
import { TripThemePreset } from "../mytheme"; import { DialogService } from 'primeng/dynamicdialog';
import { MessageService } from "primeng/api"; import { provideServiceWorker } from '@angular/service-worker';
import { provideHttpClient, withInterceptors } from "@angular/common/http";
import { Interceptor } from "./services/interceptor.service";
import { DialogService } from "primeng/dynamicdialog";
import { provideServiceWorker } from "@angular/service-worker";
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@ -27,19 +23,19 @@ export const appConfig: ApplicationConfig = {
theme: { theme: {
preset: TripThemePreset, preset: TripThemePreset,
options: { options: {
darkModeSelector: ".dark", darkModeSelector: '.dark',
cssLayer: { cssLayer: {
name: "primeng", name: 'primeng',
order: "tailwind, primeng", order: 'tailwind, primeng',
}, },
}, },
}, },
}), }),
MessageService, MessageService,
DialogService, DialogService,
provideServiceWorker("ngsw-worker.js", { provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),
registrationStrategy: "registerWhenStable:30000", registrationStrategy: 'registerWhenStable:30000',
}), }),
], ],
}; };

View File

@ -1,62 +1,62 @@
import { Routes } from "@angular/router"; import { Routes } from '@angular/router';
import { AuthComponent } from "./components/auth/auth.component"; import { AuthComponent } from './components/auth/auth.component';
import { DashboardComponent } from "./components/dashboard/dashboard.component"; import { DashboardComponent } from './components/dashboard/dashboard.component';
import { AuthGuard } from "./services/auth.guard"; import { AuthGuard } from './services/auth.guard';
import { TripComponent } from "./components/trip/trip.component"; import { TripComponent } from './components/trip/trip.component';
import { TripsComponent } from "./components/trips/trips.component"; import { TripsComponent } from './components/trips/trips.component';
import { SharedTripComponent } from "./components/shared-trip/shared-trip.component"; import { SharedTripComponent } from './components/shared-trip/shared-trip.component';
export const routes: Routes = [ export const routes: Routes = [
{ {
path: "auth", path: 'auth',
pathMatch: "full", pathMatch: 'full',
component: AuthComponent, component: AuthComponent,
title: "TRIP - Authentication", title: 'TRIP - Authentication',
}, },
{ {
path: "s", path: 's',
children: [ children: [
{ {
path: "t/:token", path: 't/:token',
component: SharedTripComponent, component: SharedTripComponent,
title: "TRIP - Shared Trip", title: 'TRIP - Shared Trip',
}, },
{ path: "**", redirectTo: "/home", pathMatch: "full" }, { path: '**', redirectTo: '/home', pathMatch: 'full' },
], ],
}, },
{ {
path: "", path: '',
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
{ {
path: "home", path: 'home',
component: DashboardComponent, component: DashboardComponent,
title: "TRIP - Map", title: 'TRIP - Map',
}, },
{ {
path: "trips", path: 'trips',
children: [ children: [
{ {
path: "", path: '',
component: TripsComponent, component: TripsComponent,
title: "TRIP - Trips", title: 'TRIP - Trips',
}, },
{ {
path: ":id", path: ':id',
component: TripComponent, component: TripComponent,
title: "TRIP - Trip", title: 'TRIP - Trip',
}, },
], ],
}, },
{ path: "**", redirectTo: "/home", pathMatch: "full" }, { path: '**', redirectTo: '/home', pathMatch: 'full' },
], ],
}, },
{ path: "**", redirectTo: "/", pathMatch: "full" }, { path: '**', redirectTo: '/', pathMatch: 'full' },
]; ];

View File

@ -1,4 +1,4 @@
.cover-auth { .cover-auth {
background: url("/cover.webp"); background: url('/cover.webp');
background-size: cover; background-size: cover;
} }

View File

@ -1,24 +1,19 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from '@angular/core';
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from 'primeng/floatlabel';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ActivatedRoute, Router } from '@angular/router';
FormGroup, import { InputTextModule } from 'primeng/inputtext';
ReactiveFormsModule, import { ButtonModule } from 'primeng/button';
Validators, import { FocusTrapModule } from 'primeng/focustrap';
} from "@angular/forms"; import { AuthParams, AuthService } from '../../services/auth.service';
import { ActivatedRoute, Router } from "@angular/router"; import { MessageModule } from 'primeng/message';
import { InputTextModule } from "primeng/inputtext"; import { HttpErrorResponse } from '@angular/common/http';
import { ButtonModule } from "primeng/button"; import { SkeletonModule } from 'primeng/skeleton';
import { FocusTrapModule } from "primeng/focustrap"; import { take } from 'rxjs';
import { AuthParams, AuthService } from "../../services/auth.service";
import { MessageModule } from "primeng/message";
import { HttpErrorResponse } from "@angular/common/http";
import { SkeletonModule } from "primeng/skeleton";
import { take } from "rxjs";
@Component({ @Component({
selector: "app-auth", selector: 'app-auth',
standalone: true, standalone: true,
imports: [ imports: [
FloatLabelModule, FloatLabelModule,
@ -29,8 +24,8 @@ import { take } from "rxjs";
FocusTrapModule, FocusTrapModule,
MessageModule, MessageModule,
], ],
templateUrl: "./auth.component.html", templateUrl: './auth.component.html',
styleUrl: "./auth.component.scss", styleUrl: './auth.component.scss',
}) })
export class AuthComponent implements OnInit { export class AuthComponent implements OnInit {
readonly redirectURL: string; readonly redirectURL: string;
@ -45,31 +40,29 @@ export class AuthComponent implements OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
private fb: FormBuilder, private fb: FormBuilder,
) { ) {
this.redirectURL = this.redirectURL = this.route.snapshot.queryParams['redirectURL'] || '/home';
this.route.snapshot.queryParams["redirectURL"] || "/home";
this.authForm = this.fb.group({ this.authForm = this.fb.group({
username: ["", { validators: Validators.required }], username: ['', { validators: Validators.required }],
password: ["", { validators: Validators.required }], password: ['', { validators: Validators.required }],
}); });
} }
ngOnInit(): void { ngOnInit(): void {
this.route.queryParams.pipe(take(1)).subscribe((params) => { this.route.queryParams.pipe(take(1)).subscribe((params) => {
const code = params["code"]; const code = params['code'];
const state = params["state"]; const state = params['state'];
if (code && state) { if (code && state) {
this.authService.oidcLogin(code, state).subscribe({ this.authService.oidcLogin(code, state).subscribe({
next: (data) => { next: (data) => {
if (!data.access_token) { if (!data.access_token) {
this.error = "Authentication failed"; this.error = 'Authentication failed';
return; return;
} }
this.router.navigateByUrl(this.redirectURL); this.router.navigateByUrl(this.redirectURL);
}, },
error: (err: HttpErrorResponse) => { error: (err: HttpErrorResponse) => {
this.error = this.error = err.error.detail || 'Login failed, check console for details';
err.error.detail || "Login failed, check console for details";
}, },
}); });
} else { } else {
@ -92,9 +85,7 @@ export class AuthComponent implements OnInit {
}, },
error: (err: HttpErrorResponse) => { error: (err: HttpErrorResponse) => {
this.authForm.reset(); this.authForm.reset();
this.error = this.error = err.error.detail || 'Registration failed, check console for details';
err.error.detail ||
"Registration failed, check console for details";
}, },
}); });
} }
@ -109,7 +100,7 @@ export class AuthComponent implements OnInit {
this.authService.login(this.authForm.value).subscribe({ this.authService.login(this.authForm.value).subscribe({
next: (data) => { next: (data) => {
if (!data.access_token) { if (!data.access_token) {
this.error = "Authentication failed"; this.error = 'Authentication failed';
return; return;
} }
this.router.navigateByUrl(this.redirectURL); this.router.navigateByUrl(this.redirectURL);

View File

@ -1,48 +1,36 @@
import { AfterViewInit, Component, OnInit } from "@angular/core"; import { AfterViewInit, Component, OnInit } from '@angular/core';
import { combineLatest, debounceTime, take, tap } from "rxjs"; import { combineLatest, debounceTime, take, tap } from 'rxjs';
import { Place, Category } from "../../types/poi"; import { Place, Category } from '../../types/poi';
import { ApiService } from "../../services/api.service"; import { ApiService } from '../../services/api.service';
import { PlaceBoxComponent } from "../../shared/place-box/place-box.component"; import { PlaceBoxComponent } from '../../shared/place-box/place-box.component';
import * as L from "leaflet"; import * as L from 'leaflet';
import "leaflet.markercluster"; import 'leaflet.markercluster';
import "leaflet-contextmenu"; import 'leaflet-contextmenu';
import { import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormControl, import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
FormGroup, import { PlaceCreateModalComponent } from '../../modals/place-create-modal/place-create-modal.component';
FormsModule, import { InputTextModule } from 'primeng/inputtext';
ReactiveFormsModule, import { SkeletonModule } from 'primeng/skeleton';
Validators, import { TabsModule } from 'primeng/tabs';
} from "@angular/forms"; import { ToggleSwitchModule } from 'primeng/toggleswitch';
import { ButtonModule } from "primeng/button"; import { FloatLabelModule } from 'primeng/floatlabel';
import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog"; import { BatchCreateModalComponent } from '../../modals/batch-create-modal/batch-create-modal.component';
import { PlaceCreateModalComponent } from "../../modals/place-create-modal/place-create-modal.component"; import { UtilsService } from '../../services/utils.service';
import { InputTextModule } from "primeng/inputtext"; import { Info } from '../../types/info';
import { SkeletonModule } from "primeng/skeleton"; import { createMap, placeToMarker, createClusterGroup, gpxToPolyline } from '../../shared/map';
import { TabsModule } from "primeng/tabs"; import { Router } from '@angular/router';
import { ToggleSwitchModule } from "primeng/toggleswitch"; import { SelectModule } from 'primeng/select';
import { FloatLabelModule } from "primeng/floatlabel"; import { MultiSelectModule } from 'primeng/multiselect';
import { BatchCreateModalComponent } from "../../modals/batch-create-modal/batch-create-modal.component"; import { TooltipModule } from 'primeng/tooltip';
import { UtilsService } from "../../services/utils.service"; import { Settings } from '../../types/settings';
import { Info } from "../../types/info"; import { SelectItemGroup } from 'primeng/api';
import { import { YesNoModalComponent } from '../../modals/yes-no-modal/yes-no-modal.component';
createMap, import { CategoryCreateModalComponent } from '../../modals/category-create-modal/category-create-modal.component';
placeToMarker, import { AuthService } from '../../services/auth.service';
createClusterGroup, import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
gpxToPolyline, import { PlaceGPXComponent } from '../../shared/place-gpx/place-gpx.component';
} from "../../shared/map"; import { CommonModule } from '@angular/common';
import { Router } from "@angular/router";
import { SelectModule } from "primeng/select";
import { MultiSelectModule } from "primeng/multiselect";
import { TooltipModule } from "primeng/tooltip";
import { Settings } from "../../types/settings";
import { SelectItemGroup } from "primeng/api";
import { YesNoModalComponent } from "../../modals/yes-no-modal/yes-no-modal.component";
import { CategoryCreateModalComponent } from "../../modals/category-create-modal/category-create-modal.component";
import { AuthService } from "../../services/auth.service";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { PlaceGPXComponent } from "../../shared/place-gpx/place-gpx.component";
import { CommonModule } from "@angular/common";
export interface ContextMenuItem { export interface ContextMenuItem {
text: string; text: string;
@ -60,7 +48,7 @@ export interface MarkerOptions extends L.MarkerOptions {
} }
@Component({ @Component({
selector: "app-dashboard", selector: 'app-dashboard',
standalone: true, standalone: true,
imports: [ imports: [
PlaceBoxComponent, PlaceBoxComponent,
@ -78,11 +66,11 @@ export interface MarkerOptions extends L.MarkerOptions {
ButtonModule, ButtonModule,
CommonModule, CommonModule,
], ],
templateUrl: "./dashboard.component.html", templateUrl: './dashboard.component.html',
styleUrls: ["./dashboard.component.scss"], styleUrls: ['./dashboard.component.scss'],
}) })
export class DashboardComponent implements OnInit, AfterViewInit { export class DashboardComponent implements OnInit, AfterViewInit {
searchInput = new FormControl(""); searchInput = new FormControl('');
info?: Info; info?: Info;
isLowNet = false; isLowNet = false;
isDarkMode = false; isDarkMode = false;
@ -123,34 +111,27 @@ export class DashboardComponent implements OnInit, AfterViewInit {
) { ) {
this.settingsForm = this.fb.group({ this.settingsForm = this.fb.group({
map_lat: [ map_lat: [
"", '',
{ {
validators: [ validators: [Validators.required, Validators.pattern('-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)')],
Validators.required,
Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"),
],
}, },
], ],
map_lng: [ map_lng: [
"", '',
{ {
validators: [ validators: [
Validators.required, Validators.required,
Validators.pattern( Validators.pattern('-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)'),
"-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)",
),
], ],
}, },
], ],
currency: ["", Validators.required], currency: ['', Validators.required],
do_not_display: [], do_not_display: [],
tile_layer: ["", Validators.required], tile_layer: ['', Validators.required],
}); });
// HACK: Subscribe in constructor for takeUntilDestroyed // HACK: Subscribe in constructor for takeUntilDestroyed
this.searchInput.valueChanges this.searchInput.valueChanges.pipe(debounceTime(200), takeUntilDestroyed()).subscribe({
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe({
next: () => this.setVisibleMarkers(), next: () => this.setVisibleMarkers(),
}); });
} }
@ -195,27 +176,24 @@ export class DashboardComponent implements OnInit, AfterViewInit {
initMap(): void { initMap(): void {
if (!this.settings) return; if (!this.settings) return;
const isTouch = "ontouchstart" in window; const isTouch = 'ontouchstart' in window;
const contentMenuItems = [ const contentMenuItems = [
{ {
text: "Add Point of Interest", text: 'Add Point of Interest',
icon: "add-location.png", icon: 'add-location.png',
callback: (e: any) => { callback: (e: any) => {
this.addPlaceModal(e); this.addPlaceModal(e);
}, },
}, },
]; ];
this.map = createMap( this.map = createMap(isTouch ? [] : contentMenuItems, this.settings?.tile_layer);
isTouch ? [] : contentMenuItems,
this.settings?.tile_layer,
);
if (isTouch) { if (isTouch) {
this.map.on("contextmenu", (e: any) => { this.map.on('contextmenu', (e: any) => {
this.addPlaceModal(e); this.addPlaceModal(e);
}); });
} }
this.map.setView(L.latLng(this.settings.map_lat, this.settings.map_lng)); this.map.setView(L.latLng(this.settings.map_lat, this.settings.map_lng));
this.map.on("moveend zoomend", () => this.setVisibleMarkers()); this.map.on('moveend zoomend', () => this.setVisibleMarkers());
this.markerClusterGroup = createClusterGroup().addTo(this.map); this.markerClusterGroup = createClusterGroup().addTo(this.map);
} }
@ -223,30 +201,22 @@ export class DashboardComponent implements OnInit, AfterViewInit {
if (!this.viewMarkersList || !this.map) return; if (!this.viewMarkersList || !this.map) return;
const bounds = this.map.getBounds(); const bounds = this.map.getBounds();
this.visiblePlaces = this.filteredPlaces.filter((p) => this.visiblePlaces = this.filteredPlaces.filter((p) => bounds.contains([p.lat, p.lng]));
bounds.contains([p.lat, p.lng]),
);
const searchValue = this.searchInput.value?.toLowerCase() ?? ""; const searchValue = this.searchInput.value?.toLowerCase() ?? '';
if (searchValue) if (searchValue)
this.visiblePlaces = this.visiblePlaces.filter( this.visiblePlaces = this.visiblePlaces.filter(
(p) => (p) => p.name.toLowerCase().includes(searchValue) || p.description?.toLowerCase().includes(searchValue),
p.name.toLowerCase().includes(searchValue) ||
p.description?.toLowerCase().includes(searchValue),
); );
this.visiblePlaces.sort((a, b) => this.visiblePlaces.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} }
resetFilters() { resetFilters() {
this.filter_display_visited = false; this.filter_display_visited = false;
this.filter_display_favorite_only = false; this.filter_display_favorite_only = false;
this.activeCategories = new Set(this.categories.map((c) => c.name)); this.activeCategories = new Set(this.categories.map((c) => c.name));
this.settings?.do_not_display.forEach((c) => this.settings?.do_not_display.forEach((c) => this.activeCategories.delete(c));
this.activeCategories.delete(c),
);
this.updateMarkersAndClusters(); this.updateMarkersAndClusters();
if (this.viewMarkersList) this.setVisibleMarkers(); if (this.viewMarkersList) this.setVisibleMarkers();
} }
@ -278,47 +248,38 @@ export class DashboardComponent implements OnInit, AfterViewInit {
} }
_placeToMarker(place: Place): L.Marker { _placeToMarker(place: Place): L.Marker {
const marker = placeToMarker( const marker = placeToMarker(place, this.isLowNet, place.visited, this.isGpxInPlaceMode);
place,
this.isLowNet,
place.visited,
this.isGpxInPlaceMode,
);
marker marker
.on("click", (e) => { .on('click', (e) => {
this.selectedPlace = { ...place }; this.selectedPlace = { ...place };
let toView = { ...e.latlng }; let toView = { ...e.latlng };
if ("ontouchstart" in window) toView.lat = toView.lat - 0.0175; if ('ontouchstart' in window) toView.lat = toView.lat - 0.0175;
marker.closeTooltip(); marker.closeTooltip();
this.map?.setView(toView); this.map?.setView(toView);
}) })
.on("contextmenu", () => { .on('contextmenu', () => {
if (this.map && (this.map as any).contextmenu) if (this.map && (this.map as any).contextmenu) (this.map as any).contextmenu.hide();
(this.map as any).contextmenu.hide();
}); });
return marker; return marker;
} }
addPlaceModal(e?: any): void { addPlaceModal(e?: any): void {
const opts = e ? { data: { place: e.latlng } } : {}; const opts = e ? { data: { place: e.latlng } } : {};
const modal: DynamicDialogRef = this.dialogService.open( const modal: DynamicDialogRef = this.dialogService.open(PlaceCreateModalComponent, {
PlaceCreateModalComponent, header: 'Create Place',
{
header: "Create Place",
modal: true, modal: true,
appendTo: "body", appendTo: 'body',
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
width: "55vw", width: '55vw',
breakpoints: { breakpoints: {
"1920px": "70vw", '1920px': '70vw',
"1260px": "90vw", '1260px': '90vw',
}, },
...opts, ...opts,
}, })!;
)!;
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
next: (place: Place | null) => { next: (place: Place | null) => {
@ -329,9 +290,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.pipe(take(1)) .pipe(take(1))
.subscribe({ .subscribe({
next: (place: Place) => { next: (place: Place) => {
this.places = [...this.places, place].sort((a, b) => this.places = [...this.places, place].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
setTimeout(() => { setTimeout(() => {
this.updateMarkersAndClusters(); this.updateMarkersAndClusters();
}, 10); }, 10);
@ -342,21 +301,18 @@ export class DashboardComponent implements OnInit, AfterViewInit {
} }
batchAddModal() { batchAddModal() {
const modal: DynamicDialogRef = this.dialogService.open( const modal: DynamicDialogRef = this.dialogService.open(BatchCreateModalComponent, {
BatchCreateModalComponent, header: 'Create Places',
{
header: "Create Places",
modal: true, modal: true,
appendTo: "body", appendTo: 'body',
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
width: "55vw", width: '55vw',
breakpoints: { breakpoints: {
"1920px": "70vw", '1920px': '70vw',
"1260px": "90vw", '1260px': '90vw',
}, },
}, })!;
)!;
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
next: (places: string | null) => { next: (places: string | null) => {
@ -367,7 +323,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
parsedPlaces = JSON.parse(places); parsedPlaces = JSON.parse(places);
if (!Array.isArray(parsedPlaces)) throw new Error(); if (!Array.isArray(parsedPlaces)) throw new Error();
} catch (err) { } catch (err) {
this.utilsService.toast("error", "Error", "Content looks invalid"); this.utilsService.toast('error', 'Error', 'Content looks invalid');
return; return;
} }
@ -375,9 +331,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.postPlaces(parsedPlaces) .postPlaces(parsedPlaces)
.pipe(take(1)) .pipe(take(1))
.subscribe((places) => { .subscribe((places) => {
this.places = [...this.places, ...places].sort((a, b) => this.places = [...this.places, ...places].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
setTimeout(() => { setTimeout(() => {
this.updateMarkersAndClusters(); this.updateMarkersAndClusters();
}, 10); }, 10);
@ -388,7 +342,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
resetHoverPlace() { resetHoverPlace() {
if (!this.hoveredElement) return; if (!this.hoveredElement) return;
this.hoveredElement.classList.remove("list-hover"); this.hoveredElement.classList.remove('list-hover');
this.hoveredElement = undefined; this.hoveredElement = undefined;
} }
@ -405,17 +359,15 @@ export class DashboardComponent implements OnInit, AfterViewInit {
if (markerElement) { if (markerElement) {
// marker, not clustered // marker, not clustered
markerElement.classList.add("list-hover"); markerElement.classList.add('list-hover');
this.hoveredElement = markerElement; this.hoveredElement = markerElement;
} else { } else {
// marker is clustered // marker is clustered
const parentCluster = (this.markerClusterGroup as any).getVisibleParent( const parentCluster = (this.markerClusterGroup as any).getVisibleParent(marker);
marker,
);
if (parentCluster) { if (parentCluster) {
const clusterEl = parentCluster.getElement(); const clusterEl = parentCluster.getElement();
if (clusterEl) { if (clusterEl) {
clusterEl.classList.add("list-hover"); clusterEl.classList.add('list-hover');
this.hoveredElement = clusterEl; this.hoveredElement = clusterEl;
} }
} }
@ -431,11 +383,8 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.pipe(take(1)) .pipe(take(1))
.subscribe({ .subscribe({
next: () => { next: () => {
const idx = this.places.findIndex( const idx = this.places.findIndex((p) => p.id === this.selectedPlace!.id);
(p) => p.id === this.selectedPlace!.id, if (idx !== -1) this.places[idx] = { ...this.places[idx], favorite: favoriteBool };
);
if (idx !== -1)
this.places[idx] = { ...this.places[idx], favorite: favoriteBool };
this.selectedPlace = { ...this.places[idx] }; this.selectedPlace = { ...this.places[idx] };
this.updateMarkersAndClusters(); this.updateMarkersAndClusters();
}, },
@ -451,11 +400,8 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.pipe(take(1)) .pipe(take(1))
.subscribe({ .subscribe({
next: () => { next: () => {
const idx = this.places.findIndex( const idx = this.places.findIndex((p) => p.id === this.selectedPlace!.id);
(p) => p.id === this.selectedPlace!.id, if (idx !== -1) this.places[idx] = { ...this.places[idx], visited: visitedBool };
);
if (idx !== -1)
this.places[idx] = { ...this.places[idx], visited: visitedBool };
this.selectedPlace = { ...this.places[idx] }; this.selectedPlace = { ...this.places[idx] };
this.updateMarkersAndClusters(); this.updateMarkersAndClusters();
}, },
@ -466,12 +412,12 @@ export class DashboardComponent implements OnInit, AfterViewInit {
if (!this.selectedPlace) return; if (!this.selectedPlace) return;
const modal = this.dialogService.open(YesNoModalComponent, { const modal = this.dialogService.open(YesNoModalComponent, {
header: "Confirm deletion", header: 'Confirm deletion',
modal: true, modal: true,
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
breakpoints: { breakpoints: {
"640px": "90vw", '640px': '90vw',
}, },
data: `Delete ${this.selectedPlace.name} ?`, data: `Delete ${this.selectedPlace.name} ?`,
})!; })!;
@ -484,9 +430,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.pipe(take(1)) .pipe(take(1))
.subscribe({ .subscribe({
next: () => { next: () => {
this.places = this.places.filter( this.places = this.places.filter((p) => p.id !== this.selectedPlace!.id);
(p) => p.id !== this.selectedPlace!.id,
);
this.closePlaceBox(); this.closePlaceBox();
this.updateMarkersAndClusters(); this.updateMarkersAndClusters();
if (this.viewMarkersList) this.setVisibleMarkers(); if (this.viewMarkersList) this.setVisibleMarkers();
@ -500,18 +444,16 @@ export class DashboardComponent implements OnInit, AfterViewInit {
if (!this.selectedPlace && !p) return; if (!this.selectedPlace && !p) return;
const _placeToEdit: Place = { ...(this.selectedPlace ?? p)! }; const _placeToEdit: Place = { ...(this.selectedPlace ?? p)! };
const modal: DynamicDialogRef = this.dialogService.open( const modal: DynamicDialogRef = this.dialogService.open(PlaceCreateModalComponent, {
PlaceCreateModalComponent, header: 'Edit Place',
{
header: "Edit Place",
modal: true, modal: true,
appendTo: "body", appendTo: 'body',
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
width: "55vw", width: '55vw',
breakpoints: { breakpoints: {
"1920px": "70vw", '1920px': '70vw',
"1260px": "90vw", '1260px': '90vw',
}, },
data: { data: {
place: { place: {
@ -519,8 +461,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
category: _placeToEdit.category.id, category: _placeToEdit.category.id,
}, },
}, },
}, })!;
)!;
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
next: (place: Place | null) => { next: (place: Place | null) => {
@ -534,9 +475,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
const places = [...this.places]; const places = [...this.places];
const idx = places.findIndex((p) => p.id == place.id); const idx = places.findIndex((p) => p.id == place.id);
if (idx > -1) places.splice(idx, 1, place); if (idx > -1) places.splice(idx, 1, place);
places.sort((a, b) => places.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
this.places = places; this.places = places;
if (this.selectedPlace) this.selectedPlace = { ...place }; if (this.selectedPlace) this.selectedPlace = { ...place };
setTimeout(() => { setTimeout(() => {
@ -551,21 +490,20 @@ export class DashboardComponent implements OnInit, AfterViewInit {
displayGPXOnMap(gpx: string) { displayGPXOnMap(gpx: string) {
if (!this.map || !this.selectedPlace) return; if (!this.map || !this.selectedPlace) return;
if (!this.gpxLayerGroup) if (!this.gpxLayerGroup) this.gpxLayerGroup = L.layerGroup().addTo(this.map);
this.gpxLayerGroup = L.layerGroup().addTo(this.map);
this.gpxLayerGroup.clearLayers(); this.gpxLayerGroup.clearLayers();
try { try {
const gpxPolyline = gpxToPolyline(gpx); const gpxPolyline = gpxToPolyline(gpx);
const selectedPlaceWithGPX = { ...this.selectedPlace, gpx }; const selectedPlaceWithGPX = { ...this.selectedPlace, gpx };
gpxPolyline.on("click", () => { gpxPolyline.on('click', () => {
this.selectedGPX = selectedPlaceWithGPX; this.selectedGPX = selectedPlaceWithGPX;
}); });
this.gpxLayerGroup?.addLayer(gpxPolyline); this.gpxLayerGroup?.addLayer(gpxPolyline);
this.map.fitBounds(gpxPolyline.getBounds(), { padding: [20, 20] }); this.map.fitBounds(gpxPolyline.getBounds(), { padding: [20, 20] });
} catch { } catch {
this.utilsService.toast("error", "Error", "Couldn't parse GPX data"); this.utilsService.toast('error', 'Error', "Couldn't parse GPX data");
} }
this.closePlaceBox(); this.closePlaceBox();
} }
@ -578,11 +516,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.subscribe({ .subscribe({
next: (p) => { next: (p) => {
if (!p.gpx) { if (!p.gpx) {
this.utilsService.toast( this.utilsService.toast('error', 'Error', "Couldn't retrieve GPX data");
"error",
"Error",
"Couldn't retrieve GPX data",
);
return; return;
} }
this.displayGPXOnMap(p.gpx); this.displayGPXOnMap(p.gpx);
@ -597,7 +531,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
this.settingsForm.reset(this.settings); this.settingsForm.reset(this.settings);
this.doNotDisplayOptions = [ this.doNotDisplayOptions = [
{ {
label: "Categories", label: 'Categories',
items: this.categories.map((c) => ({ label: c.name, value: c.name })), items: this.categories.map((c) => ({ label: c.name, value: c.name })),
}, },
]; ];
@ -610,13 +544,13 @@ export class DashboardComponent implements OnInit, AfterViewInit {
toggleMarkersList() { toggleMarkersList() {
this.viewMarkersList = !this.viewMarkersList; this.viewMarkersList = !this.viewMarkersList;
this.viewMarkersListSearch = false; this.viewMarkersListSearch = false;
this.searchInput.setValue(""); this.searchInput.setValue('');
if (this.viewMarkersList) this.setVisibleMarkers(); if (this.viewMarkersList) this.setVisibleMarkers();
} }
toggleMarkersListSearch() { toggleMarkersListSearch() {
this.viewMarkersListSearch = !this.viewMarkersListSearch; this.viewMarkersListSearch = !this.viewMarkersListSearch;
if (this.viewMarkersListSearch) this.searchInput.setValue(""); if (this.viewMarkersListSearch) this.searchInput.setValue('');
} }
setMapCenterToCurrent() { setMapCenterToCurrent() {
@ -631,7 +565,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
if (!input.files?.length) return; if (!input.files?.length) return;
const formdata = new FormData(); const formdata = new FormData();
formdata.append("file", input.files[0]); formdata.append('file', input.files[0]);
this.apiService this.apiService
.settingsUserImport(formdata) .settingsUserImport(formdata)
@ -667,12 +601,12 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.pipe(take(1)) .pipe(take(1))
.subscribe((resp: Object) => { .subscribe((resp: Object) => {
const dataBlob = new Blob([JSON.stringify(resp, null, 2)], { const dataBlob = new Blob([JSON.stringify(resp, null, 2)], {
type: "application/json", type: 'application/json',
}); });
const downloadURL = URL.createObjectURL(dataBlob); const downloadURL = URL.createObjectURL(dataBlob);
const link = document.createElement("a"); const link = document.createElement('a');
link.href = downloadURL; link.href = downloadURL;
link.download = `TRIP_backup_${new Date().toISOString().split("T")[0]}.json`; link.download = `TRIP_backup_${new Date().toISOString().split('T')[0]}.json`;
link.click(); link.click();
link.remove(); link.remove();
URL.revokeObjectURL(downloadURL); URL.revokeObjectURL(downloadURL);
@ -699,22 +633,19 @@ export class DashboardComponent implements OnInit, AfterViewInit {
} }
editCategory(c: Category) { editCategory(c: Category) {
const modal: DynamicDialogRef = this.dialogService.open( const modal: DynamicDialogRef = this.dialogService.open(CategoryCreateModalComponent, {
CategoryCreateModalComponent, header: 'Update Category',
{
header: "Update Category",
modal: true, modal: true,
appendTo: "body", appendTo: 'body',
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
data: { category: c }, data: { category: c },
width: "40vw", width: '40vw',
breakpoints: { breakpoints: {
"960px": "70vw", '960px': '70vw',
"640px": "90vw", '640px': '90vw',
}, },
}, })!;
)!;
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
next: (category: Category | null) => { next: (category: Category | null) => {
@ -725,17 +656,12 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.pipe(take(1)) .pipe(take(1))
.subscribe({ .subscribe({
next: (updated) => { next: (updated) => {
this.categories = this.categories.map((cat) => this.categories = this.categories.map((cat) => (cat.id === updated.id ? updated : cat));
cat.id === updated.id ? updated : cat,
);
this.sortCategories(); this.sortCategories();
this.activeCategories = new Set( this.activeCategories = new Set(this.categories.map((c) => c.name));
this.categories.map((c) => c.name),
);
this.places = this.places.map((p) => { this.places = this.places.map((p) => {
if (p.category.id == updated.id) if (p.category.id == updated.id) return { ...p, category: updated };
return { ...p, category: updated };
return p; return p;
}); });
setTimeout(() => { setTimeout(() => {
@ -748,21 +674,18 @@ export class DashboardComponent implements OnInit, AfterViewInit {
} }
addCategory() { addCategory() {
const modal: DynamicDialogRef = this.dialogService.open( const modal: DynamicDialogRef = this.dialogService.open(CategoryCreateModalComponent, {
CategoryCreateModalComponent, header: 'Create Category',
{
header: "Create Category",
modal: true, modal: true,
appendTo: "body", appendTo: 'body',
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
width: "40vw", width: '40vw',
breakpoints: { breakpoints: {
"960px": "70vw", '960px': '70vw',
"640px": "90vw", '640px': '90vw',
}, },
}, })!;
)!;
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
next: (category: Category | null) => { next: (category: Category | null) => {
@ -774,9 +697,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.subscribe({ .subscribe({
next: (category: Category) => { next: (category: Category) => {
this.categories.push(category); this.categories.push(category);
this.categories.sort((a, b) => this.categories.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
this.activeCategories.add(category.name); this.activeCategories.add(category.name);
}, },
}); });
@ -786,14 +707,14 @@ export class DashboardComponent implements OnInit, AfterViewInit {
deleteCategory(c_id: number) { deleteCategory(c_id: number) {
const modal = this.dialogService.open(YesNoModalComponent, { const modal = this.dialogService.open(YesNoModalComponent, {
header: "Confirm deletion", header: 'Confirm deletion',
modal: true, modal: true,
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
breakpoints: { breakpoints: {
"640px": "90vw", '640px': '90vw',
}, },
data: "Delete this category ?", data: 'Delete this category ?',
})!; })!;
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
@ -806,9 +727,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
next: () => { next: () => {
this.categories = this.categories.filter((c) => c.id !== c_id); this.categories = this.categories.filter((c) => c.id !== c_id);
this.activeCategories = new Set( this.activeCategories = new Set(this.categories.map((c) => c.name));
this.categories.map((c) => c.name),
);
}, },
}); });
}, },
@ -824,13 +743,11 @@ export class DashboardComponent implements OnInit, AfterViewInit {
} }
sortCategories() { sortCategories() {
this.categories = [...this.categories].sort((a, b) => this.categories = [...this.categories].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} }
navigateToTrips() { navigateToTrips() {
this.router.navigateByUrl("/trips"); this.router.navigateByUrl('/trips');
} }
logout() { logout() {
@ -849,7 +766,7 @@ export class DashboardComponent implements OnInit, AfterViewInit {
if (!this.selectedGPX?.gpx) return; if (!this.selectedGPX?.gpx) return;
const dataBlob = new Blob([this.selectedGPX.gpx]); const dataBlob = new Blob([this.selectedGPX.gpx]);
const downloadURL = URL.createObjectURL(dataBlob); const downloadURL = URL.createObjectURL(dataBlob);
const link = document.createElement("a"); const link = document.createElement('a');
link.href = downloadURL; link.href = downloadURL;
link.download = `TRIP_${this.selectedGPX.name}.gpx`; link.download = `TRIP_${this.selectedGPX.name}.gpx`;
link.click(); link.click();
@ -874,13 +791,8 @@ export class DashboardComponent implements OnInit, AfterViewInit {
.subscribe({ .subscribe({
next: (remote_version) => { next: (remote_version) => {
if (!remote_version) if (!remote_version)
this.utilsService.toast( this.utilsService.toast('success', 'Latest version', "You're running the latest version of TRIP");
"success", if (this.info && remote_version != this.info?.version) this.info.update = remote_version;
"Latest version",
"You're running the latest version of TRIP",
);
if (this.info && remote_version != this.info?.version)
this.info.update = remote_version;
}, },
}); });
} }

View File

@ -17,7 +17,7 @@
</div> </div>
<div class="hidden print:flex flex-col items-center"> <div class="hidden print:flex flex-col items-center">
<img src="favicon.png" class="size-20"> <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 class="flex gap-2 items-center text-xs text-gray-500"><i class="pi pi-github"></i>itskovacs/trip</div>
</div> </div>
<div class="flex items-center gap-2 print:hidden"> <div class="flex items-center gap-2 print:hidden">
@ -44,9 +44,11 @@
<p-button label="Expand" pTooltip="Expand table to full width" class="hidden lg:flex" icon="pi pi-arrows-h" <p-button label="Expand" pTooltip="Expand table to full width" class="hidden lg:flex" icon="pi pi-arrows-h"
(click)="isExpanded = !isExpanded" text /> (click)="isExpanded = !isExpanded" text />
<p-button [label]="tableExpandableMode ? 'Ungroup' : 'Group'" <p-button [label]="tableExpandableMode ? 'Ungroup' : 'Group'"
[pTooltip]="tableExpandableMode ? 'Switch table mode' : 'Switch table mode, allow column resizing'" [pTooltip]="tableExpandableMode ? 'Switch table mode' : 'Switch table mode, allow column resizing'" [icon]="
[icon]="tableExpandableMode ? 'pi pi-arrow-up-right-and-arrow-down-left-from-center' : 'pi pi-arrow-down-left-and-arrow-up-right-to-center'" tableExpandableMode
(click)="tableExpandableMode = !tableExpandableMode" text /> ? '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 />
<p-button label="GMaps" pTooltip="Open Google Maps directions" icon="pi pi-car" (click)="tripToNavigation()" <p-button label="GMaps" pTooltip="Open Google Maps directions" icon="pi pi-car" (click)="tripToNavigation()"
text /> text />
<p-button label="Highlight" pTooltip="Show itinerary on map" icon="pi pi-directions" <p-button label="Highlight" pTooltip="Show itinerary on map" icon="pi pi-directions"
@ -77,16 +79,33 @@
groupRowsBy="td_label" [resizableColumns]="tableExpandableMode"> groupRowsBy="td_label" [resizableColumns]="tableExpandableMode">
<ng-template #header> <ng-template #header>
<tr> <tr>
@if (!tableExpandableMode && tripTableSelectedColumns.includes('day')) {<th class="w-24" pResizableColumn>Day @if (!tableExpandableMode && tripTableSelectedColumns.includes('day')) {
</th>} <th class="w-24" pResizableColumn>Day</th>
@if (tripTableSelectedColumns.includes('time')) {<th class="w-12" pResizableColumn>Time</th>} }
@if (tripTableSelectedColumns.includes('text')) {<th pResizableColumn>Text</th>} @if (tripTableSelectedColumns.includes('time')) {
@if (tripTableSelectedColumns.includes('place')) {<th pResizableColumn>Place</th>} <th class="w-12" pResizableColumn>Time</th>
@if (tripTableSelectedColumns.includes('comment')) {<th pResizableColumn>Comment</th>} }
@if (tripTableSelectedColumns.includes('LatLng')) {<th class="w-12" pResizableColumn>LatLng</th>} @if (tripTableSelectedColumns.includes('text')) {
@if (tripTableSelectedColumns.includes('price')) {<th class="w-12" pResizableColumn>Price</th>} <th pResizableColumn>Text</th>
@if (tripTableSelectedColumns.includes('status')) {<th class="w-12" pResizableColumn>Status</th>} }
@if (tripTableSelectedColumns.includes('distance')) {<th pResizableColumn>Distance (km)</th>} @if (tripTableSelectedColumns.includes('place')) {
<th pResizableColumn>Place</th>
}
@if (tripTableSelectedColumns.includes('comment')) {
<th pResizableColumn>Comment</th>
}
@if (tripTableSelectedColumns.includes('LatLng')) {
<th class="w-12" pResizableColumn>LatLng</th>
}
@if (tripTableSelectedColumns.includes('price')) {
<th class="w-12" pResizableColumn>Price</th>
}
@if (tripTableSelectedColumns.includes('status')) {
<th class="w-12" pResizableColumn>Status</th>
}
@if (tripTableSelectedColumns.includes('distance')) {
<th pResizableColumn>Distance (km)</th>
}
</tr> </tr>
</ng-template> </ng-template>
@if (tableExpandableMode) { @if (tableExpandableMode) {
@ -95,9 +114,10 @@
<td colspan="8"> <td colspan="8">
<div class="flex items-center gap-2 w-full"> <div class="flex items-center gap-2 w-full">
<button type="button" pButton pRipple [pRowToggler]="tripitem" text rounded plain <button type="button" pButton pRipple [pRowToggler]="tripitem" text rounded plain
[icon]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"> [icon]="expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></button>
</button> <span class="font-bold w-xs max-w-xs min-w-0 inline-block truncate">{{
<span class="font-bold w-xs max-w-xs min-w-0 inline-block truncate">{{ tripitem.td_label }}</span> tripitem.td_label
}}</span>
<p-button class="ml-2" label="Highlight" pTooltip="Show itinerary on map" text icon="pi pi-directions" <p-button class="ml-2" label="Highlight" pTooltip="Show itinerary on map" text icon="pi pi-directions"
[severity]="tripMapAntLayerDayID == tripitem.day_id ? 'help' : 'primary'" [severity]="tripMapAntLayerDayID == tripitem.day_id ? 'help' : 'primary'"
@ -112,57 +132,87 @@
<ng-template #expandedrow let-tripitem> <ng-template #expandedrow let-tripitem>
<tr class="h-12 cursor-pointer" [class.font-bold]="selectedItem?.id === tripitem.id" <tr class="h-12 cursor-pointer" [class.font-bold]="selectedItem?.id === tripitem.id"
(click)="onRowClick(tripitem)"> (click)="onRowClick(tripitem)">
@if (tripTableSelectedColumns.includes('time')) {<td class="font-mono text-sm">{{ tripitem.time }}</td>} @if (tripTableSelectedColumns.includes('time')) {
@if (tripTableSelectedColumns.includes('text')) {<td class="relative"> <td class="font-mono text-sm">{{ tripitem.time }}</td>
}
@if (tripTableSelectedColumns.includes('text')) {
<td class="relative">
<div class="truncate"> <div class="truncate">
@if (tripitem.status) {<div class="block absolute top-3 left-1.5 size-2 rounded-full" @if (tripitem.status) {
[style.background]="tripitem.status.color"></div>} <div class="block absolute top-3 left-1.5 size-2 rounded-full" [style.background]="tripitem.status.color">
</div>
}
{{ tripitem.text }} {{ tripitem.text }}
</div> </div>
</td>} </td>
@if (tripTableSelectedColumns.includes('place')) {<td class="relative"> }
@if (tripTableSelectedColumns.includes('place')) {
<td class="relative">
@if (tripitem.place) { @if (tripitem.place) {
<div class="ml-7 print:ml-0 truncate print:whitespace-normal"> <div class="ml-7 print:ml-0 truncate print:whitespace-normal">
<img [src]="tripitem.place.image || tripitem.place.category.image" <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" /> {{ class="absolute left-0 top-1/2 -translate-y-1/2 w-9 rounded-full object-cover print:hidden" />
tripitem.place.name }} {{ tripitem.place.name }}
</div> </div>
} @else {-} } @else {
</td>} -
@if (tripTableSelectedColumns.includes('comment')) {<td class="relative"> }
</td>
}
@if (tripTableSelectedColumns.includes('comment')) {
<td class="relative">
@if (tripitem.image) { @if (tripitem.image) {
<div <div
class="ml-7 print:ml-0 truncate print:whitespace-normal line-clamp-1 whitespace-pre-line print:line-clamp-none"> class="ml-7 print:ml-0 truncate print:whitespace-normal line-clamp-1 whitespace-pre-line print:line-clamp-none">
<img [src]="tripitem.image" <img [src]="tripitem.image"
class="absolute left-0 top-1/2 -translate-y-1/2 size-9 rounded-full object-cover" /> {{ class="absolute left-0 top-1/2 -translate-y-1/2 size-9 rounded-full object-cover" />
tripitem.comment }} {{ tripitem.comment }}
</div> </div>
} @else { } @else {
<div class="line-clamp-1 whitespace-pre-line print:line-clamp-none"> <div class="line-clamp-1 whitespace-pre-line print:line-clamp-none">
{{ tripitem.comment || '-' }} {{ tripitem.comment || '-' }}
</div> </div>
} }
</td>} </td>
@if (tripTableSelectedColumns.includes('LatLng')) {<td class="font-mono text-sm"> }
@if (tripTableSelectedColumns.includes('LatLng')) {
<td class="font-mono text-sm">
<div class="print:max-w-full truncate"> <div class="print:max-w-full truncate">
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} } @if (tripitem.lat) {
@else {-} {{ tripitem.lat }}, {{ tripitem.lng }}
} @else {
-
}
</div> </div>
</td>} </td>
@if (tripTableSelectedColumns.includes('price')) {<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">{{ @if (tripTableSelectedColumns.includes('price')) {
tripitem.price }} @if (tripitem.price) { {{ trip.currency }} }</span>}</td>} <td class="truncate">
@if (tripTableSelectedColumns.includes('status')) {<td class="truncate">@if (tripitem.status) {<span @if (tripitem.price) {
[style.background]="tripitem.status.color+'1A'" [style.color]="tripitem.status.color" <span class="bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded">{{ tripitem.price }}
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{ @if (tripitem.price) {
tripitem.status.label }}</span>}</td>} {{ trip.currency }}
@if (tripTableSelectedColumns.includes('distance')) {<td class="text-sm"> }
</span>
}
</td>
}
@if (tripTableSelectedColumns.includes('status')) {
<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>
}
@if (tripTableSelectedColumns.includes('distance')) {
<td class="text-sm">
<div class="print:max-w-full truncate">{{ tripitem.distance || '-' }}</div> <div class="print:max-w-full truncate">{{ tripitem.distance || '-' }}</div>
</td>} </td>
}
</tr> </tr>
</ng-template> </ng-template>
} } @else {
@else {
<ng-template #body let-tripitem let-rowgroup="rowgroup" let-rowspan="rowspan"> <ng-template #body let-tripitem let-rowgroup="rowgroup" let-rowspan="rowspan">
<tr class="h-12 cursor-pointer" [class.font-bold]="selectedItem?.id === tripitem.id" <tr class="h-12 cursor-pointer" [class.font-bold]="selectedItem?.id === tripitem.id"
(click)="onRowClick(tripitem)"> (click)="onRowClick(tripitem)">
@ -173,15 +223,22 @@
<div class="truncate">{{ tripitem.td_label }}</div> <div class="truncate">{{ tripitem.td_label }}</div>
</td> </td>
} }
@if (tripTableSelectedColumns.includes('time')) {<td class="font-mono text-sm">{{ tripitem.time }}</td>} @if (tripTableSelectedColumns.includes('time')) {
@if (tripTableSelectedColumns.includes('text')) {<td class="relative max-w-60"> <td class="font-mono text-sm">{{ tripitem.time }}</td>
}
@if (tripTableSelectedColumns.includes('text')) {
<td class="relative max-w-60">
<div class="truncate"> <div class="truncate">
@if (tripitem.status) {<div class="block absolute top-3 left-1.5 size-2 rounded-full" @if (tripitem.status) {
[style.background]="tripitem.status.color"></div>} <div class="block absolute top-3 left-1.5 size-2 rounded-full" [style.background]="tripitem.status.color">
</div>
}
{{ tripitem.text }} {{ tripitem.text }}
</div> </div>
</td>} </td>
@if (tripTableSelectedColumns.includes('place')) {<td> }
@if (tripTableSelectedColumns.includes('place')) {
<td>
@if (tripitem.place) { @if (tripitem.place) {
<div [style.background]="tripitem.place.category.color + '1A'" <div [style.background]="tripitem.place.category.color + '1A'"
class="inline-flex items-center gap-2 text-gray-800 font-medium px-1 py-1 pr-3 rounded-full"> class="inline-flex items-center gap-2 text-gray-800 font-medium px-1 py-1 pr-3 rounded-full">
@ -190,38 +247,62 @@
</div> </div>
<span class="text-sm truncate min-w-0">{{ tripitem.place.name }}</span> <span class="text-sm truncate min-w-0">{{ tripitem.place.name }}</span>
</div> </div>
} @else {-} } @else {
</td>} -
@if (tripTableSelectedColumns.includes('comment')) {<td class="relative"> }
</td>
}
@if (tripTableSelectedColumns.includes('comment')) {
<td class="relative">
@if (tripitem.image) { @if (tripitem.image) {
<div <div
class="ml-7 print:ml-0 truncate print:whitespace-normal line-clamp-1 whitespace-pre-line print:line-clamp-none"> class="ml-7 print:ml-0 truncate print:whitespace-normal line-clamp-1 whitespace-pre-line print:line-clamp-none">
<img [src]="tripitem.image" <img [src]="tripitem.image"
class="absolute left-0 top-1/2 -translate-y-1/2 size-9 rounded-full object-cover print:hidden" /> {{ class="absolute left-0 top-1/2 -translate-y-1/2 size-9 rounded-full object-cover print:hidden" />
tripitem.comment }} {{ tripitem.comment }}
</div> </div>
} @else { } @else {
<div class="line-clamp-1 whitespace-pre-line print:line-clamp-none"> <div class="line-clamp-1 whitespace-pre-line print:line-clamp-none">
{{ tripitem.comment || '-' }} {{ tripitem.comment || '-' }}
</div> </div>
} }
</td>} </td>
@if (tripTableSelectedColumns.includes('LatLng')) {<td class="font-mono text-sm"> }
@if (tripTableSelectedColumns.includes('LatLng')) {
<td class="font-mono text-sm">
<div class="max-w-20 print:max-w-full truncate"> <div class="max-w-20 print:max-w-full truncate">
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} } @if (tripitem.lat) {
@else {-} {{ tripitem.lat }}, {{ tripitem.lng }}
} @else {
-
}
</div> </div>
</td>} </td>
@if (tripTableSelectedColumns.includes('price')) {<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">{{ @if (tripTableSelectedColumns.includes('price')) {
tripitem.price }} @if (tripitem.price) { {{ trip.currency }} }</span>}</td>} <td class="truncate">
@if (tripTableSelectedColumns.includes('status')) {<td class="truncate">@if (tripitem.status) {<span @if (tripitem.price) {
[style.background]="tripitem.status.color+'1A'" [style.color]="tripitem.status.color" <span class="bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded">{{ tripitem.price }}
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{ @if (tripitem.price) {
tripitem.status.label }}</span>}</td>} {{ trip.currency }}
@if (tripTableSelectedColumns.includes('distance')) {<td class="text-sm"> }
</span>
}
</td>
}
@if (tripTableSelectedColumns.includes('status')) {
<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>
}
@if (tripTableSelectedColumns.includes('distance')) {
<td class="text-sm">
<div class="print:max-w-full truncate">{{ tripitem.distance || '-' }}</div> <div class="print:max-w-full truncate">{{ tripitem.distance || '-' }}</div>
</td>} </td>
}
</tr> </tr>
</ng-template> </ng-template>
} }
@ -234,9 +315,7 @@
</h2> </h2>
</div> </div>
</div> </div>
<div class="hidden print:block text-center text-sm text-gray-500 mt-4"> <div class="hidden print:block text-center text-sm text-gray-500 mt-4">No Trip</div>
No Trip
</div>
} }
} @placeholder (minimum 0.4s) { } @placeholder (minimum 0.4s) {
<div class="h-[400px] w-full"> <div class="h-[400px] w-full">
@ -257,8 +336,7 @@
} }
<div class="flex items-center justify-between gap-2 w-full"> <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 class="text-start font-semibold mb-0 line-clamp-1 md:text-xl">{{ selectedItem.text }}</h1>
</h1>
<div class="flex items-center gap-2 flex-none"> <div class="flex items-center gap-2 flex-none">
@if (selectedItem.gpx) { @if (selectedItem.gpx) {
@ -302,16 +380,23 @@
<div class="rounded-md shadow p-4"> <div class="rounded-md shadow p-4">
<p class="font-bold mb-1 truncate">Latitude, Longitude</p> <p class="font-bold mb-1 truncate">Latitude, Longitude</p>
<p class="text-sm text-gray-500 truncate cursor-copy" <p class="text-sm text-gray-500 truncate cursor-copy"
[cdkCopyToClipboard]="selectedItem.lat + ',' + selectedItem.lng">{{ [cdkCopyToClipboard]="selectedItem.lat + ',' + selectedItem.lng">
selectedItem.lat }}, {{ selectedItem.lng }}</p> {{ selectedItem.lat }}, {{ selectedItem.lng }}
</p>
</div> </div>
} }
@if (selectedItem.price) { @if (selectedItem.price) {
<div class="rounded-md shadow p-4"> <div class="rounded-md shadow p-4">
<p class="font-bold mb-1">Price</p> <p class="font-bold mb-1">Price</p>
<p class="text-sm text-gray-500">{{ selectedItem.price }} @if (selectedItem.price) { {{ trip.currency }} } <p class="text-sm text-gray-500">
@if (selectedItem.paid_by) {<span class="text-xs text-gray-500">(by {{ selectedItem.paid_by }})</span>} {{ selectedItem.price }}
@if (selectedItem.price) {
{{ trip.currency }}
}
@if (selectedItem.paid_by) {
<span class="text-xs text-gray-500">(by {{ selectedItem.paid_by }})</span>
}
</p> </p>
</div> </div>
} }
@ -320,8 +405,7 @@
<div class="rounded-md shadow p-4"> <div class="rounded-md shadow p-4">
<p class="font-bold mb-1">Status</p> <p class="font-bold mb-1">Status</p>
<span [style.background]="selectedItem.status.color + '1A'" [style.color]="selectedItem.status.color" <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">{{ class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{ selectedItem.status.label }}</span>
selectedItem.status.label }}</span>
</div> </div>
} }
@ -349,8 +433,7 @@
</div> </div>
</div> </div>
<div id="map" [class.fullscreen-map]="isMapFullscreen" class="w-full rounded-md min-h-96 h-1/3 max-h-full"> <div id="map" [class.fullscreen-map]="isMapFullscreen" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div>
</div>
</div> </div>
@if (!selectedItem) { @if (!selectedItem) {
@ -370,7 +453,8 @@
<div class="flex items-center"> <div class="flex items-center">
@defer { @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">{{ <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> places.length
}}</span>
} @placeholder (minimum 0.4s) { } @placeholder (minimum 0.4s) {
<p-skeleton height="1.75rem" width="2.5rem" class="mr-1" /> <p-skeleton height="1.75rem" width="2.5rem" class="mr-1" />
} }
@ -383,7 +467,7 @@
@for (p of places; track p.id) { @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" <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)" (mouseleave)="resetPlaceHighlightMarker()"> (mouseenter)="placeHighlightMarker(p)" (mouseleave)="resetPlaceHighlightMarker()">
<img [src]="p.image || p.category.image" class="w-12 rounded-full object-fit"> <img [src]="p.image || p.category.image" class="w-12 rounded-full object-fit" />
<div class="flex flex-col gap-1 truncate"> <div class="flex flex-col gap-1 truncate">
<h1 class="tracking-tight truncate dark:text-surface-300">{{ p.name }}</h1> <h1 class="tracking-tight truncate dark:text-surface-300">{{ p.name }}</h1>
@ -404,8 +488,11 @@
<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">{{ 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 || '-' p.price || '-' }}
}} @if (p.price) { {{ trip.currency }} }</span> @if (p.price) {
{{ trip.currency }}
}
</span>
@if (trip.collaborators.length) { @if (trip.collaborators.length) {
<span class="bg-gray-100 text-gray-800 text-sm me-2 px-2.5 py-0.5 rounded dark:bg-gray-100/85">{{ p.user <span class="bg-gray-100 text-gray-800 text-sm me-2 px-2.5 py-0.5 rounded dark:bg-gray-100/85">{{ p.user
@ -454,9 +541,14 @@
</div> </div>
<div class="flex items-center gap-2 flex-none"> <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">{{ <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 || '-' }} @if (getDayStats(d).price) { {{ trip.currency }} }</span> getDayStats(d).price || '-' }}
@if (getDayStats(d).price) {
{{ trip.currency }}
}
</span>
<span class="bg-blue-100 text-blue-800 text-sm px-2.5 py-0.5 rounded-md dark:bg-blue-100/85">{{ <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> getDayStats(d).places
}}</span>
</div> </div>
</div> </div>
} @empty { } @empty {
@ -474,16 +566,19 @@
<div class="p-4 shadow rounded-md w-full min-h-20"> <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="p-2 mb-2 flex justify-between items-center">
<h1 class="font-semibold tracking-tight text-xl">About</h1> <h1 class="font-semibold tracking-tight text-xl">About</h1>
<div class="flex items-center gap-1 text-gray-500"><p-button text label="itskovacs/trip" icon="pi pi-github" <div class="flex items-center gap-1 text-gray-500">
(click)="toGithub()" /></div> <p-button text label="itskovacs/trip" icon="pi pi-github" (click)="toGithub()" />
</div>
</div> </div>
<div class="max-w-[85%] text-center mx-auto flex flex-col items-center gap-4"> <div class="max-w-[85%] text-center mx-auto flex flex-col items-center gap-4">
<div>TRIP is free and always will be. Building it takes a lot of time and effort, and since I'm committed to <div>
keeping it free, your support simply helps keep the project going. Thank you! ❤️</div> TRIP is free and always will be. Building it takes a lot of time and effort, and since I'm committed to
keeping it free, your support simply helps keep the project going. Thank you! ❤️
</div>
<a href="https://ko-fi.com/itskovacs" target="_blank" class="custom-button flex items-center">Buy me <a href="https://ko-fi.com/itskovacs" target="_blank" class="custom-button flex items-center">Buy me a
a coffee</a> coffee</a>
</div> </div>
</div> </div>
} }
@ -506,11 +601,16 @@
@if (isMapFullscreenDays) { @if (isMapFullscreenDays) {
<div animate.enter="expand" animate.leave="collapse" class="overflow-y-auto max-h-[calc(30vh-5rem)] p-2 space-y-1"> <div animate.enter="expand" animate.leave="collapse" class="overflow-y-auto max-h-[calc(30vh-5rem)] p-2 space-y-1">
@for (day of trip.days; track day.id) { @for (day of trip.days; track day.id) {
<button (click)="toggleTripDayHighlight(day.id)" <button (click)="toggleTripDayHighlight(day.id)" [ngClass]="
[ngClass]="tripMapAntLayerDayID === day.id ? 'shadow-md bg-blue-500' : 'hover:bg-blue-50 dark:hover:bg-blue-950/20'" tripMapAntLayerDayID === day.id
class="w-full flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all"> ? 'shadow-md bg-blue-500'
<span class="flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-lg font-semibold text-sm" : 'hover:bg-blue-50 dark:hover:bg-blue-950/20'
[ngClass]="tripMapAntLayerDayID === day.id ? 'bg-white text-blue-600' : 'bg-blue-100 text-blue-800 dark:bg-blue-900'"> " class="w-full flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all">
<span class="flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-lg font-semibold text-sm" [ngClass]="
tripMapAntLayerDayID === day.id
? 'bg-white text-blue-600'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900'
">
{{ day.items.length }} {{ day.items.length }}
</span> </span>
<span class="flex-1 text-left text-sm font-medium truncate" <span class="flex-1 text-left text-sm font-medium truncate"
@ -537,7 +637,7 @@
</div> </div>
<div class="mx-auto"> <div class="mx-auto">
<img class="w-full lg:h-[32rem] h-80 md:h-96 rounded-lg object-cover" src="cover.webp"> <img class="w-full lg:h-[32rem] h-80 md:h-96 rounded-lg object-cover" src="cover.webp" />
</div> </div>
<p-button text label="itskovacs/trip" icon="pi pi-github" (click)="toGithub()" /> <p-button text label="itskovacs/trip" icon="pi pi-github" (click)="toGithub()" />
@ -559,7 +659,9 @@
class="flex items-center gap-2 w-full cursor-pointer"> class="flex items-center gap-2 w-full cursor-pointer">
<p-checkbox disabled [binary]="true" [inputId]="item.id.toString()" [(ngModel)]="item.packed" /> <p-checkbox disabled [binary]="true" [inputId]="item.id.toString()" [(ngModel)]="item.packed" />
<div class="pr-6 md:pr-0 truncate select-none flex-1"> <div class="pr-6 md:pr-0 truncate select-none flex-1">
@if (item.qt) {<span class="text-gray-400 mr-0.5">{{ item.qt }}</span>} @if (item.qt) {
<span class="text-gray-400 mr-0.5">{{ item.qt }}</span>
}
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
</div> </div>
</label> </label>
@ -593,8 +695,10 @@
<div class="flex items-center gap-3 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-white/5"> <div class="flex items-center gap-3 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-white/5">
<label [for]="item.id" class="flex items-center gap-2 w-full"> <label [for]="item.id" class="flex items-center gap-2 w-full">
<div class="relative"> <div class="relative">
@if (item.status) {<div class="z-50 block absolute top-0 left-3 size-2.5 rounded-full" @if (item.status) {
[style.background]="item.status.color"></div>} <div class="z-50 block absolute top-0 left-3 size-2.5 rounded-full" [style.background]="item.status.color">
</div>
}
<p-checkbox disabled /> <p-checkbox disabled />
</div> </div>
<div class="pr-6 md:pr-0 truncate select-none flex-1"> <div class="pr-6 md:pr-0 truncate select-none flex-1">
@ -612,7 +716,7 @@
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<div class="flex flex-col items-center max-w-[55vw] md:max-w-full"> <div class="flex flex-col items-center max-w-[55vw] md:max-w-full">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<img src="favicon.png" class="size-20"> <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 class="flex gap-2 items-center text-xs text-gray-500"><i class="pi pi-github"></i>itskovacs/trip</div>
</div> </div>
@ -620,13 +724,11 @@
<div class="flex items-center"> <div class="flex items-center">
<span <span
class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-1 rounded min-w-fit flex items-center gap-2"><i class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-1 rounded min-w-fit flex items-center gap-2"><i
class="pi pi-calendar text-xs"></i> {{ class="pi pi-calendar text-xs"></i> {{ trip?.days?.length }}
trip?.days?.length }} {{ trip?.days!.length > 1 ? 'days' : {{ (trip?.days)!.length > 1 ? 'days' : 'day' }}</span>
'day'}}</span>
<span <span
class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-1 rounded min-w-fit flex items-center gap-2"><i class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-1 rounded min-w-fit flex items-center gap-2"><i
class="pi pi-wallet text-xs"></i> {{ class="pi pi-wallet text-xs"></i> {{ (totalPrice | number: '1.0-2') || '-' }} {{ trip?.currency }}</span>
(totalPrice | number:'1.0-2') || '-' }} {{ trip?.currency }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -635,8 +737,7 @@
<div class="text-2xl font-semibold">Notes</div> <div class="text-2xl font-semibold">Notes</div>
<div class="mt-4 border-l-3 border-gray-900 pl-6 py-2"> <div class="mt-4 border-l-3 border-gray-900 pl-6 py-2">
<p class="text-sm leading-relaxed text-gray-800 whitespace-pre-line">{{ trip?.notes || 'Nothing there.' <p class="text-sm leading-relaxed text-gray-800 whitespace-pre-line">{{ trip?.notes || 'Nothing there.' }}</p>
}}</p>
</div> </div>
</div> </div>
@ -680,9 +781,7 @@
@if (item.status) { @if (item.status) {
<span class="text-xs font-medium px-2.5 py-1 rounded min-w-fit" <span class="text-xs font-medium px-2.5 py-1 rounded min-w-fit"
[style.background]="statusToTripStatus(item.status)?.color + '1A'" [style.background]="statusToTripStatus(item.status)?.color + '1A'"
[style.color]="statusToTripStatus(item.status)?.color">{{ [style.color]="statusToTripStatus(item.status)?.color">{{ statusToTripStatus(item.status)?.label }}</span>
statusToTripStatus(item.status)?.label
}}</span>
} }
</div> </div>
</div> </div>

View File

@ -1,47 +1,33 @@
import { AfterViewInit, Component } from "@angular/core"; import { AfterViewInit, Component } from '@angular/core';
import { ApiService } from "../../services/api.service"; import { ApiService } from '../../services/api.service';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { SkeletonModule } from "primeng/skeleton"; import { SkeletonModule } from 'primeng/skeleton';
import * as L from "leaflet"; import * as L from 'leaflet';
import { antPath } from "leaflet-ant-path"; import { antPath } from 'leaflet-ant-path';
import { TableModule } from "primeng/table"; import { TableModule } from 'primeng/table';
import { import { Trip, FlattenedTripItem, TripDay, TripItem, TripStatus, PackingItem, ChecklistItem } from '../../types/trip';
Trip, import { Place } from '../../types/poi';
FlattenedTripItem, import { createMap, placeToMarker, createClusterGroup, tripDayMarker, gpxToPolyline } from '../../shared/map';
TripDay, import { ActivatedRoute } from '@angular/router';
TripItem, import { debounceTime, take, tap } from 'rxjs';
TripStatus, import { UtilsService } from '../../services/utils.service';
PackingItem, import { CommonModule, DecimalPipe } from '@angular/common';
ChecklistItem, import { MenuItem } from 'primeng/api';
} from "../../types/trip"; import { MenuModule } from 'primeng/menu';
import { Place } from "../../types/poi"; import { LinkifyPipe } from '../../shared/linkify.pipe';
import { import { TooltipModule } from 'primeng/tooltip';
createMap, import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
placeToMarker, import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
createClusterGroup, import { MultiSelectModule } from 'primeng/multiselect';
tripDayMarker, import { DialogModule } from 'primeng/dialog';
gpxToPolyline, import { CheckboxModule } from 'primeng/checkbox';
} from "../../shared/map"; import { InputTextModule } from 'primeng/inputtext';
import { ActivatedRoute } from "@angular/router"; import { ClipboardModule } from '@angular/cdk/clipboard';
import { debounceTime, take, tap } from "rxjs"; import { calculateDistanceBetween } from '../../shared/haversine';
import { UtilsService } from "../../services/utils.service"; import { orderByPipe } from '../../shared/order-by.pipe';
import { CommonModule, DecimalPipe } from "@angular/common";
import { MenuItem } from "primeng/api";
import { MenuModule } from "primeng/menu";
import { LinkifyPipe } from "../../shared/linkify.pipe";
import { TooltipModule } from "primeng/tooltip";
import { FormControl, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { MultiSelectModule } from "primeng/multiselect";
import { DialogModule } from "primeng/dialog";
import { CheckboxModule } from "primeng/checkbox";
import { InputTextModule } from "primeng/inputtext";
import { ClipboardModule } from "@angular/cdk/clipboard";
import { calculateDistanceBetween } from "../../shared/haversine";
import { orderByPipe } from "../../shared/order-by.pipe";
@Component({ @Component({
selector: "app-shared-trip", selector: 'app-shared-trip',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
@ -61,8 +47,8 @@ import { orderByPipe } from "../../shared/order-by.pipe";
ClipboardModule, ClipboardModule,
orderByPipe, orderByPipe,
], ],
templateUrl: "./shared-trip.component.html", templateUrl: './shared-trip.component.html',
styleUrls: ["./shared-trip.component.scss"], styleUrls: ['./shared-trip.component.scss'],
}) })
export class SharedTripComponent implements AfterViewInit { export class SharedTripComponent implements AfterViewInit {
token?: string; token?: string;
@ -98,18 +84,18 @@ export class SharedTripComponent implements AfterViewInit {
readonly menuTripActionsItems: MenuItem[] = [ readonly menuTripActionsItems: MenuItem[] = [
{ {
label: "Lists", label: 'Lists',
items: [ items: [
{ {
label: "Checklist", label: 'Checklist',
icon: "pi pi-check-square", icon: 'pi pi-check-square',
command: () => { command: () => {
this.openChecklist(); this.openChecklist();
}, },
}, },
{ {
label: "Packing", label: 'Packing',
icon: "pi pi-briefcase", icon: 'pi pi-briefcase',
command: () => { command: () => {
this.openPackingList(); this.openPackingList();
}, },
@ -117,11 +103,11 @@ export class SharedTripComponent implements AfterViewInit {
], ],
}, },
{ {
label: "Trip", label: 'Trip',
items: [ items: [
{ {
label: "Pretty Print", label: 'Pretty Print',
icon: "pi pi-print", icon: 'pi pi-print',
command: () => { command: () => {
this.togglePrint(); this.togglePrint();
}, },
@ -131,11 +117,11 @@ export class SharedTripComponent implements AfterViewInit {
]; ];
readonly menuTripTableActionsItems: MenuItem[] = [ readonly menuTripTableActionsItems: MenuItem[] = [
{ {
label: "Actions", label: 'Actions',
items: [ items: [
{ {
label: "Pretty Print", label: 'Pretty Print',
icon: "pi pi-print", icon: 'pi pi-print',
command: () => { command: () => {
this.togglePrint(); this.togglePrint();
}, },
@ -143,18 +129,18 @@ export class SharedTripComponent implements AfterViewInit {
], ],
}, },
{ {
label: "Table", label: 'Table',
items: [ items: [
{ {
label: "Filter", label: 'Filter',
icon: "pi pi-filter", icon: 'pi pi-filter',
command: () => { command: () => {
this.toggleFiltering(); this.toggleFiltering();
}, },
}, },
{ {
label: "Group", label: 'Group',
icon: "pi pi-arrow-down-left-and-arrow-up-right-to-center", icon: 'pi pi-arrow-down-left-and-arrow-up-right-to-center',
command: () => { command: () => {
this.tableExpandableMode = !this.tableExpandableMode; this.tableExpandableMode = !this.tableExpandableMode;
}, },
@ -162,18 +148,18 @@ export class SharedTripComponent implements AfterViewInit {
], ],
}, },
{ {
label: "Directions", label: 'Directions',
items: [ items: [
{ {
label: "Highlight", label: 'Highlight',
icon: "pi pi-directions", icon: 'pi pi-directions',
command: () => { command: () => {
this.toggleTripDaysHighlight(); this.toggleTripDaysHighlight();
}, },
}, },
{ {
label: "GMaps itinerary", label: 'GMaps itinerary',
icon: "pi pi-car", icon: 'pi pi-car',
command: () => { command: () => {
this.tripToNavigation(); this.tripToNavigation();
}, },
@ -182,24 +168,18 @@ export class SharedTripComponent implements AfterViewInit {
}, },
]; ];
readonly tripTableColumns: string[] = [ readonly tripTableColumns: string[] = [
"day", 'day',
"time", 'time',
"text", 'text',
"place", 'place',
"comment", 'comment',
"LatLng", 'LatLng',
"price", 'price',
"status", 'status',
"distance", 'distance',
]; ];
tripTableSelectedColumns: string[] = [ tripTableSelectedColumns: string[] = ['day', 'time', 'text', 'place', 'comment'];
"day", tripTableSearchInput = new FormControl('');
"time",
"text",
"place",
"comment",
];
tripTableSearchInput = new FormControl("");
dayStatsCache = new Map<number, { price: number; places: number }>(); dayStatsCache = new Map<number, { price: number; places: number }>();
placesUsedInTable = new Set<number>(); placesUsedInTable = new Set<number>();
@ -210,9 +190,7 @@ export class SharedTripComponent implements AfterViewInit {
private route: ActivatedRoute, private route: ActivatedRoute,
) { ) {
this.statuses = this.utilsService.statuses; this.statuses = this.utilsService.statuses;
this.tripTableSearchInput.valueChanges this.tripTableSearchInput.valueChanges.pipe(debounceTime(300), takeUntilDestroyed()).subscribe({
.pipe(debounceTime(300), takeUntilDestroyed())
.subscribe({
next: (value) => { next: (value) => {
if (value) this.flattenTripDayItems(value.toLowerCase()); if (value) this.flattenTripDayItems(value.toLowerCase());
else this.flattenTripDayItems(); else this.flattenTripDayItems();
@ -225,7 +203,7 @@ export class SharedTripComponent implements AfterViewInit {
.pipe( .pipe(
take(1), take(1),
tap((params) => { tap((params) => {
const token = params.get("token"); const token = params.get('token');
if (token) { if (token) {
this.token = token; this.token = token;
this.loadTripData(token); this.loadTripData(token);
@ -252,12 +230,10 @@ export class SharedTripComponent implements AfterViewInit {
initMap(): void { initMap(): void {
const contentMenuItems = [ const contentMenuItems = [
{ {
text: "Copy coordinates", text: 'Copy coordinates',
callback: (e: any) => { callback: (e: any) => {
const latlng = e.latlng; const latlng = e.latlng;
navigator.clipboard.writeText( navigator.clipboard.writeText(`${parseFloat(latlng.lat).toFixed(5)}, ${parseFloat(latlng.lng).toFixed(5)}`);
`${parseFloat(latlng.lat).toFixed(5)}, ${parseFloat(latlng.lng).toFixed(5)}`,
);
}, },
}, },
]; ];
@ -305,11 +281,7 @@ export class SharedTripComponent implements AfterViewInit {
if (!this.trip?.days) return []; if (!this.trip?.days) return [];
return this.trip.days return this.trip.days
.flatMap((day) => .flatMap((day) => day.items.filter((item) => ['constraint', 'pending'].includes(item.status as string)))
day.items.filter((item) =>
["constraint", "pending"].includes(item.status as string),
),
)
.map((item) => ({ .map((item) => ({
...item, ...item,
status: this.statusToTripStatus(item.status as string), status: this.statusToTripStatus(item.status as string),
@ -383,9 +355,7 @@ export class SharedTripComponent implements AfterViewInit {
setPlacesAndMarkers() { setPlacesAndMarkers() {
this.computePlacesUsedInTable(); this.computePlacesUsedInTable();
this.places = [...(this.trip?.places ?? [])].sort((a, b) => this.places = [...(this.trip?.places ?? [])].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
this.markerClusterGroup?.clearLayers(); this.markerClusterGroup?.clearLayers();
this.places.forEach((p) => { this.places.forEach((p) => {
const marker = this._placeToMarker(p); const marker = this._placeToMarker(p);
@ -394,12 +364,8 @@ export class SharedTripComponent implements AfterViewInit {
} }
_placeToMarker(place: Place): L.Marker { _placeToMarker(place: Place): L.Marker {
const marker = placeToMarker( const marker = placeToMarker(place, false, !this.placesUsedInTable.has(place.id));
place, marker.on('click', () => {
false,
!this.placesUsedInTable.has(place.id),
);
marker.on("click", () => {
this.onMapMarkerClick(place.id); this.onMapMarkerClick(place.id);
marker.closeTooltip(); marker.closeTooltip();
}); });
@ -409,9 +375,7 @@ export class SharedTripComponent implements AfterViewInit {
resetMapBounds() { resetMapBounds() {
if (!this.places.length) { if (!this.places.length) {
this.map?.fitBounds( this.map?.fitBounds(
this.flattenedTripItems this.flattenedTripItems.filter((i) => i.lat != null && i.lng != null).map((i) => [i.lat!, i.lng!]),
.filter((i) => i.lat != null && i.lng != null)
.map((i) => [i.lat!, i.lng!]),
{ padding: [30, 30] }, { padding: [30, 30] },
); );
return; return;
@ -425,7 +389,7 @@ export class SharedTripComponent implements AfterViewInit {
toggleMapFullscreen() { toggleMapFullscreen() {
this.isMapFullscreen = !this.isMapFullscreen; this.isMapFullscreen = !this.isMapFullscreen;
document.body.classList.toggle("overflow-hidden"); document.body.classList.toggle('overflow-hidden');
setTimeout(() => { setTimeout(() => {
this.map?.invalidateSize(); this.map?.invalidateSize();
@ -444,14 +408,12 @@ export class SharedTripComponent implements AfterViewInit {
return; return;
} }
this.totalPrice = this.totalPrice =
this.trip?.days this.trip?.days.flatMap((d) => d.items).reduce((price, item) => price + (item.price ?? 0), 0) ?? 0;
.flatMap((d) => d.items)
.reduce((price, item) => price + (item.price ?? 0), 0) ?? 0;
} }
resetPlaceHighlightMarker() { resetPlaceHighlightMarker() {
if (this.tripMapHoveredElement) { if (this.tripMapHoveredElement) {
this.tripMapHoveredElement.classList.remove("list-hover"); this.tripMapHoveredElement.classList.remove('list-hover');
this.tripMapHoveredElement = undefined; this.tripMapHoveredElement = undefined;
} }
@ -468,8 +430,7 @@ export class SharedTripComponent implements AfterViewInit {
} }
placeHighlightMarker(item: any) { placeHighlightMarker(item: any) {
if (this.tripMapHoveredElement || this.tripMapTemporaryMarker) if (this.tripMapHoveredElement || this.tripMapTemporaryMarker) this.resetPlaceHighlightMarker();
this.resetPlaceHighlightMarker();
let marker: L.Marker | undefined; let marker: L.Marker | undefined;
this.markerClusterGroup?.eachLayer((layer: any) => { this.markerClusterGroup?.eachLayer((layer: any) => {
@ -487,10 +448,7 @@ export class SharedTripComponent implements AfterViewInit {
// TripItem without place, but latlng // TripItem without place, but latlng
this.tripMapTemporaryMarker = tripDayMarker(item).addTo(this.map!); this.tripMapTemporaryMarker = tripDayMarker(item).addTo(this.map!);
if (this.tripMapGpxLayer) { if (this.tripMapGpxLayer) {
this.map?.fitBounds( this.map?.fitBounds([[item.lat, item.lng], (this.tripMapGpxLayer as any).getBounds()], { padding: [30, 30] });
[[item.lat, item.lng], (this.tripMapGpxLayer as any).getBounds()],
{ padding: [30, 30] },
);
} else this.map?.fitBounds([[item.lat, item.lng]], { padding: [60, 60] }); } else this.map?.fitBounds([[item.lat, item.lng]], { padding: [60, 60] });
return; return;
} }
@ -499,18 +457,16 @@ export class SharedTripComponent implements AfterViewInit {
const markerElement = marker.getElement() as HTMLElement; // search for Marker. If 'null', is inside Cluster const markerElement = marker.getElement() as HTMLElement; // search for Marker. If 'null', is inside Cluster
if (markerElement) { if (markerElement) {
// marker, not clustered // marker, not clustered
markerElement.classList.add("list-hover"); markerElement.classList.add('list-hover');
this.tripMapHoveredElement = markerElement; this.tripMapHoveredElement = markerElement;
targetLatLng = marker.getLatLng(); targetLatLng = marker.getLatLng();
} else { } else {
// marker is clustered // marker is clustered
const parentCluster = (this.markerClusterGroup as any).getVisibleParent( const parentCluster = (this.markerClusterGroup as any).getVisibleParent(marker);
marker,
);
if (parentCluster) { if (parentCluster) {
const clusterEl = parentCluster.getElement(); const clusterEl = parentCluster.getElement();
if (clusterEl) { if (clusterEl) {
clusterEl.classList.add("list-hover"); clusterEl.classList.add('list-hover');
this.tripMapHoveredElement = clusterEl; this.tripMapHoveredElement = clusterEl;
} }
targetLatLng = parentCluster.getLatLng(); targetLatLng = parentCluster.getLatLng();
@ -574,11 +530,7 @@ export class SharedTripComponent implements AfterViewInit {
.filter((n) => n !== undefined); .filter((n) => n !== undefined);
if (items.length < 2) { if (items.length < 2) {
this.utilsService.toast( this.utilsService.toast('info', 'Info', 'Not enough values to map an itinerary');
"info",
"Info",
"Not enough values to map an itinerary",
);
return; return;
} }
@ -590,20 +542,20 @@ export class SharedTripComponent implements AfterViewInit {
const layGroup = L.featureGroup(); const layGroup = L.featureGroup();
const COLORS: string[] = [ const COLORS: string[] = [
"#e6194b", '#e6194b',
"#3cb44b", '#3cb44b',
"#ffe119", '#ffe119',
"#4363d8", '#4363d8',
"#9a6324", '#9a6324',
"#f58231", '#f58231',
"#911eb4", '#911eb4',
"#46f0f0", '#46f0f0',
"#f032e6", '#f032e6',
"#bcf60c", '#bcf60c',
"#fabebe", '#fabebe',
"#008080", '#008080',
"#e6beff", '#e6beff',
"#808000", '#808000',
]; ];
let prevPoint: [number, number] | null = null; let prevPoint: [number, number] | null = null;
@ -614,7 +566,7 @@ export class SharedTripComponent implements AfterViewInit {
dashArray: [10, 20], dashArray: [10, 20],
weight: 5, weight: 5,
color: COLORS[idx % COLORS.length], color: COLORS[idx % COLORS.length],
pulseColor: "#FFFFFF", pulseColor: '#FFFFFF',
paused: false, paused: false,
reverse: false, reverse: false,
hardwareAccelerated: true, hardwareAccelerated: true,
@ -665,9 +617,7 @@ export class SharedTripComponent implements AfterViewInit {
const idx = this.trip?.days.findIndex((d) => d.id === day_id); const idx = this.trip?.days.findIndex((d) => d.id === day_id);
if (!this.trip || idx === undefined || idx == -1) return; if (!this.trip || idx === undefined || idx == -1) return;
const data = this.trip.days[idx].items.sort((a, b) => const data = this.trip.days[idx].items.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
a.time < b.time ? -1 : a.time > b.time ? 1 : 0,
);
const items = data const items = data
.map((item) => { .map((item) => {
if (item.lat && item.lng) if (item.lat && item.lng)
@ -693,11 +643,7 @@ export class SharedTripComponent implements AfterViewInit {
.filter((n) => n !== undefined); .filter((n) => n !== undefined);
if (items.length < 2) { if (items.length < 2) {
this.utilsService.toast( this.utilsService.toast('info', 'Info', 'Not enough values to map an itinerary');
"info",
"Info",
"Not enough values to map an itinerary",
);
return; return;
} }
@ -712,8 +658,8 @@ export class SharedTripComponent implements AfterViewInit {
delay: 400, delay: 400,
dashArray: [10, 20], dashArray: [10, 20],
weight: 5, weight: 5,
color: "#0000FF", color: '#0000FF',
pulseColor: "#FFFFFF", pulseColor: '#FFFFFF',
paused: false, paused: false,
reverse: false, reverse: false,
hardwareAccelerated: true, hardwareAccelerated: true,
@ -751,15 +697,9 @@ export class SharedTripComponent implements AfterViewInit {
} }
onMapMarkerClick(place_id: number) { onMapMarkerClick(place_id: number) {
const item = this.flattenedTripItems.find( const item = this.flattenedTripItems.find((i) => i.place && i.place.id == place_id);
(i) => i.place && i.place.id == place_id,
);
if (!item) { if (!item) {
this.utilsService.toast( this.utilsService.toast('info', 'Place not used', 'The place is not used in the table');
"info",
"Place not used",
"The place is not used in the table",
);
return; return;
} }
@ -773,14 +713,14 @@ export class SharedTripComponent implements AfterViewInit {
// TODO: More services // TODO: More services
// const url = `http://maps.apple.com/?daddr=${this.selectedItem.lat},${this.selectedItem.lng}`; // 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}`; const url = `https://www.google.com/maps/dir/?api=1&destination=${this.selectedItem.lat},${this.selectedItem.lng}`;
window.open(url, "_blank"); window.open(url, '_blank');
} }
downloadItemGPX() { downloadItemGPX() {
if (!this.selectedItem?.gpx) return; if (!this.selectedItem?.gpx) return;
const dataBlob = new Blob([this.selectedItem.gpx]); const dataBlob = new Blob([this.selectedItem.gpx]);
const downloadURL = URL.createObjectURL(dataBlob); const downloadURL = URL.createObjectURL(dataBlob);
const link = document.createElement("a"); const link = document.createElement('a');
link.href = downloadURL; link.href = downloadURL;
link.download = `TRIP_${this.trip?.name}_${this.selectedItem.text}.gpx`; link.download = `TRIP_${this.trip?.name}_${this.selectedItem.text}.gpx`;
link.click(); link.click();
@ -791,27 +731,23 @@ export class SharedTripComponent implements AfterViewInit {
tripDayToNavigation(day_id: number) { tripDayToNavigation(day_id: number) {
const idx = this.trip?.days.findIndex((d) => d.id === day_id); const idx = this.trip?.days.findIndex((d) => d.id === day_id);
if (!this.trip || idx === undefined || idx == -1) return; if (!this.trip || idx === undefined || idx == -1) return;
const data = this.trip.days[idx].items.sort((a, b) => const data = this.trip.days[idx].items.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
a.time < b.time ? -1 : a.time > b.time ? 1 : 0,
);
const items = data.filter((item) => item.lat && item.lng); const items = data.filter((item) => item.lat && item.lng);
if (!items.length) return; if (!items.length) return;
const waypoints = items.map((item) => `${item.lat},${item.lng}`).join("/"); const waypoints = items.map((item) => `${item.lat},${item.lng}`).join('/');
const url = `https://www.google.com/maps/dir/${waypoints}`; const url = `https://www.google.com/maps/dir/${waypoints}`;
window.open(url, "_blank"); window.open(url, '_blank');
} }
tripToNavigation() { tripToNavigation() {
// TODO: More services // TODO: More services
const items = this.flattenedTripItems.filter( const items = this.flattenedTripItems.filter((item) => item.lat && item.lng);
(item) => item.lat && item.lng,
);
if (!items.length) return; if (!items.length) return;
const waypoints = items.map((item) => `${item.lat},${item.lng}`).join("/"); const waypoints = items.map((item) => `${item.lat},${item.lng}`).join('/');
const url = `https://www.google.com/maps/dir/${waypoints}`; const url = `https://www.google.com/maps/dir/${waypoints}`;
window.open(url, "_blank"); window.open(url, '_blank');
} }
openPackingList() { openPackingList() {
@ -832,24 +768,13 @@ export class SharedTripComponent implements AfterViewInit {
computeDispPackingList() { computeDispPackingList() {
const sorted: PackingItem[] = [...this.packingList].sort((a, b) => const sorted: PackingItem[] = [...this.packingList].sort((a, b) =>
a.packed !== b.packed a.packed !== b.packed ? (a.packed ? 1 : -1) : a.text < b.text ? -1 : a.text > b.text ? 1 : 0,
? a.packed
? 1
: -1
: a.text < b.text
? -1
: a.text > b.text
? 1
: 0,
); );
this.dispPackingList = sorted.reduce<Record<string, PackingItem[]>>( this.dispPackingList = sorted.reduce<Record<string, PackingItem[]>>((acc, item) => {
(acc, item) => {
(acc[item.category] ??= []).push(item); (acc[item.category] ??= []).push(item);
return acc; return acc;
}, }, {});
{},
);
} }
openChecklist() { openChecklist() {

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from '@angular/core';
import { ApiService } from "../../services/api.service"; import { ApiService } from '../../services/api.service';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { SkeletonModule } from "primeng/skeleton"; import { SkeletonModule } from 'primeng/skeleton';
import { TripBase, TripInvitation } from "../../types/trip"; import { TripBase, TripInvitation } from '../../types/trip';
import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog"; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
import { TripCreateModalComponent } from "../../modals/trip-create-modal/trip-create-modal.component"; import { TripCreateModalComponent } from '../../modals/trip-create-modal/trip-create-modal.component';
import { Router } from "@angular/router"; import { Router } from '@angular/router';
import { forkJoin, take } from "rxjs"; import { forkJoin, take } from 'rxjs';
import { DatePipe } from "@angular/common"; import { DatePipe } from '@angular/common';
import { DialogModule } from "primeng/dialog"; import { DialogModule } from 'primeng/dialog';
interface TripBaseWithDates extends TripBase { interface TripBaseWithDates extends TripBase {
from?: Date; from?: Date;
@ -16,11 +16,11 @@ interface TripBaseWithDates extends TripBase {
} }
@Component({ @Component({
selector: "app-trips", selector: 'app-trips',
standalone: true, standalone: true,
imports: [SkeletonModule, ButtonModule, DialogModule, DatePipe], imports: [SkeletonModule, ButtonModule, DialogModule, DatePipe],
templateUrl: "./trips.component.html", templateUrl: './trips.component.html',
styleUrls: ["./trips.component.scss"], styleUrls: ['./trips.component.scss'],
}) })
export class TripsComponent implements OnInit { export class TripsComponent implements OnInit {
trips: TripBase[] = []; trips: TripBase[] = [];
@ -53,7 +53,7 @@ export class TripsComponent implements OnInit {
} }
gotoMap() { gotoMap() {
this.router.navigateByUrl("/"); this.router.navigateByUrl('/');
} }
viewTrip(id: number) { viewTrip(id: number) {
@ -61,20 +61,17 @@ export class TripsComponent implements OnInit {
} }
addTrip() { addTrip() {
const modal: DynamicDialogRef = this.dialogService.open( const modal: DynamicDialogRef = this.dialogService.open(TripCreateModalComponent, {
TripCreateModalComponent, header: 'Create Trip',
{
header: "Create Trip",
modal: true, modal: true,
appendTo: "body", appendTo: 'body',
closable: true, closable: true,
dismissableMask: true, dismissableMask: true,
width: "50vw", width: '50vw',
breakpoints: { breakpoints: {
"960px": "80vw", '960px': '80vw',
}, },
}, })!;
)!;
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
next: (trip: TripBaseWithDates | null) => { next: (trip: TripBaseWithDates | null) => {
@ -85,12 +82,8 @@ export class TripsComponent implements OnInit {
let dayCount = 0; let dayCount = 0;
if (trip.from && trip.to) { if (trip.from && trip.to) {
const obs$ = this.generateDaysLabel(trip.from!, trip.to!).map( const obs$ = this.generateDaysLabel(trip.from!, trip.to!).map((label) =>
(label) => this.apiService.postTripDay({ id: -1, label: label, items: [] }, new_trip.id),
this.apiService.postTripDay(
{ id: -1, label: label, items: [] },
new_trip.id,
),
); );
dayCount = obs$.length; dayCount = obs$.length;
forkJoin(obs$).pipe(take(1)).subscribe(); forkJoin(obs$).pipe(take(1)).subscribe();
@ -116,32 +109,17 @@ export class TripsComponent implements OnInit {
generateDaysLabel(from: Date, to: Date): string[] { generateDaysLabel(from: Date, to: Date): string[] {
const labels: string[] = []; const labels: string[] = [];
const sameMonth = const sameMonth = from.getFullYear() === to.getFullYear() && from.getMonth() === to.getMonth();
from.getFullYear() === to.getFullYear() &&
from.getMonth() === to.getMonth();
const months = [ const months = ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'];
"Jan.",
"Feb.",
"Mar.",
"Apr.",
"May.",
"Jun.",
"Jul.",
"Aug.",
"Sep.",
"Oct.",
"Nov.",
"Dec.",
];
const current = new Date(from); const current = new Date(from);
while (current <= to) { while (current <= to) {
let label = ""; let label = '';
if (sameMonth) { if (sameMonth) {
label = `${current.getDate().toString().padStart(2, "0")} ${months[current.getMonth()]}`; label = `${current.getDate().toString().padStart(2, '0')} ${months[current.getMonth()]}`;
} else { } else {
label = `${(current.getMonth() + 1).toString().padStart(2, "0")}/${current.getDate().toString().padStart(2, "0")}`; label = `${(current.getMonth() + 1).toString().padStart(2, '0')}/${current.getDate().toString().padStart(2, '0')}`;
} }
labels.push(label); labels.push(label);
current.setDate(current.getDate() + 1); current.setDate(current.getDate() + 1);

View File

@ -1 +1 @@
declare module "leaflet-ant-path"; declare module 'leaflet-ant-path';

View File

@ -1,19 +1,19 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { DynamicDialogRef } from "primeng/dynamicdialog"; import { DynamicDialogRef } from 'primeng/dynamicdialog';
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from 'primeng/floatlabel';
import { TextareaModule } from "primeng/textarea"; import { TextareaModule } from 'primeng/textarea';
@Component({ @Component({
selector: "app-batch-create-modal", selector: 'app-batch-create-modal',
imports: [FloatLabelModule, ButtonModule, ReactiveFormsModule, TextareaModule], imports: [FloatLabelModule, ButtonModule, ReactiveFormsModule, TextareaModule],
standalone: true, standalone: true,
templateUrl: "./batch-create-modal.component.html", templateUrl: './batch-create-modal.component.html',
styleUrl: "./batch-create-modal.component.scss", styleUrl: './batch-create-modal.component.scss',
}) })
export class BatchCreateModalComponent { export class BatchCreateModalComponent {
batchInput = new FormControl(""); batchInput = new FormControl('');
constructor(private ref: DynamicDialogRef) {} constructor(private ref: DynamicDialogRef) {}

View File

@ -1,31 +1,19 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormGroup, import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
ReactiveFormsModule, import { FloatLabelModule } from 'primeng/floatlabel';
Validators, import { InputTextModule } from 'primeng/inputtext';
} from "@angular/forms"; import { FocusTrapModule } from 'primeng/focustrap';
import { ButtonModule } from "primeng/button"; import { ColorPickerModule } from 'primeng/colorpicker';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { Category } from '../../types/poi';
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
import { FocusTrapModule } from "primeng/focustrap";
import { ColorPickerModule } from "primeng/colorpicker";
import { Category } from "../../types/poi";
@Component({ @Component({
selector: "app-category-create-modal", selector: 'app-category-create-modal',
imports: [ imports: [FloatLabelModule, InputTextModule, ButtonModule, ColorPickerModule, ReactiveFormsModule, FocusTrapModule],
FloatLabelModule,
InputTextModule,
ButtonModule,
ColorPickerModule,
ReactiveFormsModule,
FocusTrapModule,
],
standalone: true, standalone: true,
templateUrl: "./category-create-modal.component.html", templateUrl: './category-create-modal.component.html',
styleUrl: "./category-create-modal.component.scss", styleUrl: './category-create-modal.component.scss',
}) })
export class CategoryCreateModalComponent { export class CategoryCreateModalComponent {
categoryForm: FormGroup; categoryForm: FormGroup;
@ -38,14 +26,11 @@ export class CategoryCreateModalComponent {
) { ) {
this.categoryForm = this.fb.group({ this.categoryForm = this.fb.group({
id: -1, id: -1,
name: ["", Validators.required], name: ['', Validators.required],
color: [ color: [
"#000000", '#000000',
{ {
validators: [ validators: [Validators.required, Validators.pattern('\#[abcdefABCDEF0-9]{6}')],
Validators.required,
Validators.pattern("\#[abcdefABCDEF0-9]{6}"),
],
}, },
], ],
image: null, image: null,
@ -58,8 +43,8 @@ export class CategoryCreateModalComponent {
closeDialog() { closeDialog() {
// Normalize data for API POST // Normalize data for API POST
let ret = this.categoryForm.value; let ret = this.categoryForm.value;
if (!ret["name"]) return; if (!ret['name']) return;
if (!this.updatedImage) delete ret["image"]; if (!this.updatedImage) delete ret['image'];
this.ref.close(ret); this.ref.close(ret);
} }
@ -70,8 +55,8 @@ export class CategoryCreateModalComponent {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
this.categoryForm.get("image")?.setValue(e.target?.result as string); this.categoryForm.get('image')?.setValue(e.target?.result as string);
this.categoryForm.get("image")?.markAsDirty(); this.categoryForm.get('image')?.markAsDirty();
this.updatedImage = true; this.updatedImage = true;
}; };
@ -80,7 +65,7 @@ export class CategoryCreateModalComponent {
} }
clearImage() { clearImage() {
this.categoryForm.get("image")?.setValue(null); this.categoryForm.get('image')?.setValue(null);
this.updatedImage = false; this.updatedImage = false;
} }
} }

View File

@ -1,32 +1,27 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormGroup, import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
ReactiveFormsModule, import { FloatLabelModule } from 'primeng/floatlabel';
Validators, import { InputTextModule } from 'primeng/inputtext';
} from "@angular/forms"; import { SelectModule } from 'primeng/select';
import { ButtonModule } from "primeng/button"; import { TextareaModule } from 'primeng/textarea';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { Observable } from 'rxjs';
import { FloatLabelModule } from "primeng/floatlabel"; import { AsyncPipe } from '@angular/common';
import { InputTextModule } from "primeng/inputtext"; import { InputGroupModule } from 'primeng/inputgroup';
import { SelectModule } from "primeng/select"; import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { TextareaModule } from "primeng/textarea"; import { ApiService } from '../../services/api.service';
import { Observable } from "rxjs"; import { UtilsService } from '../../services/utils.service';
import { AsyncPipe } from "@angular/common"; import { FocusTrapModule } from 'primeng/focustrap';
import { InputGroupModule } from "primeng/inputgroup"; import { Category, Place } from '../../types/poi';
import { InputGroupAddonModule } from "primeng/inputgroupaddon"; import { CheckboxModule } from 'primeng/checkbox';
import { ApiService } from "../../services/api.service"; import { TooltipModule } from 'primeng/tooltip';
import { UtilsService } from "../../services/utils.service"; import { checkAndParseLatLng, formatLatLng } from '../../shared/latlng-parser';
import { FocusTrapModule } from "primeng/focustrap"; import { InputNumberModule } from 'primeng/inputnumber';
import { Category, Place } from "../../types/poi";
import { CheckboxModule } from "primeng/checkbox";
import { TooltipModule } from "primeng/tooltip";
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
import { InputNumberModule } from "primeng/inputnumber";
@Component({ @Component({
selector: "app-place-create-modal", selector: 'app-place-create-modal',
imports: [ imports: [
FloatLabelModule, FloatLabelModule,
InputTextModule, InputTextModule,
@ -43,8 +38,8 @@ import { InputNumberModule } from "primeng/inputnumber";
FocusTrapModule, FocusTrapModule,
], ],
standalone: true, standalone: true,
templateUrl: "./place-create-modal.component.html", templateUrl: './place-create-modal.component.html',
styleUrl: "./place-create-modal.component.scss", styleUrl: './place-create-modal.component.scss',
}) })
export class PlaceCreateModalComponent { export class PlaceCreateModalComponent {
placeForm: FormGroup; placeForm: FormGroup;
@ -66,32 +61,27 @@ export class PlaceCreateModalComponent {
this.placeForm = this.fb.group({ this.placeForm = this.fb.group({
id: -1, id: -1,
name: ["", Validators.required], name: ['', Validators.required],
place: ["", { validators: Validators.required, updateOn: "blur" }], place: ['', { validators: Validators.required, updateOn: 'blur' }],
lat: [ lat: [
"", '',
{ {
validators: [ validators: [Validators.required, Validators.pattern('-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)')],
Validators.required, updateOn: 'blur',
Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"),
],
updateOn: "blur",
}, },
], ],
lng: [ lng: [
"", '',
{ {
validators: [ validators: [
Validators.required, Validators.required,
Validators.pattern( Validators.pattern('-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)'),
"-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)",
),
], ],
}, },
], ],
category: [null, Validators.required], category: [null, Validators.required],
description: null, description: null,
duration: [null, Validators.pattern("\\d+")], duration: [null, Validators.pattern('\\d+')],
price: null, price: null,
allowdog: false, allowdog: false,
visited: false, visited: false,
@ -104,12 +94,11 @@ export class PlaceCreateModalComponent {
if (patchValue) this.placeForm.patchValue(patchValue); if (patchValue) this.placeForm.patchValue(patchValue);
this.placeForm this.placeForm
.get("place") .get('place')
?.valueChanges.pipe(takeUntilDestroyed()) ?.valueChanges.pipe(takeUntilDestroyed())
.subscribe({ .subscribe({
next: (value: string) => { next: (value: string) => {
const isGoogleMapsURL = const isGoogleMapsURL = /^(https?:\/\/)?(www\.)?google\.[a-z.]+\/maps/.test(value);
/^(https?:\/\/)?(www\.)?google\.[a-z.]+\/maps/.test(value);
if (isGoogleMapsURL) { if (isGoogleMapsURL) {
this.parseGoogleMapsUrl(value); this.parseGoogleMapsUrl(value);
} }
@ -117,7 +106,7 @@ export class PlaceCreateModalComponent {
}); });
this.placeForm this.placeForm
.get("lat") .get('lat')
?.valueChanges.pipe(takeUntilDestroyed()) ?.valueChanges.pipe(takeUntilDestroyed())
.subscribe({ .subscribe({
next: (value: string) => { next: (value: string) => {
@ -125,8 +114,8 @@ export class PlaceCreateModalComponent {
if (!result) return; if (!result) return;
const [lat, lng] = result; const [lat, lng] = result;
const latControl = this.placeForm.get("lat"); const latControl = this.placeForm.get('lat');
const lngControl = this.placeForm.get("lng"); const lngControl = this.placeForm.get('lng');
latControl?.setValue(formatLatLng(lat).trim(), { emitEvent: false }); latControl?.setValue(formatLatLng(lat).trim(), { emitEvent: false });
lngControl?.setValue(formatLatLng(lng).trim(), { emitEvent: false }); lngControl?.setValue(formatLatLng(lng).trim(), { emitEvent: false });
@ -140,15 +129,15 @@ export class PlaceCreateModalComponent {
closeDialog() { closeDialog() {
// Normalize data for API POST // Normalize data for API POST
let ret = this.placeForm.value; let ret = this.placeForm.value;
ret["category_id"] = ret["category"]; ret['category_id'] = ret['category'];
delete ret["category"]; delete ret['category'];
if (ret["image_id"]) { if (ret['image_id']) {
delete ret["image"]; delete ret['image'];
delete ret["image_id"]; delete ret['image_id'];
} }
if (ret["gpx"] == "1") delete ret["gpx"]; if (ret['gpx'] == '1') delete ret['gpx'];
ret["lat"] = +ret["lat"]; ret['lat'] = +ret['lat'];
ret["lng"] = +ret["lng"]; ret['lng'] = +ret['lng'];
this.ref.close(ret); this.ref.close(ret);
} }
@ -156,13 +145,12 @@ export class PlaceCreateModalComponent {
const [place, latlng] = this.utilsService.parseGoogleMapsUrl(url); const [place, latlng] = this.utilsService.parseGoogleMapsUrl(url);
if (!place || !latlng) return; if (!place || !latlng) return;
const [lat, lng] = latlng.split(","); const [lat, lng] = latlng.split(',');
this.placeForm.get("place")?.setValue(place); this.placeForm.get('place')?.setValue(place);
this.placeForm.get("lat")?.setValue(lat); this.placeForm.get('lat')?.setValue(lat);
this.placeForm.get("lng")?.setValue(lng); this.placeForm.get('lng')?.setValue(lng);
if (!this.placeForm.get("name")?.value) if (!this.placeForm.get('name')?.value) this.placeForm.get('name')?.setValue(place);
this.placeForm.get("name")?.setValue(place);
} }
onImageSelected(event: Event) { onImageSelected(event: Event) {
@ -172,14 +160,14 @@ export class PlaceCreateModalComponent {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (this.placeForm.get("image_id")?.value) { if (this.placeForm.get('image_id')?.value) {
this.previous_image_id = this.placeForm.get("image_id")?.value; this.previous_image_id = this.placeForm.get('image_id')?.value;
this.previous_image = this.placeForm.get("image")?.value; this.previous_image = this.placeForm.get('image')?.value;
this.placeForm.get("image_id")?.setValue(null); this.placeForm.get('image_id')?.setValue(null);
} }
this.placeForm.get("image")?.setValue(e.target?.result as string); this.placeForm.get('image')?.setValue(e.target?.result as string);
this.placeForm.get("image")?.markAsDirty(); this.placeForm.get('image')?.markAsDirty();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -187,12 +175,12 @@ export class PlaceCreateModalComponent {
} }
clearImage() { clearImage() {
this.placeForm.get("image")?.setValue(null); this.placeForm.get('image')?.setValue(null);
this.placeForm.get("image_id")?.setValue(null); this.placeForm.get('image_id')?.setValue(null);
if (this.previous_image && this.previous_image_id) { if (this.previous_image && this.previous_image_id) {
this.placeForm.get("image_id")?.setValue(this.previous_image_id); this.placeForm.get('image_id')?.setValue(this.previous_image_id);
this.placeForm.get("image")?.setValue(this.previous_image); this.placeForm.get('image')?.setValue(this.previous_image);
} }
} }
@ -203,8 +191,8 @@ export class PlaceCreateModalComponent {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
this.placeForm.get("gpx")?.setValue(e.target?.result as string); this.placeForm.get('gpx')?.setValue(e.target?.result as string);
this.placeForm.get("gpx")?.markAsDirty(); this.placeForm.get('gpx')?.markAsDirty();
}; };
reader.readAsText(file); reader.readAsText(file);
@ -212,7 +200,7 @@ export class PlaceCreateModalComponent {
} }
clearGPX() { clearGPX() {
this.placeForm.get("gpx")?.setValue(null); this.placeForm.get('gpx')?.setValue(null);
this.placeForm.get("gpx")?.markAsDirty(); this.placeForm.get('gpx')?.markAsDirty();
} }
} }

View File

@ -1,25 +1,20 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from 'primeng/floatlabel';
import { TextareaModule } from "primeng/textarea"; import { TextareaModule } from 'primeng/textarea';
import { Trip } from "../../types/trip"; import { Trip } from '../../types/trip';
@Component({ @Component({
selector: "app-trip-archive-modal", selector: 'app-trip-archive-modal',
imports: [ imports: [FloatLabelModule, TextareaModule, ButtonModule, ReactiveFormsModule],
FloatLabelModule,
TextareaModule,
ButtonModule,
ReactiveFormsModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-archive-modal.component.html", templateUrl: './trip-archive-modal.component.html',
styleUrl: "./trip-archive-modal.component.scss", styleUrl: './trip-archive-modal.component.scss',
}) })
export class TripArchiveModalComponent { export class TripArchiveModalComponent {
review = new FormControl(""); review = new FormControl('');
constructor( constructor(
private ref: DynamicDialogRef, private ref: DynamicDialogRef,
@ -34,16 +29,13 @@ export class TripArchiveModalComponent {
return; return;
} }
if (!trip.days.length) return; if (!trip.days.length) return;
let placeholder = "General feedback:\n\n"; let placeholder = 'General feedback:\n\n';
trip.days.forEach((day, index) => { trip.days.forEach((day, index) => {
placeholder += `\nDay ${index + 1} (${day.label})\n`; placeholder += `\nDay ${index + 1} (${day.label})\n`;
if (!day.items.length) placeholder += " No activities.\n"; if (!day.items.length) placeholder += ' No activities.\n';
else else day.items.forEach((item) => (placeholder += ` - ${item.time} | ${item.text}\n`));
day.items.forEach(
(item) => (placeholder += ` - ${item.time} | ${item.text}\n`),
);
}); });
placeholder += "\nAnything else?"; placeholder += '\nAnything else?';
this.review.setValue(placeholder); this.review.setValue(placeholder);
} }

View File

@ -1,28 +1,17 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormGroup, import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
ReactiveFormsModule, import { FloatLabelModule } from 'primeng/floatlabel';
Validators, import { InputTextModule } from 'primeng/inputtext';
} from "@angular/forms"; import { FocusTrapModule } from 'primeng/focustrap';
import { ButtonModule } from "primeng/button";
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
import { FocusTrapModule } from "primeng/focustrap";
@Component({ @Component({
selector: "app-trip-create-checklist-modal", selector: 'app-trip-create-checklist-modal',
imports: [ imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, FocusTrapModule],
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
FocusTrapModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-create-checklist-modal.component.html", templateUrl: './trip-create-checklist-modal.component.html',
styleUrl: "./trip-create-checklist-modal.component.scss", styleUrl: './trip-create-checklist-modal.component.scss',
}) })
export class TripCreateChecklistModalComponent { export class TripCreateChecklistModalComponent {
checklistForm: FormGroup; checklistForm: FormGroup;
@ -33,7 +22,7 @@ export class TripCreateChecklistModalComponent {
) { ) {
this.checklistForm = this.fb.group({ this.checklistForm = this.fb.group({
id: -1, id: -1,
text: ["", { validators: Validators.required }], text: ['', { validators: Validators.required }],
}); });
const patchValue = this.config.data?.packing; const patchValue = this.config.data?.packing;

View File

@ -1,30 +1,25 @@
import { Component, ViewChild } from "@angular/core"; import { Component, ViewChild } from '@angular/core';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormGroup, import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
ReactiveFormsModule, import { FloatLabelModule } from 'primeng/floatlabel';
Validators, import { InputTextModule } from 'primeng/inputtext';
} from "@angular/forms"; import { TripDay, TripMember, TripStatus } from '../../types/trip';
import { ButtonModule } from "primeng/button"; import { Place } from '../../types/poi';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { SelectModule } from 'primeng/select';
import { FloatLabelModule } from "primeng/floatlabel"; import { TextareaModule } from 'primeng/textarea';
import { InputTextModule } from "primeng/inputtext"; import { InputMaskModule } from 'primeng/inputmask';
import { TripDay, TripMember, TripStatus } from "../../types/trip"; import { UtilsService } from '../../services/utils.service';
import { Place } from "../../types/poi"; import { checkAndParseLatLng, formatLatLng } from '../../shared/latlng-parser';
import { SelectModule } from "primeng/select"; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TextareaModule } from "primeng/textarea"; import { InputNumberModule } from 'primeng/inputnumber';
import { InputMaskModule } from "primeng/inputmask"; import { MultiSelectModule } from 'primeng/multiselect';
import { UtilsService } from "../../services/utils.service"; import { InputGroupModule } from 'primeng/inputgroup';
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser"; import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Popover, PopoverModule } from 'primeng/popover';
import { InputNumberModule } from "primeng/inputnumber";
import { MultiSelectModule } from "primeng/multiselect";
import { InputGroupModule } from "primeng/inputgroup";
import { InputGroupAddonModule } from "primeng/inputgroupaddon";
import { Popover, PopoverModule } from "primeng/popover";
@Component({ @Component({
selector: "app-trip-create-day-item-modal", selector: 'app-trip-create-day-item-modal',
imports: [ imports: [
FloatLabelModule, FloatLabelModule,
InputTextModule, InputTextModule,
@ -44,11 +39,11 @@ import { Popover, PopoverModule } from "primeng/popover";
PopoverModule, PopoverModule,
], ],
standalone: true, standalone: true,
templateUrl: "./trip-create-day-item-modal.component.html", templateUrl: './trip-create-day-item-modal.component.html',
styleUrl: "./trip-create-day-item-modal.component.scss", styleUrl: './trip-create-day-item-modal.component.scss',
}) })
export class TripCreateDayItemModalComponent { export class TripCreateDayItemModalComponent {
@ViewChild("op") op!: Popover; @ViewChild('op') op!: Popover;
members: TripMember[] = []; members: TripMember[] = [];
itemForm: FormGroup; itemForm: FormGroup;
days: TripDay[] = []; days: TripDay[] = [];
@ -68,16 +63,13 @@ export class TripCreateDayItemModalComponent {
this.itemForm = this.fb.group({ this.itemForm = this.fb.group({
id: -1, id: -1,
time: [ time: [
"", '',
{ {
validators: [ validators: [Validators.required, Validators.pattern(/^([01]\d|2[0-3])(:[0-5]\d)?$/)],
Validators.required,
Validators.pattern(/^([01]\d|2[0-3])(:[0-5]\d)?$/),
],
}, },
], ],
text: ["", Validators.required], text: ['', Validators.required],
comment: "", comment: '',
day_id: [null, Validators.required], day_id: [null, Validators.required],
place: null, place: null,
status: null, status: null,
@ -86,18 +78,16 @@ export class TripCreateDayItemModalComponent {
image_id: null, image_id: null,
gpx: null, gpx: null,
lat: [ lat: [
"", '',
{ {
validators: Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"), validators: Validators.pattern('-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)'),
updateOn: "blur", updateOn: 'blur',
}, },
], ],
lng: [ lng: [
"", '',
{ {
validators: Validators.pattern( validators: Validators.pattern('-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)'),
"-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)",
),
}, },
], ],
paid_by: null, paid_by: null,
@ -115,36 +105,34 @@ export class TripCreateDayItemModalComponent {
place: data.item.place?.id ?? null, place: data.item.place?.id ?? null,
}); });
if (data.selectedDay) if (data.selectedDay) this.itemForm.get('day_id')?.setValue([data.selectedDay]);
this.itemForm.get("day_id")?.setValue([data.selectedDay]);
} }
this.itemForm this.itemForm
.get("place") .get('place')
?.valueChanges.pipe(takeUntilDestroyed()) ?.valueChanges.pipe(takeUntilDestroyed())
.subscribe({ .subscribe({
next: (value?: number) => { next: (value?: number) => {
if (!value) { if (!value) {
this.itemForm.get("lat")?.setValue(""); this.itemForm.get('lat')?.setValue('');
this.itemForm.get("lng")?.setValue(""); this.itemForm.get('lng')?.setValue('');
return; return;
} }
const p: Place = this.places.find((p) => p.id === value) as Place; const p: Place = this.places.find((p) => p.id === value) as Place;
if (p) { if (p) {
this.itemForm.get("lat")?.setValue(p.lat); this.itemForm.get('lat')?.setValue(p.lat);
this.itemForm.get("lng")?.setValue(p.lng); this.itemForm.get('lng')?.setValue(p.lng);
this.itemForm.get("price")?.setValue(p.price || 0); this.itemForm.get('price')?.setValue(p.price || 0);
if (!this.itemForm.get("text")?.value) if (!this.itemForm.get('text')?.value) this.itemForm.get('text')?.setValue(p.name);
this.itemForm.get("text")?.setValue(p.name); if (p.description && !this.itemForm.get('comment')?.value)
if (p.description && !this.itemForm.get("comment")?.value) this.itemForm.get('comment')?.setValue(p.description);
this.itemForm.get("comment")?.setValue(p.description);
} }
}, },
}); });
this.itemForm this.itemForm
.get("lat") .get('lat')
?.valueChanges.pipe(takeUntilDestroyed()) ?.valueChanges.pipe(takeUntilDestroyed())
.subscribe({ .subscribe({
next: (value: string) => { next: (value: string) => {
@ -152,8 +140,8 @@ export class TripCreateDayItemModalComponent {
if (!result) return; if (!result) return;
const [lat, lng] = result; const [lat, lng] = result;
const latControl = this.itemForm.get("lat"); const latControl = this.itemForm.get('lat');
const lngControl = this.itemForm.get("lng"); const lngControl = this.itemForm.get('lng');
latControl?.setValue(formatLatLng(lat).trim(), { emitEvent: false }); latControl?.setValue(formatLatLng(lat).trim(), { emitEvent: false });
lngControl?.setValue(formatLatLng(lng).trim(), { emitEvent: false }); lngControl?.setValue(formatLatLng(lng).trim(), { emitEvent: false });
@ -167,16 +155,16 @@ export class TripCreateDayItemModalComponent {
closeDialog() { closeDialog() {
// Normalize data for API POST // Normalize data for API POST
let ret = this.itemForm.value; let ret = this.itemForm.value;
if (!ret["lat"]) { if (!ret['lat']) {
ret["lat"] = null; ret['lat'] = null;
ret["lng"] = null; ret['lng'] = null;
} }
if (ret["image_id"]) { if (ret['image_id']) {
delete ret["image"]; delete ret['image'];
delete ret["image_id"]; delete ret['image_id'];
} }
if (ret["gpx"] == "1") delete ret["gpx"]; if (ret['gpx'] == '1') delete ret['gpx'];
if (!ret["place"]) delete ret["place"]; if (!ret['place']) delete ret['place'];
this.ref.close(ret); this.ref.close(ret);
} }
@ -185,7 +173,7 @@ export class TripCreateDayItemModalComponent {
} }
get paidByControl(): any { get paidByControl(): any {
return this.itemForm.get("paid_by"); return this.itemForm.get('paid_by');
} }
selectPriceMember(member: any) { selectPriceMember(member: any) {
@ -206,14 +194,14 @@ export class TripCreateDayItemModalComponent {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (this.itemForm.get("image_id")?.value) { if (this.itemForm.get('image_id')?.value) {
this.previous_image_id = this.itemForm.get("image_id")?.value; this.previous_image_id = this.itemForm.get('image_id')?.value;
this.previous_image = this.itemForm.get("image")?.value; this.previous_image = this.itemForm.get('image')?.value;
this.itemForm.get("image_id")?.setValue(null); this.itemForm.get('image_id')?.setValue(null);
} }
this.itemForm.get("image")?.setValue(e.target?.result as string); this.itemForm.get('image')?.setValue(e.target?.result as string);
this.itemForm.get("image")?.markAsDirty(); this.itemForm.get('image')?.markAsDirty();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -221,13 +209,13 @@ export class TripCreateDayItemModalComponent {
} }
clearImage() { clearImage() {
this.itemForm.get("image")?.setValue(null); this.itemForm.get('image')?.setValue(null);
this.itemForm.get("image_id")?.setValue(null); this.itemForm.get('image_id')?.setValue(null);
this.itemForm.markAsDirty(); this.itemForm.markAsDirty();
if (this.previous_image && this.previous_image_id) { if (this.previous_image && this.previous_image_id) {
this.itemForm.get("image_id")?.setValue(this.previous_image_id); this.itemForm.get('image_id')?.setValue(this.previous_image_id);
this.itemForm.get("image")?.setValue(this.previous_image); this.itemForm.get('image')?.setValue(this.previous_image);
} }
} }
@ -238,8 +226,8 @@ export class TripCreateDayItemModalComponent {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
this.itemForm.get("gpx")?.setValue(e.target?.result as string); this.itemForm.get('gpx')?.setValue(e.target?.result as string);
this.itemForm.get("gpx")?.markAsDirty(); this.itemForm.get('gpx')?.markAsDirty();
}; };
reader.readAsText(file); reader.readAsText(file);
@ -247,7 +235,7 @@ export class TripCreateDayItemModalComponent {
} }
clearGPX() { clearGPX() {
this.itemForm.get("gpx")?.setValue(null); this.itemForm.get('gpx')?.setValue(null);
this.itemForm.get("gpx")?.markAsDirty(); this.itemForm.get('gpx')?.markAsDirty();
} }
} }

View File

@ -1,27 +1,17 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormGroup, import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
ReactiveFormsModule, import { FloatLabelModule } from 'primeng/floatlabel';
Validators, import { InputTextModule } from 'primeng/inputtext';
} from "@angular/forms"; import { TripDay } from '../../types/trip';
import { ButtonModule } from "primeng/button";
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
import { TripDay } from "../../types/trip";
@Component({ @Component({
selector: "app-trip-create-day-modal", selector: 'app-trip-create-day-modal',
imports: [ imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule],
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-create-day-modal.component.html", templateUrl: './trip-create-day-modal.component.html',
styleUrl: "./trip-create-day-modal.component.scss", styleUrl: './trip-create-day-modal.component.scss',
}) })
export class TripCreateDayModalComponent { export class TripCreateDayModalComponent {
dayForm: FormGroup; dayForm: FormGroup;
@ -34,7 +24,7 @@ export class TripCreateDayModalComponent {
) { ) {
this.dayForm = this.fb.group({ this.dayForm = this.fb.group({
id: -1, id: -1,
label: ["", Validators.required], label: ['', Validators.required],
}); });
if (this.config.data) { if (this.config.data) {
@ -46,7 +36,7 @@ export class TripCreateDayModalComponent {
closeDialog() { closeDialog() {
// Normalize data for API POST // Normalize data for API POST
let ret = this.dayForm.value; let ret = this.dayForm.value;
if (!ret["label"]) return; if (!ret['label']) return;
this.ref.close(ret); this.ref.close(ret);
} }
} }

View File

@ -1,31 +1,31 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from 'primeng/floatlabel';
import { TextareaModule } from "primeng/textarea"; import { TextareaModule } from 'primeng/textarea';
import { TripDay, TripItem } from "../../types/trip"; import { TripDay, TripItem } from '../../types/trip';
import { SelectModule } from "primeng/select"; import { SelectModule } from 'primeng/select';
@Component({ @Component({
selector: "app-trip-create-items-modal", selector: 'app-trip-create-items-modal',
imports: [FloatLabelModule, ButtonModule, SelectModule, ReactiveFormsModule, TextareaModule], imports: [FloatLabelModule, ButtonModule, SelectModule, ReactiveFormsModule, TextareaModule],
standalone: true, standalone: true,
templateUrl: "./trip-create-items-modal.component.html", templateUrl: './trip-create-items-modal.component.html',
styleUrl: "./trip-create-items-modal.component.scss", styleUrl: './trip-create-items-modal.component.scss',
}) })
export class TripCreateItemsModalComponent { export class TripCreateItemsModalComponent {
itemBatchForm: FormGroup; itemBatchForm: FormGroup;
pholder = "eg.\n14h Just an item example\n15:10 Another format for an item\n16h30 Another item here"; pholder = 'eg.\n14h Just an item example\n15:10 Another format for an item\n16h30 Another item here';
days: TripDay[] = []; days: TripDay[] = [];
constructor( constructor(
private ref: DynamicDialogRef, private ref: DynamicDialogRef,
private fb: FormBuilder, private fb: FormBuilder,
private config: DynamicDialogConfig private config: DynamicDialogConfig,
) { ) {
this.itemBatchForm = this.fb.group({ this.itemBatchForm = this.fb.group({
batch: ["", Validators.required], batch: ['', Validators.required],
day_id: [null, Validators.required], day_id: [null, Validators.required],
}); });
@ -37,15 +37,15 @@ export class TripCreateItemsModalComponent {
closeDialog() { closeDialog() {
const ret = this.itemBatchForm.value; const ret = this.itemBatchForm.value;
const day_id = ret.day_id; const day_id = ret.day_id;
const lines: string[] = ret.batch.trim().split("\n"); const lines: string[] = ret.batch.trim().split('\n');
const tripItems: Partial<TripItem>[] = []; const tripItems: Partial<TripItem>[] = [];
lines.forEach((l) => { lines.forEach((l) => {
const match = l.match(/^(\d{1,2})(?:h|:)?(\d{0,2})?\s+(.+)$/); const match = l.match(/^(\d{1,2})(?:h|:)?(\d{0,2})?\s+(.+)$/);
if (match) { if (match) {
const [_, hoursStr, minutesStr = "", text] = match; const [_, hoursStr, minutesStr = '', text] = match;
const hours = hoursStr.padStart(2, "0"); const hours = hoursStr.padStart(2, '0');
const minutes = minutesStr.padStart(2, "0") || "00"; const minutes = minutesStr.padStart(2, '0') || '00';
const time = `${hours}:${minutes}`; const time = `${hours}:${minutes}`;
tripItems.push({ time: time, text: text, day_id: day_id }); tripItems.push({ time: time, text: text, day_id: day_id });
} }

View File

@ -1,30 +1,18 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormGroup, import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
ReactiveFormsModule, import { FloatLabelModule } from 'primeng/floatlabel';
Validators, import { InputTextModule } from 'primeng/inputtext';
} from "@angular/forms"; import { FocusTrapModule } from 'primeng/focustrap';
import { ButtonModule } from "primeng/button"; import { DatePickerModule } from 'primeng/datepicker';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
import { FocusTrapModule } from "primeng/focustrap";
import { DatePickerModule } from "primeng/datepicker";
@Component({ @Component({
selector: "app-trip-create-modal", selector: 'app-trip-create-modal',
imports: [ imports: [FloatLabelModule, InputTextModule, DatePickerModule, ButtonModule, ReactiveFormsModule, FocusTrapModule],
FloatLabelModule,
InputTextModule,
DatePickerModule,
ButtonModule,
ReactiveFormsModule,
FocusTrapModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-create-modal.component.html", templateUrl: './trip-create-modal.component.html',
styleUrl: "./trip-create-modal.component.scss", styleUrl: './trip-create-modal.component.scss',
}) })
export class TripCreateModalComponent { export class TripCreateModalComponent {
tripForm: FormGroup; tripForm: FormGroup;
@ -38,8 +26,8 @@ export class TripCreateModalComponent {
) { ) {
this.tripForm = this.fb.group({ this.tripForm = this.fb.group({
id: -1, id: -1,
name: ["", Validators.required], name: ['', Validators.required],
image: "", image: '',
currency: null, currency: null,
image_id: null, image_id: null,
from: null, from: null,
@ -48,7 +36,7 @@ export class TripCreateModalComponent {
const patchValue = this.config.data?.trip; const patchValue = this.config.data?.trip;
if (patchValue) { if (patchValue) {
if (!patchValue.image_id) delete patchValue["image"]; if (!patchValue.image_id) delete patchValue['image'];
this.tripForm.patchValue(patchValue); this.tripForm.patchValue(patchValue);
} }
} }
@ -56,10 +44,10 @@ export class TripCreateModalComponent {
closeDialog() { closeDialog() {
// Normalize data for API POST // Normalize data for API POST
let ret = this.tripForm.value; let ret = this.tripForm.value;
if (!ret["name"]) return; if (!ret['name']) return;
if (ret["image_id"]) { if (ret['image_id']) {
delete ret["image"]; delete ret['image'];
delete ret["image_id"]; delete ret['image_id'];
} }
this.ref.close(ret); this.ref.close(ret);
} }
@ -71,14 +59,14 @@ export class TripCreateModalComponent {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (this.tripForm.get("image_id")?.value) { if (this.tripForm.get('image_id')?.value) {
this.previous_image_id = this.tripForm.get("image_id")?.value; this.previous_image_id = this.tripForm.get('image_id')?.value;
this.previous_image = this.tripForm.get("image")?.value; this.previous_image = this.tripForm.get('image')?.value;
this.tripForm.get("image_id")?.setValue(null); this.tripForm.get('image_id')?.setValue(null);
} }
this.tripForm.get("image")?.setValue(e.target?.result as string); this.tripForm.get('image')?.setValue(e.target?.result as string);
this.tripForm.get("image")?.markAsDirty(); this.tripForm.get('image')?.markAsDirty();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -86,11 +74,11 @@ export class TripCreateModalComponent {
} }
clearImage() { clearImage() {
this.tripForm.get("image")?.setValue(null); this.tripForm.get('image')?.setValue(null);
if (this.previous_image && this.previous_image_id) { if (this.previous_image && this.previous_image_id) {
this.tripForm.get("image_id")?.setValue(this.previous_image_id); this.tripForm.get('image_id')?.setValue(this.previous_image_id);
this.tripForm.get("image")?.setValue(this.previous_image); this.tripForm.get('image')?.setValue(this.previous_image);
} }
} }
} }

View File

@ -1,20 +1,15 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { ButtonModule } from 'primeng/button';
FormGroup, import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
ReactiveFormsModule, import { FloatLabelModule } from 'primeng/floatlabel';
Validators, import { InputTextModule } from 'primeng/inputtext';
} from "@angular/forms"; import { FocusTrapModule } from 'primeng/focustrap';
import { ButtonModule } from "primeng/button"; import { SelectModule } from 'primeng/select';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { InputNumberModule } from 'primeng/inputnumber';
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
import { FocusTrapModule } from "primeng/focustrap";
import { SelectModule } from "primeng/select";
import { InputNumberModule } from "primeng/inputnumber";
@Component({ @Component({
selector: "app-trip-create-packing-modal", selector: 'app-trip-create-packing-modal',
imports: [ imports: [
FloatLabelModule, FloatLabelModule,
InputTextModule, InputTextModule,
@ -25,17 +20,17 @@ import { InputNumberModule } from "primeng/inputnumber";
InputNumberModule, InputNumberModule,
], ],
standalone: true, standalone: true,
templateUrl: "./trip-create-packing-modal.component.html", templateUrl: './trip-create-packing-modal.component.html',
styleUrl: "./trip-create-packing-modal.component.scss", styleUrl: './trip-create-packing-modal.component.scss',
}) })
export class TripCreatePackingModalComponent { export class TripCreatePackingModalComponent {
packingForm: FormGroup; packingForm: FormGroup;
readonly packingCategories = [ readonly packingCategories = [
{ value: "clothes", dispValue: "Clothes" }, { value: 'clothes', dispValue: 'Clothes' },
{ value: "toiletries", dispValue: "Toiletries" }, { value: 'toiletries', dispValue: 'Toiletries' },
{ value: "tech", dispValue: "Tech" }, { value: 'tech', dispValue: 'Tech' },
{ value: "documents", dispValue: "Documents" }, { value: 'documents', dispValue: 'Documents' },
{ value: "other", dispValue: "Other" }, { value: 'other', dispValue: 'Other' },
]; ];
constructor( constructor(
@ -46,8 +41,8 @@ export class TripCreatePackingModalComponent {
this.packingForm = this.fb.group({ this.packingForm = this.fb.group({
id: -1, id: -1,
qt: null, qt: null,
text: ["", { validators: Validators.required }], text: ['', { validators: Validators.required }],
category: ["", { validators: Validators.required }], category: ['', { validators: Validators.required }],
}); });
const patchValue = this.config.data?.packing; const patchValue = this.config.data?.packing;

View File

@ -1,26 +1,20 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { DynamicDialogRef } from "primeng/dynamicdialog"; import { DynamicDialogRef } from 'primeng/dynamicdialog';
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from 'primeng/floatlabel';
import { InputTextModule } from "primeng/inputtext"; import { InputTextModule } from 'primeng/inputtext';
import { FocusTrapModule } from "primeng/focustrap"; import { FocusTrapModule } from 'primeng/focustrap';
@Component({ @Component({
selector: "app-trip-invite-member-modal", selector: 'app-trip-invite-member-modal',
imports: [ imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, FocusTrapModule],
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
FocusTrapModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-invite-member-modal.component.html", templateUrl: './trip-invite-member-modal.component.html',
styleUrl: "./trip-invite-member-modal.component.scss", styleUrl: './trip-invite-member-modal.component.scss',
}) })
export class TripInviteMemberModalComponent { export class TripInviteMemberModalComponent {
memberForm = new FormControl(""); memberForm = new FormControl('');
constructor(private ref: DynamicDialogRef) {} constructor(private ref: DynamicDialogRef) {}
closeDialog() { closeDialog() {

View File

@ -1,24 +1,19 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from 'primeng/floatlabel';
import { TextareaModule } from "primeng/textarea"; import { TextareaModule } from 'primeng/textarea';
@Component({ @Component({
selector: "app-trip-notes-modal", selector: 'app-trip-notes-modal',
imports: [ imports: [FloatLabelModule, TextareaModule, ButtonModule, ReactiveFormsModule],
FloatLabelModule,
TextareaModule,
ButtonModule,
ReactiveFormsModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-notes-modal.component.html", templateUrl: './trip-notes-modal.component.html',
styleUrl: "./trip-notes-modal.component.scss", styleUrl: './trip-notes-modal.component.scss',
}) })
export class TripNotesModalComponent { export class TripNotesModalComponent {
notes = new FormControl(""); notes = new FormControl('');
isEditing: boolean = false; isEditing: boolean = false;
constructor( constructor(

View File

@ -1,28 +1,22 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ButtonModule } from "primeng/button"; import { ButtonModule } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from 'primeng/floatlabel';
import { InputTextModule } from "primeng/inputtext"; import { InputTextModule } from 'primeng/inputtext';
import { Place } from "../../types/poi"; import { Place } from '../../types/poi';
import { ApiService } from "../../services/api.service"; import { ApiService } from '../../services/api.service';
import { SkeletonModule } from "primeng/skeleton"; import { SkeletonModule } from 'primeng/skeleton';
@Component({ @Component({
selector: "app-trip-place-select-modal", selector: 'app-trip-place-select-modal',
imports: [ imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, SkeletonModule],
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
SkeletonModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-place-select-modal.component.html", templateUrl: './trip-place-select-modal.component.html',
styleUrl: "./trip-place-select-modal.component.scss", styleUrl: './trip-place-select-modal.component.scss',
}) })
export class TripPlaceSelectModalComponent { export class TripPlaceSelectModalComponent {
searchInput = new FormControl(""); searchInput = new FormControl('');
selectedPlaces: Place[] = []; selectedPlaces: Place[] = [];
showSelectedPlaces: boolean = false; showSelectedPlaces: boolean = false;
@ -38,9 +32,7 @@ export class TripPlaceSelectModalComponent {
) { ) {
this.apiService.getPlaces().subscribe({ this.apiService.getPlaces().subscribe({
next: (places) => { next: (places) => {
this.places = places.sort((a, b) => this.places = places.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
this.displayedPlaces = places; this.displayedPlaces = places;
}, },
}); });
@ -60,9 +52,7 @@ export class TripPlaceSelectModalComponent {
const v = value.toLowerCase(); const v = value.toLowerCase();
this.displayedPlaces = this.places.filter( this.displayedPlaces = this.places.filter(
(p) => (p) => p.name.toLowerCase().includes(v) || p.description?.toLowerCase().includes(v),
p.name.toLowerCase().includes(v) ||
p.description?.toLowerCase().includes(v),
); );
}, },
}); });
@ -80,9 +70,7 @@ export class TripPlaceSelectModalComponent {
this.selectedPlacesID.push(p.id); this.selectedPlacesID.push(p.id);
this.selectedPlaces.push(p); this.selectedPlaces.push(p);
this.selectedPlaces.sort((a, b) => this.selectedPlaces.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} }
closeDialog() { closeDialog() {

View File

@ -1,9 +1,9 @@
import { inject, Injectable } from "@angular/core"; import { inject, Injectable } from '@angular/core';
import { HttpClient } from "@angular/common/http"; import { HttpClient } from '@angular/common/http';
import { Category, Place } from "../types/poi"; import { Category, Place } from '../types/poi';
import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs"; import { BehaviorSubject, map, Observable, shareReplay, tap } from 'rxjs';
import { Info } from "../types/info"; import { Info } from '../types/info';
import { ImportResponse, Settings } from "../types/settings"; import { ImportResponse, Settings } from '../types/settings';
import { import {
ChecklistItem, ChecklistItem,
PackingItem, PackingItem,
@ -14,37 +14,31 @@ import {
TripInvitation, TripInvitation,
TripItem, TripItem,
TripMember, TripMember,
} from "../types/trip"; } from '../types/trip';
const NO_AUTH_HEADER = { const NO_AUTH_HEADER = {
no_auth: "1", no_auth: '1',
}; };
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class ApiService { export class ApiService {
public readonly apiBaseUrl: string = "/api"; public readonly apiBaseUrl: string = '/api';
private categoriesSubject = new BehaviorSubject<Category[] | null>(null); private categoriesSubject = new BehaviorSubject<Category[] | null>(null);
public categories$: Observable<Category[] | null> = public categories$: Observable<Category[] | null> = this.categoriesSubject.asObservable();
this.categoriesSubject.asObservable();
private settingsSubject = new BehaviorSubject<Settings | null>(null); private settingsSubject = new BehaviorSubject<Settings | null>(null);
public settings$: Observable<Settings | null> = public settings$: Observable<Settings | null> = this.settingsSubject.asObservable();
this.settingsSubject.asObservable();
private httpClient = inject(HttpClient); private httpClient = inject(HttpClient);
getInfo(): Observable<Info> { getInfo(): Observable<Info> {
return this.httpClient.get<Info>(this.apiBaseUrl + "/info"); return this.httpClient.get<Info>(this.apiBaseUrl + '/info');
} }
_categoriesSubjectNext(categories: Category[]) { _categoriesSubjectNext(categories: Category[]) {
this.categoriesSubject.next( this.categoriesSubject.next([...categories].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
[...categories].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
),
);
} }
getCategories(): Observable<Category[]> { getCategories(): Observable<Category[]> {
@ -58,21 +52,12 @@ export class ApiService {
postCategory(c: Category): Observable<Category> { postCategory(c: Category): Observable<Category> {
return this.httpClient return this.httpClient
.post<Category>(this.apiBaseUrl + "/categories", c) .post<Category>(this.apiBaseUrl + '/categories', c)
.pipe( .pipe(tap((category) => this._categoriesSubjectNext([...(this.categoriesSubject.value || []), category])));
tap((category) =>
this._categoriesSubjectNext([
...(this.categoriesSubject.value || []),
category,
]),
),
);
} }
putCategory(c_id: number, c: Partial<Category>): Observable<Category> { putCategory(c_id: number, c: Partial<Category>): Observable<Category> {
return this.httpClient return this.httpClient.put<Category>(this.apiBaseUrl + `/categories/${c_id}`, c).pipe(
.put<Category>(this.apiBaseUrl + `/categories/${c_id}`, c)
.pipe(
tap((category) => { tap((category) => {
const categories = this.categoriesSubject.value || []; const categories = this.categoriesSubject.value || [];
const idx = categories?.findIndex((c) => c.id == c_id) || -1; const idx = categories?.findIndex((c) => c.id == c_id) || -1;
@ -86,9 +71,7 @@ export class ApiService {
} }
deleteCategory(category_id: number): Observable<{}> { deleteCategory(category_id: number): Observable<{}> {
return this.httpClient return this.httpClient.delete<{}>(this.apiBaseUrl + `/categories/${category_id}`).pipe(
.delete<{}>(this.apiBaseUrl + `/categories/${category_id}`)
.pipe(
tap(() => { tap(() => {
const categories = this.categoriesSubject.value || []; const categories = this.categoriesSubject.value || [];
const idx = categories?.findIndex((c) => c.id == category_id) || -1; const idx = categories?.findIndex((c) => c.id == category_id) || -1;
@ -109,23 +92,15 @@ export class ApiService {
} }
postPlaces(places: Partial<Place[]>): Observable<Place[]> { postPlaces(places: Partial<Place[]>): Observable<Place[]> {
return this.httpClient.post<Place[]>( return this.httpClient.post<Place[]>(`${this.apiBaseUrl}/places/batch`, places);
`${this.apiBaseUrl}/places/batch`,
places,
);
} }
putPlace(place_id: number, place: Partial<Place>): Observable<Place> { putPlace(place_id: number, place: Partial<Place>): Observable<Place> {
return this.httpClient.put<Place>( return this.httpClient.put<Place>(`${this.apiBaseUrl}/places/${place_id}`, place);
`${this.apiBaseUrl}/places/${place_id}`,
place,
);
} }
deletePlace(place_id: number): Observable<null> { deletePlace(place_id: number): Observable<null> {
return this.httpClient.delete<null>( return this.httpClient.delete<null>(`${this.apiBaseUrl}/places/${place_id}`);
`${this.apiBaseUrl}/places/${place_id}`,
);
} }
getPlaceGPX(place_id: number): Observable<Place> { getPlaceGPX(place_id: number): Observable<Place> {
@ -141,9 +116,7 @@ export class ApiService {
} }
getTripBalance(id: number): Observable<{ [user: string]: number }> { getTripBalance(id: number): Observable<{ [user: string]: number }> {
return this.httpClient.get<{ [user: string]: number }>( return this.httpClient.get<{ [user: string]: number }>(`${this.apiBaseUrl}/trips/${id}/balance`);
`${this.apiBaseUrl}/trips/${id}/balance`,
);
} }
postTrip(trip: TripBase): Observable<TripBase> { postTrip(trip: TripBase): Observable<TripBase> {
@ -155,76 +128,39 @@ export class ApiService {
} }
putTrip(trip: Partial<Trip>, trip_id: number): Observable<Trip> { putTrip(trip: Partial<Trip>, trip_id: number): Observable<Trip> {
return this.httpClient.put<Trip>( return this.httpClient.put<Trip>(`${this.apiBaseUrl}/trips/${trip_id}`, trip);
`${this.apiBaseUrl}/trips/${trip_id}`,
trip,
);
} }
postTripDay(tripDay: TripDay, trip_id: number): Observable<TripDay> { postTripDay(tripDay: TripDay, trip_id: number): Observable<TripDay> {
return this.httpClient.post<TripDay>( return this.httpClient.post<TripDay>(`${this.apiBaseUrl}/trips/${trip_id}/days`, tripDay);
`${this.apiBaseUrl}/trips/${trip_id}/days`,
tripDay,
);
} }
putTripDay(tripDay: Partial<TripDay>, trip_id: number): Observable<TripDay> { putTripDay(tripDay: Partial<TripDay>, trip_id: number): Observable<TripDay> {
return this.httpClient.put<TripDay>( return this.httpClient.put<TripDay>(`${this.apiBaseUrl}/trips/${trip_id}/days/${tripDay.id}`, tripDay);
`${this.apiBaseUrl}/trips/${trip_id}/days/${tripDay.id}`,
tripDay,
);
} }
deleteTripDay(trip_id: number, day_id: number): Observable<null> { deleteTripDay(trip_id: number, day_id: number): Observable<null> {
return this.httpClient.delete<null>( return this.httpClient.delete<null>(`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}`);
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}`,
);
} }
postTripDayItem( postTripDayItem(item: TripItem, trip_id: number, day_id: number): Observable<TripItem> {
item: TripItem, return this.httpClient.post<TripItem>(`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items`, item);
trip_id: number,
day_id: number,
): Observable<TripItem> {
return this.httpClient.post<TripItem>(
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items`,
item,
);
} }
putTripDayItem( putTripDayItem(item: Partial<TripItem>, trip_id: number, day_id: number, item_id: number): Observable<TripItem> {
item: Partial<TripItem>, return this.httpClient.put<TripItem>(`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items/${item_id}`, item);
trip_id: number,
day_id: number,
item_id: number,
): Observable<TripItem> {
return this.httpClient.put<TripItem>(
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items/${item_id}`,
item,
);
} }
deleteTripDayItem( deleteTripDayItem(trip_id: number, day_id: number, item_id: number): Observable<null> {
trip_id: number, return this.httpClient.delete<null>(`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items/${item_id}`);
day_id: number,
item_id: number,
): Observable<null> {
return this.httpClient.delete<null>(
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items/${item_id}`,
);
} }
getSharedTrip(token: string): Observable<Trip> { getSharedTrip(token: string): Observable<Trip> {
return this.httpClient.get<Trip>( return this.httpClient.get<Trip>(`${this.apiBaseUrl}/trips/shared/${token}`, { headers: NO_AUTH_HEADER });
`${this.apiBaseUrl}/trips/shared/${token}`,
{ headers: NO_AUTH_HEADER },
);
} }
getSharedTripURL(trip_id: number): Observable<string> { getSharedTripURL(trip_id: number): Observable<string> {
return this.httpClient return this.httpClient.get<SharedTripURL>(`${this.apiBaseUrl}/trips/${trip_id}/share`).pipe(
.get<SharedTripURL>(`${this.apiBaseUrl}/trips/${trip_id}/share`)
.pipe(
map((t) => window.location.origin + t.url), map((t) => window.location.origin + t.url),
shareReplay(), shareReplay(),
); );
@ -237,138 +173,79 @@ export class ApiService {
} }
deleteSharedTrip(trip_id: number): Observable<null> { deleteSharedTrip(trip_id: number): Observable<null> {
return this.httpClient.delete<null>( return this.httpClient.delete<null>(`${this.apiBaseUrl}/trips/${trip_id}/share`);
`${this.apiBaseUrl}/trips/${trip_id}/share`,
);
} }
getPackingList(trip_id: number): Observable<PackingItem[]> { getPackingList(trip_id: number): Observable<PackingItem[]> {
return this.httpClient.get<PackingItem[]>( return this.httpClient.get<PackingItem[]>(`${this.apiBaseUrl}/trips/${trip_id}/packing`);
`${this.apiBaseUrl}/trips/${trip_id}/packing`,
);
} }
getSharedTripPackingList(token: string): Observable<PackingItem[]> { getSharedTripPackingList(token: string): Observable<PackingItem[]> {
return this.httpClient.get<PackingItem[]>( return this.httpClient.get<PackingItem[]>(`${this.apiBaseUrl}/trips/shared/${token}/packing`);
`${this.apiBaseUrl}/trips/shared/${token}/packing`,
);
} }
postPackingItem( postPackingItem(trip_id: number, p_item: PackingItem): Observable<PackingItem> {
trip_id: number, return this.httpClient.post<PackingItem>(`${this.apiBaseUrl}/trips/${trip_id}/packing`, p_item);
p_item: PackingItem,
): Observable<PackingItem> {
return this.httpClient.post<PackingItem>(
`${this.apiBaseUrl}/trips/${trip_id}/packing`,
p_item,
);
} }
putPackingItem( putPackingItem(trip_id: number, p_id: number, p_item: Partial<PackingItem>): Observable<PackingItem> {
trip_id: number, return this.httpClient.put<PackingItem>(`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`, p_item);
p_id: number,
p_item: Partial<PackingItem>,
): Observable<PackingItem> {
return this.httpClient.put<PackingItem>(
`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
p_item,
);
} }
deletePackingItem(trip_id: number, p_id: number): Observable<null> { deletePackingItem(trip_id: number, p_id: number): Observable<null> {
return this.httpClient.delete<null>( return this.httpClient.delete<null>(`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`);
`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
);
} }
getChecklist(trip_id: number): Observable<ChecklistItem[]> { getChecklist(trip_id: number): Observable<ChecklistItem[]> {
return this.httpClient.get<ChecklistItem[]>( return this.httpClient.get<ChecklistItem[]>(`${this.apiBaseUrl}/trips/${trip_id}/checklist`);
`${this.apiBaseUrl}/trips/${trip_id}/checklist`,
);
} }
getSharedTripChecklist(token: string): Observable<ChecklistItem[]> { getSharedTripChecklist(token: string): Observable<ChecklistItem[]> {
return this.httpClient.get<ChecklistItem[]>( return this.httpClient.get<ChecklistItem[]>(`${this.apiBaseUrl}/trips/shared/${token}/checklist`);
`${this.apiBaseUrl}/trips/shared/${token}/checklist`,
);
} }
postChecklistItem( postChecklistItem(trip_id: number, item: ChecklistItem): Observable<ChecklistItem> {
trip_id: number, return this.httpClient.post<ChecklistItem>(`${this.apiBaseUrl}/trips/${trip_id}/checklist`, item);
item: ChecklistItem,
): Observable<ChecklistItem> {
return this.httpClient.post<ChecklistItem>(
`${this.apiBaseUrl}/trips/${trip_id}/checklist`,
item,
);
} }
putChecklistItem( putChecklistItem(trip_id: number, id: number, item: Partial<ChecklistItem>): Observable<ChecklistItem> {
trip_id: number, return this.httpClient.put<ChecklistItem>(`${this.apiBaseUrl}/trips/${trip_id}/checklist/${id}`, item);
id: number,
item: Partial<ChecklistItem>,
): Observable<ChecklistItem> {
return this.httpClient.put<ChecklistItem>(
`${this.apiBaseUrl}/trips/${trip_id}/checklist/${id}`,
item,
);
} }
deleteChecklistItem(trip_id: number, id: number): Observable<null> { deleteChecklistItem(trip_id: number, id: number): Observable<null> {
return this.httpClient.delete<null>( return this.httpClient.delete<null>(`${this.apiBaseUrl}/trips/${trip_id}/checklist/${id}`);
`${this.apiBaseUrl}/trips/${trip_id}/checklist/${id}`,
);
} }
getHasTripsInvitations(): Observable<boolean> { getHasTripsInvitations(): Observable<boolean> {
return this.httpClient.get<boolean>( return this.httpClient.get<boolean>(`${this.apiBaseUrl}/trips/invitations/pending`);
`${this.apiBaseUrl}/trips/invitations/pending`,
);
} }
getTripsInvitations(): Observable<TripInvitation[]> { getTripsInvitations(): Observable<TripInvitation[]> {
return this.httpClient.get<TripInvitation[]>( return this.httpClient.get<TripInvitation[]>(`${this.apiBaseUrl}/trips/invitations`);
`${this.apiBaseUrl}/trips/invitations`,
);
} }
getTripMembers(trip_id: number): Observable<TripMember[]> { getTripMembers(trip_id: number): Observable<TripMember[]> {
return this.httpClient.get<TripMember[]>( return this.httpClient.get<TripMember[]>(`${this.apiBaseUrl}/trips/${trip_id}/members`);
`${this.apiBaseUrl}/trips/${trip_id}/members`,
);
} }
deleteTripMember(trip_id: number, username: string): Observable<null> { deleteTripMember(trip_id: number, username: string): Observable<null> {
return this.httpClient.delete<null>( return this.httpClient.delete<null>(`${this.apiBaseUrl}/trips/${trip_id}/members/${username}`);
`${this.apiBaseUrl}/trips/${trip_id}/members/${username}`,
);
} }
inviteTripMember(trip_id: number, user: string): Observable<TripMember> { inviteTripMember(trip_id: number, user: string): Observable<TripMember> {
return this.httpClient.post<TripMember>( return this.httpClient.post<TripMember>(`${this.apiBaseUrl}/trips/${trip_id}/members`, { user });
`${this.apiBaseUrl}/trips/${trip_id}/members`,
{ user },
);
} }
acceptTripMemberInvite(trip_id: number): Observable<null> { acceptTripMemberInvite(trip_id: number): Observable<null> {
return this.httpClient.post<null>( return this.httpClient.post<null>(`${this.apiBaseUrl}/trips/${trip_id}/members/accept`, {});
`${this.apiBaseUrl}/trips/${trip_id}/members/accept`,
{},
);
} }
declineTripMemberInvite(trip_id: number): Observable<null> { declineTripMemberInvite(trip_id: number): Observable<null> {
return this.httpClient.post<null>( return this.httpClient.post<null>(`${this.apiBaseUrl}/trips/${trip_id}/members/decline`, {});
`${this.apiBaseUrl}/trips/${trip_id}/members/decline`,
{},
);
} }
checkVersion(): Observable<string> { checkVersion(): Observable<string> {
return this.httpClient.get<string>( return this.httpClient.get<string>(`${this.apiBaseUrl}/settings/checkversion`);
`${this.apiBaseUrl}/settings/checkversion`,
);
} }
getSettings(): Observable<Settings> { getSettings(): Observable<Settings> {
@ -392,7 +269,7 @@ export class ApiService {
} }
settingsUserImport(formdata: FormData): Observable<ImportResponse> { settingsUserImport(formdata: FormData): Observable<ImportResponse> {
const headers = { enctype: "multipart/form-data" }; const headers = { enctype: 'multipart/form-data' };
return this.httpClient return this.httpClient
.post<ImportResponse>(`${this.apiBaseUrl}/settings/import`, formdata, { .post<ImportResponse>(`${this.apiBaseUrl}/settings/import`, formdata, {
headers: headers, headers: headers,

View File

@ -1,8 +1,8 @@
import { inject } from "@angular/core"; import { inject } from '@angular/core';
import { CanActivateFn, Router } from "@angular/router"; import { CanActivateFn, Router } from '@angular/router';
import { UtilsService } from "./utils.service"; import { UtilsService } from './utils.service';
import { AuthService } from "./auth.service"; import { AuthService } from './auth.service';
import { of, switchMap, take } from "rxjs"; import { of, switchMap, take } from 'rxjs';
export const AuthGuard: CanActivateFn = (_, state) => { export const AuthGuard: CanActivateFn = (_, state) => {
const router: Router = inject(Router); const router: Router = inject(Router);
@ -14,14 +14,9 @@ export const AuthGuard: CanActivateFn = (_, state) => {
take(1), take(1),
switchMap((authenticated) => { switchMap((authenticated) => {
if (!authenticated) { if (!authenticated) {
const redirectURL = const redirectURL = state.url === '/auth' ? '' : `redirectURL=${state.url}`;
state.url === "/auth" ? "" : `redirectURL=${state.url}`;
const urlTree = router.parseUrl(`auth?${redirectURL}`); const urlTree = router.parseUrl(`auth?${redirectURL}`);
utilsService.toast( utilsService.toast('warn', 'Authentication required', 'You must be authenticated');
"warn",
"Authentication required",
"You must be authenticated",
);
return of(urlTree); return of(urlTree);
} }

View File

@ -1,10 +1,10 @@
import { HttpClient } from "@angular/common/http"; import { HttpClient } from '@angular/common/http';
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { Router } from "@angular/router"; import { Router } from '@angular/router';
import { Observable, of, ReplaySubject } from "rxjs"; import { Observable, of, ReplaySubject } from 'rxjs';
import { tap } from "rxjs/operators"; import { tap } from 'rxjs/operators';
import { ApiService } from "./api.service"; import { ApiService } from './api.service';
import { UtilsService } from "./utils.service"; import { UtilsService } from './utils.service';
export interface Token { export interface Token {
refresh_token: string; refresh_token: string;
@ -16,11 +16,11 @@ export interface AuthParams {
oidc?: string; oidc?: string;
} }
const JWT_TOKEN = "TRIP_AT"; const JWT_TOKEN = 'TRIP_AT';
const REFRESH_TOKEN = "TRIP_RT"; const REFRESH_TOKEN = 'TRIP_RT';
const JWT_USER = "TRIP_USER"; const JWT_USER = 'TRIP_USER';
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
public readonly apiBaseUrl: string; public readonly apiBaseUrl: string;
private refreshInProgressLock$: ReplaySubject<Token> | null = null; private refreshInProgressLock$: ReplaySubject<Token> | null = null;
@ -39,7 +39,7 @@ export class AuthService {
} }
get loggedUser(): string { get loggedUser(): string {
return localStorage.getItem(JWT_USER) ?? ""; return localStorage.getItem(JWT_USER) ?? '';
} }
set accessToken(token: string) { set accessToken(token: string) {
@ -47,7 +47,7 @@ export class AuthService {
} }
get accessToken(): string { get accessToken(): string {
return localStorage.getItem(JWT_TOKEN) ?? ""; return localStorage.getItem(JWT_TOKEN) ?? '';
} }
set refreshToken(token: string) { set refreshToken(token: string) {
@ -55,11 +55,11 @@ export class AuthService {
} }
get refreshToken(): string { get refreshToken(): string {
return localStorage.getItem(REFRESH_TOKEN) ?? ""; return localStorage.getItem(REFRESH_TOKEN) ?? '';
} }
authParams(): Observable<AuthParams> { authParams(): Observable<AuthParams> {
return this.httpClient.get<AuthParams>(this.apiBaseUrl + "/auth/params"); return this.httpClient.get<AuthParams>(this.apiBaseUrl + '/auth/params');
} }
storeTokens(tokens: Token): void { storeTokens(tokens: Token): void {
@ -81,7 +81,7 @@ export class AuthService {
this.refreshInProgressLock$ = new ReplaySubject(1); this.refreshInProgressLock$ = new ReplaySubject(1);
this.httpClient this.httpClient
.post<Token>(this.apiBaseUrl + "/auth/refresh", { .post<Token>(this.apiBaseUrl + '/auth/refresh', {
refresh_token: this.refreshToken, refresh_token: this.refreshToken,
}) })
.pipe( .pipe(
@ -103,9 +103,7 @@ export class AuthService {
} }
login(authForm: { username: string; password: string }): Observable<Token> { login(authForm: { username: string; password: string }): Observable<Token> {
return this.httpClient return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/login', authForm).pipe(
.post<Token>(this.apiBaseUrl + "/auth/login", authForm)
.pipe(
tap((tokens: Token) => { tap((tokens: Token) => {
this.loggedUser = authForm.username; this.loggedUser = authForm.username;
this.storeTokens(tokens); this.storeTokens(tokens);
@ -113,13 +111,8 @@ export class AuthService {
); );
} }
register(authForm: { register(authForm: { username: string; password: string }): Observable<Token> {
username: string; return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/register', authForm).pipe(
password: string;
}): Observable<Token> {
return this.httpClient
.post<Token>(this.apiBaseUrl + "/auth/register", authForm)
.pipe(
tap((tokens: Token) => { tap((tokens: Token) => {
this.loggedUser = authForm.username; this.loggedUser = authForm.username;
this.storeTokens(tokens); this.storeTokens(tokens);
@ -128,9 +121,7 @@ export class AuthService {
} }
oidcLogin(code: string, state: string): Observable<Token> { oidcLogin(code: string, state: string): Observable<Token> {
return this.httpClient return this.httpClient.post<Token>(this.apiBaseUrl + '/auth/oidc/login', { code, state }).pipe(
.post<Token>(this.apiBaseUrl + "/auth/oidc/login", { code, state })
.pipe(
tap((data: any) => { tap((data: any) => {
if (data.access_token && data.refresh_token) { if (data.access_token && data.refresh_token) {
this.loggedUser = this._getTokenUsername(data.access_token); this.loggedUser = this._getTokenUsername(data.access_token);
@ -140,23 +131,19 @@ export class AuthService {
); );
} }
logout(custom_msg: string = "", is_error = false): void { logout(custom_msg: string = '', is_error = false): void {
this.loggedUser = ""; this.loggedUser = '';
this.removeTokens(); this.removeTokens();
if (custom_msg) { if (custom_msg) {
if (is_error) { if (is_error) {
this.utilsService.toast( this.utilsService.toast('error', 'You must be authenticated', custom_msg);
"error",
"You must be authenticated",
custom_msg,
);
} else { } else {
this.utilsService.toast("success", "Success", custom_msg); this.utilsService.toast('success', 'Success', custom_msg);
} }
} }
this.router.navigate(["/auth"]); this.router.navigate(['/auth']);
} }
private removeTokens(): void { private removeTokens(): void {
@ -167,7 +154,7 @@ export class AuthService {
isTokenExpired(token: string, offsetSeconds?: number): boolean { isTokenExpired(token: string, offsetSeconds?: number): boolean {
// Return if there is no token // Return if there is no token
if (!token || token === "") { if (!token || token === '') {
return true; return true;
} }
@ -186,25 +173,19 @@ export class AuthService {
private _b64DecodeUnicode(str: any): string { private _b64DecodeUnicode(str: any): string {
return decodeURIComponent( return decodeURIComponent(
Array.prototype.map Array.prototype.map
.call( .call(this._b64decode(str), (c: any) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
this._b64decode(str), .join(''),
(c: any) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2),
)
.join(""),
); );
} }
private _b64decode(str: string): string { private _b64decode(str: string): string {
const chars = const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; let output = '';
let output = "";
str = String(str).replace(/=+$/, ""); str = String(str).replace(/=+$/, '');
if (str.length % 4 === 1) { if (str.length % 4 === 1) {
throw new Error( throw new Error("'atob' failed: The string to be decoded is not correctly encoded.");
"'atob' failed: The string to be decoded is not correctly encoded.",
);
} }
/* eslint-disable */ /* eslint-disable */
@ -223,21 +204,21 @@ export class AuthService {
} }
private _urlBase64Decode(str: string): string { private _urlBase64Decode(str: string): string {
let output = str.replace(/-/g, "+").replace(/_/g, "/"); let output = str.replace(/-/g, '+').replace(/_/g, '/');
switch (output.length % 4) { switch (output.length % 4) {
case 0: { case 0: {
break; break;
} }
case 2: { case 2: {
output += "=="; output += '==';
break; break;
} }
case 3: { case 3: {
output += "="; output += '=';
break; break;
} }
default: { default: {
throw Error("Illegal base64url string!"); throw Error('Illegal base64url string!');
} }
} }
return this._b64DecodeUnicode(output); return this._b64DecodeUnicode(output);
@ -247,11 +228,11 @@ export class AuthService {
const decodedToken = this._decodeToken(token); const decodedToken = this._decodeToken(token);
if (decodedToken === null) { if (decodedToken === null) {
return ""; return '';
} }
if (!decodedToken.hasOwnProperty("sub")) { if (!decodedToken.hasOwnProperty('sub')) {
return ""; return '';
} }
return decodedToken.sub; return decodedToken.sub;
@ -262,7 +243,7 @@ export class AuthService {
return null; return null;
} }
const parts = token.split("."); const parts = token.split('.');
if (parts.length !== 3) { if (parts.length !== 3) {
return null; return null;
@ -284,7 +265,7 @@ export class AuthService {
return null; return null;
} }
if (!decodedToken.hasOwnProperty("exp")) { if (!decodedToken.hasOwnProperty('exp')) {
return null; return null;
} }

View File

@ -1,63 +1,52 @@
import { import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http';
HttpErrorResponse, import { inject } from '@angular/core';
HttpEvent, import { catchError, Observable, switchMap, take, throwError } from 'rxjs';
HttpHandlerFn, import { AuthService } from './auth.service';
HttpRequest, import { UtilsService } from './utils.service';
} from "@angular/common/http";
import { inject } from "@angular/core";
import { catchError, Observable, switchMap, take, throwError } from "rxjs";
import { AuthService } from "./auth.service";
import { UtilsService } from "./utils.service";
const ERROR_CONFIG: Record<number, { title: string; detail: string }> = { const ERROR_CONFIG: Record<number, { title: string; detail: string }> = {
400: { 400: {
title: "Bad Request", title: 'Bad Request',
detail: "Unknown error, check console for details", detail: 'Unknown error, check console for details',
}, },
403: { title: "Forbidden", detail: "You are not allowed to do this" }, 403: { title: 'Forbidden', detail: 'You are not allowed to do this' },
409: { title: "Conflict", detail: "Conflict on resource" }, 409: { title: 'Conflict', detail: 'Conflict on resource' },
413: { title: "Request Entity Too Large", detail: "The resource is too big" }, 413: { title: 'Request Entity Too Large', detail: 'The resource is too big' },
422: { 422: {
title: "Unprocessable Entity", title: 'Unprocessable Entity',
detail: "The resource you sent was unprocessable", detail: 'The resource you sent was unprocessable',
}, },
502: { 502: {
title: "Bad Gateway", title: 'Bad Gateway',
detail: "Check your connectivity and ensure the server is up", detail: 'Check your connectivity and ensure the server is up',
}, },
503: { title: "Service Unavailable", detail: "Resource not available" }, 503: { title: 'Service Unavailable', detail: 'Resource not available' },
}; };
export const Interceptor = ( export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> => {
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> => {
const authService = inject(AuthService); const authService = inject(AuthService);
const utilsService = inject(UtilsService); const utilsService = inject(UtilsService);
function showAndThrowError(title: string, details: string) { function showAndThrowError(title: string, details: string) {
utilsService.toast("error", title, details); utilsService.toast('error', title, details);
return throwError(() => details); return throwError(() => details);
} }
if (req.headers.has("no_auth")) { if (req.headers.has('no_auth')) {
// Shared Trip must be anonymous // Shared Trip must be anonymous
return next(req); return next(req);
} }
if (!req.headers.has("enctype") && !req.headers.has("Content-Type")) { if (!req.headers.has('enctype') && !req.headers.has('Content-Type')) {
req = req.clone({ req = req.clone({
setHeaders: { setHeaders: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
"Accept-Language": "en-US;q=0.9,en-US,en;q=0.8", 'Accept-Language': 'en-US;q=0.9,en-US,en;q=0.8',
}, },
}); });
} }
if ( if (authService.accessToken && !authService.isTokenExpired(authService.accessToken)) {
authService.accessToken &&
!authService.isTokenExpired(authService.accessToken)
) {
if (req.url.startsWith(authService.apiBaseUrl)) { if (req.url.startsWith(authService.apiBaseUrl)) {
req = req.clone({ req = req.clone({
setHeaders: { Authorization: `Bearer ${authService.accessToken}` }, setHeaders: { Authorization: `Bearer ${authService.accessToken}` },
@ -70,17 +59,14 @@ export const Interceptor = (
const errDetails = ERROR_CONFIG[err.status]; const errDetails = ERROR_CONFIG[err.status];
if (errDetails) { if (errDetails) {
console.error(err); console.error(err);
return showAndThrowError( return showAndThrowError(errDetails.title, `${err.error?.detail || err.message || errDetails.detail}`);
errDetails.title,
`${err.error?.detail || err.message || errDetails.detail}`,
);
} }
if (err.status == 401 && authService.accessToken) { if (err.status == 401 && authService.accessToken) {
// Handle 401 on Refresh (RT expired) // Handle 401 on Refresh (RT expired)
if (req.url.endsWith("/refresh")) { if (req.url.endsWith('/refresh')) {
authService.logout("Your session has expired", true); authService.logout('Your session has expired', true);
return throwError(() => "Your session has expired"); return throwError(() => 'Your session has expired');
} }
// Unauthenticated, AT exists but is expired (authServices.accessToken truethy), we refresh it // Unauthenticated, AT exists but is expired (authServices.accessToken truethy), we refresh it
@ -95,18 +81,15 @@ export const Interceptor = (
return next(refreshedReq); return next(refreshedReq);
}), }),
); );
} else if (err.status == 401 && !req.url.endsWith("/refresh")) { } else if (err.status == 401 && !req.url.endsWith('/refresh')) {
// If any API route 401 -> redirect to login. We skip /refresh/ to prevent toast on login errors. // If any API route 401 -> redirect to login. We skip /refresh/ to prevent toast on login errors.
authService.logout( authService.logout(`${err.error?.detail || err.message || 'You must be authenticated'}`, true);
`${err.error?.detail || err.message || "You must be authenticated"}`,
true,
);
} }
console.error(err); console.error(err);
return showAndThrowError( return showAndThrowError(
"Request Error", 'Request Error',
`${err.error?.detail || err.message || "Unknown error, check console for details"}`, `${err.error?.detail || err.message || 'Unknown error, check console for details'}`,
); );
}), }),
); );

View File

@ -1,47 +1,42 @@
import { inject, Injectable } from "@angular/core"; import { inject, Injectable } from '@angular/core';
import { MessageService } from "primeng/api"; import { MessageService } from 'primeng/api';
import { TripStatus } from "../types/trip"; import { TripStatus } from '../types/trip';
import { ApiService } from "./api.service"; import { ApiService } from './api.service';
import { map } from "rxjs"; import { map } from 'rxjs';
type ToastSeverity = "info" | "warn" | "error" | "success"; type ToastSeverity = 'info' | 'warn' | 'error' | 'success';
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class UtilsService { export class UtilsService {
private apiService = inject(ApiService); private apiService = inject(ApiService);
currency$ = this.apiService.settings$.pipe(map((s) => s?.currency ?? "€")); currency$ = this.apiService.settings$.pipe(map((s) => s?.currency ?? '€'));
readonly statuses: TripStatus[] = [ readonly statuses: TripStatus[] = [
{ label: "pending", color: "#3258A8" }, { label: 'pending', color: '#3258A8' },
{ label: "booked", color: "#00A341" }, { label: 'booked', color: '#00A341' },
{ label: "constraint", color: "#FFB900" }, { label: 'constraint', color: '#FFB900' },
{ label: "optional", color: "#625A84" }, { label: 'optional', color: '#625A84' },
]; ];
constructor(private ngMessageService: MessageService) {} constructor(private ngMessageService: MessageService) {}
toGithubTRIP() { toGithubTRIP() {
window.open("https://github.com/itskovacs/trip", "_blank"); window.open('https://github.com/itskovacs/trip', '_blank');
} }
toggleDarkMode() { toggleDarkMode() {
const element = document.querySelector("html"); const element = document.querySelector('html');
element?.classList.toggle("dark"); element?.classList.toggle('dark');
} }
enableDarkMode() { enableDarkMode() {
const element = document.querySelector("html"); const element = document.querySelector('html');
element?.classList.toggle("dark", true); element?.classList.toggle('dark', true);
} }
toast( toast(severity: ToastSeverity = 'info', summary = 'Info', detail = '', life = 3000): void {
severity: ToastSeverity = "info",
summary = "Info",
detail = "",
life = 3000,
): void {
this.ngMessageService.add({ this.ngMessageService.add({
severity, severity,
summary, summary,
@ -57,12 +52,12 @@ export class UtilsService {
const lngMatch = url.match(/!4d([\d\-.]+)/); const lngMatch = url.match(/!4d([\d\-.]+)/);
if (!placeMatch || !latMatch || !lngMatch) { if (!placeMatch || !latMatch || !lngMatch) {
this.toast("error", "Error", "Unrecognized Google Maps URL format"); this.toast('error', 'Error', 'Unrecognized Google Maps URL format');
console.error("Unrecognized Google Maps URL format"); console.error('Unrecognized Google Maps URL format');
return ["", ""]; return ['', ''];
} }
const place = decodeURIComponent(placeMatch[1].replace(/\+/g, " ").trim()); const place = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ').trim());
const latlng = `${latMatch[1]},${lngMatch[1]}`; const latlng = `${latMatch[1]},${lngMatch[1]}`;
return [place, latlng]; return [place, latlng];
} }

View File

@ -1,9 +1,4 @@
export function calculateDistanceBetween( export function calculateDistanceBetween(lat1: number, lon1: number, lat2: number, lon2: number) {
lat1: number,
lon1: number,
lat2: number,
lon2: number,
) {
// returns d in meter // returns d in meter
const toRad = (deg: number) => (deg * Math.PI) / 180; const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1); const dLat = toRad(lat2 - lat1);

View File

@ -1,19 +1,13 @@
import OpenLocationCode from "open-location-code-typescript"; import OpenLocationCode from 'open-location-code-typescript';
const patternDEC = /^\s*(-?\d{1,3}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)\s*$/; const patternDEC = /^\s*(-?\d{1,3}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)\s*$/;
const patternDD = const patternDD = /^\s*(\d{1,3}(?:\.\d+)?)°?\s*([NS])\s*,\s*(\d{1,3}(?:\.\d+)?)°?\s*([EW])\s*$/i;
/^\s*(\d{1,3}(?:\.\d+)?)°?\s*([NS])\s*,\s*(\d{1,3}(?:\.\d+)?)°?\s*([EW])\s*$/i;
const patternDMS = const patternDMS =
/^\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([EW])\s*$/i; /^\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([EW])\s*$/i;
const patternDMM = const patternDMM =
/^\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([EW])\s*$/i; /^\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([EW])\s*$/i;
function _dmsToDecimal( function _dmsToDecimal(deg: number, min: number, sec: number, dir: string): number {
deg: number,
min: number,
sec: number,
dir: string,
): number {
const dec = deg + min / 60 + sec / 3600; const dec = deg + min / 60 + sec / 3600;
return /[SW]/i.test(dir) ? -dec : dec; return /[SW]/i.test(dir) ? -dec : dec;
} }
@ -24,14 +18,12 @@ function _dmmToDecimal(deg: number, min: number, dir: string): number {
} }
export function formatLatLng(num: number): string { export function formatLatLng(num: number): string {
const decimals = num.toString().split(".")[1]?.length || 0; const decimals = num.toString().split('.')[1]?.length || 0;
return num.toFixed(Math.min(decimals, 5)); return num.toFixed(Math.min(decimals, 5));
} }
export function checkAndParseLatLng( export function checkAndParseLatLng(value: string | number): [number, number] | undefined {
value: string | number, if (typeof value !== 'string') return undefined;
): [number, number] | undefined {
if (typeof value !== "string") return undefined;
// Parse PlusCode // Parse PlusCode
if (OpenLocationCode.isValid(value)) { if (OpenLocationCode.isValid(value)) {

View File

@ -1,7 +1,7 @@
import { Pipe, PipeTransform } from "@angular/core"; import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Pipe({ name: "linkify", standalone: true }) @Pipe({ name: 'linkify', standalone: true })
export class LinkifyPipe implements PipeTransform { export class LinkifyPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {} constructor(private sanitizer: DomSanitizer) {}
@ -10,11 +10,11 @@ export class LinkifyPipe implements PipeTransform {
/[&<>"']/g, /[&<>"']/g,
(char) => (char) =>
({ ({
"&": "&amp;", '&': '&amp;',
"<": "&lt;", '<': '&lt;',
">": "&gt;", '>': '&gt;',
'"': "&quot;", '"': '&quot;',
"'": "&#39;", "'": '&#39;',
})[char]!, })[char]!,
); );
} }
@ -26,7 +26,7 @@ export class LinkifyPipe implements PipeTransform {
const safeText = this.basicEscape(text); const safeText = this.basicEscape(text);
const html = safeText.replace(urlRegex, (url) => { const html = safeText.replace(urlRegex, (url) => {
const href = url.startsWith("http") ? url : `https://${url}`; const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${href}" target="_blank">${url}</a>`; return `<a href="${href}" target="_blank">${url}</a>`;
}); });

View File

@ -1,10 +1,9 @@
import * as L from "leaflet"; import * as L from 'leaflet';
import "leaflet.markercluster"; import 'leaflet.markercluster';
import "leaflet-contextmenu"; import 'leaflet-contextmenu';
import { Place } from "../types/poi"; import { Place } from '../types/poi';
export const DEFAULT_TILE_URL = export const DEFAULT_TILE_URL = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
export interface ContextMenuItem { export interface ContextMenuItem {
text: string; text: string;
index?: number; index?: number;
@ -20,15 +19,12 @@ export interface MarkerOptions extends L.MarkerOptions {
contextmenuItems: ContextMenuItem[]; contextmenuItems: ContextMenuItem[];
} }
export function createMap( export function createMap(contextMenuItems: ContextMenuItem[] = [], tilelayer: string = DEFAULT_TILE_URL): L.Map {
contextMenuItems: ContextMenuItem[] = [],
tilelayer: string = DEFAULT_TILE_URL,
): L.Map {
const southWest = L.latLng(-89.99, -180); const southWest = L.latLng(-89.99, -180);
const northEast = L.latLng(89.99, 180); const northEast = L.latLng(89.99, 180);
const bounds = L.latLngBounds(southWest, northEast); const bounds = L.latLngBounds(southWest, northEast);
const map = L.map("map", { const map = L.map('map', {
maxBoundsViscosity: 1.0, maxBoundsViscosity: 1.0,
zoomControl: false, zoomControl: false,
contextmenu: true, contextmenu: true,
@ -61,34 +57,29 @@ export function createClusterGroup(): L.MarkerClusterGroup {
const count = cluster.getChildCount(); const count = cluster.getChildCount();
return L.divIcon({ return L.divIcon({
html: `<div class="custom-cluster">${count}</div>`, html: `<div class="custom-cluster">${count}</div>`,
className: "", className: '',
iconSize: [40, 40], iconSize: [40, 40],
}); });
}, },
}); });
} }
export function tripDayMarker(item: { export function tripDayMarker(item: { text: string; lat: number; lng: number; time?: string }): L.Marker {
text: string;
lat: number;
lng: number;
time?: string;
}): L.Marker {
const marker = new L.Marker([item.lat!, item.lng], { const marker = new L.Marker([item.lat!, item.lng], {
icon: L.divIcon({ icon: L.divIcon({
className: "bg-black rounded-full", className: 'bg-black rounded-full',
iconSize: [14, 14], iconSize: [14, 14],
}), }),
}); });
const touchDevice = "ontouchstart" in window; const touchDevice = 'ontouchstart' in window;
if (!touchDevice) { if (!touchDevice) {
marker.bindTooltip( marker.bindTooltip(
`<div class="text-xs text-gray-500">${item.time}</div><div class="font-semibold mb-1 truncate text-base">${item.text}</div>`, `<div class="text-xs text-gray-500">${item.time}</div><div class="font-semibold mb-1 truncate text-base">${item.text}</div>`,
{ {
direction: "right", direction: 'right',
offset: [10, 0], offset: [10, 0],
className: "class-tooltip", className: 'class-tooltip',
}, },
); );
} }
@ -104,28 +95,25 @@ export function placeToMarker(
const options: Partial<L.MarkerOptions> = { const options: Partial<L.MarkerOptions> = {
riseOnHover: true, riseOnHover: true,
title: place.name, title: place.name,
alt: "", alt: '',
}; };
const markerImage = isLowNet const markerImage = isLowNet ? place.category.image : (place.image ?? place.category.image);
? place.category.image
: (place.image ?? place.category.image);
let markerClasses = let markerClasses = 'w-full h-full rounded-full bg-center bg-cover bg-white dark:bg-surface-900';
"w-full h-full rounded-full bg-center bg-cover bg-white dark:bg-surface-900"; if (grayscale) markerClasses += ' grayscale';
if (grayscale) markerClasses += " grayscale";
const iconHtml = ` const iconHtml = `
<div class="flex items-center justify-center relative rounded-full marker-anchor size-14 box-border" style="border: 2px solid ${place.category.color};"> <div class="flex items-center justify-center relative rounded-full marker-anchor size-14 box-border" style="border: 2px solid ${place.category.color};">
<div class="${markerClasses}" style="background-image: url('${markerImage}');"></div> <div class="${markerClasses}" style="background-image: url('${markerImage}');"></div>
${gpxInBubble && place.gpx ? '<div class="absolute -top-1 -left-1 size-6 flex justify-center items-center bg-white dark:bg-surface-900 border-2 border-black rounded-full"><i class="pi pi-compass"></i></div>' : ""} ${gpxInBubble && place.gpx ? '<div class="absolute -top-1 -left-1 size-6 flex justify-center items-center bg-white dark:bg-surface-900 border-2 border-black rounded-full"><i class="pi pi-compass"></i></div>' : ''}
</div> </div>
`; `;
const icon = L.divIcon({ const icon = L.divIcon({
html: iconHtml.trim(), html: iconHtml.trim(),
iconSize: [56, 56], iconSize: [56, 56],
className: "", className: '',
}); });
const marker = new L.Marker([+place.lat, +place.lng], { const marker = new L.Marker([+place.lat, +place.lng], {
@ -133,12 +121,12 @@ export function placeToMarker(
icon, icon,
}); });
const touchDevice = "ontouchstart" in window; const touchDevice = 'ontouchstart' in window;
if (!touchDevice) { if (!touchDevice) {
marker.bindTooltip(placeHoverTooltip(place), { marker.bindTooltip(placeHoverTooltip(place), {
direction: "right", direction: 'right',
offset: [28, 0], offset: [28, 0],
className: "class-tooltip", className: 'class-tooltip',
}); });
} }
return marker; return marker;
@ -146,16 +134,12 @@ export function placeToMarker(
export function gpxToPolyline(gpx: string): L.Polyline { export function gpxToPolyline(gpx: string): L.Polyline {
const parser = new DOMParser(); const parser = new DOMParser();
const gpxDoc = parser.parseFromString(gpx, "application/xml"); const gpxDoc = parser.parseFromString(gpx, 'application/xml');
const trkpts = Array.from(gpxDoc.querySelectorAll("trkpt")); const trkpts = Array.from(gpxDoc.querySelectorAll('trkpt'));
const latlngs = trkpts.map( const latlngs = trkpts.map(
(pt) => (pt) => [parseFloat(pt.getAttribute('lat')!), parseFloat(pt.getAttribute('lon')!)] as [number, number],
[
parseFloat(pt.getAttribute("lat")!),
parseFloat(pt.getAttribute("lon")!),
] as [number, number],
); );
return L.polyline(latlngs, { color: "blue" }); return L.polyline(latlngs, { color: 'blue' });
} }

View File

@ -1,6 +1,6 @@
import { Pipe, PipeTransform } from "@angular/core"; import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ @Pipe({
name: "orderBy", name: 'orderBy',
pure: true, pure: true,
standalone: true, standalone: true,
}) })
@ -9,8 +9,6 @@ export class orderByPipe implements PipeTransform {
if (!items || items.length === 0) { if (!items || items.length === 0) {
return items; return items;
} }
return items return items.slice().sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0));
.slice()
.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0));
} }
} }

View File

@ -1,26 +1,19 @@
import { import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
ChangeDetectionStrategy, import { ButtonModule } from 'primeng/button';
Component, import { MenuModule } from 'primeng/menu';
EventEmitter, import { Place } from '../../types/poi';
Input, import { MenuItem } from 'primeng/api';
OnInit, import { UtilsService } from '../../services/utils.service';
Output, import { Observable } from 'rxjs';
} from "@angular/core"; import { AsyncPipe } from '@angular/common';
import { ButtonModule } from "primeng/button"; import { LinkifyPipe } from '../linkify.pipe';
import { MenuModule } from "primeng/menu";
import { Place } from "../../types/poi";
import { MenuItem } from "primeng/api";
import { UtilsService } from "../../services/utils.service";
import { Observable } from "rxjs";
import { AsyncPipe } from "@angular/common";
import { LinkifyPipe } from "../linkify.pipe";
@Component({ @Component({
selector: "app-place-box", selector: 'app-place-box',
standalone: true, standalone: true,
imports: [ButtonModule, MenuModule, AsyncPipe, LinkifyPipe], imports: [ButtonModule, MenuModule, AsyncPipe, LinkifyPipe],
templateUrl: "./place-box.component.html", templateUrl: './place-box.component.html',
styleUrls: ["./place-box.component.scss"], styleUrls: ['./place-box.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PlaceBoxComponent implements OnInit { export class PlaceBoxComponent implements OnInit {
@ -43,33 +36,33 @@ export class PlaceBoxComponent implements OnInit {
ngOnInit() { ngOnInit() {
const items = [ const items = [
{ {
label: "Edit", label: 'Edit',
icon: "pi pi-pencil", icon: 'pi pi-pencil',
iconClass: "text-blue-500!", iconClass: 'text-blue-500!',
command: () => { command: () => {
this.editPlace(); this.editPlace();
}, },
}, },
{ {
label: "Favorite", label: 'Favorite',
icon: "pi pi-star", icon: 'pi pi-star',
iconClass: "text-yellow-500!", iconClass: 'text-yellow-500!',
command: () => { command: () => {
this.favoritePlace(); this.favoritePlace();
}, },
}, },
{ {
label: "Mark", label: 'Mark',
icon: "pi pi-check", icon: 'pi pi-check',
iconClass: "text-green-500!", iconClass: 'text-green-500!',
command: () => { command: () => {
this.visitPlace(); this.visitPlace();
}, },
}, },
{ {
label: "Delete", label: 'Delete',
icon: "pi pi-trash", icon: 'pi pi-trash',
iconClass: "text-red-500!", iconClass: 'text-red-500!',
command: () => { command: () => {
this.deletePlace(); this.deletePlace();
}, },
@ -78,9 +71,9 @@ export class PlaceBoxComponent implements OnInit {
if (this.selectedPlace?.gpx) { if (this.selectedPlace?.gpx) {
items.unshift({ items.unshift({
label: "Display GPX", label: 'Display GPX',
icon: "pi pi-compass", icon: 'pi pi-compass',
iconClass: "text-gray-500!", iconClass: 'text-gray-500!',
command: () => { command: () => {
this.displayGPX(); this.displayGPX();
}, },
@ -89,7 +82,7 @@ export class PlaceBoxComponent implements OnInit {
this.menuItems = [ this.menuItems = [
{ {
label: "Place", label: 'Place',
items: items, items: items,
}, },
]; ];

View File

@ -1,19 +1,13 @@
import { import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
ChangeDetectionStrategy, import { ButtonModule } from 'primeng/button';
Component, import { Place } from '../../types/poi';
EventEmitter,
Input,
Output,
} from "@angular/core";
import { ButtonModule } from "primeng/button";
import { Place } from "../../types/poi";
@Component({ @Component({
selector: "app-place-gpx", selector: 'app-place-gpx',
standalone: true, standalone: true,
imports: [ButtonModule], imports: [ButtonModule],
templateUrl: "./place-gpx.component.html", templateUrl: './place-gpx.component.html',
styleUrls: ["./place-gpx.component.scss"], styleUrls: ['./place-gpx.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PlaceGPXComponent { export class PlaceGPXComponent {
@ -33,6 +27,6 @@ export class PlaceGPXComponent {
} }
downloadTrace() { downloadTrace() {
this.downloadEmitter.emit() this.downloadEmitter.emit();
} }
} }

View File

@ -1,4 +1,4 @@
import { Category, Place } from "./poi"; import { Category, Place } from './poi';
export interface Settings { export interface Settings {
username: string; username: string;

View File

@ -1,4 +1,4 @@
import { Place } from "./poi"; import { Place } from './poi';
export interface TripBase { export interface TripBase {
id: number; id: number;

View File

@ -2,6 +2,4 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) => bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));
console.error(err),
);

View File

@ -388,24 +388,20 @@ export const TripThemePreset = definePreset(Aura, {
overlay: { overlay: {
select: { select: {
borderRadius: '{border.radius.md}', borderRadius: '{border.radius.md}',
shadow: shadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
}, },
popover: { popover: {
borderRadius: '{border.radius.md}', borderRadius: '{border.radius.md}',
padding: '0.75rem', padding: '0.75rem',
shadow: shadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
}, },
modal: { modal: {
borderRadius: '{border.radius.xl}', borderRadius: '{border.radius.xl}',
padding: '1.25rem', padding: '1.25rem',
shadow: shadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
}, },
navigation: { navigation: {
shadow: shadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
}, },
}, },
colorScheme: { colorScheme: {

View File

@ -1,9 +1,9 @@
@use "primeicons/primeicons.css"; @use 'primeicons/primeicons.css';
@plugin 'tailwindcss-primeui'; @plugin 'tailwindcss-primeui';
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
@layer tailwind { @layer tailwind {
@import "tailwindcss"; @import 'tailwindcss';
} }
* { * {
@ -30,17 +30,17 @@
html { html {
font-size: 15px; font-size: 15px;
font-family: font-family:
"Inter", 'Inter',
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
"Segoe UI", 'Segoe UI',
Roboto, Roboto,
Helvetica, Helvetica,
Arial, Arial,
sans-serif, sans-serif,
"Apple Color Emoji", 'Apple Color Emoji',
"Segoe UI Emoji", 'Segoe UI Emoji',
"Segoe UI Symbol"; 'Segoe UI Symbol';
line-height: normal; line-height: normal;
} }
@ -82,7 +82,7 @@ html {
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
user-select: none; user-select: none;
font-family: "Inter", sans-serif; font-family: 'Inter', sans-serif;
font-size: 15px; font-size: 15px;
} }