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)
|
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,16 +51,25 @@ def create_place(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if place.image:
|
if place.image:
|
||||||
image_bytes = b64img_decode(place.image)
|
if place.image[:4] == "http":
|
||||||
filename = save_image_to_file(image_bytes, settings.PLACE_IMAGE_SIZE)
|
fp = await download_file(place.image)
|
||||||
if not filename:
|
if fp:
|
||||||
raise HTTPException(status_code=400, detail="Bad request")
|
patch_image(fp)
|
||||||
|
image = Image(filename=fp.split("/")[-1], user=current_user)
|
||||||
image = Image(filename=filename, user=current_user)
|
session.add(image)
|
||||||
session.add(image)
|
session.flush()
|
||||||
session.commit()
|
session.refresh(image)
|
||||||
session.refresh(image)
|
new_place.image_id = image.id
|
||||||
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.add(new_place)
|
||||||
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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
@ -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({
|
.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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
(click)="gmapsSearchText()" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<p-floatlabel variant="in">
|
<p-floatlabel variant="in">
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
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 |