Compare commits
10 Commits
dec5eebd72
...
babef48ce7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
babef48ce7 | ||
|
|
dbdac9f78f | ||
|
|
56246a9484 | ||
|
|
cfe4baa794 | ||
|
|
a7adec2675 | ||
|
|
310b4db455 | ||
|
|
f4ecacf0c8 | ||
|
|
789a3937a6 | ||
|
|
bfeb66f04f | ||
|
|
17a0249fd2 |
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
**/storage/**
|
||||
|
||||
**/**.jpeg
|
||||
3
backend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/.venv/
|
||||
|
||||
**/__pycache__/
|
||||
BIN
backend/storage/assets/218a7429-90e8-4377-9d25-c03f9cad82e6.jpeg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
backend/storage/assets/32b4ce25-ce31-4471-8054-34c696537aa4.jpeg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
backend/storage/assets/39a2b698-4469-45c5-b39a-bce131a657bc.jpeg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
backend/storage/assets/3d19ab17-0f82-42ac-a4d0-fd18409156a9.jpeg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
backend/storage/assets/55fe4a10-3732-4b4b-9997-1564fd83e8b1.jpeg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
backend/storage/assets/6489e07f-949a-4ede-851c-9118a717b3af.jpeg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
backend/storage/assets/72bf08ab-11bc-47a1-bdf3-406df28e77a9.jpeg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
backend/storage/assets/8031c6a7-1925-490c-9233-864c8b018d1e.jpeg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
backend/storage/assets/8bcaeb4a-d43f-467a-b65b-28eac855d4bf.jpeg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
backend/storage/assets/b509ba71-cfee-4fdc-81d7-7f7668ee676a.jpeg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
backend/storage/assets/cf5e96b1-a5e0-4ff0-9754-52727a85e2c2.jpeg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/storage/assets/d0c68319-6e6a-4fca-bce3-5ecf4ffdd349.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/storage/assets/fc7c0374-6120-4296-84a8-8475ce73c2ea.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/storage/trip.sqlite
Normal file
@ -11,7 +11,8 @@ from ..models.models import (Category, GooglePlaceResult, Image, Place,
|
||||
User)
|
||||
from ..security import verify_exists_and_owns
|
||||
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,
|
||||
save_image_to_file)
|
||||
|
||||
@ -31,7 +32,7 @@ def read_places(
|
||||
|
||||
|
||||
@router.post("", response_model=PlaceRead)
|
||||
def create_place(
|
||||
async def create_place(
|
||||
place: PlaceCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||
) -> PlaceRead:
|
||||
new_place = Place(
|
||||
@ -50,16 +51,25 @@ def create_place(
|
||||
)
|
||||
|
||||
if place.image:
|
||||
image_bytes = b64img_decode(place.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_place.image_id = image.id
|
||||
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)
|
||||
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_place.image_id = image.id
|
||||
|
||||
session.add(new_place)
|
||||
session.commit()
|
||||
@ -134,6 +144,16 @@ async def google_search_text(
|
||||
allowdog=place.get("allowDogs"),
|
||||
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)
|
||||
|
||||
return results
|
||||
|
||||
@ -3,6 +3,16 @@ from typing import Any
|
||||
import httpx
|
||||
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:
|
||||
if not price_range:
|
||||
@ -42,7 +52,7 @@ async def gmaps_textsearch(search: str, api_key: str) -> list[dict[str, Any]]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"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:
|
||||
|
||||
@ -117,7 +117,7 @@ async def download_file(link: str, raise_on_error: bool = False) -> str:
|
||||
path = assets_folder_path() / generate_filename(infer_extension)
|
||||
with open(path, "wb") as f:
|
||||
f.write(response.content)
|
||||
return f.name
|
||||
return str(path)
|
||||
except Exception as e:
|
||||
if raise_on_error:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to download file: {e}")
|
||||
|
||||
8
src/proxy.conf.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "warn"
|
||||
}
|
||||
}
|
||||
@ -2026,10 +2026,15 @@ export class TripComponent implements AfterViewInit {
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.trip!.attachments = this.trip?.attachments?.filter((att) => att.id != attachmentId);
|
||||
if (this.selectedItem?.attachments?.length) {
|
||||
if (this.selectedItem.attachments.some((a) => a.id == attachmentId))
|
||||
this.selectedItem.attachments = this.selectedItem.attachments.filter((a) => a.id != attachmentId);
|
||||
}
|
||||
let modifiedItem = false;
|
||||
this.trip?.days.forEach(d => d.items.forEach(i => {
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,12 +2,20 @@
|
||||
<div pFocusTrap class="grid grid-cols-2 md:grid-cols-4 gap-4" [formGroup]="placeForm">
|
||||
<div class="relative col-span-2">
|
||||
<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>
|
||||
</p-floatlabel>
|
||||
<p-button icon="pi pi-sparkles" variant="text" [disabled]="!placeForm.get('name')!.value"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2" pTooltip="Complete using GMaps API"
|
||||
(click)="gmapsSearchText()" />
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
@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()" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p-floatlabel variant="in">
|
||||
|
||||
@ -14,3 +14,40 @@
|
||||
.p-floatlabel:has(.p-inputwrapper-focus) input::placeholder {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,7 @@ export class PlaceCreateModalComponent {
|
||||
categories$?: Observable<Category[]>;
|
||||
previous_image_id: number | null = null;
|
||||
previous_image: string | null = null;
|
||||
gmapsLoading = false;
|
||||
|
||||
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>";
|
||||
@ -206,15 +207,35 @@ export class PlaceCreateModalComponent {
|
||||
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() {
|
||||
this.gmapsLoading = true;
|
||||
const query = this.placeForm.get('name')?.value;
|
||||
if (!query) return;
|
||||
this.apiService.gmapsSearchText(query).subscribe({
|
||||
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) {
|
||||
const r = results[0];
|
||||
this.placeForm.patchValue({ ...r, lat: formatLatLng(r.lat), lng: formatLatLng(r.lng), place: r.name || '' });
|
||||
this.placeForm.get('category')?.markAsDirty();
|
||||
this.gmapsToForm(results[0])
|
||||
return;
|
||||
}
|
||||
|
||||
@ -234,15 +255,11 @@ export class PlaceCreateModalComponent {
|
||||
|
||||
modal.onClose.pipe(take(1)).subscribe({
|
||||
next: (result: GooglePlaceResult | null) => {
|
||||
if (!result) return;
|
||||
const r = result;
|
||||
this.placeForm.patchValue({
|
||||
...r,
|
||||
lat: formatLatLng(r.lat),
|
||||
lng: formatLatLng(r.lng),
|
||||
place: r.name || '',
|
||||
});
|
||||
this.placeForm.get('category')?.markAsDirty();
|
||||
if (!result) {
|
||||
this.gmapsLoading = false;
|
||||
return;
|
||||
}
|
||||
this.gmapsToForm(result);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@ -54,7 +54,12 @@ export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Obs
|
||||
const errDetails = ERROR_CONFIG[err.status];
|
||||
if (errDetails) {
|
||||
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) {
|
||||
|
||||
BIN
storage/assets/218a7429-90e8-4377-9d25-c03f9cad82e6.jpeg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
storage/assets/32b4ce25-ce31-4471-8054-34c696537aa4.jpeg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
storage/assets/39a2b698-4469-45c5-b39a-bce131a657bc.jpeg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
storage/assets/3d19ab17-0f82-42ac-a4d0-fd18409156a9.jpeg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
storage/assets/55fe4a10-3732-4b4b-9997-1564fd83e8b1.jpeg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
storage/assets/6489e07f-949a-4ede-851c-9118a717b3af.jpeg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
storage/assets/72bf08ab-11bc-47a1-bdf3-406df28e77a9.jpeg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
storage/assets/8031c6a7-1925-490c-9233-864c8b018d1e.jpeg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
storage/assets/8bcaeb4a-d43f-467a-b65b-28eac855d4bf.jpeg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
storage/assets/b509ba71-cfee-4fdc-81d7-7f7668ee676a.jpeg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
storage/assets/cf5e96b1-a5e0-4ff0-9754-52727a85e2c2.jpeg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
storage/assets/d0c68319-6e6a-4fca-bce3-5ecf4ffdd349.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
storage/assets/fc7c0374-6120-4296-84a8-8475ce73c2ea.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |