🚀 TRIP 1.0.0

This commit is contained in:
itskovacs 2025-07-18 18:43:30 +02:00
commit 98dbd25ec1
116 changed files with 8295 additions and 0 deletions

BIN
.github/sc_map.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

BIN
.github/sc_map_filters_list.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
.github/sc_trip.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

BIN
.github/sc_trips.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

BIN
.github/screenshot.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

70
.github/workflows/docker-publish.yml vendored Normal file
View 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
View 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
View 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">
![Status](https://img.shields.io/badge/status-active-success?style=for-the-badge)
[![GitHub Issues](https://img.shields.io/github/issues/itskovacs/trip?style=for-the-badge&color=ededed)](https://github.com/itskovacs/trip/issues)
[![License](https://img.shields.io/badge/license-_CC_BY_NC_SA_4.0-2596be?style=for-the-badge)](/LICENSE)
</div>
<p align="center">🗺️ Tourism and Recreational Interest Points </p>
<br>
<div align="center">
![TRIP Planning](./.github/screenshot.png)
</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">
| | |
|:-------:|:-------:|
| ![](./.github/sc_map.png) | ![](./.github/sc_map_filters_list.png) |
| ![](./.github/sc_trip.png) | ![](./.github/sc_trips.png) |
</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
View 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-----
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

1
backend/trip/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = "1.0.0"

22
backend/trip/config.py Normal file
View 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()

View File

76
backend/trip/db/core.py Normal file
View 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
View 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
View 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")

View File

View 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,
)

View 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

View File

View 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")

View 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)

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

View 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]

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

View File

168
backend/trip/utils/utils.py Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

1
src/.prettierrc Normal file
View File

@ -0,0 +1 @@
{}

101
src/angular.json Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/public/cover.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

BIN
src/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

View 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"
}
]
}

View File

@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<p-toast />

View File

View 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
View 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
View 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" },
];

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

View File

@ -0,0 +1,4 @@
.cover-auth {
background: url("/cover.webp");
background-size: cover;
}

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

View 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>
}

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View 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: "$" },
];
}
}

Some files were not shown because too many files have changed in this diff Show More