🚀 TRIP 1.0.0
BIN
.github/sc_map.png
vendored
Normal file
|
After Width: | Height: | Size: 1003 KiB |
BIN
.github/sc_map_filters_list.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
.github/sc_trip.png
vendored
Normal file
|
After Width: | Height: | Size: 705 KiB |
BIN
.github/sc_trips.png
vendored
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
.github/screenshot.png
vendored
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
70
.github/workflows/docker-publish.yml
vendored
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
name: Build and Deploy Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install cosign
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||||
|
with:
|
||||||
|
cosign-release: 'v2.2.4'
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||||
|
|
||||||
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Sign the published Docker image
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
env:
|
||||||
|
TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||||
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Node builder
|
||||||
|
FROM node:22 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY src/package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY src .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Server
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
# Touch the files
|
||||||
|
COPY backend .
|
||||||
|
RUN pip install -r trip/requirements.txt
|
||||||
|
# Copy to /app/frontend, where /app has the backend python files also
|
||||||
|
COPY --from=build /app/dist/trip/browser ./frontend
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["fastapi", "run", "/app/trip/main.py", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
210
README.md
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
<p align="center"><img width="120" src="./src/public/favicon.png"></p>
|
||||||
|
<h2 align="center">TRIP</h2>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
[](https://github.com/itskovacs/trip/issues)
|
||||||
|
[](/LICENSE)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p align="center">🗺️ Tourism and Recreational Interest Points </p>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📝 Table of Contents
|
||||||
|
|
||||||
|
- 📦 [About](#about)
|
||||||
|
- 🌱 [Getting Started](#getting_started)
|
||||||
|
- 📸 [Demo](#Demo)
|
||||||
|
- 🚧 [Roadmap](#Roadmap)
|
||||||
|
- 📜 [License](#License)
|
||||||
|
- 🤝 [Contributing](#Contributing)
|
||||||
|
- 🛠️ [Tech Stack](#techstack)
|
||||||
|
- ✍️ [Authors](#authors)
|
||||||
|
|
||||||
|
## 📦 About <a name = "about"></a>
|
||||||
|
|
||||||
|
TRIP is a minimalist Map tracker and Trip planner, to visualize your points of interest (POI) and organize your next adventure details.
|
||||||
|
|
||||||
|
- 📍 Manage your POI on a Map
|
||||||
|
- 🐾 Specify metadata (*dog-friendly*, *cost*, *duration*)
|
||||||
|
- 🗂️ Categorize your points
|
||||||
|
- 🧾 Plan your next trip in a structured table, *Google Sheets*-style
|
||||||
|
- 🔍 Use map filtering and searching for fast interactions
|
||||||
|
- ⚙️ Customize your settings, import/export your data, and more
|
||||||
|
|
||||||
|
Demo is worth a thousand words, head to 📸 [Demo](#Demo).
|
||||||
|
|
||||||
|
🔒 Privacy-First – No telemetry, no tracking, fully self-hostable. You own your data. Inspect, modify, and contribute freely.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 🌱 Getting Started <a name = "getting_started"></a>
|
||||||
|
|
||||||
|
These steps will guide to deploy the app, ready to use in ⏱️ minutes.
|
||||||
|
If you need help, feel free to open an [issue](https://github.com/itskovacs/trip/issues).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Packages are available in the [packages section](https://github.com/itskovacs/trip/pkgs/container/trip) of the repository for quickstart, using just `docker pull`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:8000 -v ./storage:/app/storage ghcr.io/itskovacs/itskovacs/trip:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Preparation
|
||||||
|
|
||||||
|
Clone the repo, you're one step away from being all set
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/itskovacs/trip.git
|
||||||
|
cd trip
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 🐳 (recommended)
|
||||||
|
|
||||||
|
If needed, edit `docker-compose.yml` to modify the mapped port (default is `127.0.0.1:8080`).
|
||||||
|
|
||||||
|
Run the container, head to TRIP website, create an account, enjoy ✅
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Serving the content
|
||||||
|
You can serve TRIP using a web server, eg: Nginx
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
|
||||||
|
server_name trip.lan; # Your TRIP domain
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080; # TRIP port, default is 8080
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Sources 👩💻
|
||||||
|
|
||||||
|
Install from sources and run the backend.
|
||||||
|
Build the frontend and serve it with the web server.
|
||||||
|
|
||||||
|
**backend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Source virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r trip/requirements.txt
|
||||||
|
|
||||||
|
# Run the backend, port :8000
|
||||||
|
fastapi run trip/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the frontend
|
||||||
|
npm build
|
||||||
|
|
||||||
|
# Copy the build to your static web server directory
|
||||||
|
cp -r dist/trip/browser /var/www/html
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 📸 Demo <a name = "demo"></a>
|
||||||
|
|
||||||
|
A demo is available at [itskovacs-trip.netlify.app](https://itskovacs-trip.netlify.app/).
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|:-------:|:-------:|
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 🚧 Roadmap <a name = "roadmap"></a>
|
||||||
|
|
||||||
|
New features coming soon<sup>TM</sup>, check out the development plan in the [Roadmap Wiki](https://github.com/itskovacs/trip/wiki/Roadmap). If you have ideas 💡, feel free to open an issue.
|
||||||
|
|
||||||
|
If you want to develop new feature, feel free to open a pull request (see [🤝 Contributing](#contributing)).
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 📜 License <a name = "license"></a>
|
||||||
|
|
||||||
|
I decided to license trip under the **CC BY-NC-SA 4.0** – You may use, modify, and share freely with attribution, but **commercial use is prohibited**.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 🤝 Contributing <a name = "contributing"></a>
|
||||||
|
|
||||||
|
Contributions are welcome! Feel free to open issues if you find bugs and pull requests for your new features!
|
||||||
|
|
||||||
|
1. Fork the repo
|
||||||
|
2. Create a new branch (`my-new-trip-feature`)
|
||||||
|
3. Commit changes
|
||||||
|
4. Open a pull request
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack <a name = "techstack"></a>
|
||||||
|
|
||||||
|
### **Frontend**
|
||||||
|
|
||||||
|
- 🅰️ Angular 19
|
||||||
|
- 🏗️ PrimeNG 19
|
||||||
|
- 🎨 Tailwind CSS 4
|
||||||
|
- 🗺️ Leaflet 1.9 (plugins: [Leaflet.markercluster](https://github.com/Leaflet/Leaflet.markercluster), [Leaflet.contextmenu](https://github.com/aratcliffe/Leaflet.contextmenu))
|
||||||
|
|
||||||
|
### **Backend**
|
||||||
|
|
||||||
|
- 🐍 FastAPI, SQLModel
|
||||||
|
- 🗃️ SQLite
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## ✍️ Authors <a name = "authors"></a>
|
||||||
|
|
||||||
|
- [@itskovacs](https://github.com/itskovacs)
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
If you like TRIP, consider giving it a **star** ⭐!
|
||||||
|
Made with ❤️ in BZH
|
||||||
|
|
||||||
|
</div>
|
||||||
44
SECURITY.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
Thank you for your interest in TRIP!
|
||||||
|
|
||||||
|
If you discover a security vulnerability in this repository, please report it to me so I can commit a fix.
|
||||||
|
|
||||||
|
### How to Report
|
||||||
|
- **Email:** Send me details at [itskovacs@proton.me](mailto:itskovacs@proton.me)
|
||||||
|
- **PGP Key:** (Optional) If you need to encrypt your email, you can find my PGP key at the bottom of this page
|
||||||
|
- **Issue Tracker:** Please do **not** report vulnerabilities via public issues. Use email instead.
|
||||||
|
|
||||||
|
### What to Include
|
||||||
|
- Steps to reproduce
|
||||||
|
- Affected versions
|
||||||
|
- Possible fixes (if known)
|
||||||
|
|
||||||
|
## Response Time
|
||||||
|
I will acknowledge the report within 48 hours, and will fix as soon as I can.
|
||||||
|
|
||||||
|
## Responsible Disclosure
|
||||||
|
I kindly request that you give me sufficient time to fix the issue before making any public disclosure.
|
||||||
|
|
||||||
|
**Thank you for helping me enhance TRIP's security as well as expand my knowledge!**
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### PGP Key <a name = "pgp"></a>
|
||||||
|
```
|
||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
xjMEZb+LFBYJKwYBBAHaRw8BAQdAozb/pSDJS6OFSr8Gz7081e61NtbXOdlo
|
||||||
|
67Aty9Jq5ZPNIWl0c2tvdmFjc0BwbS5tZSA8aXRza292YWNzQHBtLm1lPsKM
|
||||||
|
BBAWCgA+BYJlv4sUBAsJBwgJkMljPHJAdiJZAxUICgQWAAIBAhkBApsDAh4B
|
||||||
|
FiEEkgaL5LX1XOLxWKEKyWM8ckB2IlkAADF2AQCSRhCVo82HdKLJmy1G5mnH
|
||||||
|
H2+p5naJEEQ2vjMuSb6yIgD/TPnx0HStHxrvDo7BvPS9mnu2zRBvpmIiaA3P
|
||||||
|
9AQ4lQ3OOARlv4sUEgorBgEEAZdVAQUBAQdAmmzQPcphs7e6PjyY+7PS/dvp
|
||||||
|
WPB2+BOjSuSrNIAsvHsDAQgHwngEGBYKACoFgmW/ixQJkMljPHJAdiJZApsM
|
||||||
|
FiEEkgaL5LX1XOLxWKEKyWM8ckB2IlkAAH/NAQDctI6cShPzqKpYdC1jOLty
|
||||||
|
71Zl62RL0IC5DoXlPUiO8QD/TkLGMoCWft1YPmSsBms1q2YbtrwFUnwSkXb/
|
||||||
|
SH9m4Ak=
|
||||||
|
=HiZL
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
|
```
|
||||||
BIN
backend/storage/assets/accommodation.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
backend/storage/assets/adventure.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
backend/storage/assets/culture.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
backend/storage/assets/entertainment.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
backend/storage/assets/event.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
backend/storage/assets/food.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
backend/storage/assets/nature.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
backend/storage/assets/wellness.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
0
backend/storage/config.yml
Normal file
1
backend/trip/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "1.0.0"
|
||||||
22
backend/trip/config.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
ASSETS_FOLDER: str = "storage/assets"
|
||||||
|
FRONTEND_FOLDER: str = "frontend"
|
||||||
|
SQLITE_FILE: str = "storage/trip.sqlite"
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
0
backend/trip/db/__init__.py
Normal file
76
backend/trip/db/core.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..models.models import Category, Image
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if not _engine:
|
||||||
|
_engine = create_engine(
|
||||||
|
f"sqlite:///{settings.SQLITE_FILE}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Engine, "connect")
|
||||||
|
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
engine = get_engine()
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for element in data:
|
||||||
|
img = Image(**element["image"])
|
||||||
|
session.add(img)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
category = Category(**element["category"], image_id=img.id)
|
||||||
|
session.add(category)
|
||||||
|
session.commit()
|
||||||
36
backend/trip/deps.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .db.core import get_engine
|
||||||
|
from .models.models import User
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
engine = get_engine()
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
SessionDep = Annotated[Session, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_username(token: Annotated[str, Depends(oauth2_scheme)], session: SessionDep) -> str:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
username = payload.get("sub")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid Token")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid Token")
|
||||||
|
|
||||||
|
user = session.get(User, username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid Token")
|
||||||
|
return user.username
|
||||||
60
backend/trip/main.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .config import settings
|
||||||
|
from .db.core import init_db
|
||||||
|
from .routers import auth, categories, places
|
||||||
|
from .routers import settings as settings_r
|
||||||
|
from .routers import trips
|
||||||
|
|
||||||
|
if not Path(settings.FRONTEND_FOLDER).is_dir():
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
Path(settings.ASSETS_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(categories.router)
|
||||||
|
app.include_router(places.router)
|
||||||
|
app.include_router(settings_r.router)
|
||||||
|
app.include_router(trips.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/info")
|
||||||
|
def info():
|
||||||
|
return {"version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def not_found_to_spa(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if response.status_code == 404 and not request.url.path.startswith(("/api", "/assets")):
|
||||||
|
return FileResponse(Path(settings.FRONTEND_FOLDER) / "index.html")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup_event():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/api/assets", StaticFiles(directory=settings.ASSETS_FOLDER), name="static")
|
||||||
|
app.mount("/", StaticFiles(directory=settings.FRONTEND_FOLDER, html=True), name="frontend")
|
||||||
0
backend/trip/models/__init__.py
Normal file
358
backend/trip/models/models.py
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
import re
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import BaseModel, StringConstraints, field_validator
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
convention = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
SQLModel.metadata = MetaData(naming_convention=convention)
|
||||||
|
|
||||||
|
|
||||||
|
class TripItemStatusEnum(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
CONFIRMED = "booked"
|
||||||
|
CONSTRAINT = "constraint"
|
||||||
|
OPTIONAL = "optional"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRegisterModel(BaseModel):
|
||||||
|
username: Annotated[
|
||||||
|
str,
|
||||||
|
StringConstraints(min_length=1, max_length=19, pattern=r"^[a-zA-Z0-9_-]+$"),
|
||||||
|
]
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ImageBase(SQLModel):
|
||||||
|
filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class Image(ImageBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||||
|
|
||||||
|
categories: list["Category"] = Relationship(back_populates="image")
|
||||||
|
places: list["Place"] = Relationship(back_populates="image")
|
||||||
|
trips: list["Trip"] = Relationship(back_populates="image")
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(SQLModel):
|
||||||
|
mapLat: float = 48.107
|
||||||
|
mapLng: float = -2.988
|
||||||
|
currency: str = "€"
|
||||||
|
do_not_display: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserBase, table=True):
|
||||||
|
username: str = Field(primary_key=True)
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(UserBase):
|
||||||
|
mapLat: float | None = None
|
||||||
|
mapLng: float | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
do_not_display: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(UserBase):
|
||||||
|
username: str
|
||||||
|
do_not_display: list[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, obj: User) -> "UserRead":
|
||||||
|
return cls(
|
||||||
|
username=obj.username,
|
||||||
|
mapLat=obj.mapLat,
|
||||||
|
mapLng=obj.mapLng,
|
||||||
|
currency=obj.currency,
|
||||||
|
do_not_display=obj.do_not_display.split(",") if obj.do_not_display else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBase(SQLModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class Category(CategoryBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
image_id: int | None = Field(default=None, foreign_key="image.id", ondelete="CASCADE")
|
||||||
|
image: Image | None = Relationship(back_populates="categories")
|
||||||
|
places: list["Place"] = Relationship(back_populates="category")
|
||||||
|
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(CategoryBase):
|
||||||
|
name: str
|
||||||
|
image: str
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(CategoryBase):
|
||||||
|
name: str | None = None
|
||||||
|
image: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryRead(CategoryBase):
|
||||||
|
id: int
|
||||||
|
image: str
|
||||||
|
image_id: int
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TripPlaceLink(SQLModel, table=True):
|
||||||
|
trip_id: int = Field(foreign_key="trip.id", primary_key=True)
|
||||||
|
place_id: int = Field(foreign_key="place.id", primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceBase(SQLModel):
|
||||||
|
name: str
|
||||||
|
lat: float
|
||||||
|
lng: float
|
||||||
|
place: str
|
||||||
|
allowdog: bool | None = None
|
||||||
|
description: str | None = None
|
||||||
|
price: float | None = None
|
||||||
|
duration: int | None = None
|
||||||
|
favorite: bool | None = None
|
||||||
|
visited: bool | None = None
|
||||||
|
gpx: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Place(PlaceBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
cdate: date = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||||
|
|
||||||
|
image_id: int | None = Field(default=None, foreign_key="image.id", ondelete="CASCADE")
|
||||||
|
image: Image | None = Relationship(back_populates="places")
|
||||||
|
|
||||||
|
category_id: int = Field(foreign_key="category.id")
|
||||||
|
category: Category | None = Relationship(back_populates="places")
|
||||||
|
|
||||||
|
trip_items: list["TripItem"] = Relationship(back_populates="place")
|
||||||
|
|
||||||
|
trips: list["Trip"] = Relationship(back_populates="places", link_model=TripPlaceLink)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceCreate(PlaceBase):
|
||||||
|
image: str | None = None
|
||||||
|
category_id: int
|
||||||
|
gpx: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlacesCreate(PlaceBase):
|
||||||
|
image: str | None = None
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceUpdate(PlaceBase):
|
||||||
|
name: str | None = None
|
||||||
|
lat: float | None = None
|
||||||
|
lng: float | None = None
|
||||||
|
place: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
image: str | None = None
|
||||||
|
gpx: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceRead(PlaceBase):
|
||||||
|
id: int
|
||||||
|
category: CategoryRead
|
||||||
|
image: str | None
|
||||||
|
image_id: int | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, obj: Place, exclude_gpx=True) -> "PlaceRead":
|
||||||
|
return cls(
|
||||||
|
id=obj.id,
|
||||||
|
name=obj.name,
|
||||||
|
lat=obj.lat,
|
||||||
|
lng=obj.lng,
|
||||||
|
place=obj.place,
|
||||||
|
category=CategoryRead.serialize(obj.category),
|
||||||
|
allowdog=obj.allowdog,
|
||||||
|
description=obj.description,
|
||||||
|
price=obj.price,
|
||||||
|
duration=obj.duration,
|
||||||
|
visited=obj.visited,
|
||||||
|
image=obj.image.filename if obj.image else None,
|
||||||
|
image_id=obj.image_id,
|
||||||
|
favorite=obj.favorite,
|
||||||
|
gpx=("1" if obj.gpx else None)
|
||||||
|
if exclude_gpx
|
||||||
|
else obj.gpx, # Generic PlaceRead. Avoid large resp.
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TripBase(SQLModel):
|
||||||
|
name: str
|
||||||
|
archived: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Trip(TripBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
image_id: int | None = Field(default=None, foreign_key="image.id", ondelete="CASCADE")
|
||||||
|
image: Image | None = Relationship(back_populates="trips")
|
||||||
|
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||||
|
|
||||||
|
places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink)
|
||||||
|
days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TripCreate(TripBase):
|
||||||
|
image: str | None = None
|
||||||
|
place_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TripUpdate(TripBase):
|
||||||
|
name: str | None = None
|
||||||
|
image: str | None = None
|
||||||
|
place_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TripReadBase(TripBase):
|
||||||
|
id: int
|
||||||
|
image: str | None
|
||||||
|
image_id: int | None
|
||||||
|
days: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, obj: Trip) -> "TripRead":
|
||||||
|
return cls(
|
||||||
|
id=obj.id,
|
||||||
|
name=obj.name,
|
||||||
|
archived=obj.archived,
|
||||||
|
image=obj.image.filename if obj.image else None,
|
||||||
|
image_id=obj.image_id,
|
||||||
|
days=len(obj.days),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TripRead(TripBase):
|
||||||
|
id: int
|
||||||
|
image: str | None
|
||||||
|
image_id: int | None
|
||||||
|
days: list["TripDayRead"]
|
||||||
|
places: list["PlaceRead"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, obj: Trip) -> "TripRead":
|
||||||
|
return cls(
|
||||||
|
id=obj.id,
|
||||||
|
name=obj.name,
|
||||||
|
archived=obj.archived,
|
||||||
|
image=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],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TripDayBase(SQLModel):
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class TripDay(TripDayBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||||
|
|
||||||
|
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
|
||||||
|
trip: Trip | None = Relationship(back_populates="days")
|
||||||
|
|
||||||
|
items: list["TripItem"] = Relationship(back_populates="day", cascade_delete=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TripDayRead(TripDayBase):
|
||||||
|
id: int
|
||||||
|
items: list["TripItemRead"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, obj: TripDay) -> "TripDayRead":
|
||||||
|
return cls(
|
||||||
|
id=obj.id,
|
||||||
|
label=obj.label,
|
||||||
|
items=[TripItemRead.serialize(item) for item in obj.items],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TripItemBase(SQLModel):
|
||||||
|
time: Annotated[
|
||||||
|
str,
|
||||||
|
StringConstraints(min_length=2, max_length=5, pattern=r"^([01]\d|2[0-3])(:[0-5]\d)?$"),
|
||||||
|
]
|
||||||
|
text: str
|
||||||
|
comment: str | None = None
|
||||||
|
lat: float | None = None
|
||||||
|
price: float | None = None
|
||||||
|
lng: float | None = None
|
||||||
|
status: TripItemStatusEnum | None = None
|
||||||
|
|
||||||
|
@field_validator("time", mode="before")
|
||||||
|
def pad_mm_if_needed(cls, value: str) -> str:
|
||||||
|
if re.fullmatch(r"^([01]\d|2[0-3])$", value): # If it's just HH
|
||||||
|
return f"{value}:00"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class TripItem(TripItemBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
|
||||||
|
place_id: int | None = Field(default=None, foreign_key="place.id")
|
||||||
|
place: Place | None = Relationship(back_populates="trip_items")
|
||||||
|
|
||||||
|
day_id: int = Field(foreign_key="tripday.id", ondelete="CASCADE")
|
||||||
|
day: TripDay | None = Relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
class TripItemCreate(TripItemBase):
|
||||||
|
place: int | None = None
|
||||||
|
status: TripItemStatusEnum | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TripItemUpdate(TripItemBase):
|
||||||
|
time: str | None = None
|
||||||
|
text: str | None = None
|
||||||
|
place: int | None = None
|
||||||
|
status: TripItemStatusEnum | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TripItemRead(TripItemBase):
|
||||||
|
id: int
|
||||||
|
place: PlaceRead | None
|
||||||
|
day_id: int
|
||||||
|
status: TripItemStatusEnum | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, obj: TripItem) -> "TripItemRead":
|
||||||
|
return cls(
|
||||||
|
id=obj.id,
|
||||||
|
time=obj.time,
|
||||||
|
text=obj.text,
|
||||||
|
comment=obj.comment,
|
||||||
|
lat=obj.lat,
|
||||||
|
lng=obj.lng,
|
||||||
|
price=obj.price,
|
||||||
|
day_id=obj.day_id,
|
||||||
|
status=obj.status,
|
||||||
|
place=PlaceRead.serialize(obj.place) if obj.place else None,
|
||||||
|
)
|
||||||
7
backend/trip/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
fastapi[standard]~=0.115
|
||||||
|
sqlmodel~=0.0
|
||||||
|
pydantic~=2.11
|
||||||
|
PyJWT~=2.10
|
||||||
|
argon2-cffi~=25.1
|
||||||
|
pydantic_settings~=2.9
|
||||||
|
Pillow~=11.2
|
||||||
0
backend/trip/routers/__init__.py
Normal file
57
backend/trip/routers/auth.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, Body, HTTPException
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..db.core import init_user_data
|
||||||
|
from ..deps import SessionDep
|
||||||
|
from ..models.models import LoginRegisterModel, Token, User
|
||||||
|
from ..security import (create_access_token, create_tokens, hash_password,
|
||||||
|
verify_password)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(req: LoginRegisterModel, session: SessionDep) -> Token:
|
||||||
|
db_user = session.get(User, req.username)
|
||||||
|
if not db_user or not verify_password(req.password, db_user.password):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
return create_tokens(data={"sub": db_user.username})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=Token)
|
||||||
|
def register(req: LoginRegisterModel, session: SessionDep) -> Token:
|
||||||
|
db_user = session.get(User, req.username)
|
||||||
|
if db_user:
|
||||||
|
raise HTTPException(status_code=409, detail="The resource already exists")
|
||||||
|
|
||||||
|
new_user = User(username=req.username, password=hash_password(req.password))
|
||||||
|
session.add(new_user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
init_user_data(session, new_user.username)
|
||||||
|
|
||||||
|
return create_tokens(data={"sub": new_user.username})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
def refresh_token(refresh_token: str = Body(..., embed=True)):
|
||||||
|
if not refresh_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Refresh token expected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
username = payload.get("sub", None)
|
||||||
|
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid Token")
|
||||||
|
|
||||||
|
new_access_token = create_access_token(data={"sub": username})
|
||||||
|
|
||||||
|
return {"access_token": new_access_token}
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid Token")
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid Token")
|
||||||
121
backend/trip/routers/categories.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..deps import SessionDep, get_current_username
|
||||||
|
from ..models.models import (Category, CategoryCreate, CategoryRead,
|
||||||
|
CategoryUpdate, Image)
|
||||||
|
from ..security import verify_exists_and_owns
|
||||||
|
from ..utils.utils import b64img_decode, remove_image, save_image_to_file
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/categories", tags=["categories"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CategoryRead])
|
||||||
|
def read_categories(
|
||||||
|
session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> list[Category]:
|
||||||
|
categories = session.exec(select(Category).filter(Category.user == current_user))
|
||||||
|
return [CategoryRead.serialize(category) for category in categories]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CategoryRead)
|
||||||
|
def post_category(
|
||||||
|
category: CategoryCreate,
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> 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")
|
||||||
|
|
||||||
|
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()
|
||||||
|
session.refresh(new_category)
|
||||||
|
return CategoryRead.serialize(new_category)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{category_id}", response_model=CategoryRead)
|
||||||
|
def put_category(
|
||||||
|
session: SessionDep,
|
||||||
|
category_id: int,
|
||||||
|
category: CategoryUpdate,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> CategoryRead:
|
||||||
|
db_category = session.get(Category, category_id)
|
||||||
|
verify_exists_and_owns(current_user, db_category)
|
||||||
|
|
||||||
|
category_data = category.model_dump(exclude_unset=True)
|
||||||
|
if category_data.get("image"):
|
||||||
|
try:
|
||||||
|
image_bytes = b64img_decode(category_data.pop("image"))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if db_category.image_id:
|
||||||
|
old_image = session.get(Image, db_category.image_id)
|
||||||
|
try:
|
||||||
|
remove_image(old_image.filename)
|
||||||
|
session.delete(old_image)
|
||||||
|
db_category.image_id = None
|
||||||
|
session.refresh(db_category)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
db_category.image_id = image.id
|
||||||
|
|
||||||
|
for key, value in category_data.items():
|
||||||
|
setattr(db_category, key, value)
|
||||||
|
|
||||||
|
session.add(db_category)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_category)
|
||||||
|
return CategoryRead.serialize(db_category)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{category_id}")
|
||||||
|
def delete_category(
|
||||||
|
session: SessionDep,
|
||||||
|
category_id: int,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> dict:
|
||||||
|
db_category = session.get(Category, category_id)
|
||||||
|
verify_exists_and_owns(current_user, db_category)
|
||||||
|
|
||||||
|
if get_category_placess_cnt(session, category_id, current_user) > 0:
|
||||||
|
raise HTTPException(status_code=409, detail="The resource is not orphan")
|
||||||
|
|
||||||
|
session.delete(db_category)
|
||||||
|
session.commit()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{category_id}/count")
|
||||||
|
def get_category_placess_cnt(
|
||||||
|
session: SessionDep,
|
||||||
|
category_id: int,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> int:
|
||||||
|
db_category = session.get(Category, category_id)
|
||||||
|
verify_exists_and_owns(current_user, db_category)
|
||||||
|
return len(db_category.places)
|
||||||
172
backend/trip/routers/places.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..deps import SessionDep, get_current_username
|
||||||
|
from ..models.models import (Category, Image, Place, PlaceCreate, PlaceRead,
|
||||||
|
PlacesCreate, PlaceUpdate)
|
||||||
|
from ..security import verify_exists_and_owns
|
||||||
|
from ..utils.utils import (b64img_decode, download_file, patch_image,
|
||||||
|
remove_image, save_image_to_file)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/places", tags=["places"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[PlaceRead])
|
||||||
|
def read_places(
|
||||||
|
session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> list[PlaceRead]:
|
||||||
|
places = session.exec(select(Place).filter(Place.user == current_user))
|
||||||
|
return [PlaceRead.serialize(p) for p in places]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=PlaceRead)
|
||||||
|
def create_place(
|
||||||
|
place: PlaceCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> PlaceRead:
|
||||||
|
new_place = Place(
|
||||||
|
name=place.name,
|
||||||
|
lat=place.lat,
|
||||||
|
lng=place.lng,
|
||||||
|
place=place.place,
|
||||||
|
allowdog=place.allowdog,
|
||||||
|
description=place.description,
|
||||||
|
price=place.price,
|
||||||
|
duration=place.duration,
|
||||||
|
category_id=place.category_id,
|
||||||
|
visited=place.visited,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
session.add(new_place)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(new_place)
|
||||||
|
return PlaceRead.serialize(new_place)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=list[PlaceRead])
|
||||||
|
async def create_places(
|
||||||
|
places: list[PlacesCreate],
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> list[PlaceRead]:
|
||||||
|
new_places = []
|
||||||
|
|
||||||
|
for place in places:
|
||||||
|
category_name = place.category
|
||||||
|
category = session.exec(
|
||||||
|
select(Category).filter(Category.user == current_user, Category.name == category_name)
|
||||||
|
).first()
|
||||||
|
if not category:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_place = Place(
|
||||||
|
name=place.name,
|
||||||
|
lat=place.lat,
|
||||||
|
lng=place.lng,
|
||||||
|
place=place.place,
|
||||||
|
allowdog=place.allowdog,
|
||||||
|
description=place.description,
|
||||||
|
price=place.price,
|
||||||
|
duration=place.duration,
|
||||||
|
category_id=category.id,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
if place.image: # It's a link, dl file
|
||||||
|
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()
|
||||||
|
new_place.image_id = image.id
|
||||||
|
|
||||||
|
session.add(new_place)
|
||||||
|
new_places.append(new_place)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return [PlaceRead.serialize(p) for p in new_places]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{place_id}", response_model=PlaceRead)
|
||||||
|
def update_place(
|
||||||
|
session: SessionDep,
|
||||||
|
place_id: int,
|
||||||
|
place: PlaceUpdate,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> PlaceRead:
|
||||||
|
db_place = session.get(Place, place_id)
|
||||||
|
verify_exists_and_owns(current_user, db_place)
|
||||||
|
|
||||||
|
place_data = place.model_dump(exclude_unset=True)
|
||||||
|
image = place_data.pop("image")
|
||||||
|
if image:
|
||||||
|
try:
|
||||||
|
image_bytes = b64img_decode(image)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
place_data.pop("image")
|
||||||
|
place_data["image_id"] = image.id
|
||||||
|
|
||||||
|
if db_place.image_id:
|
||||||
|
old_image = session.get(Image, db_place.image_id)
|
||||||
|
try:
|
||||||
|
remove_image(old_image.filename)
|
||||||
|
session.delete(old_image)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
for key, value in place_data.items():
|
||||||
|
setattr(db_place, key, value)
|
||||||
|
|
||||||
|
session.add(db_place)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_place)
|
||||||
|
return PlaceRead.serialize(db_place)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{place_id}")
|
||||||
|
def delete_place(
|
||||||
|
session: SessionDep, place_id: int, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
):
|
||||||
|
db_place = session.get(Place, place_id)
|
||||||
|
verify_exists_and_owns(current_user, db_place)
|
||||||
|
|
||||||
|
if db_place.image:
|
||||||
|
try:
|
||||||
|
remove_image(db_place.image.filename)
|
||||||
|
session.delete(db_place.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_place)
|
||||||
|
session.commit()
|
||||||
|
return {}
|
||||||
236
backend/trip/routers/settings.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..deps import SessionDep, get_current_username
|
||||||
|
from ..models.models import (Category, CategoryRead, Image, Place, PlaceRead,
|
||||||
|
Trip, TripDay, TripItem, TripRead, User, UserRead,
|
||||||
|
UserUpdate)
|
||||||
|
from ..utils.utils import b64e, b64img_decode, check_update, save_image_to_file
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserRead)
|
||||||
|
def get_user_settings(
|
||||||
|
session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> UserRead:
|
||||||
|
db_user = session.get(User, current_user)
|
||||||
|
return UserRead.serialize(db_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=UserRead)
|
||||||
|
def put_user_settings(
|
||||||
|
session: SessionDep, data: UserUpdate, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> UserRead:
|
||||||
|
db_user = session.get(User, current_user)
|
||||||
|
|
||||||
|
user_data = data.model_dump(exclude_unset=True)
|
||||||
|
if "do_not_display" in user_data:
|
||||||
|
user_data["do_not_display"] = ",".join(user_data["do_not_display"]) if user_data["do_not_display"] else ""
|
||||||
|
|
||||||
|
for key, value in user_data.items():
|
||||||
|
setattr(db_user, key, value)
|
||||||
|
|
||||||
|
session.add(db_user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_user)
|
||||||
|
return UserRead.serialize(db_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/checkversion")
|
||||||
|
async def check_version(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]):
|
||||||
|
return await check_update()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_data(session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]):
|
||||||
|
data = {
|
||||||
|
"_": {
|
||||||
|
"at": datetime.timestamp(datetime.now()),
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
CategoryRead.serialize(c)
|
||||||
|
for c in session.exec(select(Category).filter(Category.user == current_user))
|
||||||
|
],
|
||||||
|
"places": [
|
||||||
|
PlaceRead.serialize(place)
|
||||||
|
for place in session.exec(select(Place).filter(Place.user == current_user))
|
||||||
|
],
|
||||||
|
"images": {},
|
||||||
|
"trips": [
|
||||||
|
TripRead.serialize(c) for c in session.exec(select(Trip).filter(Trip.user == current_user))
|
||||||
|
],
|
||||||
|
"settings": UserRead.serialize(session.get(User, current_user)),
|
||||||
|
}
|
||||||
|
|
||||||
|
images = session.exec(select(Image).where(Image.user == current_user))
|
||||||
|
for im in images:
|
||||||
|
with open(Path(settings.ASSETS_FOLDER) / im.filename, "rb") as f:
|
||||||
|
data["images"][im.id] = b64e(f.read())
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=list[PlaceRead])
|
||||||
|
async def import_data(
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> list[PlaceRead]:
|
||||||
|
if file.content_type != "application/json":
|
||||||
|
raise HTTPException(status_code=415, detail="File must be a JSON file")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
data = json.loads(content)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid file")
|
||||||
|
|
||||||
|
for category in data.get("categories", []):
|
||||||
|
category_name = category.get("category", {}).get("name")
|
||||||
|
category_exists = session.exec(
|
||||||
|
select(Category).filter(Category.user == current_user, Category.name == category_name)
|
||||||
|
).first()
|
||||||
|
if category_exists:
|
||||||
|
continue # This category label exists
|
||||||
|
|
||||||
|
category_data = {
|
||||||
|
key: category[key] for key in category.keys() if key not in {"id", "image", "image_id"}
|
||||||
|
}
|
||||||
|
category_data["user"] = current_user
|
||||||
|
|
||||||
|
if category.get("image_id"):
|
||||||
|
b64_image = category.get("images", {}).get(str(category.get("image_id")))
|
||||||
|
if b64_image is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_bytes = b64img_decode(b64_image)
|
||||||
|
filename = save_image_to_file(image_bytes, settings.PLACE_IMAGE_SIZE)
|
||||||
|
if not filename:
|
||||||
|
raise HTTPException(status_code=500, detail="Error saving image")
|
||||||
|
|
||||||
|
image = Image(filename=filename, user=current_user)
|
||||||
|
session.add(image)
|
||||||
|
session.flush()
|
||||||
|
session.refresh(image)
|
||||||
|
category_data["image_id"] = image.id
|
||||||
|
|
||||||
|
new_category = Category(**category_data)
|
||||||
|
session.add(new_category)
|
||||||
|
session.flush()
|
||||||
|
session.refresh()
|
||||||
|
|
||||||
|
places = []
|
||||||
|
for place in data.get("places", []):
|
||||||
|
category_name = place.get("category", {}).get("name")
|
||||||
|
category = session.exec(
|
||||||
|
select(Category).filter(Category.user == current_user, Category.name == category_name)
|
||||||
|
).first()
|
||||||
|
if not category:
|
||||||
|
continue
|
||||||
|
|
||||||
|
place_data = {
|
||||||
|
key: place[key]
|
||||||
|
for key in place.keys()
|
||||||
|
if key not in {"id", "image", "image_id", "category", "category_id"}
|
||||||
|
}
|
||||||
|
place_data["user"] = current_user
|
||||||
|
place_data["category_id"] = category.id
|
||||||
|
|
||||||
|
if place.get("image_id"):
|
||||||
|
b64_image = data.get("images", {}).get(str(place.get("image_id")))
|
||||||
|
if b64_image is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_bytes = b64img_decode(b64_image)
|
||||||
|
filename = save_image_to_file(image_bytes, settings.PLACE_IMAGE_SIZE)
|
||||||
|
if not filename:
|
||||||
|
raise HTTPException(status_code=500, detail="Error saving image")
|
||||||
|
|
||||||
|
image = Image(filename=filename, user=current_user)
|
||||||
|
session.add(image)
|
||||||
|
session.flush()
|
||||||
|
session.refresh(image)
|
||||||
|
place_data["image_id"] = image.id
|
||||||
|
|
||||||
|
new_place = Place(**place_data)
|
||||||
|
session.add(new_place)
|
||||||
|
session.flush()
|
||||||
|
places.append(new_place)
|
||||||
|
|
||||||
|
db_user = session.get(User, current_user)
|
||||||
|
if data.get("settings"):
|
||||||
|
settings_data = data["settings"]
|
||||||
|
if settings_data.get("mapLat"):
|
||||||
|
db_user.mapLat = settings_data["mapLat"]
|
||||||
|
|
||||||
|
if settings_data.get("mapLng"):
|
||||||
|
db_user.mapLng = settings_data["mapLng"]
|
||||||
|
|
||||||
|
if settings_data.get("currency"):
|
||||||
|
db_user.currency = settings_data["currency"]
|
||||||
|
|
||||||
|
session.add(db_user)
|
||||||
|
session.refresh(db_user)
|
||||||
|
|
||||||
|
trip_place_id_map = {p["id"]: new_p.id for p, new_p in zip(data.get("places", []), places)}
|
||||||
|
for trip in data.get("trips", []):
|
||||||
|
trip_data = {
|
||||||
|
key: trip[key] for key in trip.keys() if key not in {"id", "image", "image_id", "places", "days"}
|
||||||
|
}
|
||||||
|
trip_data["user"] = current_user
|
||||||
|
|
||||||
|
if trip.get("image_id"):
|
||||||
|
b64_image = data.get("images", {}).get(str(trip.get("image_id")))
|
||||||
|
if b64_image:
|
||||||
|
image_bytes = b64img_decode(b64_image)
|
||||||
|
filename = save_image_to_file(image_bytes, settings.TRIP_IMAGE_SIZE)
|
||||||
|
if filename:
|
||||||
|
image = Image(filename=filename, user=current_user)
|
||||||
|
session.add(image)
|
||||||
|
session.flush()
|
||||||
|
session.refresh(image)
|
||||||
|
trip_data["image_id"] = image.id
|
||||||
|
|
||||||
|
new_trip = Trip(**trip_data)
|
||||||
|
session.add(new_trip)
|
||||||
|
session.flush()
|
||||||
|
session.refresh(new_trip)
|
||||||
|
|
||||||
|
for place in trip.get("places", []):
|
||||||
|
old_id = place["id"]
|
||||||
|
new_place_id = trip_place_id_map.get(old_id)
|
||||||
|
if new_place_id:
|
||||||
|
db_place = session.get(Place, new_place_id)
|
||||||
|
if db_place:
|
||||||
|
new_trip.places.append(db_place)
|
||||||
|
|
||||||
|
for day in trip.get("days", []):
|
||||||
|
day_data = {key: day[key] for key in day if key not in {"id", "items"}}
|
||||||
|
new_day = TripDay(**day_data, trip_id=new_trip.id, user=current_user)
|
||||||
|
session.add(new_day)
|
||||||
|
session.flush()
|
||||||
|
session.refresh(new_day)
|
||||||
|
|
||||||
|
for item in day.get("items", []):
|
||||||
|
item_data = {key: item[key] for key in item if key not in {"id", "place"}}
|
||||||
|
place = item.get("place")
|
||||||
|
if (
|
||||||
|
place
|
||||||
|
and (place_id := place.get("id"))
|
||||||
|
and (new_place_id := trip_place_id_map.get(place_id))
|
||||||
|
):
|
||||||
|
item_data["place_id"] = new_place_id
|
||||||
|
|
||||||
|
item_data["day_id"] = new_day.id
|
||||||
|
trip_item = TripItem(**item_data)
|
||||||
|
session.add(trip_item)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return [PlaceRead.serialize(p) for p in places]
|
||||||
316
backend/trip/routers/trips.py
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..deps import SessionDep, get_current_username
|
||||||
|
from ..models.models import (Image, Place, Trip, TripCreate, TripDay,
|
||||||
|
TripDayBase, TripDayRead, TripItem,
|
||||||
|
TripItemCreate, TripItemRead, TripItemUpdate,
|
||||||
|
TripPlaceLink, TripRead, TripReadBase, TripUpdate)
|
||||||
|
from ..security import verify_exists_and_owns
|
||||||
|
from ..utils.utils import b64img_decode, remove_image, save_image_to_file
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/trips", tags=["trips"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TripReadBase])
|
||||||
|
def read_trips(
|
||||||
|
session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> list[TripReadBase]:
|
||||||
|
trips = session.exec(select(Trip).filter(Trip.user == current_user))
|
||||||
|
return [TripReadBase.serialize(trip) for trip in trips]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{trip_id}", response_model=TripRead)
|
||||||
|
def read_trip(
|
||||||
|
session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> TripRead:
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
return TripRead.serialize(db_trip)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TripRead)
|
||||||
|
def create_trip(
|
||||||
|
trip: TripCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
) -> TripRead:
|
||||||
|
new_trip = Trip(
|
||||||
|
name=trip.name,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
if trip.image:
|
||||||
|
image_bytes = b64img_decode(trip.image)
|
||||||
|
filename = save_image_to_file(image_bytes, settings.TRIP_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_trip.image_id = image.id
|
||||||
|
|
||||||
|
if trip.place_ids:
|
||||||
|
for place_id in trip.place_ids:
|
||||||
|
db_place = session.get(Place, place_id)
|
||||||
|
verify_exists_and_owns(current_user, db_place)
|
||||||
|
session.add(TripPlaceLink(trip_id=new_trip.id, place_id=db_place.id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
session.add(new_trip)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(new_trip)
|
||||||
|
return TripRead.serialize(new_trip)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{trip_id}", response_model=TripRead)
|
||||||
|
def update_trip(
|
||||||
|
session: SessionDep,
|
||||||
|
trip_id: int,
|
||||||
|
trip: TripUpdate,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> TripRead:
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
trip_data = trip.model_dump(exclude_unset=True)
|
||||||
|
if trip_data.get("image"):
|
||||||
|
try:
|
||||||
|
image_bytes = b64img_decode(trip_data.pop("image"))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
filename = save_image_to_file(image_bytes, settings.TRIP_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)
|
||||||
|
|
||||||
|
if db_trip.image_id:
|
||||||
|
old_image = session.get(Image, db_trip.image_id)
|
||||||
|
try:
|
||||||
|
remove_image(old_image.filename)
|
||||||
|
session.delete(old_image)
|
||||||
|
db_trip.image_id = None
|
||||||
|
session.refresh(db_trip)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
db_trip.image_id = image.id
|
||||||
|
|
||||||
|
if "place_ids" in trip_data: # Could be empty [], so 'in'
|
||||||
|
place_ids = trip_data.pop("place_ids")
|
||||||
|
db_trip.places.clear()
|
||||||
|
if place_ids:
|
||||||
|
for place_id in place_ids:
|
||||||
|
db_place = session.get(Place, place_id)
|
||||||
|
verify_exists_and_owns(current_user, db_place)
|
||||||
|
db_trip.places.append(db_place)
|
||||||
|
|
||||||
|
item_place_ids = {
|
||||||
|
item.place.id for day in db_trip.days for item in day.items if item.place is not None
|
||||||
|
}
|
||||||
|
invalid_place_ids = item_place_ids - set(place.id for place in db_trip.places)
|
||||||
|
if invalid_place_ids: # TripItem references a Place that Trip.places misses
|
||||||
|
raise HTTPException(status_code=400, detail="Bad Request")
|
||||||
|
|
||||||
|
for key, value in trip_data.items():
|
||||||
|
setattr(db_trip, key, value)
|
||||||
|
|
||||||
|
session.add(db_trip)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_trip)
|
||||||
|
return TripRead.serialize(db_trip)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{trip_id}")
|
||||||
|
def delete_trip(
|
||||||
|
session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)]
|
||||||
|
):
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
if db_trip.image:
|
||||||
|
try:
|
||||||
|
remove_image(db_trip.image.filename)
|
||||||
|
session.delete(db_trip.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_trip)
|
||||||
|
session.commit()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{trip_id}/days", response_model=TripDayRead)
|
||||||
|
def create_tripday(
|
||||||
|
td: TripDayBase,
|
||||||
|
trip_id: int,
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> TripDayRead:
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
new_day = TripDay(label=td.label, trip_id=trip_id, user=current_user)
|
||||||
|
|
||||||
|
session.add(new_day)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(new_day)
|
||||||
|
return TripDayRead.serialize(new_day)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{trip_id}/days/{day_id}", response_model=TripDayRead)
|
||||||
|
def update_tripday(
|
||||||
|
td: TripDayBase,
|
||||||
|
trip_id: int,
|
||||||
|
day_id: int,
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> TripDayRead:
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
db_day = session.get(TripDay, day_id)
|
||||||
|
verify_exists_and_owns(current_user, db_day)
|
||||||
|
if db_day.trip_id != trip_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
td_data = td.model_dump(exclude_unset=True)
|
||||||
|
for key, value in td_data.items():
|
||||||
|
setattr(db_day, key, value)
|
||||||
|
|
||||||
|
session.add(db_day)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_day)
|
||||||
|
return TripDayRead.serialize(db_day)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{trip_id}/days/{day_id}")
|
||||||
|
def delete_tripday(
|
||||||
|
session: SessionDep,
|
||||||
|
trip_id: int,
|
||||||
|
day_id: int,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
):
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
db_day = session.get(TripDay, day_id)
|
||||||
|
verify_exists_and_owns(current_user, db_day)
|
||||||
|
if db_day.trip_id != trip_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
session.delete(db_day)
|
||||||
|
session.commit()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{trip_id}/days/{day_id}/items", response_model=TripItemRead)
|
||||||
|
def create_tripitem(
|
||||||
|
item: TripItemCreate,
|
||||||
|
trip_id: int,
|
||||||
|
day_id: int,
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> TripItemRead:
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
db_day = session.get(TripDay, day_id)
|
||||||
|
if db_day.trip_id != trip_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
new_item = TripItem(
|
||||||
|
time=item.time,
|
||||||
|
text=item.text,
|
||||||
|
comment=item.comment,
|
||||||
|
lat=item.lat,
|
||||||
|
lng=item.lng,
|
||||||
|
day_id=day_id,
|
||||||
|
price=item.price,
|
||||||
|
status=item.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
if item.place and item.place != "":
|
||||||
|
place_in_trip = any(place.id == item.place for place in db_trip.places)
|
||||||
|
if not place_in_trip:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
new_item.place_id = item.place
|
||||||
|
|
||||||
|
session.add(new_item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(new_item)
|
||||||
|
return TripItemRead.serialize(new_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{trip_id}/days/{day_id}/items/{item_id}", response_model=TripItemRead)
|
||||||
|
def update_tripitem(
|
||||||
|
item: TripItemUpdate,
|
||||||
|
trip_id: int,
|
||||||
|
day_id: int,
|
||||||
|
item_id: int,
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
) -> TripItemRead:
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
db_day = session.get(TripDay, day_id)
|
||||||
|
if db_day.trip_id != trip_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
db_item = session.get(TripItem, item_id)
|
||||||
|
if db_item.day_id != day_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
if item.place:
|
||||||
|
place_in_trip = any(place.id == item.place for place in db_trip.places)
|
||||||
|
if not place_in_trip:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
item_data = item.model_dump(exclude_unset=True)
|
||||||
|
if item_data.get("place"):
|
||||||
|
place_id = item_data.pop("place")
|
||||||
|
db_item.place_id = place_id
|
||||||
|
|
||||||
|
for key, value in item_data.items():
|
||||||
|
setattr(db_item, key, value)
|
||||||
|
|
||||||
|
session.add(db_item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_item)
|
||||||
|
return TripItemRead.serialize(db_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{trip_id}/days/{day_id}/items/{item_id}")
|
||||||
|
def delete_tripitem(
|
||||||
|
session: SessionDep,
|
||||||
|
trip_id: int,
|
||||||
|
day_id: int,
|
||||||
|
item_id: int,
|
||||||
|
current_user: Annotated[str, Depends(get_current_username)],
|
||||||
|
):
|
||||||
|
db_trip = session.get(Trip, trip_id)
|
||||||
|
verify_exists_and_owns(current_user, db_trip)
|
||||||
|
|
||||||
|
db_day = session.get(TripDay, day_id)
|
||||||
|
if db_day.trip_id != trip_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
db_item = session.get(TripItem, item_id)
|
||||||
|
if db_item.day_id != day_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Bad request")
|
||||||
|
|
||||||
|
session.delete(db_item)
|
||||||
|
session.commit()
|
||||||
|
return {}
|
||||||
54
backend/trip/security.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2 import exceptions as argon_exceptions
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .models.models import Token
|
||||||
|
|
||||||
|
ph = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return ph.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
try:
|
||||||
|
return ph.verify(hashed_password, plain_password)
|
||||||
|
except (
|
||||||
|
argon_exceptions.VerifyMismatchError,
|
||||||
|
argon_exceptions.VerificationError,
|
||||||
|
argon_exceptions.InvalidHashError,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(UTC) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire.timestamp()})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tokens(data: dict) -> Token:
|
||||||
|
return Token(access_token=create_access_token(data), refresh_token=create_refresh_token(data))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_exists_and_owns(username: str, obj) -> None:
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="The resource does not exist")
|
||||||
|
|
||||||
|
if obj.user != username:
|
||||||
|
raise PermissionError
|
||||||
|
|
||||||
|
return None
|
||||||
0
backend/trip/utils/__init__.py
Normal file
168
backend/trip/utils/utils.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import base64
|
||||||
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
|
from ..config import Settings
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_filename(format: str) -> str:
|
||||||
|
return f"{uuid4()}.{format}"
|
||||||
|
|
||||||
|
|
||||||
|
def assets_folder_path() -> Path:
|
||||||
|
return Path(settings.ASSETS_FOLDER)
|
||||||
|
|
||||||
|
|
||||||
|
def b64e(data: bytes) -> bytes:
|
||||||
|
return base64.b64encode(data)
|
||||||
|
|
||||||
|
|
||||||
|
def b64img_decode(data: str) -> bytes:
|
||||||
|
return (
|
||||||
|
base64.b64decode(data.split(",", 1)[1]) if data.startswith("data:image/") else base64.b64decode(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_image(path: str):
|
||||||
|
try:
|
||||||
|
Path(assets_folder_path() / path).unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
raise Exception("Error deleting image:", exc, path)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_str_or_date_to_date(cdate: str | date) -> date:
|
||||||
|
if isinstance(cdate, str):
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(cdate)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format, use YYYY-MM-DD")
|
||||||
|
return cdate
|
||||||
|
|
||||||
|
|
||||||
|
async def download_file(link: str, raise_on_error: bool = False) -> str:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
|
"Referer": link,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, headers=headers, timeout=5) as client:
|
||||||
|
response = await client.get(link)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
path = assets_folder_path() / generate_filename(link.split("?")[0].split(".")[-1])
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(response.content)
|
||||||
|
return f.name
|
||||||
|
except Exception as e:
|
||||||
|
if raise_on_error:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Failed to download file: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def check_update():
|
||||||
|
url = "https://api.github.com/repos/itskovacs/trip/releases/latest"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=5) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
latest_version = response.json()["tag_name"]
|
||||||
|
if __version__ != latest_version:
|
||||||
|
return latest_version
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=503, detail="Couldn't verify for update")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_image(fp: str, size: int = 400) -> bool:
|
||||||
|
try:
|
||||||
|
with Image.open(fp) as im:
|
||||||
|
if im.mode not in ("RGB", "RGBA"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
|
||||||
|
# Resize and crop to square of size x size
|
||||||
|
if size > 0:
|
||||||
|
im_ratio = im.width / im.height
|
||||||
|
|
||||||
|
if im_ratio > 1:
|
||||||
|
new_height = size
|
||||||
|
new_width = int(size * im_ratio)
|
||||||
|
else:
|
||||||
|
new_width = size
|
||||||
|
new_height = int(size / im_ratio)
|
||||||
|
|
||||||
|
im = im.resize((new_width, new_height), Image.LANCZOS)
|
||||||
|
|
||||||
|
left = (im.width - size) // 2
|
||||||
|
top = (im.height - size) // 2
|
||||||
|
right = left + size
|
||||||
|
bottom = top + size
|
||||||
|
|
||||||
|
im = im.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
im.save(fp)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
...
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def save_image_to_file(content: bytes, size: int = 600) -> str:
|
||||||
|
try:
|
||||||
|
with Image.open(BytesIO(content)) as im:
|
||||||
|
if im.mode not in ("RGB", "RGBA"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
|
||||||
|
if size > 0: # Crop as square of (size * size)
|
||||||
|
im_ratio = im.width / im.height
|
||||||
|
target_ratio = 1 # Square ratio is 1
|
||||||
|
|
||||||
|
if im_ratio > target_ratio:
|
||||||
|
new_height = size
|
||||||
|
new_width = int(new_height * im_ratio)
|
||||||
|
else:
|
||||||
|
new_width = size
|
||||||
|
new_height = int(new_width / im_ratio)
|
||||||
|
|
||||||
|
im = im.resize((new_width, new_height), Image.LANCZOS)
|
||||||
|
|
||||||
|
left = (im.width - size) // 2
|
||||||
|
top = (im.height - size) // 2
|
||||||
|
right = left + size
|
||||||
|
bottom = top + size
|
||||||
|
|
||||||
|
im = im.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
if content.startswith(b"\x89PNG"):
|
||||||
|
image_ext = "png"
|
||||||
|
elif content.startswith(b"\xff\xd8"):
|
||||||
|
image_ext = "jpeg"
|
||||||
|
elif content.startswith(b"RIFF") and content[8:12] == b"WEBP":
|
||||||
|
image_ext = "webp"
|
||||||
|
else:
|
||||||
|
raise ValueError("Unsupported image format")
|
||||||
|
|
||||||
|
filename = generate_filename(image_ext)
|
||||||
|
filepath = assets_folder_path() / filename
|
||||||
|
im.save(filepath)
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
...
|
||||||
|
return ""
|
||||||
11
docker-compose.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8080:8000 #127.0.0.1: locally exposed, on port 8080 by default
|
||||||
|
volumes:
|
||||||
|
- trip-storage:/app/storage #Do not change /app/storage, only the first part (./storage) if needed
|
||||||
|
command: ["fastapi", "run", "/app/trip/main.py", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
trip-storage:
|
||||||
408
license.txt
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
Attribution-NonCommercial 4.0 International
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||||
|
does not provide legal services or legal advice. Distribution of
|
||||||
|
Creative Commons public licenses does not create a lawyer-client or
|
||||||
|
other relationship. Creative Commons makes its licenses and related
|
||||||
|
information available on an "as-is" basis. Creative Commons gives no
|
||||||
|
warranties regarding its licenses, any material licensed under their
|
||||||
|
terms and conditions, or any related information. Creative Commons
|
||||||
|
disclaims all liability for damages resulting from their use to the
|
||||||
|
fullest extent possible.
|
||||||
|
|
||||||
|
Using Creative Commons Public Licenses
|
||||||
|
|
||||||
|
Creative Commons public licenses provide a standard set of terms and
|
||||||
|
conditions that creators and other rights holders may use to share
|
||||||
|
original works of authorship and other material subject to copyright
|
||||||
|
and certain other rights specified in the public license below. The
|
||||||
|
following considerations are for informational purposes only, are not
|
||||||
|
exhaustive, and do not form part of our licenses.
|
||||||
|
|
||||||
|
Considerations for licensors: Our public licenses are
|
||||||
|
intended for use by those authorized to give the public
|
||||||
|
permission to use material in ways otherwise restricted by
|
||||||
|
copyright and certain other rights. Our licenses are
|
||||||
|
irrevocable. Licensors should read and understand the terms
|
||||||
|
and conditions of the license they choose before applying it.
|
||||||
|
Licensors should also secure all rights necessary before
|
||||||
|
applying our licenses so that the public can reuse the
|
||||||
|
material as expected. Licensors should clearly mark any
|
||||||
|
material not subject to the license. This includes other CC-
|
||||||
|
licensed material, or material used under an exception or
|
||||||
|
limitation to copyright. More considerations for licensors:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensors
|
||||||
|
|
||||||
|
Considerations for the public: By using one of our public
|
||||||
|
licenses, a licensor grants the public permission to use the
|
||||||
|
licensed material under specified terms and conditions. If
|
||||||
|
the licensor's permission is not necessary for any reason--for
|
||||||
|
example, because of any applicable exception or limitation to
|
||||||
|
copyright--then that use is not regulated by the license. Our
|
||||||
|
licenses grant only permissions under copyright and certain
|
||||||
|
other rights that a licensor has authority to grant. Use of
|
||||||
|
the licensed material may still be restricted for other
|
||||||
|
reasons, including because others have copyright or other
|
||||||
|
rights in the material. A licensor may make special requests,
|
||||||
|
such as asking that all changes be marked or described.
|
||||||
|
Although not required by our licenses, you are encouraged to
|
||||||
|
respect those requests where reasonable. More considerations
|
||||||
|
for the public:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensees
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Attribution-NonCommercial 4.0 International Public
|
||||||
|
License
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
|
Attribution-NonCommercial 4.0 International Public License ("Public
|
||||||
|
License"). To the extent this Public License may be interpreted as a
|
||||||
|
contract, You are granted the Licensed Rights in consideration of Your
|
||||||
|
acceptance of these terms and conditions, and the Licensor grants You
|
||||||
|
such rights in consideration of benefits the Licensor receives from
|
||||||
|
making the Licensed Material available under these terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
|
||||||
|
Section 1 -- Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
|
Rights that is derived from or based upon the Licensed Material
|
||||||
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
d. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
f. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
g. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
i. NonCommercial means not primarily intended for or directed towards
|
||||||
|
commercial advantage or monetary compensation. For purposes of
|
||||||
|
this Public License, the exchange of the Licensed Material for
|
||||||
|
other material subject to Copyright and Similar Rights by digital
|
||||||
|
file-sharing or similar means is NonCommercial provided there is
|
||||||
|
no payment of monetary compensation in connection with the
|
||||||
|
exchange.
|
||||||
|
|
||||||
|
j. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
k. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
l. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part, for NonCommercial purposes only; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material for
|
||||||
|
NonCommercial purposes only.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient of the Licensed
|
||||||
|
Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties, including when
|
||||||
|
the Licensed Material is used other than for NonCommercial
|
||||||
|
purposes.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
4. If You Share Adapted Material You produce, the Adapter's
|
||||||
|
License You apply must not prevent recipients of the Adapted
|
||||||
|
Material from complying with this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database for NonCommercial purposes
|
||||||
|
only;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material; and
|
||||||
|
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||||
|
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public
|
||||||
|
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||||
|
its public licenses to material it publishes and in those instances
|
||||||
|
will be considered the “Licensor.” The text of the Creative Commons
|
||||||
|
public licenses is dedicated to the public domain under the CC0 Public
|
||||||
|
Domain Dedication. Except for the limited purpose of indicating that
|
||||||
|
material is shared under a Creative Commons public license or as
|
||||||
|
otherwise permitted by the Creative Commons policies published at
|
||||||
|
creativecommons.org/policies, Creative Commons does not authorize the
|
||||||
|
use of the trademark "Creative Commons" or any other trademark or logo
|
||||||
|
of Creative Commons without its prior written consent including,
|
||||||
|
without limitation, in connection with any unauthorized modifications
|
||||||
|
to any of its public licenses or any other arrangements,
|
||||||
|
understandings, or agreements concerning use of licensed material. For
|
||||||
|
the avoidance of doubt, this paragraph does not form part of the
|
||||||
|
public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
|
|
||||||
40
src/.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
5
src/.postcssrc.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/.prettierrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
101
src/angular.json
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"trip": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/trip",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"node_modules/leaflet/dist/leaflet.css",
|
||||||
|
"./node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css",
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1MB",
|
||||||
|
"maximumError": "4MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "16kB",
|
||||||
|
"maximumError": "32kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all",
|
||||||
|
"serviceWorker": "ngsw-config.json"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "trip:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "trip:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": ["zone.js", "zone.js/testing"],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/ngsw-config.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||||
|
"index": "/index.html",
|
||||||
|
"assetGroups": [
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/index.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"installMode": "lazy",
|
||||||
|
"updateMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
53
src/package.json
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "trip",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^19.1.7",
|
||||||
|
"@angular/common": "^19.1.7",
|
||||||
|
"@angular/compiler": "^19.1.7",
|
||||||
|
"@angular/core": "^19.1.7",
|
||||||
|
"@angular/forms": "^19.1.7",
|
||||||
|
"@angular/platform-browser": "^19.1.7",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.1.7",
|
||||||
|
"@angular/router": "^19.1.7",
|
||||||
|
"@angular/service-worker": "^19.1.7",
|
||||||
|
"@primeng/themes": "^19.1.3",
|
||||||
|
"@tailwindcss/postcss": "^4.0.8",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primeng": "^19.1.3",
|
||||||
|
"rxjs": "~7.8.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"@types/leaflet": "^1.9.19",
|
||||||
|
"@types/leaflet.markercluster": "^1.5.5",
|
||||||
|
"leaflet-contextmenu": "^1.4.0",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
"tailwindcss": "^4.1.8",
|
||||||
|
"tailwindcss-primeui": "^0.6.1",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.1.8",
|
||||||
|
"@angular/cli": "^19.1.8",
|
||||||
|
"@angular/compiler-cli": "^19.1.7",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jasmine-core": "~5.5.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"prettier": "^3.5.0",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/public/add-location.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/public/cover.webp
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
src/public/favicon.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/public/icons/TRIP_128.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/public/icons/TRIP_192.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
src/public/icons/TRIP_512.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
30
src/public/manifest.webmanifest
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "TRIP",
|
||||||
|
"short_name": "TRIP",
|
||||||
|
"description": "Tourism and Recreational Interest Points",
|
||||||
|
"theme_color": "#1E293B",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "./",
|
||||||
|
"start_url": "./",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/TRIP_128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/TRIP_192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/TRIP_512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
src/src/app/app.component.html
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
|
<p-toast />
|
||||||
0
src/src/app/app.component.scss
Normal file
13
src/src/app/app.component.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import {} from 'primeng/api';
|
||||||
|
import { ToastModule } from 'primeng/toast';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, ToastModule],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss',
|
||||||
|
})
|
||||||
|
export class AppComponent {}
|
||||||
40
src/src/app/app.config.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from "@angular/core";
|
||||||
|
import { provideAnimationsAsync } from "@angular/platform-browser/animations/async";
|
||||||
|
import { provideRouter } from "@angular/router";
|
||||||
|
|
||||||
|
import { routes } from "./app.routes";
|
||||||
|
|
||||||
|
import { providePrimeNG } from "primeng/config";
|
||||||
|
import { TripThemePreset } from "../mytheme";
|
||||||
|
import { MessageService } from "primeng/api";
|
||||||
|
import { provideHttpClient, withInterceptors } from "@angular/common/http";
|
||||||
|
import { Interceptor } from "./services/interceptor.service";
|
||||||
|
import { DialogService } from "primeng/dynamicdialog";
|
||||||
|
import { provideServiceWorker } from "@angular/service-worker";
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideAnimationsAsync(),
|
||||||
|
provideHttpClient(withInterceptors([Interceptor])),
|
||||||
|
providePrimeNG({
|
||||||
|
theme: {
|
||||||
|
preset: TripThemePreset,
|
||||||
|
options: {
|
||||||
|
darkModeSelector: ".dark",
|
||||||
|
cssLayer: {
|
||||||
|
name: "primeng",
|
||||||
|
order: "tailwind, primeng",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
MessageService,
|
||||||
|
DialogService,
|
||||||
|
provideServiceWorker("ngsw-worker.js", {
|
||||||
|
enabled: !isDevMode(),
|
||||||
|
registrationStrategy: "registerWhenStable:30000",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
48
src/src/app/app.routes.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Routes } from "@angular/router";
|
||||||
|
|
||||||
|
import { AuthComponent } from "./components/auth/auth.component";
|
||||||
|
|
||||||
|
import { DashboardComponent } from "./components/dashboard/dashboard.component";
|
||||||
|
import { AuthGuard } from "./services/auth.guard";
|
||||||
|
import { TripComponent } from "./components/trip/trip.component";
|
||||||
|
import { TripsComponent } from "./components/trips/trips.component";
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: "auth",
|
||||||
|
pathMatch: "full",
|
||||||
|
component: AuthComponent,
|
||||||
|
title: "TRIP - Authentication",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "home",
|
||||||
|
component: DashboardComponent,
|
||||||
|
title: "TRIP - Map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "trips",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
component: TripsComponent,
|
||||||
|
title: "TRIP - Trips",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ":id",
|
||||||
|
component: TripComponent,
|
||||||
|
title: "TRIP - Trip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ path: "**", redirectTo: "/home", pathMatch: "full" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ path: "**", redirectTo: "/", pathMatch: "full" },
|
||||||
|
];
|
||||||
93
src/src/app/components/auth/auth.component.html
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<section class="flex flex-col md:flex-row h-screen items-center">
|
||||||
|
<div
|
||||||
|
class="relative bg-white dark:bg-surface-900 w-full md:max-w-md lg:max-w-full md:mx-auto md:w-1/2 xl:w-1/3 h-screen px-6 lg:px-16 xl:px-12 flex items-center justify-center">
|
||||||
|
<div class="max-w-xl w-full mx-auto h-100">
|
||||||
|
<div class="max-w-32 mx-auto">
|
||||||
|
<img src="favicon.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xl md:text-2xl font-bold leading-tight mt-10">
|
||||||
|
{{ isRegistering ? "Register" : "Sign in" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="mt-4">
|
||||||
|
<p-message severity="error" variant="outlined" [life]="3000">{{
|
||||||
|
error
|
||||||
|
}}</p-message>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div pFocusTrap class="mt-4" [formGroup]="authForm">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input #username pInputText id="username" formControlName="username" autocorrect="off" autocapitalize="none"
|
||||||
|
fluid autofocus />
|
||||||
|
<label for="username">Username</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
@if (
|
||||||
|
authForm.get("username")?.dirty &&
|
||||||
|
authForm.get("username")?.hasError("required")
|
||||||
|
) {
|
||||||
|
<span class="text-base text-red-600">Username is required</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p-floatlabel variant="in" class="mt-4">
|
||||||
|
<input pInputText type="password" inputId="password" formControlName="password" fluid
|
||||||
|
(keyup.enter)="auth_or_register()" />
|
||||||
|
<label for="password">Password</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
@if (
|
||||||
|
authForm.get("password")?.dirty &&
|
||||||
|
authForm.get("password")?.hasError("required")
|
||||||
|
) {
|
||||||
|
<span class="text-base text-red-600">Password is required</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
@if (isRegistering) {
|
||||||
|
<p-button [disabled]="!authForm.valid" (click)="register()">
|
||||||
|
Register
|
||||||
|
</p-button>
|
||||||
|
} @else {
|
||||||
|
<p-button [disabled]="!authForm.valid" (click)="authenticate()">
|
||||||
|
Sign in
|
||||||
|
</p-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-6 border-gray-300 w-full" />
|
||||||
|
@if (isRegistering) {
|
||||||
|
<p class="mt-8">
|
||||||
|
Have an account?
|
||||||
|
<a (click)="isRegistering = false"
|
||||||
|
class="text-blue-500 hover:text-blue-700 font-semibold cursor-pointer">Login</a>
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p class="mt-8">
|
||||||
|
First time?
|
||||||
|
<a (click)="isRegistering = true" class="text-blue-500 hover:text-blue-700 font-semibold cursor-pointer">Create
|
||||||
|
an account</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-sm flex flex-col items-center gap-2">
|
||||||
|
<a href="https://github.com/itskovacs/trip" class="flex items-center gap-1" target="_blank"><i
|
||||||
|
class="pi pi-github"></i>itskovacs/trip</a>
|
||||||
|
<span class="font-light text-gray-500 text-xs">Made with ❤️ in BZH</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden lg:block w-full md:w-1/2 xl:w-2/3 h-full cover-auth">
|
||||||
|
<div class="h-full flex items-center">
|
||||||
|
<div class="ml-20 mt-12">
|
||||||
|
<div class="text-7xl font-bold leading-none text-gray-100">
|
||||||
|
Welcome to TRIP
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 text-lg tracking-tight leading-6 text-gray-400">
|
||||||
|
Tourism and Recreation Interest Points.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
4
src/src/app/components/auth/auth.component.scss
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.cover-auth {
|
||||||
|
background: url("/cover.webp");
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
89
src/src/app/components/auth/auth.component.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
import { FloatLabelModule } from 'primeng/floatlabel';
|
||||||
|
import {
|
||||||
|
FormBuilder,
|
||||||
|
FormGroup,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { FocusTrapModule } from 'primeng/focustrap';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { MessageModule } from 'primeng/message';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-auth',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FloatLabelModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ButtonModule,
|
||||||
|
FormsModule,
|
||||||
|
InputTextModule,
|
||||||
|
FocusTrapModule,
|
||||||
|
MessageModule,
|
||||||
|
],
|
||||||
|
templateUrl: './auth.component.html',
|
||||||
|
styleUrl: './auth.component.scss',
|
||||||
|
})
|
||||||
|
export class AuthComponent {
|
||||||
|
private redirectURL: string;
|
||||||
|
authForm: FormGroup;
|
||||||
|
error: string = '';
|
||||||
|
isRegistering: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private router: Router,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
) {
|
||||||
|
this.redirectURL =
|
||||||
|
this.route.snapshot.queryParams['redirectURL'] || '/home';
|
||||||
|
|
||||||
|
this.authForm = this.fb.group({
|
||||||
|
username: ['', { validators: Validators.required }],
|
||||||
|
password: ['', { validators: Validators.required }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_or_register() {
|
||||||
|
if (this.isRegistering) this.register();
|
||||||
|
else this.authenticate();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(): void {
|
||||||
|
this.error = '';
|
||||||
|
if (this.authForm.valid) {
|
||||||
|
this.authService.register(this.authForm.value).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigateByUrl(this.redirectURL);
|
||||||
|
},
|
||||||
|
error: (err: HttpErrorResponse) => {
|
||||||
|
this.authForm.reset();
|
||||||
|
this.error = err.error.detail;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate(): void {
|
||||||
|
this.error = '';
|
||||||
|
if (this.authForm.valid) {
|
||||||
|
this.authService.login(this.authForm.value).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigateByUrl(this.redirectURL);
|
||||||
|
},
|
||||||
|
error: (err: HttpErrorResponse) => {
|
||||||
|
this.authForm.reset();
|
||||||
|
this.error = err.error.detail;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
299
src/src/app/components/dashboard/dashboard.component.html
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
@if (selectedPlace) {
|
||||||
|
<app-place-box [selectedPlace]="selectedPlace" (deleteEmitter)="deletePlace()" (editEmitter)="editPlace()"
|
||||||
|
(favoriteEmitter)="favoritePlace()" (visitEmitter)="visitPlace()" (closeEmitter)="closePlaceBox()"></app-place-box>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute z-30 top-20 right-2 p-2 bg-white shadow rounded">
|
||||||
|
<p-button (click)="toggleFilters()" text [severity]="viewFilters ? 'danger' : 'secondary'"
|
||||||
|
[icon]="viewFilters ? 'pi pi-times' : 'pi pi-filter'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div [class.z-50]="viewSettings" class="absolute z-30 top-[9.5rem] right-2 p-2 bg-white shadow rounded">
|
||||||
|
<p-button (click)="toggleSettings()" text [severity]="viewSettings ? 'danger' : 'secondary'"
|
||||||
|
[icon]="viewSettings ? 'pi pi-times' : 'pi pi-cog'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute z-30 bottom-2 right-2">
|
||||||
|
<div class="relative group flex flex-col-reverse items-end h-28">
|
||||||
|
<div
|
||||||
|
class="absolute right-0 bottom-16 p-2 bg-white shadow rounded transition-all duration-200 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||||
|
<p-button (click)="batchAddModal()" text severity="secondary" icon="pi pi-ellipsis-v" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-2 bg-white shadow rounded">
|
||||||
|
<p-button (click)="addPlaceModal()" text severity="secondary" icon="pi pi-plus" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute z-30 bottom-2 left-2">
|
||||||
|
<div class="p-2 bg-white shadow rounded">
|
||||||
|
<p-button (click)="gotoTrips()" text severity="secondary" icon="pi pi-calendar-clock" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (viewMarkersList) {
|
||||||
|
<section
|
||||||
|
class="absolute left-2 right-2 top-4 bottom-4 md:max-w-md bg-white z-40 rounded-xl shadow-2xl p-4 flex flex-col">
|
||||||
|
<div class="mt-1 p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Points</h1>
|
||||||
|
<span class="text-xs text-gray-500">Currently displayed points</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<p-button (click)="toggleMarkersListSearch()" icon="pi pi-search" text severity="secondary" />
|
||||||
|
<p-button (click)="toggleMarkersList()" icon="pi pi-times" text severity="danger" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-full overflow-y-auto">
|
||||||
|
@if (viewMarkersListSearch) {
|
||||||
|
<div class="mb-4">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="search" pSize="small" [formControl]="searchInput" pInputText fluid />
|
||||||
|
<label for="search">Search...</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 truncate">
|
||||||
|
<h1 class="tracking-tight truncate">{{ p.name }}</h1>
|
||||||
|
<span class="text-xs text-gray-500 truncate">{{ p.place }}</span>
|
||||||
|
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
@if (p.allowdog) {
|
||||||
|
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded">🐶</span>
|
||||||
|
} @else {
|
||||||
|
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded">🐶</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (p.visited) {
|
||||||
|
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||||
|
class="pi pi-eye text-xs"></i></span>
|
||||||
|
} @else {
|
||||||
|
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||||
|
class="pi pi-eye-slash text-xs"></i></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="tracking-tight">No data</h1>
|
||||||
|
<span class="text-xs text-gray-500">Try moving the map to see markers</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (viewFilters) {
|
||||||
|
<section class="absolute right-2 top-36 bg-white z-40 rounded-xl shadow-2xl p-8 max-w-screen md:max-w-md">
|
||||||
|
|
||||||
|
<div class="mt-1 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Filters</h1>
|
||||||
|
<span class="text-xs text-gray-500">You can customize the view</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p-button text icon="pi pi-refresh" severity="danger" (click)="resetFilters()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 grid gap-2 select-none">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div>Visited</div>
|
||||||
|
<p-toggleswitch [(ngModel)]="filter_display_visited" (onChange)="updateMarkersAndClusters()" />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div>Allow dog only</div>
|
||||||
|
<p-toggleswitch [(ngModel)]="filter_dog_only" (onChange)="updateMarkersAndClusters()" />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div>Favorites only</div>
|
||||||
|
<p-toggleswitch [(ngModel)]="filter_display_favorite_only" (onChange)="updateMarkersAndClusters()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Categories</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid items-center gap-2">
|
||||||
|
@for (c of categories; track c.id) {
|
||||||
|
<div class="flex justify-between truncate select-none">
|
||||||
|
<div class="truncate pr-8">{{ c.name }}</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p-toggleswitch [ngModel]="activeCategories.has(c.name)" (onChange)="updateActiveCategories(c.name)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (viewSettings) {
|
||||||
|
<section class="absolute inset-0 flex items-center justify-center z-40 bg-black/30">
|
||||||
|
<div class="w-10/12 max-w-screen md:max-w-3xl h-fit max-h-screen bg-white rounded-xl shadow-2xl p-8 z-50">
|
||||||
|
<p-tabs value="0" scrollable>
|
||||||
|
<p-tablist>
|
||||||
|
<p-tab value="0" class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-map"></i><span class="font-bold whitespace-nowrap">Settings</span>
|
||||||
|
</p-tab>
|
||||||
|
<p-tab value="1" class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-th-large"></i><span class="font-bold whitespace-nowrap">Categories</span>
|
||||||
|
</p-tab>
|
||||||
|
<p-tab value="2" class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-database"></i><span class="font-bold whitespace-nowrap">Data</span>
|
||||||
|
</p-tab>
|
||||||
|
<p-tab value="3" class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-info-circle"></i><span class="font-bold whitespace-nowrap">About</span>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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">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>
|
||||||
|
|
||||||
|
</p-tabpanel>
|
||||||
|
<p-tabpanel value="1">
|
||||||
|
|
||||||
|
<div class="mt-1 p-2 mb-2 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Categories</h1>
|
||||||
|
<span class="text-xs text-gray-500">You can modify the categories.</span>
|
||||||
|
<span class="ml-1 text-xs text-orange-500">You cannot delete a used category.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p-button icon="pi pi-plus" (click)="addCategory()" text />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col">
|
||||||
|
@for (category of categories; track category.id) {
|
||||||
|
<div class="p-3 flex items-center justify-between rounded-md hover:bg-gray-50">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img [src]="category.image" class="size-8 rounded-full" />{{ category.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<p-button severity="danger" (click)="deleteCategory(category.id)" icon="pi pi-trash" text />
|
||||||
|
<p-button (click)="editCategory(category)" icon="pi pi-pencil" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</p-tabpanel>
|
||||||
|
<p-tabpanel value="2">
|
||||||
|
<div class="mt-1">
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Data</h1>
|
||||||
|
<span class="text-xs text-gray-500">You can import/export your data</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-around mt-4 gap-4">
|
||||||
|
<p-button (click)="exportData()" text icon="pi pi-download" label="Export" />
|
||||||
|
<p-button (click)="fileUpload.click()" text icon="pi pi-upload" label="Import" />
|
||||||
|
<input type="file" class="file-input" style="display: none;" (change)="importData($event)" #fileUpload>
|
||||||
|
</div>
|
||||||
|
</p-tabpanel>
|
||||||
|
<p-tabpanel value="3">
|
||||||
|
<div class="mt-1 flex justify-between align-items">
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">About</h1>
|
||||||
|
<p-button (click)="toGithub()" text severity="primary" icon="pi pi-github" size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row justify-center md:justify-start items-center gap-4 mt-8 md:mt-4">
|
||||||
|
<a href="https://ko-fi.com/itskovacs" target="_blank" class="custom-button flex items-center">Buy me
|
||||||
|
a
|
||||||
|
coffee</a>
|
||||||
|
<span class="text-center text-gray-400">Coffee and contributions are greatly appreciated!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row justify-center md:justify-start items-center gap-4 mt-8 md:mt-4">
|
||||||
|
@if (this.info?.update) {
|
||||||
|
<button class="custom-button orange" (click)="toGithub()">
|
||||||
|
Open Github
|
||||||
|
</button>
|
||||||
|
<span class="text-center flex items-center gap-2 text-gray-400">TRIP {{ this.info?.update }}
|
||||||
|
available on
|
||||||
|
Github</span>
|
||||||
|
} @else {
|
||||||
|
<button class="custom-button" (click)="check_update()">
|
||||||
|
Check for updates
|
||||||
|
</button>
|
||||||
|
<span class="text-center flex items-center gap-2 text-gray-400">TRIP {{ info?.version }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center text-sm text-gray-500">Made with ❤️ in BZH</div>
|
||||||
|
</p-tabpanel>
|
||||||
|
</p-tabpanels>
|
||||||
|
</p-tabs>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
58
src/src/app/components/dashboard/dashboard.component.scss
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
#map {
|
||||||
|
z-index: 1;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-button {
|
||||||
|
appearance: button;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: #3851e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-width: 0;
|
||||||
|
box-shadow:
|
||||||
|
rgba(50, 50, 93, 0.1) 0 0 0 1px inset,
|
||||||
|
rgba(50, 50, 93, 0.1) 0 2px 5px 0,
|
||||||
|
rgba(0, 0, 0, 0.07) 0 1px 1px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 100%;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 1.15;
|
||||||
|
outline: none;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 25px;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: none;
|
||||||
|
transform: translateZ(0);
|
||||||
|
transition:
|
||||||
|
all 0.2s,
|
||||||
|
box-shadow 0.08s ease-in;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
width: 100%;
|
||||||
|
max-width: fit-content;
|
||||||
|
|
||||||
|
&.orange {
|
||||||
|
background-color: #f57340;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #a6a6a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow:
|
||||||
|
rgba(50, 50, 93, 0.1) 0 0 0 1px inset,
|
||||||
|
rgba(50, 50, 93, 0.2) 0 6px 15px 0,
|
||||||
|
rgba(0, 0, 0, 0.1) 0 2px 2px 0,
|
||||||
|
rgba(50, 151, 211, 0.3) 0 0 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
692
src/src/app/components/dashboard/dashboard.component.ts
Normal file
@ -0,0 +1,692 @@
|
|||||||
|
import { AfterViewInit, Component, OnDestroy } from "@angular/core";
|
||||||
|
import {
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
debounceTime,
|
||||||
|
forkJoin,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
Subscription,
|
||||||
|
tap,
|
||||||
|
} from "rxjs";
|
||||||
|
import { Place, Category } from "../../types/poi";
|
||||||
|
import { ApiService } from "../../services/api.service";
|
||||||
|
import { PlaceBoxComponent } from "../../shared/place-box/place-box.component";
|
||||||
|
import * as L from "leaflet";
|
||||||
|
import "leaflet.markercluster";
|
||||||
|
import "leaflet-contextmenu";
|
||||||
|
import {
|
||||||
|
FormBuilder,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
} from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { PlaceCreateModalComponent } from "../../modals/place-create-modal/place-create-modal.component";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { SkeletonModule } from "primeng/skeleton";
|
||||||
|
import { TabsModule } from "primeng/tabs";
|
||||||
|
import { ToggleSwitchModule } from "primeng/toggleswitch";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { BatchCreateModalComponent } from "../../modals/batch-create-modal/batch-create-modal.component";
|
||||||
|
import { UtilsService } from "../../services/utils.service";
|
||||||
|
import { Info } from "../../types/info";
|
||||||
|
import { createMap, placeToMarker, createClusterGroup } from "../../shared/map";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { SelectModule } from "primeng/select";
|
||||||
|
import { MultiSelectModule } from "primeng/multiselect";
|
||||||
|
import { TooltipModule } from "primeng/tooltip";
|
||||||
|
import { Settings } from "../../types/settings";
|
||||||
|
import { SelectItemGroup } from "primeng/api";
|
||||||
|
import { YesNoModalComponent } from "../../modals/yes-no-modal/yes-no-modal.component";
|
||||||
|
import { CategoryCreateModalComponent } from "../../modals/category-create-modal/category-create-modal.component";
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
text: string;
|
||||||
|
index?: number;
|
||||||
|
icon?: string;
|
||||||
|
callback?: any;
|
||||||
|
}
|
||||||
|
export interface MapOptions extends L.MapOptions {
|
||||||
|
contextmenu: boolean;
|
||||||
|
contextmenuItems: ContextMenuItem[];
|
||||||
|
}
|
||||||
|
export interface MarkerOptions extends L.MarkerOptions {
|
||||||
|
contextmenu: boolean;
|
||||||
|
contextmenuItems: ContextMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-dashboard",
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
PlaceBoxComponent,
|
||||||
|
FormsModule,
|
||||||
|
SkeletonModule,
|
||||||
|
ToggleSwitchModule,
|
||||||
|
MultiSelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
InputTextModule,
|
||||||
|
TooltipModule,
|
||||||
|
FloatLabelModule,
|
||||||
|
SelectModule,
|
||||||
|
TabsModule,
|
||||||
|
ButtonModule,
|
||||||
|
],
|
||||||
|
templateUrl: "./dashboard.component.html",
|
||||||
|
styleUrls: ["./dashboard.component.scss"],
|
||||||
|
})
|
||||||
|
export class DashboardComponent implements AfterViewInit {
|
||||||
|
searchInput = new FormControl("");
|
||||||
|
info: Info | undefined;
|
||||||
|
|
||||||
|
viewSettings = false;
|
||||||
|
viewFilters = false;
|
||||||
|
viewMarkersList = false;
|
||||||
|
viewMarkersListSearch = false;
|
||||||
|
settingsForm: FormGroup;
|
||||||
|
hoveredElements: HTMLElement[] = [];
|
||||||
|
|
||||||
|
map: any;
|
||||||
|
settings: Settings | undefined;
|
||||||
|
currencySigns: { c: string; s: string }[] = [];
|
||||||
|
doNotDisplayOptions: SelectItemGroup[] = [];
|
||||||
|
markerClusterGroup: L.MarkerClusterGroup | undefined;
|
||||||
|
|
||||||
|
places: Place[] = [];
|
||||||
|
visiblePlaces: Place[] = [];
|
||||||
|
selectedPlace: Place | undefined;
|
||||||
|
categories: Category[] = [];
|
||||||
|
|
||||||
|
filter_display_visited: boolean = false;
|
||||||
|
filter_display_favorite_only: boolean = false;
|
||||||
|
filter_dog_only: boolean = false;
|
||||||
|
activeCategories: Set<string> = new Set();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private utilsService: UtilsService,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
private router: Router,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
) {
|
||||||
|
this.currencySigns = this.utilsService.currencySigns();
|
||||||
|
|
||||||
|
this.settingsForm = this.fb.group({
|
||||||
|
mapLat: [
|
||||||
|
"",
|
||||||
|
{
|
||||||
|
validators: [
|
||||||
|
Validators.required,
|
||||||
|
Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mapLng: [
|
||||||
|
"",
|
||||||
|
{
|
||||||
|
validators: [
|
||||||
|
Validators.required,
|
||||||
|
Validators.pattern(
|
||||||
|
"-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currency: ["", Validators.required],
|
||||||
|
do_not_display: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.apiService.getInfo().subscribe({
|
||||||
|
next: (info) => (this.info = info),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.searchInput.valueChanges.pipe(debounceTime(200)).subscribe({
|
||||||
|
next: () => this.setVisibleMarkers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closePlaceBox() {
|
||||||
|
this.selectedPlace = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
toGithub() {
|
||||||
|
this.utilsService.toGithubTRIP();
|
||||||
|
}
|
||||||
|
|
||||||
|
check_update() {
|
||||||
|
this.apiService.checkVersion().subscribe({
|
||||||
|
next: (remote_version) => {
|
||||||
|
if (!remote_version)
|
||||||
|
this.utilsService.toast(
|
||||||
|
"success",
|
||||||
|
"Latest version",
|
||||||
|
"You're running the latest version of TRIP",
|
||||||
|
);
|
||||||
|
if (this.info && remote_version != this.info?.version)
|
||||||
|
this.info.update = remote_version;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initMap();
|
||||||
|
combineLatest({
|
||||||
|
categories: this.apiService.getCategories(),
|
||||||
|
places: this.apiService.getPlaces(),
|
||||||
|
settings: this.apiService.getSettings(),
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap(({ categories, places, settings }) => {
|
||||||
|
this.categories = categories;
|
||||||
|
this.activeCategories = new Set(categories.map((c) => c.name));
|
||||||
|
|
||||||
|
this.settings = settings;
|
||||||
|
this.map.setView(L.latLng(settings.mapLat, +settings.mapLng));
|
||||||
|
this.resetFilters();
|
||||||
|
|
||||||
|
this.map.on("moveend zoomend", () => {
|
||||||
|
this.setVisibleMarkers();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.markerClusterGroup = createClusterGroup().addTo(this.map);
|
||||||
|
this.places.push(...places);
|
||||||
|
this.updateMarkersAndClusters(); //Not optimized as I could do it on the forEach, but it allows me to modify only one function instead of multiple places
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
initMap(): void {
|
||||||
|
let contentMenuItems = [
|
||||||
|
{
|
||||||
|
text: "Add Point of Interest",
|
||||||
|
icon: "add-location.png",
|
||||||
|
callback: (e: any) => {
|
||||||
|
this.addPlaceModal(e);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.map = createMap(contentMenuItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleMarkers() {
|
||||||
|
const bounds = this.map.getBounds();
|
||||||
|
this.visiblePlaces = this.filteredPlaces
|
||||||
|
.filter((p) => bounds.contains([p.lat, p.lng]))
|
||||||
|
.filter((p) => {
|
||||||
|
const v = this.searchInput.value;
|
||||||
|
if (v)
|
||||||
|
return (
|
||||||
|
p.name.toLowerCase().includes(v) ||
|
||||||
|
p.description?.toLowerCase().includes(v)
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
this.filter_display_visited = false;
|
||||||
|
this.filter_display_favorite_only = false;
|
||||||
|
this.activeCategories = new Set(this.categories.map((c) => c.name));
|
||||||
|
this.settings?.do_not_display.forEach((c) =>
|
||||||
|
this.activeCategories.delete(c),
|
||||||
|
);
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
if (this.viewMarkersList) this.setVisibleMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveCategories(c: string) {
|
||||||
|
if (this.activeCategories.has(c)) this.activeCategories.delete(c);
|
||||||
|
else this.activeCategories.add(c);
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
if (this.viewMarkersList) this.setVisibleMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredPlaces(): Place[] {
|
||||||
|
return this.places.filter((p) => {
|
||||||
|
if (!this.filter_display_visited && p.visited) return false;
|
||||||
|
if (this.filter_display_favorite_only && !p.favorite) return false;
|
||||||
|
if (this.filter_dog_only && !p.allowdog) return false;
|
||||||
|
if (!this.activeCategories.has(p.category.name)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMarkersAndClusters(): void {
|
||||||
|
this.markerClusterGroup?.clearLayers();
|
||||||
|
|
||||||
|
this.filteredPlaces.forEach((place) => {
|
||||||
|
const marker = this.placeToMarker(place);
|
||||||
|
this.markerClusterGroup?.addLayer(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
placeToMarker(place: Place): L.Marker {
|
||||||
|
let marker = placeToMarker(place);
|
||||||
|
marker
|
||||||
|
.on("click", (e) => {
|
||||||
|
this.selectedPlace = place;
|
||||||
|
|
||||||
|
let toView = { ...e.latlng };
|
||||||
|
if ("ontouchstart" in window) toView.lat = toView.lat - 0.0175;
|
||||||
|
|
||||||
|
marker.closeTooltip();
|
||||||
|
this.map.setView(toView);
|
||||||
|
})
|
||||||
|
.on("contextmenu", () => {
|
||||||
|
this.map.contextmenu.hide();
|
||||||
|
});
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPlaceModal(e?: any): void {
|
||||||
|
let opts = {};
|
||||||
|
if (e) opts = { data: { place: e.latlng } };
|
||||||
|
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
PlaceCreateModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Place",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "40vw",
|
||||||
|
breakpoints: {
|
||||||
|
"960px": "60vw",
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
...opts,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (place: Place | null) => {
|
||||||
|
if (!place) return;
|
||||||
|
|
||||||
|
this.apiService.postPlace(place).subscribe({
|
||||||
|
next: (place: Place) => {
|
||||||
|
this.places.push(place);
|
||||||
|
this.places.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
}, 10);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
batchAddModal() {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
BatchCreateModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Places",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "40vw",
|
||||||
|
breakpoints: {
|
||||||
|
"960px": "60vw",
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (places: string | null) => {
|
||||||
|
if (!places) return;
|
||||||
|
|
||||||
|
let parsedPlaces = [];
|
||||||
|
try {
|
||||||
|
parsedPlaces = JSON.parse(places);
|
||||||
|
if (!Array.isArray(parsedPlaces)) return;
|
||||||
|
} catch (err) {
|
||||||
|
this.utilsService.toast("error", "Error", "Content looks invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiService.postPlaces(parsedPlaces).subscribe((places) => {
|
||||||
|
places.forEach((p) => this.places.push(p));
|
||||||
|
this.places.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
gotoPlace(p: Place) {
|
||||||
|
this.map.flyTo([p.lat, p.lng]);
|
||||||
|
}
|
||||||
|
|
||||||
|
gotoTrips() {
|
||||||
|
this.router.navigateByUrl("/trips");
|
||||||
|
}
|
||||||
|
|
||||||
|
resetHoverPlace() {
|
||||||
|
this.hoveredElements.forEach((elem) => elem.classList.remove("listHover"));
|
||||||
|
this.hoveredElements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverPlace(p: Place) {
|
||||||
|
let marker: L.Marker | undefined;
|
||||||
|
this.markerClusterGroup?.eachLayer((layer: any) => {
|
||||||
|
if (layer.getLatLng && layer.getLatLng().equals([p.lat, p.lng])) {
|
||||||
|
marker = layer;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!marker) return;
|
||||||
|
let markerElement = marker.getElement() as HTMLElement; // search for Marker. If 'null', is inside Cluster
|
||||||
|
|
||||||
|
if (markerElement) {
|
||||||
|
// marker, not clustered
|
||||||
|
markerElement.classList.add("listHover");
|
||||||
|
this.hoveredElements.push(markerElement);
|
||||||
|
} else {
|
||||||
|
// marker , clustered
|
||||||
|
const parentCluster = (this.markerClusterGroup as any).getVisibleParent(
|
||||||
|
marker,
|
||||||
|
);
|
||||||
|
if (parentCluster) {
|
||||||
|
const clusterEl = parentCluster.getElement();
|
||||||
|
if (clusterEl) {
|
||||||
|
clusterEl.classList.add("listHover");
|
||||||
|
this.hoveredElements.push(clusterEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
favoritePlace() {
|
||||||
|
if (!this.selectedPlace) return;
|
||||||
|
|
||||||
|
let favoriteBool = !this.selectedPlace.favorite;
|
||||||
|
this.apiService
|
||||||
|
.putPlace(this.selectedPlace.id, { favorite: favoriteBool })
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.selectedPlace!.favorite = favoriteBool;
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
visitPlace() {
|
||||||
|
if (!this.selectedPlace) return;
|
||||||
|
|
||||||
|
let visitedBool = !this.selectedPlace.visited;
|
||||||
|
this.apiService
|
||||||
|
.putPlace(this.selectedPlace.id, { visited: visitedBool })
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.selectedPlace!.visited = visitedBool;
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePlace() {
|
||||||
|
if (!this.selectedPlace) return;
|
||||||
|
|
||||||
|
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||||
|
header: "Confirm deletion",
|
||||||
|
modal: true,
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
data: `Delete ${this.selectedPlace.name} ?`,
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (bool) => {
|
||||||
|
if (bool)
|
||||||
|
this.apiService.deletePlace(this.selectedPlace!.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
let index = this.places.findIndex(
|
||||||
|
(p) => p.id == this.selectedPlace!.id,
|
||||||
|
);
|
||||||
|
if (index > -1) this.places.splice(index, 1);
|
||||||
|
this.closePlaceBox();
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editPlace() {
|
||||||
|
if (!this.selectedPlace) return;
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
PlaceCreateModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Place",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "40vw",
|
||||||
|
data: {
|
||||||
|
place: {
|
||||||
|
...this.selectedPlace,
|
||||||
|
category: this.selectedPlace.category.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
"960px": "60vw",
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (place: Place | null) => {
|
||||||
|
if (!place) return;
|
||||||
|
|
||||||
|
this.apiService.putPlace(place.id, place).subscribe({
|
||||||
|
next: (place: Place) => {
|
||||||
|
let index = this.places.findIndex((p) => p.id == place.id);
|
||||||
|
if (index > -1) this.places.splice(index, 1, place);
|
||||||
|
this.places.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
this.selectedPlace = place;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
}, 10);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSettings() {
|
||||||
|
this.viewSettings = !this.viewSettings;
|
||||||
|
if (this.viewSettings && this.settings) {
|
||||||
|
this.settingsForm.reset();
|
||||||
|
this.settingsForm.patchValue(this.settings);
|
||||||
|
this.doNotDisplayOptions = [
|
||||||
|
{
|
||||||
|
label: "Categories",
|
||||||
|
items: this.categories.map((c) => ({ label: c.name, value: c.name })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFilters() {
|
||||||
|
this.viewFilters = !this.viewFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMarkersList() {
|
||||||
|
this.viewMarkersList = !this.viewMarkersList;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMarkersListSearch() {
|
||||||
|
this.searchInput.setValue("");
|
||||||
|
this.viewMarkersListSearch = !this.viewMarkersListSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapCenterToCurrent() {
|
||||||
|
let latlng: L.LatLng = this.map.getCenter();
|
||||||
|
this.settingsForm.patchValue({ mapLat: latlng.lat, mapLng: latlng.lng });
|
||||||
|
this.settingsForm.markAsDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
importData(e: any): void {
|
||||||
|
const formdata = new FormData();
|
||||||
|
if (e.target.files[0]) {
|
||||||
|
formdata.append("file", e.target.files[0]);
|
||||||
|
|
||||||
|
this.apiService.settingsUserImport(formdata).subscribe({
|
||||||
|
next: (places) => {
|
||||||
|
places.forEach((p) => this.places.push(p));
|
||||||
|
this.places.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
}, 10);
|
||||||
|
this.viewSettings = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData(): void {
|
||||||
|
this.apiService.settingsUserExport().subscribe((resp: Object) => {
|
||||||
|
let _datablob = new Blob([JSON.stringify(resp, null, 2)], {
|
||||||
|
type: "text/json",
|
||||||
|
});
|
||||||
|
var downloadURL = URL.createObjectURL(_datablob);
|
||||||
|
var link = document.createElement("a");
|
||||||
|
link.href = downloadURL;
|
||||||
|
link.download =
|
||||||
|
"TRIP_backup_" + new Date().toISOString().split("T")[0] + ".json";
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings() {
|
||||||
|
this.apiService.putSettings(this.settingsForm.value).subscribe({
|
||||||
|
next: (settings) => {
|
||||||
|
this.settings = settings;
|
||||||
|
this.resetFilters();
|
||||||
|
this.toggleSettings();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editCategory(c: Category) {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
CategoryCreateModalComponent,
|
||||||
|
{
|
||||||
|
header: "Update Category",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
data: { category: c },
|
||||||
|
width: "20vw",
|
||||||
|
breakpoints: {
|
||||||
|
"960px": "60vw",
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (category: Category | null) => {
|
||||||
|
if (!category) return;
|
||||||
|
|
||||||
|
this.apiService.putCategory(c.id, category).subscribe({
|
||||||
|
next: (category) => {
|
||||||
|
const index = this.categories.findIndex(
|
||||||
|
(categ) => categ.id == c.id,
|
||||||
|
);
|
||||||
|
if (index > -1) {
|
||||||
|
this.categories.splice(index, 1, category);
|
||||||
|
this.activeCategories = new Set(
|
||||||
|
this.categories.map((c) => c.name),
|
||||||
|
);
|
||||||
|
this.updateMarkersAndClusters();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addCategory() {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
CategoryCreateModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Category",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "20vw",
|
||||||
|
breakpoints: {
|
||||||
|
"960px": "60vw",
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (category: Category | null) => {
|
||||||
|
if (!category) return;
|
||||||
|
|
||||||
|
this.apiService.postCategory(category).subscribe({
|
||||||
|
next: (category: Category) => {
|
||||||
|
this.categories.push(category);
|
||||||
|
this.categories.sort((categoryA: Category, categoryB: Category) =>
|
||||||
|
categoryA.name.localeCompare(categoryB.name),
|
||||||
|
);
|
||||||
|
this.activeCategories.add(category.name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory(c_id: number) {
|
||||||
|
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||||
|
header: "Confirm deletion",
|
||||||
|
modal: true,
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
data: "Delete this category ?",
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (bool) => {
|
||||||
|
if (bool)
|
||||||
|
this.apiService.deleteCategory(c_id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
const index = this.categories.findIndex(
|
||||||
|
(categ) => categ.id == c_id,
|
||||||
|
);
|
||||||
|
if (index > -1) {
|
||||||
|
this.categories.splice(index, 1);
|
||||||
|
this.activeCategories = new Set(
|
||||||
|
this.categories.map((c) => c.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/src/app/components/trip/trip.component.html
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
<section class="mt-4">
|
||||||
|
<div class="p-4 print:p-0 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p-button text icon="pi pi-chevron-left" class="print:hidden" (click)="back()" severity="secondary" />
|
||||||
|
<div class="flex flex-col max-w-[55vw] md:max-w-full">
|
||||||
|
<h1 class="font-medium tracking-tight text-2xl truncate">{{ trip?.name }}</h1>
|
||||||
|
<span class="text-xs text-gray-500">{{ trip?.days?.length }} {{ trip?.days!.length > 1 ? 'days' : 'day'}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden print:flex flex-col items-center">
|
||||||
|
<img src="favicon.png" class="size-20">
|
||||||
|
<div class="flex gap-2 items-center text-xs text-gray-500"><i class="pi pi-github"></i>itskovacs/trip</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col md:flex-row items-center gap-2 print:hidden">
|
||||||
|
<div>
|
||||||
|
<p-button text (click)="deleteTrip()" icon="pi pi-trash" severity="danger" />
|
||||||
|
<p-button text (click)="editTrip()" icon="pi pi-pencil" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="bg-gray-100 text-gray-800 text-xs md:text-sm font-medium me-2 px-2.5 py-0.5 rounded">{{ totalPrice
|
||||||
|
|| '-' }} {{ currency$ | async }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="p-4 print:px-1 grid md:grid-cols-3 gap-4 print:block">
|
||||||
|
<div class="p-4 shadow rounded-md md:col-span-2 max-w-screen print:col-span-full">
|
||||||
|
<div class="p-2 mb-2 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Plans</h1>
|
||||||
|
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} plans</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 print:hidden">
|
||||||
|
<p-button icon="pi pi-print" (click)="printTable()" text />
|
||||||
|
<div class="border-l border-solid border-gray-700 h-4"></div>
|
||||||
|
<p-button icon="pi pi-ellipsis-v" (click)="addItems()" text />
|
||||||
|
<p-button icon="pi pi-plus" (click)="addItem()" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@defer {
|
||||||
|
@if (flattenedTripItems.length) {
|
||||||
|
<p-table [value]="flattenedTripItems" styleClass="max-w-[85vw] md:max-w-full" rowGroupMode="rowspan"
|
||||||
|
groupRowsBy="td_label">
|
||||||
|
<ng-template #header>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th class="w-10">Time</th>
|
||||||
|
<th>Text</th>
|
||||||
|
<th class="w-24">Place</th>
|
||||||
|
<th>Comment</th>
|
||||||
|
<th class="w-20">LatLng</th>
|
||||||
|
<th class="w-12">Price</th>
|
||||||
|
<th class="w-12">Status</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #body let-tripitem let-rowIndex="rowIndex" let-rowgroup="rowgroup" let-rowspan="rowspan">
|
||||||
|
<tr class="h-12 cursor-pointer" [class.font-bold]="selectedItem?.id === tripitem.id"
|
||||||
|
(click)="onRowClick(tripitem)">
|
||||||
|
@if (rowgroup) {
|
||||||
|
<td [attr.rowspan]="rowspan" class="!font-normal max-w-20 truncate cursor-default"
|
||||||
|
(click)="$event.stopPropagation()">
|
||||||
|
<div class="truncate">{{tripitem.td_label }}</div>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td class="font-mono text-sm">{{ tripitem.time }}</td>
|
||||||
|
<td class="max-w-60 truncate">{{ tripitem.text }}</td>
|
||||||
|
<td class="relative">
|
||||||
|
@if (tripitem.place) {
|
||||||
|
<div class="ml-7 print:ml-0 max-w-24 truncate">
|
||||||
|
<img [src]="tripitem.place.image"
|
||||||
|
class="absolute left-0 top-1/2 -translate-y-1/2 w-9 rounded-full object-cover print:hidden" /> {{
|
||||||
|
tripitem.place.name }}
|
||||||
|
</div>
|
||||||
|
} @else {-}
|
||||||
|
</td>
|
||||||
|
<td class="max-w-20 truncate">{{ tripitem.comment || '-' }}</td>
|
||||||
|
<td class="font-mono text-sm">
|
||||||
|
<div class="max-w-20 truncate">
|
||||||
|
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} }
|
||||||
|
@else {-}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="truncate">@if (tripitem.price) {<span
|
||||||
|
class="bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||||
|
tripitem.price }} {{ currency$ | async }}</span>}</td>
|
||||||
|
<td class="truncate">@if (tripitem.status) {<span [style.background]="tripitem.status.color+'1A'"
|
||||||
|
[style.color]="tripitem.status.color" class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||||
|
tripitem.status.label }}</span>}</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
} @else {
|
||||||
|
<div class="px-4 mx-auto max-w-screen-xl mt-8 col-span-full print:hidden">
|
||||||
|
<div class="py-8 px-4 flex flex-col items-center gap-1">
|
||||||
|
<h2 class="mb-0 text-4xl text-center tracking-tight font-extrabold text-gray-900 dark:text-gray-200">
|
||||||
|
No Trip.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="mt-4 font-light text-gray-500 sm:text-xl">
|
||||||
|
Add <i>Day</i> to your <i>Trip</i> to start organizing !
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p-button styleClass="mt-4" label="Add" icon="pi pi-plus" (click)="addDay()" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden print:block text-center text-sm text-gray-500 mt-4">
|
||||||
|
No Trip
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @placeholder (minimum 0.4s) {
|
||||||
|
<div class="h-[400px] w-full">
|
||||||
|
<p-skeleton height="100%" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 sticky top-4 self-start max-w-screen print:hidden">
|
||||||
|
@if (selectedItem) {
|
||||||
|
<div class="p-4 w-full min-h-20 md:max-h-[600px] rounded-md shadow text-center">
|
||||||
|
<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" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl md:text-3xl font-semibold mb-0 truncate max-w-96 md:mx-auto">{{ selectedItem.text }}</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p-button icon="pi pi-trash" severity="danger" (click)="deleteItem(selectedItem)" text />
|
||||||
|
<p-button icon="pi pi-pencil" (click)="editItem(selectedItem)" text />
|
||||||
|
<p-button icon="pi pi-times" (click)="selectedItem = undefined" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 px-2 grid md:grid-cols-3 gap-4 overflow-auto w-full">
|
||||||
|
<div class="rounded-md shadow p-4 w-full">
|
||||||
|
<p class="font-bold mb-1">Time</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ selectedItem.time }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 rounded-md shadow p-4">
|
||||||
|
<p class="font-bold mb-1">Text</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ selectedItem.text }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedItem.place) {
|
||||||
|
<div class="rounded-md shadow p-4">
|
||||||
|
<p class="font-bold mb-1">Place</p>
|
||||||
|
<div class="truncate">{{ selectedItem.place.name }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedItem.comment) {
|
||||||
|
<div class="md:col-span-2 rounded-md shadow p-4">
|
||||||
|
<p class="font-bold mb-1">Comment</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ selectedItem.comment }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedItem.lat) {
|
||||||
|
<div class="rounded-md shadow p-4">
|
||||||
|
<p class="font-bold mb-1">Latitude</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ selectedItem.lat }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedItem.lng) {
|
||||||
|
<div class="rounded-md shadow p-4">
|
||||||
|
<p class="font-bold mb-1">Longitude</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ selectedItem.lng }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedItem.price) {
|
||||||
|
<div class="rounded-md shadow p-4">
|
||||||
|
<p class="font-bold mb-1">Price</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ selectedItem.price }} {{ currency$ | async }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedItem.status) {
|
||||||
|
<div class="rounded-md shadow p-4">
|
||||||
|
<p class="font-bold mb-1">Status</p>
|
||||||
|
<span [style.background]="selectedItem.status.color+'1A'" [style.color]="selectedItem.status.color"
|
||||||
|
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
|
||||||
|
selectedItem.status.label }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
} @else {
|
||||||
|
<div class="p-4 shadow rounded-md w-full min-h-20">
|
||||||
|
<div class="p-2 mb-2 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Days</h1>
|
||||||
|
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} days</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p-button icon="pi pi-plus" (click)="addDay()" text />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[20vh] overflow-y-auto">
|
||||||
|
@defer {
|
||||||
|
@for (d of trip?.days; track d.id) {
|
||||||
|
<div class="group flex items-center rounded-md justify-between h-10 px-4 py-2 hover:bg-gray-50">
|
||||||
|
{{ d.label }}
|
||||||
|
<div>
|
||||||
|
<span class="bg-gray-100 text-gray-800 text-sm me-2 px-2.5 py-0.5 rounded-md group-hover:hidden">{{
|
||||||
|
getDayStats(d).price || '-' }} {{ currency$ | async }}</span>
|
||||||
|
<span class="bg-blue-100 text-blue-800 text-sm me-2 px-2.5 py-0.5 rounded-md group-hover:hidden">{{
|
||||||
|
getDayStats(d).places }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hidden group-hover:flex gap-2 items-center">
|
||||||
|
<p-button icon="pi pi-trash" severity="danger" (click)="deleteDay(d)" text />
|
||||||
|
<p-button icon="pi pi-pencil" (click)="editDay(d)" label="Edit" text />
|
||||||
|
<p-button icon="pi pi-plus" (click)="addItem(d.id)" label="Item" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p-button label="Add" icon="pi pi-plus" (click)="addDay()" text />
|
||||||
|
}
|
||||||
|
} @placeholder (minimum 0.4s) {
|
||||||
|
<div class="h-16">
|
||||||
|
<p-skeleton height="100%" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 shadow rounded-md w-full min-h-20">
|
||||||
|
<div class="p-2 mb-2 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Places</h1>
|
||||||
|
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
@defer {
|
||||||
|
<span class="bg-blue-100 text-blue-800 text-sm me-2 px-2.5 py-0.5 rounded-md">{{ places.length }}</span>
|
||||||
|
} @placeholder (minimum 0.4s) {
|
||||||
|
<p-skeleton height="1.75rem" width="2.5rem" class="mr-1" />
|
||||||
|
}
|
||||||
|
<p-button icon="pi pi-plus" (click)="manageTripPlaces()" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[25vh] overflow-y-auto">
|
||||||
|
@defer {
|
||||||
|
@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)="highlightMarker(p.lat, p.lng)" (mouseleave)="resetHighlightMarker()">
|
||||||
|
<img [src]="p.image" class="w-12 rounded-full object-fit">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 truncate">
|
||||||
|
<h1 class="tracking-tight truncate">{{ p.name }}</h1>
|
||||||
|
<span class="text-xs text-gray-500 truncate">{{ p.place }}</span>
|
||||||
|
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
<span
|
||||||
|
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) {
|
||||||
|
<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 {
|
||||||
|
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||||
|
class="pi pi-map-marker text-xs"></i></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="bg-gray-100 text-gray-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded">{{ p.price || '-'
|
||||||
|
}} {{ currency$ | async }}</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p-button label="Add" icon="pi pi-plus" (click)="manageTripPlaces()" text />
|
||||||
|
}
|
||||||
|
} @placeholder (minimum 0.4s) {
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
@for (_ of [1,2,3]; track _) {
|
||||||
|
<div class="h-16">
|
||||||
|
<p-skeleton height="100%" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="z-10 p-4 shadow rounded-md w-full min-h-20 max-h-full overflow-y-auto">
|
||||||
|
<div class="p-2 mb-2 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Map</h1>
|
||||||
|
<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)="setMapBounds()" text />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="map" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
0
src/src/app/components/trip/trip.component.scss
Normal file
612
src/src/app/components/trip/trip.component.ts
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
import { AfterViewInit, Component } from "@angular/core";
|
||||||
|
import { ApiService } from "../../services/api.service";
|
||||||
|
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { SkeletonModule } from "primeng/skeleton";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import * as L from "leaflet";
|
||||||
|
import { TableModule } from "primeng/table";
|
||||||
|
import {
|
||||||
|
Trip,
|
||||||
|
FlattenedTripItem,
|
||||||
|
TripDay,
|
||||||
|
TripItem,
|
||||||
|
TripStatus,
|
||||||
|
} from "../../types/trip";
|
||||||
|
import { Place } from "../../types/poi";
|
||||||
|
import { createMap, placeToMarker, createClusterGroup } from "../../shared/map";
|
||||||
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
|
import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { TripPlaceSelectModalComponent } from "../../modals/trip-place-select-modal/trip-place-select-modal.component";
|
||||||
|
import { TripCreateDayModalComponent } from "../../modals/trip-create-day-modal/trip-create-day-modal.component";
|
||||||
|
import { TripCreateDayItemModalComponent } from "../../modals/trip-create-day-item-modal/trip-create-day-item-modal.component";
|
||||||
|
import { TripCreateItemsModalComponent } from "../../modals/trip-create-items-modal/trip-create-items-modal.component";
|
||||||
|
import { forkJoin, map, Observable } from "rxjs";
|
||||||
|
import { YesNoModalComponent } from "../../modals/yes-no-modal/yes-no-modal.component";
|
||||||
|
import { UtilsService } from "../../services/utils.service";
|
||||||
|
import { TripCreateModalComponent } from "../../modals/trip-create-modal/trip-create-modal.component";
|
||||||
|
import { AsyncPipe } from "@angular/common";
|
||||||
|
|
||||||
|
interface PlaceWithUsage extends Place {
|
||||||
|
placeUsage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-trip",
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
SkeletonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
InputTextModule,
|
||||||
|
AsyncPipe,
|
||||||
|
FloatLabelModule,
|
||||||
|
TableModule,
|
||||||
|
ButtonModule,
|
||||||
|
],
|
||||||
|
templateUrl: "./trip.component.html",
|
||||||
|
styleUrls: ["./trip.component.scss"],
|
||||||
|
})
|
||||||
|
export class TripComponent implements AfterViewInit {
|
||||||
|
map: any;
|
||||||
|
markerClusterGroup: any;
|
||||||
|
selectedItem: (TripItem & { status?: TripStatus }) | undefined;
|
||||||
|
statuses: TripStatus[] = [];
|
||||||
|
hoveredElement: HTMLElement | undefined;
|
||||||
|
currency$: Observable<string>;
|
||||||
|
|
||||||
|
trip: Trip | undefined;
|
||||||
|
totalPrice: number = 0;
|
||||||
|
dayStatsCache = new Map<number, { price: number; places: number }>();
|
||||||
|
|
||||||
|
places: PlaceWithUsage[] = [];
|
||||||
|
flattenedTripItems: FlattenedTripItem[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private router: Router,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
private utilsService: UtilsService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
) {
|
||||||
|
this.currency$ = this.utilsService.currency$;
|
||||||
|
this.statuses = this.utilsService.statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
back() {
|
||||||
|
this.router.navigateByUrl("/trips");
|
||||||
|
}
|
||||||
|
|
||||||
|
printTable() {
|
||||||
|
this.selectedItem = undefined;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
}, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.route.paramMap.subscribe((params) => {
|
||||||
|
const id = params.get("id");
|
||||||
|
if (id) {
|
||||||
|
this.apiService.getTrip(+id).subscribe({
|
||||||
|
next: (trip) => {
|
||||||
|
this.trip = trip;
|
||||||
|
this.flattenedTripItems = this.flattenTripDayItems(trip.days);
|
||||||
|
|
||||||
|
this.updateTotalPrice();
|
||||||
|
|
||||||
|
this.map = createMap();
|
||||||
|
this.markerClusterGroup = createClusterGroup().addTo(this.map);
|
||||||
|
this.setPlacesAndMarkers();
|
||||||
|
|
||||||
|
this.map.setView([48.107, -2.988]);
|
||||||
|
this.setMapBounds();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDayStats(day: TripDay): { price: number; places: number } {
|
||||||
|
if (this.dayStatsCache.has(day.id)) {
|
||||||
|
return this.dayStatsCache.get(day.id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = day.items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.price += item.price || item.place?.price || 0;
|
||||||
|
if (item.place) acc.places += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ price: 0, places: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dayStatsCache.set(day.id, stats);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusToTripStatus(status?: string): TripStatus | undefined {
|
||||||
|
if (!status) return undefined;
|
||||||
|
return this.statuses.find((s) => s.label == status) as TripStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
flattenTripDayItems(days: TripDay[]): FlattenedTripItem[] {
|
||||||
|
return days.flatMap((day) =>
|
||||||
|
[...day.items]
|
||||||
|
.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
.map((item) => ({
|
||||||
|
td_id: day.id,
|
||||||
|
td_label: day.label,
|
||||||
|
id: item.id,
|
||||||
|
time: item.time,
|
||||||
|
text: item.text,
|
||||||
|
status: this.statusToTripStatus(item.status as string),
|
||||||
|
comment: item.comment,
|
||||||
|
price: item.price || (item.place ? item.place.price : undefined),
|
||||||
|
day_id: item.day_id,
|
||||||
|
place: item.place,
|
||||||
|
lat: item.lat || (item.place ? item.place.lat : undefined),
|
||||||
|
lng: item.lng || (item.place ? item.place.lng : undefined),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
this.places.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
this.markerClusterGroup?.clearLayers();
|
||||||
|
this.places.forEach((p) => {
|
||||||
|
const marker = placeToMarker(p);
|
||||||
|
this.markerClusterGroup?.addLayer(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapBounds() {
|
||||||
|
if (!this.places.length) return;
|
||||||
|
this.map.fitBounds(
|
||||||
|
this.places.map((p) => [p.lat, p.lng]),
|
||||||
|
{ padding: [30, 30] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTotalPrice(n?: number) {
|
||||||
|
if (n) this.totalPrice += n;
|
||||||
|
else
|
||||||
|
this.totalPrice =
|
||||||
|
this.trip?.days
|
||||||
|
.flatMap((d) => d.items)
|
||||||
|
.reduce(
|
||||||
|
(price, item) => price + (item.price ?? item.place?.price ?? 0),
|
||||||
|
0,
|
||||||
|
) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetHighlightMarker() {
|
||||||
|
if (this.hoveredElement) {
|
||||||
|
this.hoveredElement.classList.remove("listHover");
|
||||||
|
this.hoveredElement = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightMarker(lat: number, lng: number) {
|
||||||
|
if (this.hoveredElement) {
|
||||||
|
this.hoveredElement.classList.remove("listHover");
|
||||||
|
this.hoveredElement = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker: L.Marker | undefined;
|
||||||
|
this.markerClusterGroup?.eachLayer((layer: any) => {
|
||||||
|
if (layer.getLatLng && layer.getLatLng().equals([lat, lng])) {
|
||||||
|
marker = layer;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!marker) return;
|
||||||
|
let markerElement = marker.getElement() as HTMLElement; // search for Marker. If 'null', is inside Cluster
|
||||||
|
|
||||||
|
if (markerElement) {
|
||||||
|
// marker, not clustered
|
||||||
|
markerElement.classList.add("listHover");
|
||||||
|
this.hoveredElement = markerElement;
|
||||||
|
} else {
|
||||||
|
// marker , clustered
|
||||||
|
const parentCluster = (this.markerClusterGroup as any).getVisibleParent(
|
||||||
|
marker,
|
||||||
|
);
|
||||||
|
if (parentCluster) {
|
||||||
|
const clusterEl = parentCluster.getElement();
|
||||||
|
if (clusterEl) {
|
||||||
|
clusterEl.classList.add("listHover");
|
||||||
|
this.hoveredElement = clusterEl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRowClick(item: FlattenedTripItem) {
|
||||||
|
if (this.selectedItem && this.selectedItem.id === item.id) {
|
||||||
|
this.selectedItem = undefined;
|
||||||
|
this.resetHighlightMarker();
|
||||||
|
} else {
|
||||||
|
this.selectedItem = item;
|
||||||
|
if (item.lat && item.lng) this.highlightMarker(item.lat, item.lng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTrip() {
|
||||||
|
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||||
|
header: "Confirm deletion",
|
||||||
|
modal: true,
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
data: `Delete ${this.trip?.name} ? This will delete everything.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (bool) => {
|
||||||
|
if (bool)
|
||||||
|
this.apiService.deleteTrip(this.trip?.id!).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigateByUrl("/trips");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editTrip() {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
TripCreateModalComponent,
|
||||||
|
{
|
||||||
|
header: "Update Trip",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "30vw",
|
||||||
|
data: { trip: this.trip },
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (new_trip: Trip | null) => {
|
||||||
|
if (!new_trip) return;
|
||||||
|
|
||||||
|
this.apiService.putTrip(new_trip, this.trip?.id!).subscribe({
|
||||||
|
next: (trip: Trip) => (this.trip = trip),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addDay() {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
TripCreateDayModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Day",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "30vw",
|
||||||
|
data: { days: this.trip?.days },
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (day: TripDay | null) => {
|
||||||
|
if (!day) return;
|
||||||
|
|
||||||
|
this.apiService.postTripDay(day, this.trip?.id!).subscribe({
|
||||||
|
next: (day) => {
|
||||||
|
this.trip?.days.push(day);
|
||||||
|
this.flattenedTripItems.push(...this.flattenTripDayItems([day]));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editDay(day: TripDay) {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
TripCreateDayModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Day",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "30vw",
|
||||||
|
data: { day: day, days: this.trip?.days },
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (day: TripDay | null) => {
|
||||||
|
if (!day) return;
|
||||||
|
|
||||||
|
this.apiService.putTripDay(day, this.trip?.id!).subscribe({
|
||||||
|
next: (day) => {
|
||||||
|
let index = this.trip?.days.findIndex((d) => d.id == day.id);
|
||||||
|
if (index != -1) {
|
||||||
|
this.trip?.days.splice(index as number, 1, day);
|
||||||
|
this.flattenedTripItems = this.flattenTripDayItems(
|
||||||
|
this.trip?.days!,
|
||||||
|
);
|
||||||
|
this.dayStatsCache.delete(day.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDay(day: TripDay) {
|
||||||
|
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||||
|
header: "Confirm deletion",
|
||||||
|
modal: true,
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
data: `Delete ${day.label} ? This will delete everything for this day.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (bool) => {
|
||||||
|
if (bool)
|
||||||
|
this.apiService.deleteTripDay(this.trip?.id!, day.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
let index = this.trip?.days.findIndex((d) => d.id == day.id);
|
||||||
|
if (index != -1) {
|
||||||
|
this.trip?.days.splice(index as number, 1);
|
||||||
|
this.flattenedTripItems = this.flattenTripDayItems(
|
||||||
|
this.trip?.days!,
|
||||||
|
);
|
||||||
|
this.dayStatsCache.delete(day.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
manageTripPlaces() {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
TripPlaceSelectModalComponent,
|
||||||
|
{
|
||||||
|
header: "Select Place(s)",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
width: "30vw",
|
||||||
|
data: { places: this.places },
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (places: Place[] | null) => {
|
||||||
|
if (!places) return;
|
||||||
|
|
||||||
|
this.apiService
|
||||||
|
.putTrip({ place_ids: places.map((p) => p.id) }, this.trip?.id!)
|
||||||
|
.subscribe({
|
||||||
|
next: (trip) => {
|
||||||
|
this.trip = trip;
|
||||||
|
this.setPlacesAndMarkers();
|
||||||
|
this.setMapBounds();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem(day_id?: number) {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
TripCreateDayItemModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Item",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "40vw",
|
||||||
|
data: {
|
||||||
|
places: this.places,
|
||||||
|
days: this.trip?.days,
|
||||||
|
selectedDay: day_id,
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (item: TripItem | null) => {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
this.apiService
|
||||||
|
.postTripDayItem(item, this.trip?.id!, item.day_id)
|
||||||
|
.subscribe({
|
||||||
|
next: (resp) => {
|
||||||
|
let index = this.trip?.days.findIndex((d) => d.id == item.day_id);
|
||||||
|
if (index != -1) {
|
||||||
|
let td: TripDay = this.trip?.days[index as number]!;
|
||||||
|
td.items.push(resp);
|
||||||
|
this.flattenedTripItems = this.flattenTripDayItems(
|
||||||
|
this.trip?.days!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editItem(item: TripItem) {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
TripCreateDayItemModalComponent,
|
||||||
|
{
|
||||||
|
header: "Update Item",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "40vw",
|
||||||
|
data: {
|
||||||
|
places: this.places,
|
||||||
|
days: this.trip?.days,
|
||||||
|
item: item,
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (it: TripItem | null) => {
|
||||||
|
if (!it) return;
|
||||||
|
|
||||||
|
this.apiService
|
||||||
|
.putTripDayItem(it, this.trip?.id!, item.day_id, item.id)
|
||||||
|
.subscribe({
|
||||||
|
next: (item) => {
|
||||||
|
let index = this.trip?.days.findIndex((d) => d.id == item.day_id);
|
||||||
|
if (index != -1) {
|
||||||
|
let td: TripDay = this.trip?.days[index as number]!;
|
||||||
|
td.items.splice(
|
||||||
|
td.items.findIndex((i) => i.id == item.id),
|
||||||
|
1,
|
||||||
|
item,
|
||||||
|
);
|
||||||
|
this.flattenedTripItems = this.flattenTripDayItems(
|
||||||
|
this.trip?.days!,
|
||||||
|
);
|
||||||
|
if (this.selectedItem && this.selectedItem.id === item.id)
|
||||||
|
this.selectedItem = {
|
||||||
|
...item,
|
||||||
|
status: item.status
|
||||||
|
? this.statusToTripStatus(item.status as string)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
this.dayStatsCache.delete(item.day_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPrice = -(item.price || 0) + (it.price || 0);
|
||||||
|
this.updateTotalPrice(updatedPrice);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteItem(item: TripItem) {
|
||||||
|
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||||
|
header: "Confirm deletion",
|
||||||
|
modal: true,
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
data: `Delete ${item.text.substring(0, 50)} ? This will delete everything for this day.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (bool) => {
|
||||||
|
if (bool)
|
||||||
|
this.apiService
|
||||||
|
.deleteTripDayItem(this.trip?.id!, item.day_id, item.id)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
let index = this.trip?.days.findIndex(
|
||||||
|
(d) => d.id == item.day_id,
|
||||||
|
);
|
||||||
|
if (index != -1) {
|
||||||
|
let td: TripDay = this.trip?.days[index as number]!;
|
||||||
|
td.items.splice(
|
||||||
|
td.items.findIndex((i) => i.id == item.id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
this.flattenedTripItems = this.flattenTripDayItems(
|
||||||
|
this.trip?.days!,
|
||||||
|
);
|
||||||
|
this.dayStatsCache.delete(item.day_id);
|
||||||
|
this.selectedItem = undefined;
|
||||||
|
this.resetHighlightMarker();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addItems() {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(
|
||||||
|
TripCreateItemsModalComponent,
|
||||||
|
{
|
||||||
|
header: "Create Items",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "40vw",
|
||||||
|
data: { days: this.trip?.days },
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (items: TripItem[] | null) => {
|
||||||
|
if (!items?.length) return;
|
||||||
|
const day_id = items[0].day_id;
|
||||||
|
|
||||||
|
const obs$ = items.map((item) =>
|
||||||
|
this.apiService.postTripDayItem(item, this.trip?.id!, item.day_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
forkJoin(obs$)
|
||||||
|
.pipe(
|
||||||
|
map((items) => {
|
||||||
|
let index = this.trip?.days.findIndex((d) => d.id == day_id);
|
||||||
|
if (index != -1) {
|
||||||
|
let td: TripDay = this.trip?.days[index as number]!;
|
||||||
|
td.items.push(...items);
|
||||||
|
this.flattenedTripItems = this.flattenTripDayItems(
|
||||||
|
this.trip?.days!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/src/app/components/trips/trips.component.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<div class="max-w-7xl mx-auto px-4 md:px-0 m-4">
|
||||||
|
<div class="p-4 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<img src="favicon.png" (click)="gotoMap()" class="cursor-pointer w-24" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<p-button icon="pi pi-map" (click)="gotoMap()" text />
|
||||||
|
<p-button icon="pi pi-plus" (click)="addTrip()" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
@defer {
|
||||||
|
@for (trip of trips; track trip.id) {
|
||||||
|
<div class="group relative rounded-lg overflow-hidden shadow-lg cursor-pointer" (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" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<p class="text-sm ">{{ trip.days || 0 }} {{ trip.days > 1 ? 'days' : 'day'}}</p>
|
||||||
|
|
||||||
|
<i
|
||||||
|
class="pi pi-arrow-right text-xl absolute right-4 bottom-4 h-4 opacity-0 -translate-x-4 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="px-4 mx-auto max-w-screen-xl mt-8 col-span-full">
|
||||||
|
<div class="py-8 px-4 flex flex-col items-center gap-1">
|
||||||
|
<h2 class="mb-0 text-4xl text-center tracking-tight font-extrabold text-gray-900 dark:text-gray-200">
|
||||||
|
No Trip.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="mt-4 font-light text-gray-500 sm:text-xl">
|
||||||
|
Add <i>Trip</i> to start organizing !
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p-button styleClass="mt-4" label="Create" icon="pi pi-plus" (click)="addTrip()" text />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @placeholder (minimum 0.4s) {
|
||||||
|
@for (_ of [1,2,3]; track _) {
|
||||||
|
<div>
|
||||||
|
<p-skeleton height="12rem" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
0
src/src/app/components/trips/trips.component.scss
Normal file
70
src/src/app/components/trips/trips.component.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { ApiService } from "../../services/api.service";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { SkeletonModule } from "primeng/skeleton";
|
||||||
|
import { Trip, TripBase } from "../../types/trip";
|
||||||
|
import { Category } from "../../types/poi";
|
||||||
|
import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { TripCreateModalComponent } from "../../modals/trip-create-modal/trip-create-modal.component";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-trips",
|
||||||
|
standalone: true,
|
||||||
|
imports: [SkeletonModule, ButtonModule],
|
||||||
|
templateUrl: "./trips.component.html",
|
||||||
|
styleUrls: ["./trips.component.scss"],
|
||||||
|
})
|
||||||
|
export class TripsComponent {
|
||||||
|
categories: Category[] = [];
|
||||||
|
map: any;
|
||||||
|
mapSettings: L.LatLng | undefined;
|
||||||
|
markerClusterGroup: any;
|
||||||
|
|
||||||
|
trips: TripBase[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private dialogService: DialogService,
|
||||||
|
private router: Router
|
||||||
|
) {
|
||||||
|
this.apiService.getTrips().subscribe({
|
||||||
|
next: (trips) => (this.trips = trips),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewTrip(id: number) {
|
||||||
|
this.router.navigateByUrl(`/trips/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
gotoMap() {
|
||||||
|
this.router.navigateByUrl("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
addTrip() {
|
||||||
|
const modal: DynamicDialogRef = this.dialogService.open(TripCreateModalComponent, {
|
||||||
|
header: "Create Place",
|
||||||
|
modal: true,
|
||||||
|
appendTo: "body",
|
||||||
|
closable: true,
|
||||||
|
dismissableMask: true,
|
||||||
|
width: "30vw",
|
||||||
|
breakpoints: {
|
||||||
|
"640px": "90vw",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.onClose.subscribe({
|
||||||
|
next: (trip: TripBase | null) => {
|
||||||
|
if (!trip) return;
|
||||||
|
|
||||||
|
this.apiService.postTrip(trip).subscribe({
|
||||||
|
next: (trip: TripBase) => {
|
||||||
|
this.trips.push(trip);
|
||||||
|
this.trips.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
<section>
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<textarea pTextarea id="batch" [formControl]="batchInput" rows="6" fluid></textarea>
|
||||||
|
<label for="batch">Batch</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" text [disabled]="!batchInput.value" label="Add" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormControl, ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { TextareaModule } from "primeng/textarea";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-batch-create-modal",
|
||||||
|
imports: [FloatLabelModule, ButtonModule, ReactiveFormsModule, TextareaModule],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./batch-create-modal.component.html",
|
||||||
|
styleUrl: "./batch-create-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class BatchCreateModalComponent {
|
||||||
|
batchInput = new FormControl("");
|
||||||
|
|
||||||
|
constructor(private ref: DynamicDialogRef) {}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
this.ref.close(this.batchInput.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<div pFocusTrap class="grid items-center gap-4" [formGroup]="categoryForm">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="name" formControlName="name" pInputText fluid />
|
||||||
|
<label for="name">Name</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<input type="file" accept="image/*" #fileInput class="hidden" (change)="onFileSelected($event)" />
|
||||||
|
<div class="grid place-items-center">
|
||||||
|
<div class="max-w-80 max-h-80 relative group cursor-pointer" (click)="fileInput.click()">
|
||||||
|
@if (categoryForm.get("image")?.value) {
|
||||||
|
<img [src]="categoryForm.get('image')?.value"
|
||||||
|
class="min-h-24 min-w-24 max-w-80 max-h-80 object-cover rounded-lg transition-transform duration-300" />
|
||||||
|
} @else {
|
||||||
|
<img src="/favicon.png"
|
||||||
|
class="min-h-24 min-w-24 max-w-80 max-h-80 object-cover rounded-lg transition-transform duration-300" />
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50 rounded-lg flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<span class="text-sm text-gray-300">Click to edit</span><i class="pi pi-upload text-white"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" [disabled]="!categoryForm.dirty || !categoryForm.valid">{{
|
||||||
|
categoryForm.get("id")?.value
|
||||||
|
!== -1 ? "Update" : "Create" }}</p-button>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { FocusTrapModule } from "primeng/focustrap";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-category-create-modal",
|
||||||
|
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, FocusTrapModule],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./category-create-modal.component.html",
|
||||||
|
styleUrl: "./category-create-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class CategoryCreateModalComponent {
|
||||||
|
categoryForm: FormGroup;
|
||||||
|
previous_image: string | null = null;
|
||||||
|
updatedImage = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private config: DynamicDialogConfig
|
||||||
|
) {
|
||||||
|
this.categoryForm = this.fb.group({
|
||||||
|
id: -1,
|
||||||
|
name: ["", Validators.required],
|
||||||
|
image: ["", Validators.required],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.data) {
|
||||||
|
let patchValue = this.config.data.category;
|
||||||
|
this.categoryForm.patchValue(patchValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
// Normalize data for API POST
|
||||||
|
let ret = this.categoryForm.value;
|
||||||
|
if (!this.updatedImage) delete ret["image"];
|
||||||
|
this.ref.close(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
const file = input.files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.categoryForm.get("image")?.setValue(e.target?.result as string);
|
||||||
|
this.categoryForm.get("image")?.markAsDirty();
|
||||||
|
this.updatedImage = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImage() {
|
||||||
|
this.categoryForm.get("image")?.setValue(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
<section>
|
||||||
|
<div pFocusTrap class="grid grid-cols-2 md:grid-cols-4 gap-4" [formGroup]="placeForm">
|
||||||
|
<p-floatlabel variant="in" class="col-span-2">
|
||||||
|
<input id="name" formControlName="name" pInputText fluid />
|
||||||
|
<label for="name">Name</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="lat" formControlName="lat" pInputText fluid />
|
||||||
|
<label for="lat">Latitude</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="lng" formControlName="lng" pInputText fluid />
|
||||||
|
<label for="lng">Longitude</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in" class="col-span-2 md:col-span-3">
|
||||||
|
<input id="place" formControlName="place" pInputText fluid />
|
||||||
|
<label for="place">Place</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in" class="col-span-2 md:col-span-1">
|
||||||
|
<p-select [options]="(categories$ | async) || []" optionValue="id" optionLabel="name"
|
||||||
|
[loading]="!(categories$ | async)?.length" inputId="category" id="category" formControlName="category"
|
||||||
|
[checkmark]="true" class="w-full capitalize" fluid />
|
||||||
|
<label for="category">Category</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="duration" formControlName="duration" pInputText fluid />
|
||||||
|
<label for="duration">Duration</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="price" formControlName="price" pInputText fluid />
|
||||||
|
<label for="price">Price</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<p-checkbox formControlName="allowdog" [binary]="true" inputId="allowdog" />
|
||||||
|
<label for="allowdog" class="ml-2">Allow 🐶</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<p-checkbox formControlName="visited" [binary]="true" inputId="visited" />
|
||||||
|
<label for="visited" class="ml-2">Visited</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid col-span-full md:grid-cols-4">
|
||||||
|
<p-floatlabel variant="in" class="col-span-full md:col-span-3">
|
||||||
|
<textarea pTextarea id="description" formControlName="description" rows="3" autoResize fluid></textarea>
|
||||||
|
<label for="description">Description</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<div class="mt-4 md:mt-0 grid place-items-center col-span-full md:col-span-1">
|
||||||
|
@if (placeForm.get("image_id")?.value) {
|
||||||
|
<div class="w-2/3 relative group cursor-pointer" (click)="fileInput.click()">
|
||||||
|
<img [src]="placeForm.get('image')?.value"
|
||||||
|
class="w-full max-h-20 object-cover rounded-full shadow-lg transition-transform duration-300" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50 rounded-full flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<span class="text-sm text-gray-300">Click to edit</span><i class="pi pi-upload text-white"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" #fileInput class="hidden" (change)="onFileSelected($event)" />
|
||||||
|
} @else {
|
||||||
|
@if (placeForm.get("image")?.value) {
|
||||||
|
<div class="w-2/3 relative group cursor-pointer" (click)="clearImage()">
|
||||||
|
<img [src]="placeForm.get('image')?.value"
|
||||||
|
class="w-full max-h-20 object-cover rounded-full shadow-lg transition-transform duration-300" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<i class="pi pi-trash text-white text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="w-2/3 relative group cursor-pointer" (click)="fileInput.click()">
|
||||||
|
<img src="/favicon.png"
|
||||||
|
class="w-full max-h-20 object-cover rounded-full shadow-lg transition-transform duration-300" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50 rounded-full flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<span class="text-sm text-gray-300">Click to edit</span><i class="pi pi-upload text-white"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" #fileInput class="hidden" (change)="onFileSelected($event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" [disabled]="!placeForm.dirty || !placeForm.valid">{{ placeForm.get("id")?.value
|
||||||
|
!== -1 ? "Update" : "Create" }}</p-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,154 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { SelectModule } from "primeng/select";
|
||||||
|
import { TextareaModule } from "primeng/textarea";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { AsyncPipe } from "@angular/common";
|
||||||
|
import { ApiService } from "../../services/api.service";
|
||||||
|
import { UtilsService } from "../../services/utils.service";
|
||||||
|
import { FocusTrapModule } from "primeng/focustrap";
|
||||||
|
import { Category, Place } from "../../types/poi";
|
||||||
|
import { CheckboxModule } from "primeng/checkbox";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-place-create-modal",
|
||||||
|
imports: [
|
||||||
|
FloatLabelModule,
|
||||||
|
InputTextModule,
|
||||||
|
ButtonModule,
|
||||||
|
SelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
TextareaModule,
|
||||||
|
CheckboxModule,
|
||||||
|
AsyncPipe,
|
||||||
|
FocusTrapModule,
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./place-create-modal.component.html",
|
||||||
|
styleUrl: "./place-create-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class PlaceCreateModalComponent {
|
||||||
|
placeForm: FormGroup;
|
||||||
|
categories$?: Observable<Category[]>;
|
||||||
|
previous_image_id: number | null = null;
|
||||||
|
previous_image: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private utilsService: UtilsService,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private config: DynamicDialogConfig
|
||||||
|
) {
|
||||||
|
this.categories$ = this.apiService.getCategories();
|
||||||
|
|
||||||
|
this.placeForm = this.fb.group({
|
||||||
|
id: -1,
|
||||||
|
name: ["", Validators.required],
|
||||||
|
place: ["", { validators: Validators.required, updateOn: "blur" }],
|
||||||
|
lat: ["", { validators: [Validators.required, Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)")] }],
|
||||||
|
lng: [
|
||||||
|
"",
|
||||||
|
{
|
||||||
|
validators: [Validators.required, Validators.pattern("-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
category: [null, Validators.required],
|
||||||
|
description: "",
|
||||||
|
duration: "",
|
||||||
|
price: "",
|
||||||
|
allowdog: false,
|
||||||
|
visited: false,
|
||||||
|
image: "",
|
||||||
|
image_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.data) {
|
||||||
|
let patchValue: Place = this.config.data.place;
|
||||||
|
if (patchValue.imageDefault) delete patchValue["image"];
|
||||||
|
this.placeForm.patchValue(patchValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.placeForm.get("place")?.valueChanges.subscribe({
|
||||||
|
next: (value: string) => {
|
||||||
|
if (value.startsWith("https://www.google.com/maps")) {
|
||||||
|
this.parseGoogleMapsUrl(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.placeForm.get("lat")?.valueChanges.subscribe({
|
||||||
|
next: (value: string) => {
|
||||||
|
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) {
|
||||||
|
let [lat, lng] = value.split(", ");
|
||||||
|
this.placeForm.get("lat")?.setValue(parseFloat(lat).toFixed(5));
|
||||||
|
this.placeForm.get("lng")?.setValue(parseFloat(lng).toFixed(5));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
// Normalize data for API POST
|
||||||
|
let ret = this.placeForm.value;
|
||||||
|
ret["category_id"] = ret["category"];
|
||||||
|
delete ret["category"];
|
||||||
|
if (!ret["price"]) ret["price"] = null;
|
||||||
|
if (!ret["duration"]) ret["duration"] = null;
|
||||||
|
if (ret["image_id"]) {
|
||||||
|
delete ret["image"];
|
||||||
|
delete ret["image_id"];
|
||||||
|
}
|
||||||
|
ret["lat"] = +ret["lat"];
|
||||||
|
ret["lng"] = +ret["lng"];
|
||||||
|
this.ref.close(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseGoogleMapsUrl(url: string): void {
|
||||||
|
const [place, latlng] = this.utilsService.parseGoogleMapsUrl(url);
|
||||||
|
if (!place || !latlng) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lat, lng] = latlng.split(",");
|
||||||
|
this.placeForm.get("place")?.setValue(place);
|
||||||
|
this.placeForm.get("lat")?.setValue(lat);
|
||||||
|
this.placeForm.get("lng")?.setValue(lng);
|
||||||
|
|
||||||
|
if (!this.placeForm.get("name")?.value) this.placeForm.get("name")?.setValue(place);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
const file = input.files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
if (this.placeForm.get("image_id")?.value) {
|
||||||
|
this.previous_image_id = this.placeForm.get("image_id")?.value;
|
||||||
|
this.previous_image = this.placeForm.get("image")?.value;
|
||||||
|
this.placeForm.get("image_id")?.setValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.placeForm.get("image")?.setValue(e.target?.result as string);
|
||||||
|
this.placeForm.get("image")?.markAsDirty();
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImage() {
|
||||||
|
this.placeForm.get("image")?.setValue(null);
|
||||||
|
|
||||||
|
if (this.previous_image && this.previous_image_id) {
|
||||||
|
this.placeForm.get("image_id")?.setValue(this.previous_image_id);
|
||||||
|
this.placeForm.get("image")?.setValue(this.previous_image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
<section>
|
||||||
|
<div [formGroup]="itemForm">
|
||||||
|
<div class="grid md:grid-cols-5 gap-4">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
|
||||||
|
formControlName="day_id" [checkmark]="true" class="capitalize" fluid />
|
||||||
|
<label for="day_id">Day</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in" class="w-full">
|
||||||
|
<p-inputmask id="time" formControlName="time" mask="99?:99" fluid styleClass="w-full" />
|
||||||
|
<label for="time">Time (HH, HH:MM)</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="text" formControlName="text" pInputText fluid />
|
||||||
|
<label for="text">Text</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid md:grid-cols-7 gap-4">
|
||||||
|
<p-floatlabel variant="in" class="md:col-span-2">
|
||||||
|
<p-select [options]="places" optionValue="id" optionLabel="name" inputId="place" id="place"
|
||||||
|
formControlName="place" [showClear]="true" class="capitalize" fluid>
|
||||||
|
|
||||||
|
<ng-template let-place #item>
|
||||||
|
<div class="flex items-center gap-2" [class.text-gray-500]="place.placeUsage">
|
||||||
|
<img [src]="place.image" class="rounded-full size-6" />
|
||||||
|
<div>{{ place.name }}</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</p-select>
|
||||||
|
<label for="place">Place</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="lat" formControlName="lat" pInputText fluid />
|
||||||
|
<label for="lat">Latitude</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="lng" formControlName="lng" pInputText fluid />
|
||||||
|
<label for="lng">Longitude</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="price" formControlName="price" type="number" pInputText fluid />
|
||||||
|
<label for="price">Price</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in" class="md:col-span-2">
|
||||||
|
<p-select [options]="statuses" optionValue="label" optionLabel="label" inputId="status" id="status"
|
||||||
|
class="capitalize" formControlName="status" [checkmark]="true" [showClear]="true" fluid>
|
||||||
|
<ng-template #selectedItem let-selectedOption>
|
||||||
|
@if (selectedOption) {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="size-4 rounded-md" [style]="'background:'+selectedOption.color"></div>
|
||||||
|
<div>{{ selectedOption.label }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</p-select>
|
||||||
|
<label for="status">Status</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<p-floatlabel variant="in" class="w-full">
|
||||||
|
<textarea pTextarea id="comment" formControlName="comment" rows="5" autoResize fluid></textarea>
|
||||||
|
<label for="comment">Comment</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" [disabled]="!itemForm.dirty || !itemForm.valid">{{ itemForm.get("id")?.value
|
||||||
|
!== -1 ? "Update" : "Create" }}</p-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { TripDay, TripStatus } from "../../types/trip";
|
||||||
|
import { Place } from "../../types/poi";
|
||||||
|
import { SelectModule } from "primeng/select";
|
||||||
|
import { TextareaModule } from "primeng/textarea";
|
||||||
|
import { InputMaskModule } from "primeng/inputmask";
|
||||||
|
import { UtilsService } from "../../services/utils.service";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-trip-create-day-item-modal",
|
||||||
|
imports: [
|
||||||
|
FloatLabelModule,
|
||||||
|
InputTextModule,
|
||||||
|
ButtonModule,
|
||||||
|
SelectModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
TextareaModule,
|
||||||
|
FloatLabelModule,
|
||||||
|
InputTextModule,
|
||||||
|
ButtonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
InputMaskModule,
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./trip-create-day-item-modal.component.html",
|
||||||
|
styleUrl: "./trip-create-day-item-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class TripCreateDayItemModalComponent {
|
||||||
|
itemForm: FormGroup;
|
||||||
|
days: TripDay[] = [];
|
||||||
|
places: Place[] = [];
|
||||||
|
statuses: TripStatus[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private config: DynamicDialogConfig,
|
||||||
|
private utilsService: UtilsService
|
||||||
|
) {
|
||||||
|
this.statuses = this.utilsService.statuses;
|
||||||
|
|
||||||
|
this.itemForm = this.fb.group({
|
||||||
|
id: -1,
|
||||||
|
time: ["", { validators: [Validators.required, Validators.pattern(/^([01]\d|2[0-3])(:[0-5]\d)?$/)] }],
|
||||||
|
text: ["", Validators.required],
|
||||||
|
comment: "",
|
||||||
|
day_id: [null, Validators.required],
|
||||||
|
place: null,
|
||||||
|
status: null,
|
||||||
|
price: 0,
|
||||||
|
lat: ["", { validators: Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)") }],
|
||||||
|
lng: ["", { validators: Validators.pattern("-?(180(\\.0+)?|1[0-7]\\d(\\.\\d+)?|[1-9]?\\d(\\.\\d+)?)") }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.data) {
|
||||||
|
const item = this.config.data.item;
|
||||||
|
if (item) this.itemForm.patchValue({ ...item, place: item.place?.id || null });
|
||||||
|
|
||||||
|
this.places = this.config.data.places;
|
||||||
|
this.days = this.config.data.days;
|
||||||
|
if (this.config.data.selectedDay) this.itemForm.get("day_id")?.setValue(this.config.data.selectedDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemForm.get("place")?.valueChanges.subscribe({
|
||||||
|
next: (value?: number) => {
|
||||||
|
if (!value) {
|
||||||
|
this.itemForm.get("lat")?.setValue("");
|
||||||
|
this.itemForm.get("lng")?.setValue("");
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
const p: Place = this.places.find((p) => p.id === value) as Place;
|
||||||
|
this.itemForm.get("lat")?.setValue(p.lat);
|
||||||
|
this.itemForm.get("lng")?.setValue(p.lng);
|
||||||
|
this.itemForm.get("price")?.setValue(p.price || 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.itemForm.get("lat")?.valueChanges.subscribe({
|
||||||
|
next: (value: string) => {
|
||||||
|
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) {
|
||||||
|
let [lat, lng] = value.split(", ");
|
||||||
|
this.itemForm.get("lat")?.setValue(parseFloat(lat).toFixed(5));
|
||||||
|
this.itemForm.get("lng")?.setValue(parseFloat(lng).toFixed(5));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
// Normalize data for API POST
|
||||||
|
let ret = this.itemForm.value;
|
||||||
|
if (!ret["lat"]) {
|
||||||
|
delete ret["lat"];
|
||||||
|
delete ret["lng"];
|
||||||
|
}
|
||||||
|
if (!ret["place"]) delete ret["place"];
|
||||||
|
this.ref.close(ret);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<section>
|
||||||
|
<div [formGroup]="dayForm">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="label" formControlName="label" pInputText fluid />
|
||||||
|
<label for="label">Label</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" [disabled]="!dayForm.dirty || !dayForm.valid">{{ dayForm.get("id")?.value
|
||||||
|
!== -1 ? "Update" : "Create" }}</p-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { TripDay } from "../../types/trip";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-trip-create-day-modal",
|
||||||
|
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./trip-create-day-modal.component.html",
|
||||||
|
styleUrl: "./trip-create-day-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class TripCreateDayModalComponent {
|
||||||
|
dayForm: FormGroup;
|
||||||
|
days: TripDay[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private config: DynamicDialogConfig
|
||||||
|
) {
|
||||||
|
this.dayForm = this.fb.group({
|
||||||
|
id: -1,
|
||||||
|
label: ["", Validators.required],
|
||||||
|
order: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.data) {
|
||||||
|
if (this.config.data.day) this.dayForm.patchValue(this.config.data.day);
|
||||||
|
this.days.push(...this.config.data.days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
// Normalize data for API POST
|
||||||
|
let ret = this.dayForm.value;
|
||||||
|
this.ref.close(ret);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<section [formGroup]="itemBatchForm" class="grid gap-4">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
|
||||||
|
formControlName="day_id" [checkmark]="true" class="w-full capitalize" fluid />
|
||||||
|
<label for="day_id">Day</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<textarea pTextarea id="batch" formControlName="batch" rows="6" [placeholder]="pholder" fluid></textarea>
|
||||||
|
<label for="batch">Batch</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" text [disabled]="!itemBatchForm.valid" label="Add" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { TextareaModule } from "primeng/textarea";
|
||||||
|
import { TripDay, TripItem } from "../../types/trip";
|
||||||
|
import { SelectModule } from "primeng/select";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-trip-create-items-modal",
|
||||||
|
imports: [FloatLabelModule, ButtonModule, SelectModule, ReactiveFormsModule, TextareaModule],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./trip-create-items-modal.component.html",
|
||||||
|
styleUrl: "./trip-create-items-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class TripCreateItemsModalComponent {
|
||||||
|
itemBatchForm: FormGroup;
|
||||||
|
pholder = "eg.\n14h Just an item example\n15:10 Another format for an item\n16h30 Another item here";
|
||||||
|
days: TripDay[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private config: DynamicDialogConfig
|
||||||
|
) {
|
||||||
|
this.itemBatchForm = this.fb.group({
|
||||||
|
batch: ["", Validators.required],
|
||||||
|
day_id: [null, Validators.required],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.data) {
|
||||||
|
this.days = this.config.data.days;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
const ret = this.itemBatchForm.value;
|
||||||
|
const day_id = ret.day_id;
|
||||||
|
const lines: string[] = ret.batch.trim().split("\n");
|
||||||
|
const tripItems: Partial<TripItem>[] = [];
|
||||||
|
|
||||||
|
lines.forEach((l) => {
|
||||||
|
const match = l.match(/^(\d{1,2})(?:h|:)?(\d{0,2})?\s+(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
const [_, hoursStr, minutesStr = "", text] = match;
|
||||||
|
const hours = hoursStr.padStart(2, "0");
|
||||||
|
const minutes = minutesStr.padStart(2, "0") || "00";
|
||||||
|
const time = `${hours}:${minutes}`;
|
||||||
|
tripItems.push({ time: time, text: text, day_id: day_id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ref.close(tripItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<div pFocusTrap class="grid items-center gap-4" [formGroup]="tripForm">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="name" formControlName="name" pInputText fluid />
|
||||||
|
<label for="name">Name</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
|
||||||
|
<div class="grid place-items-center">
|
||||||
|
@if (tripForm.get("image_id")?.value) {
|
||||||
|
<div class="max-w-80 max-h-80 relative group cursor-pointer" (click)="fileInput.click()">
|
||||||
|
<img [src]="tripForm.get('image')?.value"
|
||||||
|
class="max-w-80 max-h-80 object-cover rounded-lg shadow-lg transition-transform duration-300" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50 rounded-lg flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<span class="text-sm text-gray-300">Click to edit</span><i class="pi pi-upload text-white"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" #fileInput class="hidden" (change)="onFileSelected($event)" />
|
||||||
|
} @else {
|
||||||
|
@if (tripForm.get("image")?.value) {
|
||||||
|
<div class="max-w-80 max-h-80 relative group cursor-pointer" (click)="clearImage()">
|
||||||
|
<img [src]="tripForm.get('image')?.value"
|
||||||
|
class="max-w-80 max-h-80 object-cover rounded-lg shadow-lg transition-transform duration-300" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<i class="pi pi-trash text-white text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="max-w-80 max-h-80 relative group cursor-pointer" (click)="fileInput.click()">
|
||||||
|
<img src="/favicon.png" class="max-w-80 max-h-80 object-cover transition-transform duration-300" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50 rounded-lg flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<span class="text-sm text-gray-300">Click to edit</span><i class="pi pi-upload text-white"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" #fileInput class="hidden" (change)="onFileSelected($event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<p-button (click)="closeDialog()" [disabled]="!tripForm.dirty || !tripForm.valid">{{ tripForm.get("id")?.value
|
||||||
|
!== -1 ? "Update" : "Create" }}</p-button>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { FocusTrapModule } from "primeng/focustrap";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-trip-create-modal",
|
||||||
|
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, FocusTrapModule],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./trip-create-modal.component.html",
|
||||||
|
styleUrl: "./trip-create-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class TripCreateModalComponent {
|
||||||
|
tripForm: FormGroup;
|
||||||
|
previous_image_id: number | null = null;
|
||||||
|
previous_image: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private config: DynamicDialogConfig
|
||||||
|
) {
|
||||||
|
this.tripForm = this.fb.group({
|
||||||
|
id: -1,
|
||||||
|
name: ["", Validators.required],
|
||||||
|
image: "",
|
||||||
|
image_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.data) {
|
||||||
|
let patchValue = this.config.data.trip;
|
||||||
|
if (!patchValue.image_id) delete patchValue["image"];
|
||||||
|
this.tripForm.patchValue(patchValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
// Normalize data for API POST
|
||||||
|
let ret = this.tripForm.value;
|
||||||
|
if (ret["image_id"]) {
|
||||||
|
delete ret["image"];
|
||||||
|
delete ret["image_id"];
|
||||||
|
}
|
||||||
|
this.ref.close(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
const file = input.files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
if (this.tripForm.get("image_id")?.value) {
|
||||||
|
this.previous_image_id = this.tripForm.get("image_id")?.value;
|
||||||
|
this.previous_image = this.tripForm.get("image")?.value;
|
||||||
|
this.tripForm.get("image_id")?.setValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tripForm.get("image")?.setValue(e.target?.result as string);
|
||||||
|
this.tripForm.get("image")?.markAsDirty();
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImage() {
|
||||||
|
this.tripForm.get("image")?.setValue(null);
|
||||||
|
|
||||||
|
if (this.previous_image && this.previous_image_id) {
|
||||||
|
this.tripForm.get("image_id")?.setValue(this.previous_image_id);
|
||||||
|
this.tripForm.get("image")?.setValue(this.previous_image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
<section>
|
||||||
|
<div class="max-w-full overflow-y-auto">
|
||||||
|
<div class="mb-4">
|
||||||
|
<p-floatlabel variant="in">
|
||||||
|
<input id="search" pSize="small" [formControl]="searchInput" pInputText fluid />
|
||||||
|
<label for="search">Search...</label>
|
||||||
|
</p-floatlabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">Selected</h1>
|
||||||
|
<span class="bg-blue-100 text-blue-800 text-sm me-2 px-2.5 py-0.5 rounded"> {{ selectedPlaces.length }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500">Here are your selected place{{ selectedPlaces.length > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
@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">
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 truncate">
|
||||||
|
<h1 class="tracking-tight truncate">{{ p.name }}</h1>
|
||||||
|
<span class="text-xs text-gray-500 truncate">{{ p.place }}</span>
|
||||||
|
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
@if (p.allowdog) {
|
||||||
|
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded">🐶</span>
|
||||||
|
} @else {
|
||||||
|
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded">🐶</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (p.visited) {
|
||||||
|
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||||
|
class="pi pi-eye text-xs"></i></span>
|
||||||
|
} @else {
|
||||||
|
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||||
|
class="pi pi-eye-slash text-xs"></i></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h1 class="font-semibold tracking-tight text-xl">List</h1>
|
||||||
|
<span class="text-xs text-gray-500">Currently displayed points</span>
|
||||||
|
@defer {
|
||||||
|
|
||||||
|
@for (p of displayedPlaces; track p.id) {
|
||||||
|
<div class="mt-4 flex items-center gap-4 hover:bg-gray-50 rounded-xl cursor-pointer py-2 px-4"
|
||||||
|
[class.font-bold]="selectedPlacesID.includes(p.id)" (click)="togglePlace(p)">
|
||||||
|
<div class="flex flex-col gap-1 truncate">
|
||||||
|
<h1 class="tracking-tight truncate">{{ p.name }}</h1>
|
||||||
|
<span class="text-xs text-gray-500 truncate">{{ p.place }}</span>
|
||||||
|
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
@if (p.allowdog) {
|
||||||
|
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded">🐶</span>
|
||||||
|
} @else {
|
||||||
|
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded">🐶</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (p.visited) {
|
||||||
|
<span class="bg-green-100 text-green-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||||
|
class="pi pi-eye text-xs"></i></span>
|
||||||
|
} @else {
|
||||||
|
<span class="bg-red-100 text-red-800 text-sm me-2 px-2.5 py-0.5 rounded"><i
|
||||||
|
class="pi pi-eye-slash text-xs"></i></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="tracking-tight">Nothing to see</h1>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="z-50 absolute w-full bg-white shadow p-4 bottom-0 left-0 text-center">
|
||||||
|
<p-button (click)="closeDialog()" label="Confirm" />
|
||||||
|
</div>
|
||||||
|
} @placeholder (minimum 0.4s) {
|
||||||
|
<div class="my-2">
|
||||||
|
<p-skeleton height="4rem" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { FormControl, ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { ButtonModule } from "primeng/button";
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||||
|
import { FloatLabelModule } from "primeng/floatlabel";
|
||||||
|
import { InputTextModule } from "primeng/inputtext";
|
||||||
|
import { Place } from "../../types/poi";
|
||||||
|
import { ApiService } from "../../services/api.service";
|
||||||
|
import { SkeletonModule } from "primeng/skeleton";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "app-trip-place-select-modal",
|
||||||
|
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, SkeletonModule],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: "./trip-place-select-modal.component.html",
|
||||||
|
styleUrl: "./trip-place-select-modal.component.scss",
|
||||||
|
})
|
||||||
|
export class TripPlaceSelectModalComponent {
|
||||||
|
searchInput = new FormControl("");
|
||||||
|
|
||||||
|
selectedPlaces: Place[] = [];
|
||||||
|
selectedPlacesID: number[] = [];
|
||||||
|
|
||||||
|
places: Place[] = [];
|
||||||
|
displayedPlaces: Place[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private config: DynamicDialogConfig
|
||||||
|
) {
|
||||||
|
this.apiService.getPlaces().subscribe({
|
||||||
|
next: (places) => {
|
||||||
|
this.places = places;
|
||||||
|
this.displayedPlaces = places;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.data) {
|
||||||
|
let places: Place[] = this.config.data.places;
|
||||||
|
this.selectedPlaces.push(...places);
|
||||||
|
this.selectedPlacesID = places.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchInput.valueChanges.subscribe({
|
||||||
|
next: (value) => {
|
||||||
|
if (!value) {
|
||||||
|
this.displayedPlaces = this.places;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let v = value.toLowerCase();
|
||||||
|
this.displayedPlaces = this.places.filter(
|
||||||
|
(p) => p.name.toLowerCase().includes(v) || p.description?.toLowerCase().includes(v)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlace(p: Place) {
|
||||||
|
if (this.selectedPlacesID.includes(p.id)) {
|
||||||
|
this.selectedPlacesID.splice(this.selectedPlacesID.indexOf(p.id), 1);
|
||||||
|
this.selectedPlaces.splice(
|
||||||
|
this.selectedPlaces.findIndex((place) => place.id === p.id),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedPlacesID.push(p.id);
|
||||||
|
this.selectedPlaces.push(p);
|
||||||
|
this.selectedPlaces.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
this.ref.close(this.selectedPlaces);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/src/app/modals/yes-no-modal/yes-no-modal.component.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="p-2">
|
||||||
|
<span
|
||||||
|
class="pi pi-exclamation-triangle text-4xl text-red-400 bg-gray-50 p-4 rounded-full"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span>{{ msg }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-4 mt-4">
|
||||||
|
<p-button
|
||||||
|
label="Cancel"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="success"
|
||||||
|
(click)="confirm()"
|
||||||
|
/>
|
||||||
|
<p-button
|
||||||
|
label="Confirm"
|
||||||
|
icon="pi pi-check"
|
||||||
|
variant="outlined"
|
||||||
|
severity="danger"
|
||||||
|
(click)="confirm(true)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
25
src/src/app/modals/yes-no-modal/yes-no-modal.component.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-yes-no-modal',
|
||||||
|
imports: [ButtonModule],
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './yes-no-modal.component.html',
|
||||||
|
styleUrl: './yes-no-modal.component.scss',
|
||||||
|
})
|
||||||
|
export class YesNoModalComponent {
|
||||||
|
msg = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private ref: DynamicDialogRef,
|
||||||
|
private config: DynamicDialogConfig,
|
||||||
|
) {
|
||||||
|
this.msg = this.config.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(confirm = false) {
|
||||||
|
this.ref.close(confirm);
|
||||||
|
}
|
||||||
|
}
|
||||||
357
src/src/app/services/api.service.ts
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { Category, Place } from "../types/poi";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
shareReplay,
|
||||||
|
tap,
|
||||||
|
} from "rxjs";
|
||||||
|
import { Info } from "../types/info";
|
||||||
|
import { Settings } from "../types/settings";
|
||||||
|
import { Trip, TripBase, TripDay, TripItem } from "../types/trip";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root",
|
||||||
|
})
|
||||||
|
export class ApiService {
|
||||||
|
public apiBaseUrl: string = "/api";
|
||||||
|
public assetsBaseUrl: string = "/api/assets";
|
||||||
|
|
||||||
|
private categoriesSubject = new BehaviorSubject<Category[] | null>(null);
|
||||||
|
public categories$: Observable<Category[] | null> =
|
||||||
|
this.categoriesSubject.asObservable();
|
||||||
|
|
||||||
|
private settingsSubject = new BehaviorSubject<Settings | null>(null);
|
||||||
|
public settings$: Observable<Settings | null> =
|
||||||
|
this.settingsSubject.asObservable();
|
||||||
|
private httpClient = inject(HttpClient);
|
||||||
|
|
||||||
|
getInfo(): Observable<Info> {
|
||||||
|
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) =>
|
||||||
|
categoryA.name.localeCompare(categoryB.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategories(): Observable<Category[]> {
|
||||||
|
if (!this.categoriesSubject.value) {
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.categories$ as Observable<Category[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
postCategory(c: Category): Observable<Category> {
|
||||||
|
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 || []),
|
||||||
|
category,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
putCategory(c_id: number, c: Partial<Category>): Observable<Category> {
|
||||||
|
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;
|
||||||
|
if (categoryIndex > -1) {
|
||||||
|
categories[categoryIndex] = category;
|
||||||
|
this._categoriesSubjectNext(categories);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory(category_id: number): Observable<{}> {
|
||||||
|
return this.httpClient
|
||||||
|
.delete<{}>(this.apiBaseUrl + `/categories/${category_id}`)
|
||||||
|
.pipe(
|
||||||
|
tap((_) => {
|
||||||
|
let categories = this.categoriesSubject.value || [];
|
||||||
|
let categoryIndex =
|
||||||
|
categories?.findIndex((c) => c.id == category_id) || -1;
|
||||||
|
if (categoryIndex > -1) {
|
||||||
|
categories.splice(categoryIndex, 1);
|
||||||
|
this._categoriesSubjectNext(categories);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaces(): Observable<Place[]> {
|
||||||
|
return this.httpClient.get<Place[]>(`${this.apiBaseUrl}/places`).pipe(
|
||||||
|
map((resp) => resp.map((p) => this._normalizePlaceImage(p))),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
shareReplay(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postPlace(place: Place): Observable<Place> {
|
||||||
|
return this.httpClient
|
||||||
|
.post<Place>(`${this.apiBaseUrl}/places`, place)
|
||||||
|
.pipe(map((p) => this._normalizePlaceImage(p)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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))));
|
||||||
|
}
|
||||||
|
|
||||||
|
putPlace(place_id: number, place: Partial<Place>): Observable<Place> {
|
||||||
|
return this.httpClient
|
||||||
|
.put<Place>(`${this.apiBaseUrl}/places/${place_id}`, place)
|
||||||
|
.pipe(
|
||||||
|
map((place) => {
|
||||||
|
if (place.image) place.image = `${this.assetsBaseUrl}/${place.image}`;
|
||||||
|
else
|
||||||
|
place.image = `${this.assetsBaseUrl}/${(place.category as Category).image}`;
|
||||||
|
return place;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePlace(place_id: number): Observable<null> {
|
||||||
|
return this.httpClient.delete<null>(
|
||||||
|
`${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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTrip(trip_id: number): Observable<null> {
|
||||||
|
return this.httpClient.delete<null>(`${this.apiBaseUrl}/trips/${trip_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postTripDay(tripDay: TripDay, trip_id: number): Observable<TripDay> {
|
||||||
|
return this.httpClient.post<TripDay>(
|
||||||
|
`${this.apiBaseUrl}/trips/${trip_id}/days`,
|
||||||
|
tripDay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTripDay(trip_id: number, day_id: number): Observable<null> {
|
||||||
|
return this.httpClient.delete<null>(
|
||||||
|
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postTripDayItem(
|
||||||
|
item: TripItem,
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
putTripDayItem(
|
||||||
|
item: Partial<TripItem>,
|
||||||
|
trip_id: number,
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTripDayItem(
|
||||||
|
trip_id: number,
|
||||||
|
day_id: number,
|
||||||
|
item_id: number,
|
||||||
|
): Observable<null> {
|
||||||
|
return this.httpClient.delete<null>(
|
||||||
|
`${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items/${item_id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkVersion(): Observable<string> {
|
||||||
|
return this.httpClient.get<string>(
|
||||||
|
`${this.apiBaseUrl}/settings/checkversion`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettings(): Observable<Settings> {
|
||||||
|
if (!this.settingsSubject.value) {
|
||||||
|
return this.httpClient
|
||||||
|
.get<Settings>(`${this.apiBaseUrl}/settings`)
|
||||||
|
.pipe(tap((settings) => this.settingsSubject.next(settings)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.settings$ as Observable<Settings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
putSettings(settings: Partial<Settings>): Observable<Settings> {
|
||||||
|
return this.httpClient
|
||||||
|
.put<Settings>(`${this.apiBaseUrl}/settings`, settings)
|
||||||
|
.pipe(tap((settings) => this.settingsSubject.next(settings)));
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsUserExport(): Observable<any> {
|
||||||
|
return this.httpClient.get<any>(`${this.apiBaseUrl}/settings/export`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/src/app/services/auth.guard.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateChildFn, CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { UtilsService } from './utils.service';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { of, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
export const AuthGuard: CanActivateFn | CanActivateChildFn = (_, state) => {
|
||||||
|
const router: Router = inject(Router);
|
||||||
|
const utilsService = inject(UtilsService);
|
||||||
|
|
||||||
|
return inject(AuthService)
|
||||||
|
.isLoggedIn()
|
||||||
|
.pipe(
|
||||||
|
switchMap((authenticated) => {
|
||||||
|
if (!authenticated) {
|
||||||
|
const redirectURL =
|
||||||
|
state.url === '/auth' ? '' : `redirectURL=${state.url}`;
|
||||||
|
const urlTree = router.parseUrl(`auth?${redirectURL}`);
|
||||||
|
utilsService.toast('error', 'Error', 'You must be authenticated');
|
||||||
|
return of(urlTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
225
src/src/app/services/auth.service.ts
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { Injectable } from "@angular/core";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { Observable, of } from "rxjs";
|
||||||
|
import { tap } from "rxjs/operators";
|
||||||
|
import { ApiService } from "./api.service";
|
||||||
|
import { UtilsService } from "./utils.service";
|
||||||
|
|
||||||
|
export interface Token {
|
||||||
|
refresh_token: string;
|
||||||
|
access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JWT_TOKEN = "TRIP_AT";
|
||||||
|
const REFRESH_TOKEN = "TRIP_RT";
|
||||||
|
const JWT_USER = "TRIP_USER";
|
||||||
|
|
||||||
|
@Injectable({ providedIn: "root" })
|
||||||
|
export class AuthService {
|
||||||
|
public apiBaseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private httpClient: HttpClient,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private utilsService: UtilsService
|
||||||
|
) {
|
||||||
|
this.apiBaseUrl = this.apiService.apiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
set loggedUser(user: string) {
|
||||||
|
localStorage.setItem(JWT_USER, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
get loggedUser(): string {
|
||||||
|
return localStorage.getItem(JWT_USER) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
set accessToken(token: string) {
|
||||||
|
localStorage.setItem(JWT_TOKEN, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
get accessToken(): string {
|
||||||
|
return localStorage.getItem(JWT_TOKEN) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
set refreshToken(token: string) {
|
||||||
|
localStorage.setItem(REFRESH_TOKEN, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
get refreshToken(): string {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
storeTokens(tokens: Token): void {
|
||||||
|
this.accessToken = tokens.access_token;
|
||||||
|
this.refreshToken = tokens.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn(): Observable<boolean> {
|
||||||
|
if (this.loggedUser) return of(true);
|
||||||
|
if (this.accessToken) return of(true);
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAccessToken(): Observable<Token> {
|
||||||
|
return this.httpClient
|
||||||
|
.post<Token>(this.apiBaseUrl + "/auth/refresh", {
|
||||||
|
refresh_token: this.refreshToken,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((tokens: Token) => {
|
||||||
|
this.accessToken = tokens.access_token;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
login(authForm: { username: string; password: string }): Observable<Token> {
|
||||||
|
return this.httpClient.post<Token>(this.apiBaseUrl + "/auth/login", authForm).pipe(
|
||||||
|
tap((tokens: Token) => {
|
||||||
|
this.loggedUser = authForm.username;
|
||||||
|
this.storeTokens(tokens);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
register(authForm: { username: string; password: string }): Observable<Token> {
|
||||||
|
return this.httpClient.post<Token>(this.apiBaseUrl + "/auth/register", authForm).pipe(
|
||||||
|
tap((tokens: Token) => {
|
||||||
|
this.loggedUser = authForm.username;
|
||||||
|
this.storeTokens(tokens);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(custom_msg: string = "", is_error = false): void {
|
||||||
|
this.loggedUser = "";
|
||||||
|
this.removeTokens();
|
||||||
|
|
||||||
|
if (custom_msg) {
|
||||||
|
if (is_error) {
|
||||||
|
this.utilsService.toast("error", "You must be authenticated", custom_msg);
|
||||||
|
} else {
|
||||||
|
this.utilsService.toast("success", "Success", custom_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(["/auth"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeTokens(): void {
|
||||||
|
localStorage.removeItem(JWT_TOKEN);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN);
|
||||||
|
localStorage.removeItem(JWT_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenExpired(token: string, offsetSeconds?: number): boolean {
|
||||||
|
// Return if there is no token
|
||||||
|
if (!token || token === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the expiration date
|
||||||
|
const date = this._getTokenExpirationDate(token);
|
||||||
|
offsetSeconds = offsetSeconds || 0;
|
||||||
|
|
||||||
|
if (date === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the token is expired
|
||||||
|
return !(date.valueOf() > new Date().valueOf() + offsetSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _b64DecodeUnicode(str: any): string {
|
||||||
|
return decodeURIComponent(
|
||||||
|
Array.prototype.map
|
||||||
|
.call(this._b64decode(str), (c: any) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _b64decode(str: string): string {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
str = String(str).replace(/=+$/, "");
|
||||||
|
|
||||||
|
if (str.length % 4 === 1) {
|
||||||
|
throw new Error("'atob' failed: The string to be decoded is not correctly encoded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
for (
|
||||||
|
let bc = 0, bs: any, buffer: any, idx = 0;
|
||||||
|
(buffer = str.charAt(idx++));
|
||||||
|
~buffer && ((bs = bc % 4 ? bs * 64 + buffer : buffer), bc++ % 4)
|
||||||
|
? (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))))
|
||||||
|
: 0
|
||||||
|
) {
|
||||||
|
buffer = chars.indexOf(buffer);
|
||||||
|
}
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _urlBase64Decode(str: string): string {
|
||||||
|
let output = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
switch (output.length % 4) {
|
||||||
|
case 0: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
output += "==";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
output += "=";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw Error("Illegal base64url string!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._b64DecodeUnicode(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _decodeToken(token: string): any {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = this._urlBase64Decode(parts[1]);
|
||||||
|
|
||||||
|
if (!decoded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getTokenExpirationDate(token: string): Date | null {
|
||||||
|
const decodedToken = this._decodeToken(token);
|
||||||
|
|
||||||
|
if (decodedToken === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decodedToken.hasOwnProperty("exp")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(0);
|
||||||
|
date.setUTCSeconds(decodedToken.exp);
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/src/app/services/interceptor.service.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpRequest } from "@angular/common/http";
|
||||||
|
import { inject } from "@angular/core";
|
||||||
|
import { catchError, Observable, switchMap, throwError } from "rxjs";
|
||||||
|
import { AuthService } from "./auth.service";
|
||||||
|
import { UtilsService } from "./utils.service";
|
||||||
|
|
||||||
|
export const Interceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const utilsService = inject(UtilsService);
|
||||||
|
|
||||||
|
if (!req.headers.has("enctype") && !req.headers.has("Content-Type")) {
|
||||||
|
req = req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept-Language": "en-US;q=0.9,en-US,en;q=0.8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authService.accessToken && !authService.isTokenExpired(authService.accessToken)) {
|
||||||
|
if (req.url.startsWith(authService.apiBaseUrl)) {
|
||||||
|
req = req.clone({
|
||||||
|
setHeaders: { Authorization: `Bearer ${authService.accessToken}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req).pipe(
|
||||||
|
catchError((err: HttpErrorResponse) => {
|
||||||
|
if (err.status == 400) {
|
||||||
|
console.error(err);
|
||||||
|
utilsService.toast(
|
||||||
|
"error",
|
||||||
|
"Bad Request",
|
||||||
|
`${err.error?.detail || err.error || "Unknown error, check console for details"}`
|
||||||
|
);
|
||||||
|
return throwError(
|
||||||
|
() => `Bad Request: ${err.error?.detail || err.error || "Unknown error, check console for details"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status == 403) {
|
||||||
|
console.error(err);
|
||||||
|
utilsService.toast("error", "Error", `${err.error?.detail || err.error || "You are not allowed to do this"}`);
|
||||||
|
return throwError(() => `Bad Request: ${err.error?.detail || err.error || "You are not allowed to do this"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status == 409) {
|
||||||
|
console.error(err);
|
||||||
|
utilsService.toast("error", "Error", `${err.error?.detail || err.error || "Conflict on resource"}`);
|
||||||
|
return throwError(() => `Bad Request: ${err.error?.detail || err.error || "Conflict on resource"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status == 413) {
|
||||||
|
console.error(err);
|
||||||
|
utilsService.toast(
|
||||||
|
"error",
|
||||||
|
"Request entity too large",
|
||||||
|
"The resource you are trying to upload or create is too big"
|
||||||
|
);
|
||||||
|
return throwError(() => "Request entity too large, the resource you are trying to upload or create is too big");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status == 422) {
|
||||||
|
console.error(err);
|
||||||
|
utilsService.toast("error", "Unprocessable Entity ", "The resource you sent was unprocessable");
|
||||||
|
return throwError(() => "Resource sent was unprocessable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status == 502) {
|
||||||
|
console.error(err);
|
||||||
|
utilsService.toast("error", "Bad Gateway", "Check your connectivity and ensure the server is up and running");
|
||||||
|
return throwError(() => "Bad Request: Check your connectivity and ensure the server is up and running");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status == 503) {
|
||||||
|
console.error(err);
|
||||||
|
utilsService.toast(
|
||||||
|
"error",
|
||||||
|
"Service Unavailable",
|
||||||
|
`${err.error?.detail || err.statusText || "Resource not available"}`
|
||||||
|
);
|
||||||
|
return throwError(() => "Service Unavailable: Resource not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.status == 401 && authService.accessToken) {
|
||||||
|
// Handle 401 on Refresh (RT expired)
|
||||||
|
if (req.url.endsWith("/refresh")) {
|
||||||
|
authService.logout("Your session has expired", true);
|
||||||
|
return throwError(() => "Your session has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated, AT exists but is expired (authServices.accessToken truethy), we refresh it
|
||||||
|
return authService.refreshAccessToken().pipe(
|
||||||
|
switchMap((tokens) => {
|
||||||
|
req = req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${tokens.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return next(req);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If any API route 401 -> redirect to login. We skip /refresh/ to prevent toast on login errors.
|
||||||
|
if (!req.url.endsWith("/refresh")) {
|
||||||
|
if (err instanceof HttpErrorResponse && err.status === 401) {
|
||||||
|
authService.logout(`${err.error?.detail || err.error || "You must be authenticated"}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
71
src/src/app/services/utils.service.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
import { MessageService } from "primeng/api";
|
||||||
|
import { TripStatus } from "../types/trip";
|
||||||
|
import { ApiService } from "./api.service";
|
||||||
|
import { map } from "rxjs";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root",
|
||||||
|
})
|
||||||
|
export class UtilsService {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
currency$ = this.apiService.settings$.pipe(map((s) => s?.currency ?? "€"));
|
||||||
|
|
||||||
|
constructor(private ngMessageService: MessageService) {}
|
||||||
|
|
||||||
|
toGithubTRIP() {
|
||||||
|
window.open("https://github.com/itskovacs/trip", "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
get statuses(): TripStatus[] {
|
||||||
|
return [
|
||||||
|
{ label: "pending", color: "#3258A8" },
|
||||||
|
{ label: "booked", color: "#007A30" },
|
||||||
|
{ label: "constraint", color: "#FFB900" },
|
||||||
|
{ label: "optional", color: "#625A84" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(severity = "info", summary = "Info", detail = "", life = 3000): void {
|
||||||
|
this.ngMessageService.add({
|
||||||
|
severity,
|
||||||
|
summary,
|
||||||
|
detail,
|
||||||
|
life,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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\-.]+)/);
|
||||||
|
|
||||||
|
if (!match?.length || match?.length < 3) {
|
||||||
|
console.error("Incorrect Google Maps URL format");
|
||||||
|
return ["", ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
let place = decodeURIComponent(match[1].trim().replace(/\+/g, " "));
|
||||||
|
let latlng = match[2].trim();
|
||||||
|
|
||||||
|
return [place, latlng];
|
||||||
|
}
|
||||||
|
|
||||||
|
currencySigns(): { c: string; s: string }[] {
|
||||||
|
return [
|
||||||
|
{ c: "EUR", s: "€" },
|
||||||
|
{ c: "GBP", s: "£" },
|
||||||
|
{ c: "JPY", s: "¥" },
|
||||||
|
{ c: "USD", s: "$" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||