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

This commit is contained in:
itskovacs 2025-07-22 21:24:11 +02:00
parent 4cdc11af69
commit 6a538982a2
29 changed files with 303 additions and 309 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -1 +1 @@
__version__ = "1.4.0"
__version__ = "1.5.0"

View File

@ -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"

View File

@ -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()

View File

@ -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],

View File

@ -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 {}

View File

@ -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">

View File

@ -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);
}
},
});

View File

@ -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 {
@ -396,3 +401,14 @@
</div>
</section>
<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>
}

View File

@ -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;
}

View File

@ -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();

View File

@ -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>

View File

@ -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) {

View File

@ -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);
}

View File

@ -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>

View File

@ -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>

View File

@ -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 },
);
}
}

View File

@ -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\-.]+)/);

View File

@ -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],

View File

@ -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>

View File

@ -22,5 +22,4 @@ export interface Place {
allowdog?: boolean;
visited?: boolean;
favorite?: boolean;
imageDefault?: boolean; // Injected in service
}