✨ Optional Category image, ⚡ Low Network mode, ✨ Trip map: fullscreen and context menu, ⚡ backend prefix assets, 🐛 fix category image fallback, 🐛 fix category delete, 💄 Trip places: Fix usage badge, 💄 Remove external default TRIP image, 🔥 Remove optional image files
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
@ -1 +1 @@
|
||||
__version__ = "1.4.0"
|
||||
__version__ = "1.5.0"
|
||||
|
||||
@ -4,17 +4,19 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
ASSETS_FOLDER: str = "storage/assets"
|
||||
FRONTEND_FOLDER: str = "frontend"
|
||||
SQLITE_FILE: str = "storage/trip.sqlite"
|
||||
|
||||
ASSETS_FOLDER: str = "storage/assets"
|
||||
ASSETS_URL: str = "/api/assets"
|
||||
PLACE_IMAGE_SIZE: int = 500
|
||||
TRIP_IMAGE_SIZE: int = 600
|
||||
|
||||
SECRET_KEY: str = secrets.token_hex(32)
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||
|
||||
PLACE_IMAGE_SIZE: int = 500
|
||||
TRIP_IMAGE_SIZE: int = 600
|
||||
|
||||
class Config:
|
||||
env_file = "storage/config.yml"
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ from sqlalchemy.engine import Engine
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
from ..config import settings
|
||||
from ..models.models import Category, Image
|
||||
from ..models.models import Category
|
||||
|
||||
_engine = None
|
||||
|
||||
@ -32,45 +32,17 @@ def init_db():
|
||||
|
||||
def init_user_data(session: Session, username: str):
|
||||
data = [
|
||||
{
|
||||
"image": {"filename": "nature.png", "user": username},
|
||||
"category": {"user": username, "name": "Nature & Outdoor"},
|
||||
},
|
||||
{
|
||||
"image": {"filename": "entertainment.png", "user": username},
|
||||
"category": {"user": username, "name": "Entertainment & Leisure"},
|
||||
},
|
||||
{
|
||||
"image": {"filename": "culture.png", "user": username},
|
||||
"category": {"user": username, "name": "Culture"},
|
||||
},
|
||||
{
|
||||
"image": {"filename": "food.png", "user": username},
|
||||
"category": {"user": username, "name": "Food & Drink"},
|
||||
},
|
||||
{
|
||||
"image": {"filename": "adventure.png", "user": username},
|
||||
"category": {"user": username, "name": "Adventure & Sports"},
|
||||
},
|
||||
{
|
||||
"image": {"filename": "event.png", "user": username},
|
||||
"category": {"user": username, "name": "Festival & Event"},
|
||||
},
|
||||
{
|
||||
"image": {"filename": "wellness.png", "user": username},
|
||||
"category": {"user": username, "name": "Wellness"},
|
||||
},
|
||||
{
|
||||
"image": {"filename": "accommodation.png", "user": username},
|
||||
"category": {"user": username, "name": "Accommodation"},
|
||||
},
|
||||
{"category": {"user": username, "name": "Nature & Outdoor"}},
|
||||
{"category": {"user": username, "name": "Entertainment & Leisure"}},
|
||||
{"category": {"user": username, "name": "Culture"}},
|
||||
{"category": {"user": username, "name": "Food & Drink"}},
|
||||
{"category": {"user": username, "name": "Adventure & Sports"}},
|
||||
{"category": {"user": username, "name": "Festival & Event"}},
|
||||
{"category": {"user": username, "name": "Wellness"}},
|
||||
{"category": {"user": username, "name": "Accommodation"}},
|
||||
]
|
||||
|
||||
for element in data:
|
||||
img = Image(**element["image"])
|
||||
session.add(img)
|
||||
session.flush()
|
||||
|
||||
category = Category(**element["category"], image_id=img.id)
|
||||
category = Category(**element["category"])
|
||||
session.add(category)
|
||||
session.commit()
|
||||
|
||||
@ -7,6 +7,8 @@ from pydantic import BaseModel, StringConstraints, field_validator
|
||||
from sqlalchemy import MetaData
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from ..config import settings
|
||||
|
||||
convention = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
@ -18,6 +20,13 @@ convention = {
|
||||
SQLModel.metadata = MetaData(naming_convention=convention)
|
||||
|
||||
|
||||
def _prefix_assets_url(filename: str) -> str:
|
||||
base = settings.ASSETS_URL
|
||||
if not base.endswith("/"):
|
||||
base += "/"
|
||||
return base + filename
|
||||
|
||||
|
||||
class TripItemStatusEnum(str, Enum):
|
||||
PENDING = "pending"
|
||||
CONFIRMED = "booked"
|
||||
@ -99,7 +108,7 @@ class Category(CategoryBase, table=True):
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
name: str
|
||||
image: str
|
||||
image: str | None = None
|
||||
|
||||
|
||||
class CategoryUpdate(CategoryBase):
|
||||
@ -109,13 +118,16 @@ class CategoryUpdate(CategoryBase):
|
||||
|
||||
class CategoryRead(CategoryBase):
|
||||
id: int
|
||||
image: str
|
||||
image_id: int
|
||||
image: str | None
|
||||
image_id: int | None
|
||||
|
||||
@classmethod
|
||||
def serialize(cls, obj: Category) -> "CategoryRead":
|
||||
return cls(
|
||||
id=obj.id, name=obj.name, image_id=obj.image_id, image=obj.image.filename if obj.image else None
|
||||
id=obj.id,
|
||||
name=obj.name,
|
||||
image_id=obj.image_id,
|
||||
image=_prefix_assets_url(obj.image.filename) if obj.image else "/favicon.png",
|
||||
)
|
||||
|
||||
|
||||
@ -194,7 +206,7 @@ class PlaceRead(PlaceBase):
|
||||
price=obj.price,
|
||||
duration=obj.duration,
|
||||
visited=obj.visited,
|
||||
image=obj.image.filename if obj.image else None,
|
||||
image=_prefix_assets_url(obj.image.filename) if obj.image else None,
|
||||
image_id=obj.image_id,
|
||||
favorite=obj.favorite,
|
||||
gpx=("1" if obj.gpx else None)
|
||||
@ -241,7 +253,7 @@ class TripReadBase(TripBase):
|
||||
id=obj.id,
|
||||
name=obj.name,
|
||||
archived=obj.archived,
|
||||
image=obj.image.filename if obj.image else None,
|
||||
image=_prefix_assets_url(obj.image.filename) if obj.image else None,
|
||||
image_id=obj.image_id,
|
||||
days=len(obj.days),
|
||||
)
|
||||
@ -260,7 +272,7 @@ class TripRead(TripBase):
|
||||
id=obj.id,
|
||||
name=obj.name,
|
||||
archived=obj.archived,
|
||||
image=obj.image.filename if obj.image else None,
|
||||
image=_prefix_assets_url(obj.image.filename) if obj.image else None,
|
||||
image_id=obj.image_id,
|
||||
days=[TripDayRead.serialize(day) for day in obj.days],
|
||||
places=[PlaceRead.serialize(place) for place in obj.places],
|
||||
|
||||
@ -29,16 +29,17 @@ def post_category(
|
||||
) -> CategoryRead:
|
||||
new_category = Category(name=category.name, user=current_user)
|
||||
|
||||
image_bytes = b64img_decode(category.image)
|
||||
filename = save_image_to_file(image_bytes, settings.PLACE_IMAGE_SIZE)
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
if category.image:
|
||||
image_bytes = b64img_decode(category.image)
|
||||
filename = save_image_to_file(image_bytes, settings.PLACE_IMAGE_SIZE)
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
image = Image(filename=filename, user=current_user)
|
||||
session.add(image)
|
||||
session.commit()
|
||||
session.refresh(image)
|
||||
new_category.image_id = image.id
|
||||
image = Image(filename=filename, user=current_user)
|
||||
session.add(image)
|
||||
session.commit()
|
||||
session.refresh(image)
|
||||
new_category.image_id = image.id
|
||||
|
||||
session.add(new_category)
|
||||
session.commit()
|
||||
@ -57,9 +58,10 @@ def put_category(
|
||||
verify_exists_and_owns(current_user, db_category)
|
||||
|
||||
category_data = category.model_dump(exclude_unset=True)
|
||||
if category_data.get("image"):
|
||||
category_image = category_data.pop("image", None)
|
||||
if category_image:
|
||||
try:
|
||||
image_bytes = b64img_decode(category_data.pop("image"))
|
||||
image_bytes = b64img_decode(category_image)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
@ -105,6 +107,16 @@ def delete_category(
|
||||
if get_category_placess_cnt(session, category_id, current_user) > 0:
|
||||
raise HTTPException(status_code=409, detail="The resource is not orphan")
|
||||
|
||||
if db_category.image:
|
||||
try:
|
||||
remove_image(db_category.image.filename)
|
||||
session.delete(db_category.image)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Roses are red, violets are blue, if you're reading this, I'm sorry for you",
|
||||
)
|
||||
|
||||
session.delete(db_category)
|
||||
session.commit()
|
||||
return {}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
<div class="absolute z-30 top-2 right-2 p-2 bg-white shadow rounded">
|
||||
<p-button (click)="toggleMarkersList()" text severity="secondary" icon="pi pi-bars" />
|
||||
<p-button (click)="toggleMarkersList()" text severity="secondary" icon="pi pi-map-marker" />
|
||||
</div>
|
||||
|
||||
<div class="absolute z-30 top-20 right-2 p-2 bg-white shadow rounded">
|
||||
@ -65,7 +65,7 @@
|
||||
@for (p of visiblePlaces; track p.id) {
|
||||
<div class="mt-4 flex items-center gap-4 hover:bg-gray-50 rounded-xl cursor-pointer py-2 px-4"
|
||||
(click)="gotoPlace(p)" (mouseenter)="hoverPlace(p)" (mouseleave)="resetHoverPlace()">
|
||||
<img [src]="p.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">
|
||||
<h1 class="tracking-tight truncate">{{ p.name }}</h1>
|
||||
@ -166,59 +166,68 @@
|
||||
</p-tab>
|
||||
</p-tablist>
|
||||
<p-tabpanels>
|
||||
<p-tabpanel value="0" [formGroup]="settingsForm">
|
||||
<div class="mt-1 p-2 mb-2 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="font-semibold tracking-tight text-xl">Map parameters</h1>
|
||||
<span class="text-xs text-gray-500">You can customize the default view on map loading</span>
|
||||
<p-tabpanel value="0">
|
||||
<div class="mt-4">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Low Network Mode</h1>
|
||||
<span class="text-xs text-gray-500">You can disable Low Network Mode. Default is true. Display Category
|
||||
image instead of Place image.</span>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between">
|
||||
<div>Low Network Mode</div>
|
||||
<p-toggleswitch [(ngModel)]="isLowNet" (onChange)="toggleLowNet()" />
|
||||
</div>
|
||||
|
||||
<section [formGroup]="settingsForm">
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="font-semibold tracking-tight text-xl">Map parameters</h1>
|
||||
<span class="text-xs text-gray-500">You can customize the default view on map loading</span>
|
||||
</div>
|
||||
|
||||
<p-button icon="pi pi-ethereum" pTooltip="Set current map center as default"
|
||||
(click)="setMapCenterToCurrent()" text />
|
||||
</div>
|
||||
|
||||
<p-button icon="pi pi-ethereum" pTooltip="Set current map center as default"
|
||||
(click)="setMapCenterToCurrent()" text />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
<p-floatlabel variant="in">
|
||||
<input id="mapLat" formControlName="mapLat" pInputText fluid />
|
||||
<label for="mapLat">Lat.</label>
|
||||
</p-floatlabel>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
<p-floatlabel variant="in">
|
||||
<input id="mapLat" formControlName="mapLat" pInputText fluid />
|
||||
<label for="mapLat">Lat.</label>
|
||||
</p-floatlabel>
|
||||
<p-floatlabel variant="in">
|
||||
<input id="mapLng" formControlName="mapLng" pInputText fluid />
|
||||
<label for="mapLng">Long.</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
|
||||
<p-floatlabel variant="in">
|
||||
<input id="mapLng" formControlName="mapLng" pInputText fluid />
|
||||
<label for="mapLng">Long.</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Currency</h1>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p-floatlabel variant="in" class="md:col-span-2">
|
||||
<p-select [options]="currencySigns" optionValue="s" optionLabel="c" inputId="currency" id="currency"
|
||||
class="capitalize" formControlName="currency" [checkmark]="true" [showClear]="true" fluid />
|
||||
<label for="currency">Currency</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Currency</h1>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p-floatlabel variant="in" class="md:col-span-2">
|
||||
<p-select [options]="currencySigns" optionValue="s" optionLabel="c" inputId="currency" id="currency"
|
||||
class="capitalize" formControlName="currency" [checkmark]="true" [showClear]="true" fluid />
|
||||
<label for="currency">Currency</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Filters</h1>
|
||||
<span class="text-xs text-gray-500">You can customize the categories and attributes to hide by
|
||||
default</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p-floatlabel variant="in" class="md:col-span-2">
|
||||
<p-multiselect [options]="doNotDisplayOptions" [group]="true" [filter]="false" [showToggleAll]="false"
|
||||
class="capitalize" formControlName="do_not_display" [showClear]="true" fluid />
|
||||
<label for="do_not_display">Hide</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-2 w-full text-right">
|
||||
<p-button (click)="updateSettings()" label="Update" text
|
||||
[disabled]="!settingsForm.valid || settingsForm.pristine" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Filters</h1>
|
||||
<span class="text-xs text-gray-500">You can customize the categories to hide by default</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p-floatlabel variant="in" class="md:col-span-2">
|
||||
<p-multiselect [options]="doNotDisplayOptions" [group]="true" [filter]="false" [showToggleAll]="false"
|
||||
class="capitalize" formControlName="do_not_display" [showClear]="true" fluid />
|
||||
<label for="do_not_display">Hide</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 w-full text-right">
|
||||
<p-button (click)="updateSettings()" label="Update" text
|
||||
[disabled]="!settingsForm.valid || settingsForm.pristine" />
|
||||
</div>
|
||||
</section>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="1">
|
||||
|
||||
|
||||
@ -78,6 +78,7 @@ export interface MarkerOptions extends L.MarkerOptions {
|
||||
export class DashboardComponent implements AfterViewInit {
|
||||
searchInput = new FormControl("");
|
||||
info: Info | undefined;
|
||||
isLowNet: boolean = false;
|
||||
|
||||
viewSettings = false;
|
||||
viewFilters = false;
|
||||
@ -111,6 +112,7 @@ export class DashboardComponent implements AfterViewInit {
|
||||
private fb: FormBuilder,
|
||||
) {
|
||||
this.currencySigns = this.utilsService.currencySigns();
|
||||
this.isLowNet = this.utilsService.isLowNet;
|
||||
|
||||
this.settingsForm = this.fb.group({
|
||||
mapLat: [
|
||||
@ -244,6 +246,13 @@ export class DashboardComponent implements AfterViewInit {
|
||||
if (this.viewMarkersList) this.setVisibleMarkers();
|
||||
}
|
||||
|
||||
toggleLowNet() {
|
||||
this.utilsService.toggleLowNet();
|
||||
setTimeout(() => {
|
||||
this.updateMarkersAndClusters();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
get filteredPlaces(): Place[] {
|
||||
return this.places.filter((p) => {
|
||||
if (!this.filter_display_visited && p.visited) return false;
|
||||
@ -264,7 +273,7 @@ export class DashboardComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
placeToMarker(place: Place): L.Marker {
|
||||
let marker = placeToMarker(place);
|
||||
let marker = placeToMarker(place, this.isLowNet);
|
||||
marker
|
||||
.on("click", (e) => {
|
||||
this.selectedPlace = place;
|
||||
@ -456,6 +465,7 @@ export class DashboardComponent implements AfterViewInit {
|
||||
if (index > -1) this.places.splice(index, 1);
|
||||
this.closePlaceBox();
|
||||
this.updateMarkersAndClusters();
|
||||
if (this.viewMarkersList) this.setVisibleMarkers();
|
||||
},
|
||||
});
|
||||
},
|
||||
@ -499,6 +509,7 @@ export class DashboardComponent implements AfterViewInit {
|
||||
setTimeout(() => {
|
||||
this.updateMarkersAndClusters();
|
||||
}, 10);
|
||||
if (this.viewMarkersList) this.setVisibleMarkers();
|
||||
},
|
||||
});
|
||||
},
|
||||
@ -561,6 +572,7 @@ export class DashboardComponent implements AfterViewInit {
|
||||
|
||||
toggleMarkersList() {
|
||||
this.viewMarkersList = !this.viewMarkersList;
|
||||
if (this.viewMarkersList) this.setVisibleMarkers();
|
||||
}
|
||||
|
||||
toggleMarkersListSearch() {
|
||||
@ -649,7 +661,13 @@ export class DashboardComponent implements AfterViewInit {
|
||||
this.activeCategories = new Set(
|
||||
this.categories.map((c) => c.name),
|
||||
);
|
||||
this.updateMarkersAndClusters();
|
||||
this.places = this.places.map((p) => {
|
||||
if (p.category.id == category.id) return { ...p, category };
|
||||
return p;
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.updateMarkersAndClusters();
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -93,7 +93,7 @@
|
||||
<td class="relative">
|
||||
@if (tripitem.place) {
|
||||
<div class="ml-7 print:ml-0 max-w-24 truncate print:whitespace-normal">
|
||||
<img [src]="tripitem.place.image"
|
||||
<img [src]="tripitem.place.image || tripitem.place.category.image"
|
||||
class="absolute left-0 top-1/2 -translate-y-1/2 w-9 rounded-full object-cover print:hidden" /> {{
|
||||
tripitem.place.name }}
|
||||
</div>
|
||||
@ -146,7 +146,8 @@
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<div class="hidden md:flex h-20 w-32">
|
||||
@if (selectedItem.place) {
|
||||
<img [src]="selectedItem.place.image" class="h-full w-full rounded-md object-cover" />
|
||||
<img [src]="selectedItem.place.image || selectedItem.place.category.image"
|
||||
class="h-full w-full rounded-md object-cover" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -224,10 +225,14 @@
|
||||
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
|
||||
</div>
|
||||
|
||||
<p-button icon="pi pi-refresh" [disabled]="!places.length" (click)="resetMapBounds()" text />
|
||||
<div class="flex gap-2">
|
||||
<p-button icon="pi pi-window-maximize" (click)="toggleMapFullscreen()" text />
|
||||
<p-button icon="pi pi-refresh" [disabled]="!places.length" (click)="resetMapBounds()" text />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div>
|
||||
<div id="map" [class.fullscreen-map]="isMapFullscreen" class="w-full rounded-md min-h-96 h-1/3 max-h-full z-20">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!selectedItem) {
|
||||
@ -259,7 +264,7 @@
|
||||
@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"
|
||||
(mouseenter)="placeHighlightMarker(p.lat, p.lng)" (mouseleave)="resetPlaceHighlightMarker()">
|
||||
<img [src]="p.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">
|
||||
<h1 class="tracking-tight truncate">{{ p.name }}</h1>
|
||||
@ -270,7 +275,7 @@
|
||||
class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded flex gap-2 items-center truncate"><i
|
||||
class="pi pi-box text-xs"></i>{{ p.category.name }}</span>
|
||||
|
||||
@if (p.placeUsage) {
|
||||
@if (isPlaceUsed(p.id)) {
|
||||
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||
class="pi pi-check-square text-xs"></i></span>
|
||||
} @else {
|
||||
@ -395,4 +400,15 @@
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<p-menu #menuTripDayActions [model]="menuTripDayActionsItems" appendTo="body" [popup]="true" />
|
||||
<p-menu #menuTripDayActions [model]="menuTripDayActionsItems" appendTo="body" [popup]="true" />
|
||||
|
||||
@if (isMapFullscreen) {
|
||||
<div class="absolute z-30 top-2 right-2 p-2 bg-white shadow rounded">
|
||||
<p-button (click)="toggleMapFullscreen()" text icon="pi pi-window-minimize" />
|
||||
</div>
|
||||
|
||||
<div class="absolute z-30 top-20 right-2 p-2 bg-white shadow rounded">
|
||||
<p-button (click)="toggleTripDaysHighlight()" text icon="pi pi-directions"
|
||||
[severity]="tripMapAntLayerDayID == -1 ? 'help' : 'primary'" />
|
||||
</div>
|
||||
}
|
||||
@ -8,3 +8,13 @@
|
||||
background-color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreen-map {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@ -31,10 +31,6 @@ import { AsyncPipe } from "@angular/common";
|
||||
import { MenuItem } from "primeng/api";
|
||||
import { MenuModule } from "primeng/menu";
|
||||
|
||||
interface PlaceWithUsage extends Place {
|
||||
placeUsage?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-trip",
|
||||
standalone: true,
|
||||
@ -59,15 +55,17 @@ export class TripComponent implements AfterViewInit {
|
||||
statuses: TripStatus[] = [];
|
||||
hoveredElement: HTMLElement | undefined;
|
||||
currency$: Observable<string>;
|
||||
placesUsedInTable = new Set<number>();
|
||||
|
||||
trip: Trip | undefined;
|
||||
tripMapAntLayer: undefined;
|
||||
tripMapAntLayerDayID: number | undefined;
|
||||
isMapFullscreen: boolean = false;
|
||||
|
||||
totalPrice: number = 0;
|
||||
dayStatsCache = new Map<number, { price: number; places: number }>();
|
||||
|
||||
places: PlaceWithUsage[] = [];
|
||||
places: Place[] = [];
|
||||
flattenedTripItems: FlattenedTripItem[] = [];
|
||||
menuTripActionsItems: MenuItem[] = [];
|
||||
|
||||
@ -176,7 +174,18 @@ export class TripComponent implements AfterViewInit {
|
||||
|
||||
this.updateTotalPrice();
|
||||
|
||||
this.map = createMap();
|
||||
let contentMenuItems = [
|
||||
{
|
||||
text: "Copy coordinates",
|
||||
callback: (e: any) => {
|
||||
const latlng = e.latlng;
|
||||
navigator.clipboard.writeText(
|
||||
`${parseFloat(latlng.lat).toFixed(5)}, ${parseFloat(latlng.lng).toFixed(5)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
this.map = createMap(contentMenuItems);
|
||||
this.markerClusterGroup = createClusterGroup().addTo(this.map);
|
||||
this.setPlacesAndMarkers();
|
||||
|
||||
@ -224,6 +233,10 @@ export class TripComponent implements AfterViewInit {
|
||||
})) as (TripItem & { status: TripStatus })[];
|
||||
}
|
||||
|
||||
isPlaceUsed(id: number): boolean {
|
||||
return this.placesUsedInTable.has(id);
|
||||
}
|
||||
|
||||
statusToTripStatus(status?: string): TripStatus | undefined {
|
||||
if (!status) return undefined;
|
||||
return this.statuses.find((s) => s.label == status) as TripStatus;
|
||||
@ -250,18 +263,21 @@ export class TripComponent implements AfterViewInit {
|
||||
);
|
||||
}
|
||||
|
||||
setPlacesAndMarkers() {
|
||||
let usedPlaces = this.flattenedTripItems.map((i) => i.place?.id);
|
||||
this.places = (this.trip?.places || []).map((p) => {
|
||||
let ret: PlaceWithUsage = { ...p };
|
||||
if (usedPlaces.includes(p.id)) ret.placeUsage = true;
|
||||
return ret;
|
||||
makePlacesUsedInTable() {
|
||||
this.placesUsedInTable.clear();
|
||||
this.flattenedTripItems.forEach((i) => {
|
||||
if (i.place?.id) this.placesUsedInTable.add(i.place.id);
|
||||
});
|
||||
}
|
||||
|
||||
setPlacesAndMarkers() {
|
||||
this.makePlacesUsedInTable();
|
||||
this.places = this.trip?.places || [];
|
||||
this.places.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
this.markerClusterGroup?.clearLayers();
|
||||
this.places.forEach((p) => {
|
||||
const marker = placeToMarker(p);
|
||||
const marker = placeToMarker(p, false);
|
||||
this.markerClusterGroup?.addLayer(marker);
|
||||
});
|
||||
}
|
||||
@ -274,6 +290,15 @@ export class TripComponent implements AfterViewInit {
|
||||
);
|
||||
}
|
||||
|
||||
toggleMapFullscreen() {
|
||||
this.isMapFullscreen = !this.isMapFullscreen;
|
||||
|
||||
setTimeout(() => {
|
||||
this.map.invalidateSize();
|
||||
this.resetMapBounds();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
updateTotalPrice(n?: number) {
|
||||
if (n) this.totalPrice += n;
|
||||
else
|
||||
@ -352,7 +377,7 @@ export class TripComponent implements AfterViewInit {
|
||||
this.map.fitBounds(coords, { padding: [30, 30] });
|
||||
|
||||
const path = antPath(coords, {
|
||||
delay: 400,
|
||||
delay: 600,
|
||||
dashArray: [10, 20],
|
||||
weight: 5,
|
||||
color: "#0000FF",
|
||||
@ -607,6 +632,7 @@ export class TripComponent implements AfterViewInit {
|
||||
this.trip?.days!,
|
||||
);
|
||||
this.dayStatsCache.delete(day.id);
|
||||
this.makePlacesUsedInTable();
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -685,6 +711,7 @@ export class TripComponent implements AfterViewInit {
|
||||
);
|
||||
}
|
||||
if (item.price) this.updateTotalPrice(item.price);
|
||||
if (item.place?.id) this.placesUsedInTable.add(item.place.id);
|
||||
},
|
||||
});
|
||||
},
|
||||
@ -718,6 +745,7 @@ export class TripComponent implements AfterViewInit {
|
||||
modal.onClose.subscribe({
|
||||
next: (it: TripItem | null) => {
|
||||
if (!it) return;
|
||||
if (item.place?.id) this.placesUsedInTable.delete(item.place.id);
|
||||
|
||||
this.apiService
|
||||
.putTripDayItem(it, this.trip?.id!, item.day_id, item.id)
|
||||
@ -744,6 +772,7 @@ export class TripComponent implements AfterViewInit {
|
||||
this.dayStatsCache.delete(item.day_id);
|
||||
}
|
||||
|
||||
if (item.place?.id) this.placesUsedInTable.add(item.place.id);
|
||||
const updatedPrice = -(item.price || 0) + (it.price || 0);
|
||||
this.updateTotalPrice(updatedPrice);
|
||||
|
||||
@ -786,6 +815,8 @@ export class TripComponent implements AfterViewInit {
|
||||
this.flattenedTripItems = this.flattenTripDayItems(
|
||||
this.trip?.days!,
|
||||
);
|
||||
if (item.place?.id)
|
||||
this.placesUsedInTable.delete(item.place.id);
|
||||
this.dayStatsCache.delete(item.day_id);
|
||||
this.selectedItem = undefined;
|
||||
this.resetPlaceHighlightMarker();
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<div class="group relative rounded-lg overflow-hidden shadow-lg cursor-pointer" [class.grayscale]="trip.archived"
|
||||
(click)="viewTrip(trip.id)">
|
||||
<img class="rounded-lg object-cover transform transition-transform duration-300 ease-in-out group-hover:scale-105"
|
||||
[src]="trip.image" />
|
||||
[src]="trip.image || 'cover.webp'" />
|
||||
|
||||
<div class="absolute inset-0 bg-black/5 flex flex-col justify-end p-4 text-white">
|
||||
<h3 class="text-lg font-semibold line-clamp-2">{{ trip.name }}</h3>
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||
import { FloatLabelModule } from "primeng/floatlabel";
|
||||
@ -8,7 +13,13 @@ import { FocusTrapModule } from "primeng/focustrap";
|
||||
|
||||
@Component({
|
||||
selector: "app-category-create-modal",
|
||||
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, FocusTrapModule],
|
||||
imports: [
|
||||
FloatLabelModule,
|
||||
InputTextModule,
|
||||
ButtonModule,
|
||||
ReactiveFormsModule,
|
||||
FocusTrapModule,
|
||||
],
|
||||
standalone: true,
|
||||
templateUrl: "./category-create-modal.component.html",
|
||||
styleUrl: "./category-create-modal.component.scss",
|
||||
@ -21,12 +32,12 @@ export class CategoryCreateModalComponent {
|
||||
constructor(
|
||||
private ref: DynamicDialogRef,
|
||||
private fb: FormBuilder,
|
||||
private config: DynamicDialogConfig
|
||||
private config: DynamicDialogConfig,
|
||||
) {
|
||||
this.categoryForm = this.fb.group({
|
||||
id: -1,
|
||||
name: ["", Validators.required],
|
||||
image: ["", Validators.required],
|
||||
image: null,
|
||||
});
|
||||
|
||||
if (this.config.data) {
|
||||
|
||||
@ -99,7 +99,6 @@ export class PlaceCreateModalComponent {
|
||||
|
||||
if (this.config.data) {
|
||||
let patchValue: Place = this.config.data.place;
|
||||
if (patchValue.imageDefault) delete patchValue["image"];
|
||||
this.placeForm.patchValue(patchValue);
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
<ng-template let-place #item>
|
||||
<div class="flex items-center whitespace-normal gap-2" [class.text-gray-500]="place.placeUsage">
|
||||
<img [src]="place.image" class="rounded-full size-6" />
|
||||
<img [src]="place.image || place.category.image" class="rounded-full size-6" />
|
||||
<div>{{ place.name }}</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
@for (p of selectedPlaces; track p.id) {
|
||||
<div class="mt-4 flex items-center gap-4 hover:bg-gray-50 rounded-xl cursor-pointer py-2 px-4"
|
||||
(click)="togglePlace(p)">
|
||||
<img [src]="p.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">
|
||||
<h1 class="tracking-tight truncate">{{ p.name }}</h1>
|
||||
|
||||
@ -4,7 +4,6 @@ import { Category, Place } from "../types/poi";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
tap,
|
||||
@ -18,7 +17,6 @@ import { Trip, TripBase, TripDay, TripItem } from "../types/trip";
|
||||
})
|
||||
export class ApiService {
|
||||
public apiBaseUrl: string = "/api";
|
||||
public assetsBaseUrl: string = "/api/assets";
|
||||
|
||||
private categoriesSubject = new BehaviorSubject<Category[] | null>(null);
|
||||
public categories$: Observable<Category[] | null> =
|
||||
@ -33,23 +31,6 @@ export class ApiService {
|
||||
return this.httpClient.get<Info>(this.apiBaseUrl + "/info");
|
||||
}
|
||||
|
||||
_normalizeTripImage(trip: Trip | TripBase): Trip | TripBase {
|
||||
if (trip.image) trip.image = `${this.assetsBaseUrl}/${trip.image}`;
|
||||
else trip.image = "cover.webp";
|
||||
return trip;
|
||||
}
|
||||
|
||||
_normalizePlaceImage(place: Place): Place {
|
||||
if (place.image) {
|
||||
place.image = `${this.assetsBaseUrl}/${place.image}`;
|
||||
place.imageDefault = false;
|
||||
} else {
|
||||
place.image = `${this.assetsBaseUrl}/${(place.category as Category).image}`;
|
||||
place.imageDefault = true;
|
||||
}
|
||||
return place;
|
||||
}
|
||||
|
||||
_categoriesSubjectNext(categories: Category[]) {
|
||||
this.categoriesSubject.next(
|
||||
categories.sort((categoryA: Category, categoryB: Category) =>
|
||||
@ -63,11 +44,6 @@ export class ApiService {
|
||||
return this.httpClient
|
||||
.get<Category[]>(`${this.apiBaseUrl}/categories`)
|
||||
.pipe(
|
||||
map((resp) => {
|
||||
return resp.map((c) => {
|
||||
return { ...c, image: `${this.assetsBaseUrl}/${c.image}` };
|
||||
});
|
||||
}),
|
||||
tap((categories) => this._categoriesSubjectNext(categories)),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(),
|
||||
@ -80,12 +56,6 @@ export class ApiService {
|
||||
return this.httpClient
|
||||
.post<Category>(this.apiBaseUrl + "/categories", c)
|
||||
.pipe(
|
||||
map((category) => {
|
||||
return {
|
||||
...category,
|
||||
image: `${this.assetsBaseUrl}/${category.image}`,
|
||||
};
|
||||
}),
|
||||
tap((category) =>
|
||||
this._categoriesSubjectNext([
|
||||
...(this.categoriesSubject.value || []),
|
||||
@ -99,12 +69,6 @@ export class ApiService {
|
||||
return this.httpClient
|
||||
.put<Category>(this.apiBaseUrl + `/categories/${c_id}`, c)
|
||||
.pipe(
|
||||
map((category) => {
|
||||
return {
|
||||
...category,
|
||||
image: `${this.assetsBaseUrl}/${category.image}`,
|
||||
};
|
||||
}),
|
||||
tap((category) => {
|
||||
let categories = this.categoriesSubject.value || [];
|
||||
let categoryIndex = categories?.findIndex((c) => c.id == c_id) || -1;
|
||||
@ -133,29 +97,27 @@ export class ApiService {
|
||||
}
|
||||
|
||||
getPlaces(): Observable<Place[]> {
|
||||
return this.httpClient.get<Place[]>(`${this.apiBaseUrl}/places`).pipe(
|
||||
map((resp) => resp.map((p) => this._normalizePlaceImage(p))),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(),
|
||||
);
|
||||
return this.httpClient
|
||||
.get<Place[]>(`${this.apiBaseUrl}/places`)
|
||||
.pipe(distinctUntilChanged(), shareReplay());
|
||||
}
|
||||
|
||||
postPlace(place: Place): Observable<Place> {
|
||||
return this.httpClient
|
||||
.post<Place>(`${this.apiBaseUrl}/places`, place)
|
||||
.pipe(map((p) => this._normalizePlaceImage(p)));
|
||||
return this.httpClient.post<Place>(`${this.apiBaseUrl}/places`, place);
|
||||
}
|
||||
|
||||
postPlaces(places: Partial<Place[]>): Observable<Place[]> {
|
||||
return this.httpClient
|
||||
.post<Place[]>(`${this.apiBaseUrl}/places/batch`, places)
|
||||
.pipe(map((resp) => resp.map((p) => this._normalizePlaceImage(p))));
|
||||
return this.httpClient.post<Place[]>(
|
||||
`${this.apiBaseUrl}/places/batch`,
|
||||
places,
|
||||
);
|
||||
}
|
||||
|
||||
putPlace(place_id: number, place: Partial<Place>): Observable<Place> {
|
||||
return this.httpClient
|
||||
.put<Place>(`${this.apiBaseUrl}/places/${place_id}`, place)
|
||||
.pipe(map((p) => this._normalizePlaceImage(p)));
|
||||
return this.httpClient.put<Place>(
|
||||
`${this.apiBaseUrl}/places/${place_id}`,
|
||||
place,
|
||||
);
|
||||
}
|
||||
|
||||
deletePlace(place_id: number): Observable<null> {
|
||||
@ -165,50 +127,23 @@ export class ApiService {
|
||||
}
|
||||
|
||||
getPlaceGPX(place_id: number): Observable<Place> {
|
||||
return this.httpClient
|
||||
.get<Place>(`${this.apiBaseUrl}/places/${place_id}`)
|
||||
.pipe(map((p) => this._normalizePlaceImage(p)));
|
||||
return this.httpClient.get<Place>(`${this.apiBaseUrl}/places/${place_id}`);
|
||||
}
|
||||
|
||||
getTrips(): Observable<TripBase[]> {
|
||||
return this.httpClient.get<TripBase[]>(`${this.apiBaseUrl}/trips`).pipe(
|
||||
map((resp) => {
|
||||
return resp.map((trip: TripBase) => {
|
||||
trip = this._normalizeTripImage(trip) as TripBase;
|
||||
return trip;
|
||||
});
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(),
|
||||
);
|
||||
return this.httpClient
|
||||
.get<TripBase[]>(`${this.apiBaseUrl}/trips`)
|
||||
.pipe(distinctUntilChanged(), shareReplay());
|
||||
}
|
||||
|
||||
getTrip(id: number): Observable<Trip> {
|
||||
return this.httpClient.get<Trip>(`${this.apiBaseUrl}/trips/${id}`).pipe(
|
||||
map((trip) => {
|
||||
trip = this._normalizeTripImage(trip) as Trip;
|
||||
trip.places = trip.places.map((p) => this._normalizePlaceImage(p));
|
||||
trip.days.map((day) => {
|
||||
day.items.forEach((item) => {
|
||||
if (item.place) this._normalizePlaceImage(item.place);
|
||||
});
|
||||
});
|
||||
return trip;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(),
|
||||
);
|
||||
return this.httpClient
|
||||
.get<Trip>(`${this.apiBaseUrl}/trips/${id}`)
|
||||
.pipe(distinctUntilChanged(), shareReplay());
|
||||
}
|
||||
|
||||
postTrip(trip: TripBase): Observable<TripBase> {
|
||||
return this.httpClient
|
||||
.post<TripBase>(`${this.apiBaseUrl}/trips`, trip)
|
||||
.pipe(
|
||||
map((trip) => {
|
||||
trip = this._normalizeTripImage(trip) as TripBase;
|
||||
return trip;
|
||||
}),
|
||||
);
|
||||
return this.httpClient.post<TripBase>(`${this.apiBaseUrl}/trips`, trip);
|
||||
}
|
||||
|
||||
deleteTrip(trip_id: number): Observable<null> {
|
||||
@ -216,20 +151,10 @@ export class ApiService {
|
||||
}
|
||||
|
||||
putTrip(trip: Partial<Trip>, trip_id: number): Observable<Trip> {
|
||||
return this.httpClient
|
||||
.put<Trip>(`${this.apiBaseUrl}/trips/${trip_id}`, trip)
|
||||
.pipe(
|
||||
map((trip) => {
|
||||
trip = this._normalizeTripImage(trip) as Trip;
|
||||
trip.places = trip.places.map((p) => this._normalizePlaceImage(p));
|
||||
trip.days.map((day) => {
|
||||
day.items.forEach((item) => {
|
||||
if (item.place) this._normalizePlaceImage(item.place);
|
||||
});
|
||||
});
|
||||
return trip;
|
||||
}),
|
||||
);
|
||||
return this.httpClient.put<Trip>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}`,
|
||||
trip,
|
||||
);
|
||||
}
|
||||
|
||||
postTripDay(tripDay: TripDay, trip_id: number): Observable<TripDay> {
|
||||
@ -240,19 +165,10 @@ export class ApiService {
|
||||
}
|
||||
|
||||
putTripDay(tripDay: Partial<TripDay>, trip_id: number): Observable<TripDay> {
|
||||
return this.httpClient
|
||||
.put<TripDay>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/days/${tripDay.id}`,
|
||||
tripDay,
|
||||
)
|
||||
.pipe(
|
||||
map((td) => {
|
||||
td.items.forEach((item) => {
|
||||
if (item.place) this._normalizePlaceImage(item.place);
|
||||
});
|
||||
return td;
|
||||
}),
|
||||
);
|
||||
return this.httpClient.put<TripDay>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/days/${tripDay.id}`,
|
||||
tripDay,
|
||||
);
|
||||
}
|
||||
|
||||
deleteTripDay(trip_id: number, day_id: number): Observable<null> {
|
||||
@ -266,17 +182,10 @@ export class ApiService {
|
||||
trip_id: number,
|
||||
day_id: number,
|
||||
): Observable<TripItem> {
|
||||
return this.httpClient
|
||||
.post<TripItem>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items`,
|
||||
item,
|
||||
)
|
||||
.pipe(
|
||||
map((item) => {
|
||||
if (item.place) item.place = this._normalizePlaceImage(item.place);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
return this.httpClient.post<TripItem>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items`,
|
||||
item,
|
||||
);
|
||||
}
|
||||
|
||||
putTripDayItem(
|
||||
@ -285,17 +194,10 @@ export class ApiService {
|
||||
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,
|
||||
)
|
||||
.pipe(
|
||||
map((item) => {
|
||||
if (item.place) item.place = this._normalizePlaceImage(item.place);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
return this.httpClient.put<TripItem>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items/${item_id}`,
|
||||
item,
|
||||
);
|
||||
}
|
||||
|
||||
deleteTripDayItem(
|
||||
@ -336,21 +238,10 @@ export class ApiService {
|
||||
|
||||
settingsUserImport(formdata: FormData): Observable<Place[]> {
|
||||
const headers = { enctype: "multipart/form-data" };
|
||||
return this.httpClient
|
||||
.post<
|
||||
Place[]
|
||||
>(`${this.apiBaseUrl}/settings/import`, formdata, { headers: headers })
|
||||
.pipe(
|
||||
map((resp) => {
|
||||
return resp.map((c) => {
|
||||
if (c.image) c.image = `${this.assetsBaseUrl}/${c.image}`;
|
||||
else {
|
||||
c.image = `${this.assetsBaseUrl}/${(c.category as Category).image}`;
|
||||
c.imageDefault = true;
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}),
|
||||
);
|
||||
return this.httpClient.post<Place[]>(
|
||||
`${this.apiBaseUrl}/settings/import`,
|
||||
formdata,
|
||||
{ headers: headers },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,19 +4,33 @@ import { TripStatus } from "../types/trip";
|
||||
import { ApiService } from "./api.service";
|
||||
import { map } from "rxjs";
|
||||
|
||||
const DISABLE_LOWNET = "TRIP_DISABLE_LOWNET";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class UtilsService {
|
||||
private apiService = inject(ApiService);
|
||||
currency$ = this.apiService.settings$.pipe(map((s) => s?.currency ?? "€"));
|
||||
public isLowNet: boolean = true;
|
||||
|
||||
constructor(private ngMessageService: MessageService) {}
|
||||
constructor(private ngMessageService: MessageService) {
|
||||
this.isLowNet = !localStorage.getItem(DISABLE_LOWNET);
|
||||
}
|
||||
|
||||
toGithubTRIP() {
|
||||
window.open("https://github.com/itskovacs/trip", "_blank");
|
||||
}
|
||||
|
||||
toggleLowNet() {
|
||||
if (this.isLowNet) {
|
||||
localStorage.setItem(DISABLE_LOWNET, "1");
|
||||
} else {
|
||||
localStorage.removeItem(DISABLE_LOWNET);
|
||||
}
|
||||
this.isLowNet = !this.isLowNet;
|
||||
}
|
||||
|
||||
get statuses(): TripStatus[] {
|
||||
return [
|
||||
{ label: "pending", color: "#3258A8" },
|
||||
@ -35,17 +49,6 @@ export class UtilsService {
|
||||
});
|
||||
}
|
||||
|
||||
getObjectDiffFields<T extends object>(a: T, b: T): Partial<T> {
|
||||
const diff: Partial<T> = {};
|
||||
|
||||
for (const key in b) {
|
||||
if (!Object.is(a[key], b[key]) && JSON.stringify(a[key]) !== JSON.stringify(b[key])) {
|
||||
diff[key] = b[key];
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
parseGoogleMapsUrl(url: string): [string, string] {
|
||||
const match = url.match(/place\/(.*)\/@([\d\-.]+,[\d\-.]+)/);
|
||||
|
||||
|
||||
@ -69,7 +69,10 @@ export function createClusterGroup(): L.MarkerClusterGroup {
|
||||
});
|
||||
}
|
||||
|
||||
export function placeToMarker(place: Place): L.Marker {
|
||||
export function placeToMarker(
|
||||
place: Place,
|
||||
isLowNet: boolean = true,
|
||||
): L.Marker {
|
||||
let marker: L.Marker;
|
||||
let options: any = {
|
||||
riseOnHover: true,
|
||||
@ -79,8 +82,13 @@ export function placeToMarker(place: Place): L.Marker {
|
||||
};
|
||||
|
||||
marker = new L.Marker([+place.lat, +place.lng], options);
|
||||
|
||||
const markerImage = isLowNet
|
||||
? place.category.image
|
||||
: (place.image ?? place.category.image);
|
||||
|
||||
marker.options.icon = L.icon({
|
||||
iconUrl: place.image!,
|
||||
iconUrl: markerImage,
|
||||
iconSize: [56, 56],
|
||||
iconAnchor: [28, 28],
|
||||
shadowSize: [0, 0],
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
<div class="place-box-dialog">
|
||||
<div class="place-box-dialog-content">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<img [src]="selectedPlace.image" class="object-cover rounded-full size-16">
|
||||
<div class="flex md:flex-col">
|
||||
<div class="flex items-center gap-4 w-full">
|
||||
<img [src]="selectedPlace.image || selectedPlace.category.image"
|
||||
class="object-cover rounded-full size-16">
|
||||
<div class="flex grow md:flex-col">
|
||||
<h1 class="text-gray-800 font-bold mb-0 line-clamp-1">{{ selectedPlace.name }}
|
||||
</h1>
|
||||
|
||||
|
||||
@ -22,5 +22,4 @@ export interface Place {
|
||||
allowdog?: boolean;
|
||||
visited?: boolean;
|
||||
favorite?: boolean;
|
||||
imageDefault?: boolean; // Injected in service
|
||||
}
|
||||
|
||||