Compare commits

...

10 Commits

Author SHA1 Message Date
Francesco Albano
babef48ce7 Add .gitignore to exclude storage directory and JPEG files 2025-11-11 11:17:01 +08:00
Francesco Albano
dbdac9f78f Refactor README for clarity and remove obsolete push script 2025-11-11 11:02:04 +08:00
Francesco Albano
56246a9484 Refactor code structure for improved readability and maintainability 2025-11-11 10:58:31 +08:00
itskovacs
cfe4baa794 💄 GMaps autocompletion: loading indicator 2025-11-09 19:44:32 +01:00
itskovacs
a7adec2675 💄 on attachment delete: refresh items 2025-11-09 19:32:24 +01:00
itskovacs
310b4db455 💄 Update HTTP error toast text 2025-11-09 19:20:08 +01:00
itskovacs
f4ecacf0c8 Handle image URL for place creation 2025-11-09 19:18:13 +01:00
itskovacs
789a3937a6 Place creation: GMaps autocomplete Image and Category, Shift+Enter and Ctrl+Enter shortcuts 2025-11-09 19:14:38 +01:00
itskovacs
bfeb66f04f map GMaps Place type to Category 2025-11-09 19:13:15 +01:00
itskovacs
17a0249fd2 GMaps autocomplete: image URL 2025-11-09 19:11:52 +01:00
39 changed files with 151 additions and 35 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
**/storage/**
**/**.jpeg

3
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/.venv/
**/__pycache__/

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
backend/storage/trip.sqlite Normal file

Binary file not shown.

View File

@ -11,7 +11,8 @@ from ..models.models import (Category, GooglePlaceResult, Image, Place,
User) User)
from ..security import verify_exists_and_owns from ..security import verify_exists_and_owns
from ..utils.gmaps import (compute_avg_price, compute_description, from ..utils.gmaps import (compute_avg_price, compute_description,
gmaps_get_boundaries, gmaps_textsearch) gmaps_get_boundaries, gmaps_photo, gmaps_textsearch,
gmaps_types_mapper)
from ..utils.utils import (b64img_decode, download_file, patch_image, from ..utils.utils import (b64img_decode, download_file, patch_image,
save_image_to_file) save_image_to_file)
@ -31,7 +32,7 @@ def read_places(
@router.post("", response_model=PlaceRead) @router.post("", response_model=PlaceRead)
def create_place( async def create_place(
place: PlaceCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)] place: PlaceCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
) -> PlaceRead: ) -> PlaceRead:
new_place = Place( new_place = Place(
@ -50,11 +51,20 @@ def create_place(
) )
if place.image: if place.image:
if place.image[:4] == "http":
fp = await download_file(place.image)
if fp:
patch_image(fp)
image = Image(filename=fp.split("/")[-1], user=current_user)
session.add(image)
session.flush()
session.refresh(image)
new_place.image_id = image.id
else:
image_bytes = b64img_decode(place.image) image_bytes = b64img_decode(place.image)
filename = save_image_to_file(image_bytes, settings.PLACE_IMAGE_SIZE) filename = save_image_to_file(image_bytes, settings.PLACE_IMAGE_SIZE)
if not filename: if not filename:
raise HTTPException(status_code=400, detail="Bad request") raise HTTPException(status_code=400, detail="Bad request")
image = Image(filename=filename, user=current_user) image = Image(filename=filename, user=current_user)
session.add(image) session.add(image)
session.commit() session.commit()
@ -134,6 +144,16 @@ async def google_search_text(
allowdog=place.get("allowDogs"), allowdog=place.get("allowDogs"),
description=compute_description(place), description=compute_description(place),
) )
if place.get("photos"):
photo_name = place.get("photos")[0].get("name")
if photo_name:
result.image = await gmaps_photo(photo_name, db_user.google_apikey)
# FIXME: Using default categories, update Settings/Categories to integrate types mapping
place_types = set(place.get("types", []))
for key, v in gmaps_types_mapper.items():
if any(any(substring in place_type for place_type in place_types) for substring in v):
result.category = key
break
results.append(result) results.append(result)
return results return results

View File

@ -3,6 +3,16 @@ from typing import Any
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
gmaps_types_mapper: dict[str, list] = {
"Nature & Outdoor": ["natural_feature", "landmark"],
"Entertainment & Leisure": ["amusement", "aquarium"],
"Culture": ["museum", "historical", "art_", "church"],
"Food & Drink": ["food", "bar", "bakery", "coffee_shop", "restaurant"],
"Adventure & Sports": ["adventure_sports_center"],
"Wellness": ["wellness"],
"Accommodation": ["hotel", "camping"],
}
def compute_avg_price(price_range: dict | None) -> float | None: def compute_avg_price(price_range: dict | None) -> float | None:
if not price_range: if not price_range:
@ -42,7 +52,7 @@ async def gmaps_textsearch(search: str, api_key: str) -> list[dict[str, Any]]:
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Goog-Api-Key": api_key, "X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": "places.id,places.types,places.location,places.priceRange,places.formattedAddress,places.websiteUri,places.internationalPhoneNumber,places.displayName,places.allowsDogs", "X-Goog-FieldMask": "places.id,places.types,places.location,places.priceRange,places.formattedAddress,places.websiteUri,places.internationalPhoneNumber,places.displayName,places.allowsDogs,places.photos",
} }
try: try:

View File

@ -117,7 +117,7 @@ async def download_file(link: str, raise_on_error: bool = False) -> str:
path = assets_folder_path() / generate_filename(infer_extension) path = assets_folder_path() / generate_filename(infer_extension)
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(response.content) f.write(response.content)
return f.name return str(path)
except Exception as e: except Exception as e:
if raise_on_error: if raise_on_error:
raise HTTPException(status_code=400, detail=f"Failed to download file: {e}") raise HTTPException(status_code=400, detail=f"Failed to download file: {e}")

8
src/proxy.conf.json Normal file
View File

@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8000",
"secure": false,
"changeOrigin": true,
"logLevel": "warn"
}
}

View File

@ -2026,10 +2026,15 @@ export class TripComponent implements AfterViewInit {
.subscribe({ .subscribe({
next: () => { next: () => {
this.trip!.attachments = this.trip?.attachments?.filter((att) => att.id != attachmentId); this.trip!.attachments = this.trip?.attachments?.filter((att) => att.id != attachmentId);
if (this.selectedItem?.attachments?.length) { let modifiedItem = false;
if (this.selectedItem.attachments.some((a) => a.id == attachmentId)) this.trip?.days.forEach(d => d.items.forEach(i => {
this.selectedItem.attachments = this.selectedItem.attachments.filter((a) => a.id != attachmentId); if (i.attachments?.length) {
i.attachments = i.attachments.filter(attachment => attachment.id != attachmentId);
modifiedItem = true;
} }
}))
if (this.selectedItem?.attachments?.length) this.selectedItem.attachments = this.selectedItem.attachments.filter((a) => a.id != attachmentId);
if (modifiedItem) this.flattenTripDayItems();
}, },
}); });
} }

View File

@ -2,12 +2,20 @@
<div pFocusTrap class="grid grid-cols-2 md:grid-cols-4 gap-4" [formGroup]="placeForm"> <div pFocusTrap class="grid grid-cols-2 md:grid-cols-4 gap-4" [formGroup]="placeForm">
<div class="relative col-span-2"> <div class="relative col-span-2">
<p-floatlabel variant="in"> <p-floatlabel variant="in">
<input id="name" formControlName="name" pInputText autofocus fluid /> <input id="name" formControlName="name" pInputText autofocus fluid (keydown.shift.enter)="gmapsSearchText()"
(keydown.control.enter)="closeDialog()" />
<label for="name">Name</label> <label for="name">Name</label>
</p-floatlabel> </p-floatlabel>
<p-button icon="pi pi-sparkles" variant="text" [disabled]="!placeForm.get('name')!.value" <div class="absolute right-2 top-1/2 -translate-y-1/2">
class="absolute right-2 top-1/2 -translate-y-1/2" pTooltip="Complete using GMaps API" @if (gmapsLoading) {
<svg viewBox="25 25 50 50">
<circle r="20" cy="50" cx="50"></circle>
</svg>
} @else {
<p-button icon="pi pi-sparkles" variant="text" [disabled]="!placeForm.get('name')!.value" pTooltip="Complete using GMaps API"
(click)="gmapsSearchText()" /> (click)="gmapsSearchText()" />
}
</div>
</div> </div>
<p-floatlabel variant="in"> <p-floatlabel variant="in">

View File

@ -14,3 +14,40 @@
.p-floatlabel:has(.p-inputwrapper-focus) input::placeholder { .p-floatlabel:has(.p-inputwrapper-focus) input::placeholder {
color: var(--p-inputtext-placeholder-color) !important; color: var(--p-inputtext-placeholder-color) !important;
} }
svg {
width: 2rem;
animation: loading-rotate 1.5s linear infinite;
}
circle {
fill: none;
stroke: #4f46e5;
stroke-width: 2;
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
stroke-linecap: round;
animation: loading-dash 1.5s ease-in-out infinite;
}
@keyframes loading-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes loading-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dashoffset: -125px;
}
}

View File

@ -47,6 +47,7 @@ export class PlaceCreateModalComponent {
categories$?: Observable<Category[]>; categories$?: Observable<Category[]>;
previous_image_id: number | null = null; previous_image_id: number | null = null;
previous_image: string | null = null; previous_image: string | null = null;
gmapsLoading = false;
placeInputTooltip: string = placeInputTooltip: string =
"<div class='text-center'>You can paste a Google Maps Place link to fill <i>Name</i>, <i>Place</i>, <i>Lat</i>, <i>Lng</i>.</div>\n<div class='text-sm text-center'>https://google.com/maps/place/XXX</div>\n<div class='text-xs text-center'>Either « click » on a point of interest or « search » for it (eg: British Museum) and copy the URL</div>"; "<div class='text-center'>You can paste a Google Maps Place link to fill <i>Name</i>, <i>Place</i>, <i>Lat</i>, <i>Lng</i>.</div>\n<div class='text-sm text-center'>https://google.com/maps/place/XXX</div>\n<div class='text-xs text-center'>Either « click » on a point of interest or « search » for it (eg: British Museum) and copy the URL</div>";
@ -206,15 +207,35 @@ export class PlaceCreateModalComponent {
this.placeForm.get('gpx')?.markAsDirty(); this.placeForm.get('gpx')?.markAsDirty();
} }
gmapsToForm(r: GooglePlaceResult) {
this.placeForm.patchValue({ ...r, lat: formatLatLng(r.lat), lng: formatLatLng(r.lng), place: r.name || '' });
this.placeForm.get('category')?.markAsDirty();
this.gmapsLoading = false;
if (r.category) {
this.categories$?.pipe(take(1)).subscribe({
next: categories => {
const category: Category | undefined = categories.find(c => c.name == r.category);
if (!category) return;
this.placeForm.get('category')?.setValue(category.id);
}
})
}
}
gmapsSearchText() { gmapsSearchText() {
this.gmapsLoading = true;
const query = this.placeForm.get('name')?.value; const query = this.placeForm.get('name')?.value;
if (!query) return; if (!query) return;
this.apiService.gmapsSearchText(query).subscribe({ this.apiService.gmapsSearchText(query).subscribe({
next: (results) => { next: (results) => {
if (!results.length) {
this.utilsService.toast('warn', 'No result', 'No result available for this autocompletion');
this.gmapsLoading = false;
return;
}
if (results.length == 1) { if (results.length == 1) {
const r = results[0]; this.gmapsToForm(results[0])
this.placeForm.patchValue({ ...r, lat: formatLatLng(r.lat), lng: formatLatLng(r.lng), place: r.name || '' });
this.placeForm.get('category')?.markAsDirty();
return; return;
} }
@ -234,15 +255,11 @@ export class PlaceCreateModalComponent {
modal.onClose.pipe(take(1)).subscribe({ modal.onClose.pipe(take(1)).subscribe({
next: (result: GooglePlaceResult | null) => { next: (result: GooglePlaceResult | null) => {
if (!result) return; if (!result) {
const r = result; this.gmapsLoading = false;
this.placeForm.patchValue({ return;
...r, }
lat: formatLatLng(r.lat), this.gmapsToForm(result);
lng: formatLatLng(r.lng),
place: r.name || '',
});
this.placeForm.get('category')?.markAsDirty();
}, },
}); });
}, },

View File

@ -54,7 +54,12 @@ export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Obs
const errDetails = ERROR_CONFIG[err.status]; const errDetails = ERROR_CONFIG[err.status];
if (errDetails) { if (errDetails) {
console.error(err); console.error(err);
return showAndThrowError(errDetails.title, `${err.error?.detail || err.message || errDetails.detail}`); let msg = ""
msg = err.message || errDetails.detail;
if (!Array.isArray(err.error?.detail)) {
msg = err.error.detail;
}
return showAndThrowError(errDetails.title, msg);
} }
if (err.status == 401 && authService.accessToken) { if (err.status == 401 && authService.accessToken) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
storage/trip.sqlite Normal file

Binary file not shown.