commit 98dbd25ec1251b71b839b1aff5455b3ad59cc6f2 Author: itskovacs Date: Fri Jul 18 18:43:30 2025 +0200 πŸš€ TRIP 1.0.0 diff --git a/.github/sc_map.png b/.github/sc_map.png new file mode 100644 index 0000000..95ed1bf Binary files /dev/null and b/.github/sc_map.png differ diff --git a/.github/sc_map_filters_list.png b/.github/sc_map_filters_list.png new file mode 100644 index 0000000..1e5aac4 Binary files /dev/null and b/.github/sc_map_filters_list.png differ diff --git a/.github/sc_trip.png b/.github/sc_trip.png new file mode 100644 index 0000000..eec414d Binary files /dev/null and b/.github/sc_trip.png differ diff --git a/.github/sc_trips.png b/.github/sc_trips.png new file mode 100644 index 0000000..d99dfb3 Binary files /dev/null and b/.github/sc_trips.png differ diff --git a/.github/screenshot.png b/.github/screenshot.png new file mode 100644 index 0000000..3ced4db Binary files /dev/null and b/.github/screenshot.png differ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..66aeee0 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..477f154 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a32a07a --- /dev/null +++ b/README.md @@ -0,0 +1,210 @@ +

+

TRIP

+ +
+ +![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) + +
+ +

πŸ—ΊοΈ Tourism and Recreational Interest Points

+
+ +
+ +![TRIP Planning](./.github/screenshot.png) + +
+ +## πŸ“ Table of Contents + +- πŸ“¦ [About](#about) +- 🌱 [Getting Started](#getting_started) +- πŸ“Έ [Demo](#Demo) +- 🚧 [Roadmap](#Roadmap) +- πŸ“œ [License](#License) +- 🀝 [Contributing](#Contributing) +- πŸ› οΈ [Tech Stack](#techstack) +- ✍️ [Authors](#authors) + +## πŸ“¦ About + +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. + +
+ +## 🌱 Getting Started + +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 +``` + +
+ +### 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 +``` + +
+ +## πŸ“Έ Demo + +A demo is available at [itskovacs-trip.netlify.app](https://itskovacs-trip.netlify.app/). + +
+ +| | | +|:-------:|:-------:| +| ![](./.github/sc_map.png) | ![](./.github/sc_map_filters_list.png) | +| ![](./.github/sc_trip.png) | ![](./.github/sc_trips.png) | + +
+ +
+ +## 🚧 Roadmap + +New features coming soonTM, 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)). + +
+ +## πŸ“œ License + +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**. + +
+ +## 🀝 Contributing + +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 + +
+ +## πŸ› οΈ Tech Stack + +### **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 + +
+ +## ✍️ Authors + +- [@itskovacs](https://github.com/itskovacs) + +
+ +
+ +If you like TRIP, consider giving it a **star** ⭐! +Made with ❀️ in BZH + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7bf836c --- /dev/null +++ b/SECURITY.md @@ -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!** + +
+ +### PGP Key +``` +-----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----- +``` \ No newline at end of file diff --git a/backend/storage/assets/accommodation.png b/backend/storage/assets/accommodation.png new file mode 100644 index 0000000..1e10866 Binary files /dev/null and b/backend/storage/assets/accommodation.png differ diff --git a/backend/storage/assets/adventure.png b/backend/storage/assets/adventure.png new file mode 100644 index 0000000..2530d32 Binary files /dev/null and b/backend/storage/assets/adventure.png differ diff --git a/backend/storage/assets/culture.png b/backend/storage/assets/culture.png new file mode 100644 index 0000000..93d2b80 Binary files /dev/null and b/backend/storage/assets/culture.png differ diff --git a/backend/storage/assets/entertainment.png b/backend/storage/assets/entertainment.png new file mode 100644 index 0000000..51b74b1 Binary files /dev/null and b/backend/storage/assets/entertainment.png differ diff --git a/backend/storage/assets/event.png b/backend/storage/assets/event.png new file mode 100644 index 0000000..329b80c Binary files /dev/null and b/backend/storage/assets/event.png differ diff --git a/backend/storage/assets/food.png b/backend/storage/assets/food.png new file mode 100644 index 0000000..e5d1642 Binary files /dev/null and b/backend/storage/assets/food.png differ diff --git a/backend/storage/assets/nature.png b/backend/storage/assets/nature.png new file mode 100644 index 0000000..acc8bbf Binary files /dev/null and b/backend/storage/assets/nature.png differ diff --git a/backend/storage/assets/wellness.png b/backend/storage/assets/wellness.png new file mode 100644 index 0000000..52457d8 Binary files /dev/null and b/backend/storage/assets/wellness.png differ diff --git a/backend/storage/config.yml b/backend/storage/config.yml new file mode 100644 index 0000000..e69de29 diff --git a/backend/trip/__init__.py b/backend/trip/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/backend/trip/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/backend/trip/config.py b/backend/trip/config.py new file mode 100644 index 0000000..1638aa7 --- /dev/null +++ b/backend/trip/config.py @@ -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() diff --git a/backend/trip/db/__init__.py b/backend/trip/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/trip/db/core.py b/backend/trip/db/core.py new file mode 100644 index 0000000..e1f5349 --- /dev/null +++ b/backend/trip/db/core.py @@ -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() diff --git a/backend/trip/deps.py b/backend/trip/deps.py new file mode 100644 index 0000000..68064bf --- /dev/null +++ b/backend/trip/deps.py @@ -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 diff --git a/backend/trip/main.py b/backend/trip/main.py new file mode 100644 index 0000000..e4fd8c5 --- /dev/null +++ b/backend/trip/main.py @@ -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") diff --git a/backend/trip/models/__init__.py b/backend/trip/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/trip/models/models.py b/backend/trip/models/models.py new file mode 100644 index 0000000..e033b2f --- /dev/null +++ b/backend/trip/models/models.py @@ -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, + ) diff --git a/backend/trip/requirements.txt b/backend/trip/requirements.txt new file mode 100644 index 0000000..9e1d31a --- /dev/null +++ b/backend/trip/requirements.txt @@ -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 \ No newline at end of file diff --git a/backend/trip/routers/__init__.py b/backend/trip/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/trip/routers/auth.py b/backend/trip/routers/auth.py new file mode 100644 index 0000000..910eb9d --- /dev/null +++ b/backend/trip/routers/auth.py @@ -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") diff --git a/backend/trip/routers/categories.py b/backend/trip/routers/categories.py new file mode 100644 index 0000000..c1db7b8 --- /dev/null +++ b/backend/trip/routers/categories.py @@ -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) diff --git a/backend/trip/routers/places.py b/backend/trip/routers/places.py new file mode 100644 index 0000000..2c8ba93 --- /dev/null +++ b/backend/trip/routers/places.py @@ -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 {} diff --git a/backend/trip/routers/settings.py b/backend/trip/routers/settings.py new file mode 100644 index 0000000..c32b189 --- /dev/null +++ b/backend/trip/routers/settings.py @@ -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] diff --git a/backend/trip/routers/trips.py b/backend/trip/routers/trips.py new file mode 100644 index 0000000..53aec16 --- /dev/null +++ b/backend/trip/routers/trips.py @@ -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 {} diff --git a/backend/trip/security.py b/backend/trip/security.py new file mode 100644 index 0000000..98555fc --- /dev/null +++ b/backend/trip/security.py @@ -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 diff --git a/backend/trip/utils/__init__.py b/backend/trip/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/trip/utils/utils.py b/backend/trip/utils/utils.py new file mode 100644 index 0000000..61c9c80 --- /dev/null +++ b/backend/trip/utils/utils.py @@ -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 "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86b51f2 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..fe463e0 --- /dev/null +++ b/license.txt @@ -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. + diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..3a023f5 --- /dev/null +++ b/src/.gitignore @@ -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 \ No newline at end of file diff --git a/src/.postcssrc.json b/src/.postcssrc.json new file mode 100644 index 0000000..e092dc7 --- /dev/null +++ b/src/.postcssrc.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/src/.prettierrc b/src/.prettierrc new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/.prettierrc @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/angular.json b/src/angular.json new file mode 100644 index 0000000..e0f45be --- /dev/null +++ b/src/angular.json @@ -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 + } +} diff --git a/src/ngsw-config.json b/src/ngsw-config.json new file mode 100644 index 0000000..20536a7 --- /dev/null +++ b/src/ngsw-config.json @@ -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)" + ] + } + } + ] +} diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..db31031 --- /dev/null +++ b/src/package.json @@ -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" + } +} diff --git a/src/public/add-location.png b/src/public/add-location.png new file mode 100644 index 0000000..1cb24cb Binary files /dev/null and b/src/public/add-location.png differ diff --git a/src/public/cover.webp b/src/public/cover.webp new file mode 100644 index 0000000..ae6828c Binary files /dev/null and b/src/public/cover.webp differ diff --git a/src/public/favicon.png b/src/public/favicon.png new file mode 100644 index 0000000..9ccde0a Binary files /dev/null and b/src/public/favicon.png differ diff --git a/src/public/icons/TRIP_128.png b/src/public/icons/TRIP_128.png new file mode 100644 index 0000000..9ccde0a Binary files /dev/null and b/src/public/icons/TRIP_128.png differ diff --git a/src/public/icons/TRIP_192.png b/src/public/icons/TRIP_192.png new file mode 100644 index 0000000..4636184 Binary files /dev/null and b/src/public/icons/TRIP_192.png differ diff --git a/src/public/icons/TRIP_512.png b/src/public/icons/TRIP_512.png new file mode 100644 index 0000000..a2fe3c8 Binary files /dev/null and b/src/public/icons/TRIP_512.png differ diff --git a/src/public/manifest.webmanifest b/src/public/manifest.webmanifest new file mode 100644 index 0000000..bfd4dab --- /dev/null +++ b/src/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/src/src/app/app.component.html b/src/src/app/app.component.html new file mode 100644 index 0000000..0c2a390 --- /dev/null +++ b/src/src/app/app.component.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/src/app/app.component.scss b/src/src/app/app.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/app.component.ts b/src/src/app/app.component.ts new file mode 100644 index 0000000..3faed49 --- /dev/null +++ b/src/src/app/app.component.ts @@ -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 {} diff --git a/src/src/app/app.config.ts b/src/src/app/app.config.ts new file mode 100644 index 0000000..0be9ea7 --- /dev/null +++ b/src/src/app/app.config.ts @@ -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", + }), + ], +}; diff --git a/src/src/app/app.routes.ts b/src/src/app/app.routes.ts new file mode 100644 index 0000000..926f0d7 --- /dev/null +++ b/src/src/app/app.routes.ts @@ -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" }, +]; diff --git a/src/src/app/components/auth/auth.component.html b/src/src/app/components/auth/auth.component.html new file mode 100644 index 0000000..1faa35c --- /dev/null +++ b/src/src/app/components/auth/auth.component.html @@ -0,0 +1,93 @@ +
+
+
+
+ +
+ +
+ {{ isRegistering ? "Register" : "Sign in" }} +
+ + @if (error) { +
+ {{ + error + }} +
+ } + +
+ + + + + @if ( + authForm.get("username")?.dirty && + authForm.get("username")?.hasError("required") + ) { + Username is required + } + + + + + + @if ( + authForm.get("password")?.dirty && + authForm.get("password")?.hasError("required") + ) { + Password is required + } + +
+ @if (isRegistering) { + + Register + + } @else { + + Sign in + + } +
+
+ +
+ @if (isRegistering) { +

+ Have an account? + Login +

+ } @else { +

+ First time? + Create + an account +

+ } +
+ +
+ itskovacs/trip + Made with ❀️ in BZH +
+
+ +
\ No newline at end of file diff --git a/src/src/app/components/auth/auth.component.scss b/src/src/app/components/auth/auth.component.scss new file mode 100644 index 0000000..85ca5ac --- /dev/null +++ b/src/src/app/components/auth/auth.component.scss @@ -0,0 +1,4 @@ +.cover-auth { + background: url("/cover.webp"); + background-size: cover; +} diff --git a/src/src/app/components/auth/auth.component.ts b/src/src/app/components/auth/auth.component.ts new file mode 100644 index 0000000..456c009 --- /dev/null +++ b/src/src/app/components/auth/auth.component.ts @@ -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; + }, + }); + } + } +} diff --git a/src/src/app/components/dashboard/dashboard.component.html b/src/src/app/components/dashboard/dashboard.component.html new file mode 100644 index 0000000..8b648c5 --- /dev/null +++ b/src/src/app/components/dashboard/dashboard.component.html @@ -0,0 +1,299 @@ +
+ +@if (selectedPlace) { + +} + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+
+ +
+
+ +@if (viewMarkersList) { +
+
+
+

Points

+ Currently displayed points +
+ +
+ + +
+
+ +
+ @if (viewMarkersListSearch) { +
+ + + + +
+ } + + @for (p of visiblePlaces; track p.id) { +
+ + +
+

{{ p.name }}

+ {{ p.place }} + +
+ @if (p.allowdog) { + 🐢 + } @else { + 🐢 + } + + @if (p.visited) { + + } @else { + + } + + {{ p.category.name }} +
+
+
+ } @empty { +
+

No data

+ Try moving the map to see markers +
+ } +
+
+} + +@if (viewFilters) { +
+ +
+
+

Filters

+ You can customize the view +
+ + +
+ +
+
+
Visited
+ +
+
+
Allow dog only
+ +
+
+
Favorites only
+ +
+
+ +
+

Categories

+
+ +
+ @for (c of categories; track c.id) { +
+
{{ c.name }}
+ +
+ +
+
+ } +
+
+} + +@if (viewSettings) { +
+
+ + + + Settings + + + Categories + + + Data + + + About + + + + +
+
+

Map parameters

+ You can customize the default view on map loading +
+ + +
+ +
+ + + + + + + + + +
+ +
+

Currency

+
+
+ + + + +
+ +
+

Filters

+ You can customize the categories and attributes to hide by + default +
+
+ + + + +
+ + +
+ +
+ +
+ + +
+
+

Categories

+ You can modify the categories. + You cannot delete a used category. +
+ + +
+ +
+ @for (category of categories; track category.id) { +
+
+ {{ category.name }} +
+ +
+ + +
+
+ } +
+
+ +
+

Data

+ You can import/export your data +
+ +
+ + + +
+
+ +
+

About

+ +
+ +
+ Buy me + a + coffee + Coffee and contributions are greatly appreciated! +
+ +
+ @if (this.info?.update) { + + TRIP {{ this.info?.update }} + available on + Github + } @else { + + TRIP {{ info?.version }} + } +
+ +
Made with ❀️ in BZH
+
+
+
+
+
+} \ No newline at end of file diff --git a/src/src/app/components/dashboard/dashboard.component.scss b/src/src/app/components/dashboard/dashboard.component.scss new file mode 100644 index 0000000..cbc8f2d --- /dev/null +++ b/src/src/app/components/dashboard/dashboard.component.scss @@ -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; + } +} diff --git a/src/src/app/components/dashboard/dashboard.component.ts b/src/src/app/components/dashboard/dashboard.component.ts new file mode 100644 index 0000000..9474b11 --- /dev/null +++ b/src/src/app/components/dashboard/dashboard.component.ts @@ -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 = 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), + ); + } + }, + }); + }, + }); + } +} diff --git a/src/src/app/components/trip/trip.component.html b/src/src/app/components/trip/trip.component.html new file mode 100644 index 0000000..04163be --- /dev/null +++ b/src/src/app/components/trip/trip.component.html @@ -0,0 +1,311 @@ +
+
+
+ +
+

{{ trip?.name }}

+ {{ trip?.days?.length }} {{ trip?.days!.length > 1 ? 'days' : 'day'}} +
+
+ + +
+
+ + +
+
+ {{ totalPrice + || '-' }} {{ currency$ | async }} +
+
+
+
+ +
+
+
+
+

Plans

+ {{ trip?.name }} plans +
+ +
+ +
+ + +
+
+ + @defer { + @if (flattenedTripItems.length) { + + + + Day + Time + Text + Place + Comment + LatLng + Price + Status + + + + + @if (rowgroup) { + +
{{tripitem.td_label }}
+ + } + {{ tripitem.time }} + {{ tripitem.text }} + + @if (tripitem.place) { +
+ {{ + tripitem.place.name }} +
+ } @else {-} + + {{ tripitem.comment || '-' }} + +
+ @if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} } + @else {-} +
+ + @if (tripitem.price) {{{ + tripitem.price }} {{ currency$ | async }}} + @if (tripitem.status) {{{ + tripitem.status.label }}} + +
+
+ } @else { +
+
+

+ No Trip. +

+ +

+ Add Day to your Trip to start organizing ! +

+ + +
+
+ + } + } @placeholder (minimum 0.4s) { +
+ +
+ } +
+ +
+ @if (selectedItem) { +
+
+ + +

{{ selectedItem.text }}

+
+ + + +
+
+ +
+
+

Time

+

{{ selectedItem.time }}

+
+ +
+

Text

+

{{ selectedItem.text }}

+
+ + @if (selectedItem.place) { +
+

Place

+
{{ selectedItem.place.name }}
+
+ } + + @if (selectedItem.comment) { +
+

Comment

+

{{ selectedItem.comment }}

+
+ } + + @if (selectedItem.lat) { +
+

Latitude

+

{{ selectedItem.lat }}

+
+ } + + @if (selectedItem.lng) { +
+

Longitude

+

{{ selectedItem.lng }}

+
+ } + + @if (selectedItem.price) { +
+

Price

+

{{ selectedItem.price }} {{ currency$ | async }}

+
+ } + + @if (selectedItem.status) { +
+

Status

+ {{ + selectedItem.status.label }} +
+ } +
+
+ + } @else { +
+
+
+

Days

+ {{ trip?.name }} days +
+ + +
+ +
+ @defer { + @for (d of trip?.days; track d.id) { +
+ {{ d.label }} +
+ {{ + getDayStats(d).price || '-' }} {{ currency$ | async }} + {{ + getDayStats(d).places }} +
+ +
+ } @empty { + + } + } @placeholder (minimum 0.4s) { +
+ +
+ } +
+
+ +
+
+
+

Places

+ {{ trip?.name }} places +
+ +
+ @defer { + {{ places.length }} + } @placeholder (minimum 0.4s) { + + } + +
+
+ +
+ @defer { + @for (p of places; track p.id) { +
+ + +
+

{{ p.name }}

+ {{ p.place }} + +
+ {{ p.category.name }} + + @if (p.placeUsage) { + + } @else { + + } + + {{ p.price || '-' + }} {{ currency$ | async }} + +
+
+
+ } @empty { + + } + } @placeholder (minimum 0.4s) { +
+ @for (_ of [1,2,3]; track _) { +
+ +
+ } +
+ } +
+
+ } + +
+
+
+

Map

+ {{ trip?.name }} places +
+ + +
+ +
+
+
+
\ No newline at end of file diff --git a/src/src/app/components/trip/trip.component.scss b/src/src/app/components/trip/trip.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/components/trip/trip.component.ts b/src/src/app/components/trip/trip.component.ts new file mode 100644 index 0000000..09b8516 --- /dev/null +++ b/src/src/app/components/trip/trip.component.ts @@ -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; + + trip: Trip | undefined; + totalPrice: number = 0; + dayStatsCache = new Map(); + + 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(); + }, + }); + } +} diff --git a/src/src/app/components/trips/trips.component.html b/src/src/app/components/trips/trips.component.html new file mode 100644 index 0000000..26521f2 --- /dev/null +++ b/src/src/app/components/trips/trips.component.html @@ -0,0 +1,50 @@ +
+
+
+ +
+
+ + +
+
+ +
+ @defer { + @for (trip of trips; track trip.id) { +
+ + +
+

{{ trip.name }}

+

{{ trip.days || 0 }} {{ trip.days > 1 ? 'days' : 'day'}}

+ + +
+
+ } @empty { +
+
+

+ No Trip. +

+ +

+ Add Trip to start organizing ! +

+ + +
+
+ } + } @placeholder (minimum 0.4s) { + @for (_ of [1,2,3]; track _) { +
+ +
+ } + } +
+
\ No newline at end of file diff --git a/src/src/app/components/trips/trips.component.scss b/src/src/app/components/trips/trips.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/components/trips/trips.component.ts b/src/src/app/components/trips/trips.component.ts new file mode 100644 index 0000000..180fd73 --- /dev/null +++ b/src/src/app/components/trips/trips.component.ts @@ -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)); + }, + }); + }, + }); + } +} diff --git a/src/src/app/modals/batch-create-modal/batch-create-modal.component.html b/src/src/app/modals/batch-create-modal/batch-create-modal.component.html new file mode 100644 index 0000000..16c08ed --- /dev/null +++ b/src/src/app/modals/batch-create-modal/batch-create-modal.component.html @@ -0,0 +1,10 @@ +
+ + + + + +
+ +
+
\ No newline at end of file diff --git a/src/src/app/modals/batch-create-modal/batch-create-modal.component.scss b/src/src/app/modals/batch-create-modal/batch-create-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/batch-create-modal/batch-create-modal.component.ts b/src/src/app/modals/batch-create-modal/batch-create-modal.component.ts new file mode 100644 index 0000000..71721a7 --- /dev/null +++ b/src/src/app/modals/batch-create-modal/batch-create-modal.component.ts @@ -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); + } +} diff --git a/src/src/app/modals/category-create-modal/category-create-modal.component.html b/src/src/app/modals/category-create-modal/category-create-modal.component.html new file mode 100644 index 0000000..af1b1fd --- /dev/null +++ b/src/src/app/modals/category-create-modal/category-create-modal.component.html @@ -0,0 +1,28 @@ +
+ + + + + + +
+
+ @if (categoryForm.get("image")?.value) { + + } @else { + + } +
+ Click to edit +
+
+
+ +
+ {{ + categoryForm.get("id")?.value + !== -1 ? "Update" : "Create" }} +
\ No newline at end of file diff --git a/src/src/app/modals/category-create-modal/category-create-modal.component.scss b/src/src/app/modals/category-create-modal/category-create-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/category-create-modal/category-create-modal.component.ts b/src/src/app/modals/category-create-modal/category-create-modal.component.ts new file mode 100644 index 0000000..013746f --- /dev/null +++ b/src/src/app/modals/category-create-modal/category-create-modal.component.ts @@ -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); + } +} diff --git a/src/src/app/modals/place-create-modal/place-create-modal.component.html b/src/src/app/modals/place-create-modal/place-create-modal.component.html new file mode 100644 index 0000000..4431e1b --- /dev/null +++ b/src/src/app/modals/place-create-modal/place-create-modal.component.html @@ -0,0 +1,97 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ @if (placeForm.get("image_id")?.value) { +
+ +
+ Click to edit +
+
+ + } @else { + @if (placeForm.get("image")?.value) { +
+ +
+ +
+
+ } @else { +
+ +
+ Click to edit +
+
+ + } + } +
+
+
+ +
+ {{ placeForm.get("id")?.value + !== -1 ? "Update" : "Create" }} +
+
\ No newline at end of file diff --git a/src/src/app/modals/place-create-modal/place-create-modal.component.scss b/src/src/app/modals/place-create-modal/place-create-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/place-create-modal/place-create-modal.component.ts b/src/src/app/modals/place-create-modal/place-create-modal.component.ts new file mode 100644 index 0000000..b48f71e --- /dev/null +++ b/src/src/app/modals/place-create-modal/place-create-modal.component.ts @@ -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; + 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); + } + } +} diff --git a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html new file mode 100644 index 0000000..b48eb58 --- /dev/null +++ b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html @@ -0,0 +1,81 @@ +
+
+
+ + + + + + + + + + +
+ + + + +
+
+ +
+ + + + +
+ +
{{ place.name }}
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + @if (selectedOption) { +
+
+
{{ selectedOption.label }}
+
+ } +
+
+ +
+
+ +
+ + + + +
+
+ +
+ {{ itemForm.get("id")?.value + !== -1 ? "Update" : "Create" }} +
+
\ No newline at end of file diff --git a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.scss b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts new file mode 100644 index 0000000..2e285cf --- /dev/null +++ b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts @@ -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); + } +} diff --git a/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.html b/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.html new file mode 100644 index 0000000..86c6fd0 --- /dev/null +++ b/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.html @@ -0,0 +1,13 @@ +
+
+ + + + +
+ +
+ {{ dayForm.get("id")?.value + !== -1 ? "Update" : "Create" }} +
+
\ No newline at end of file diff --git a/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.scss b/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.ts b/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.ts new file mode 100644 index 0000000..2afd9cf --- /dev/null +++ b/src/src/app/modals/trip-create-day-modal/trip-create-day-modal.component.ts @@ -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); + } +} diff --git a/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.html b/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.html new file mode 100644 index 0000000..eec7e8e --- /dev/null +++ b/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.html @@ -0,0 +1,16 @@ +
+ + + + + + + + + + +
+ +
+
\ No newline at end of file diff --git a/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.scss b/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.ts b/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.ts new file mode 100644 index 0000000..f38a6aa --- /dev/null +++ b/src/src/app/modals/trip-create-items-modal/trip-create-items-modal.component.ts @@ -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[] = []; + + 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); + } +} diff --git a/src/src/app/modals/trip-create-modal/trip-create-modal.component.html b/src/src/app/modals/trip-create-modal/trip-create-modal.component.html new file mode 100644 index 0000000..24d4061 --- /dev/null +++ b/src/src/app/modals/trip-create-modal/trip-create-modal.component.html @@ -0,0 +1,45 @@ +
+ + + + + +
+ @if (tripForm.get("image_id")?.value) { +
+ +
+ Click to edit +
+
+ + } @else { + @if (tripForm.get("image")?.value) { +
+ +
+ +
+
+ } @else { +
+ +
+ Click to edit +
+
+ + } + } +
+
+ +
+ {{ tripForm.get("id")?.value + !== -1 ? "Update" : "Create" }} +
\ No newline at end of file diff --git a/src/src/app/modals/trip-create-modal/trip-create-modal.component.scss b/src/src/app/modals/trip-create-modal/trip-create-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/trip-create-modal/trip-create-modal.component.ts b/src/src/app/modals/trip-create-modal/trip-create-modal.component.ts new file mode 100644 index 0000000..54e9836 --- /dev/null +++ b/src/src/app/modals/trip-create-modal/trip-create-modal.component.ts @@ -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); + } + } +} diff --git a/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.html b/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.html new file mode 100644 index 0000000..e675fac --- /dev/null +++ b/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.html @@ -0,0 +1,98 @@ +
+
+
+ + + + +
+ +
+
+

Selected

+ {{ selectedPlaces.length }} +
+ Here are your selected place{{ selectedPlaces.length > 1 ? 's' : '' }} +
+ @for (p of selectedPlaces; track p.id) { +
+ + +
+

{{ p.name }}

+ {{ p.place }} + +
+ @if (p.allowdog) { + 🐢 + } @else { + 🐢 + } + + @if (p.visited) { + + } @else { + + } + + {{ p.category.name }} +
+
+
+ } + +
+

List

+ Currently displayed points + @defer { + + @for (p of displayedPlaces; track p.id) { +
+
+

{{ p.name }}

+ {{ p.place }} + +
+ @if (p.allowdog) { + 🐢 + } @else { + 🐢 + } + + @if (p.visited) { + + } @else { + + } + + {{ p.category.name }} +
+
+
+ } @empty { +
+

Nothing to see

+
+ } + +
+ +
+ } @placeholder (minimum 0.4s) { +
+ +
+ } +
+
+
\ No newline at end of file diff --git a/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.scss b/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.ts b/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.ts new file mode 100644 index 0000000..d0e7197 --- /dev/null +++ b/src/src/app/modals/trip-place-select-modal/trip-place-select-modal.component.ts @@ -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); + } +} diff --git a/src/src/app/modals/yes-no-modal/yes-no-modal.component.html b/src/src/app/modals/yes-no-modal/yes-no-modal.component.html new file mode 100644 index 0000000..c35c765 --- /dev/null +++ b/src/src/app/modals/yes-no-modal/yes-no-modal.component.html @@ -0,0 +1,24 @@ +
+
+ +
+ {{ msg }} +
+ +
+ + +
diff --git a/src/src/app/modals/yes-no-modal/yes-no-modal.component.scss b/src/src/app/modals/yes-no-modal/yes-no-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/yes-no-modal/yes-no-modal.component.ts b/src/src/app/modals/yes-no-modal/yes-no-modal.component.ts new file mode 100644 index 0000000..d1fa4c5 --- /dev/null +++ b/src/src/app/modals/yes-no-modal/yes-no-modal.component.ts @@ -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); + } +} diff --git a/src/src/app/services/api.service.ts b/src/src/app/services/api.service.ts new file mode 100644 index 0000000..f4ee057 --- /dev/null +++ b/src/src/app/services/api.service.ts @@ -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(null); + public categories$: Observable = + this.categoriesSubject.asObservable(); + + private settingsSubject = new BehaviorSubject(null); + public settings$: Observable = + this.settingsSubject.asObservable(); + private httpClient = inject(HttpClient); + + getInfo(): Observable { + return this.httpClient.get(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 { + if (!this.categoriesSubject.value) { + return this.httpClient + .get(`${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; + } + + postCategory(c: Category): Observable { + return this.httpClient + .post(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): Observable { + return this.httpClient + .put(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 { + return this.httpClient.get(`${this.apiBaseUrl}/places`).pipe( + map((resp) => resp.map((p) => this._normalizePlaceImage(p))), + distinctUntilChanged(), + shareReplay(), + ); + } + + postPlace(place: Place): Observable { + return this.httpClient + .post(`${this.apiBaseUrl}/places`, place) + .pipe(map((p) => this._normalizePlaceImage(p))); + } + + postPlaces(places: Partial): Observable { + return this.httpClient + .post(`${this.apiBaseUrl}/places/batch`, places) + .pipe(map((resp) => resp.map((p) => this._normalizePlaceImage(p)))); + } + + putPlace(place_id: number, place: Partial): Observable { + return this.httpClient + .put(`${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 { + return this.httpClient.delete( + `${this.apiBaseUrl}/places/${place_id}`, + ); + } + + getTrips(): Observable { + return this.httpClient.get(`${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 { + return this.httpClient.get(`${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 { + return this.httpClient + .post(`${this.apiBaseUrl}/trips`, trip) + .pipe( + map((trip) => { + trip = this._normalizeTripImage(trip) as TripBase; + return trip; + }), + ); + } + + deleteTrip(trip_id: number): Observable { + return this.httpClient.delete(`${this.apiBaseUrl}/trips/${trip_id}`); + } + + putTrip(trip: Partial, trip_id: number): Observable { + return this.httpClient + .put(`${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 { + return this.httpClient.post( + `${this.apiBaseUrl}/trips/${trip_id}/days`, + tripDay, + ); + } + + putTripDay(tripDay: Partial, trip_id: number): Observable { + return this.httpClient + .put( + `${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 { + return this.httpClient.delete( + `${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}`, + ); + } + + postTripDayItem( + item: TripItem, + trip_id: number, + day_id: number, + ): Observable { + return this.httpClient + .post( + `${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, + trip_id: number, + day_id: number, + item_id: number, + ): Observable { + return this.httpClient + .put( + `${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 { + return this.httpClient.delete( + `${this.apiBaseUrl}/trips/${trip_id}/days/${day_id}/items/${item_id}`, + ); + } + + checkVersion(): Observable { + return this.httpClient.get( + `${this.apiBaseUrl}/settings/checkversion`, + ); + } + + getSettings(): Observable { + if (!this.settingsSubject.value) { + return this.httpClient + .get(`${this.apiBaseUrl}/settings`) + .pipe(tap((settings) => this.settingsSubject.next(settings))); + } + + return this.settings$ as Observable; + } + + putSettings(settings: Partial): Observable { + return this.httpClient + .put(`${this.apiBaseUrl}/settings`, settings) + .pipe(tap((settings) => this.settingsSubject.next(settings))); + } + + settingsUserExport(): Observable { + return this.httpClient.get(`${this.apiBaseUrl}/settings/export`); + } + + settingsUserImport(formdata: FormData): Observable { + 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; + }); + }), + ); + } +} diff --git a/src/src/app/services/auth.guard.ts b/src/src/app/services/auth.guard.ts new file mode 100644 index 0000000..2d0b605 --- /dev/null +++ b/src/src/app/services/auth.guard.ts @@ -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); + }), + ); +}; diff --git a/src/src/app/services/auth.service.ts b/src/src/app/services/auth.service.ts new file mode 100644 index 0000000..d660c3c --- /dev/null +++ b/src/src/app/services/auth.service.ts @@ -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 { + if (this.loggedUser) return of(true); + if (this.accessToken) return of(true); + return of(false); + } + + refreshAccessToken(): Observable { + return this.httpClient + .post(this.apiBaseUrl + "/auth/refresh", { + refresh_token: this.refreshToken, + }) + .pipe( + tap((tokens: Token) => { + this.accessToken = tokens.access_token; + }) + ); + } + + login(authForm: { username: string; password: string }): Observable { + return this.httpClient.post(this.apiBaseUrl + "/auth/login", authForm).pipe( + tap((tokens: Token) => { + this.loggedUser = authForm.username; + this.storeTokens(tokens); + }) + ); + } + + register(authForm: { username: string; password: string }): Observable { + return this.httpClient.post(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; + } +} diff --git a/src/src/app/services/interceptor.service.ts b/src/src/app/services/interceptor.service.ts new file mode 100644 index 0000000..e5c6221 --- /dev/null +++ b/src/src/app/services/interceptor.service.ts @@ -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, next: HttpHandlerFn): Observable> => { + 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); + }) + ); +}; diff --git a/src/src/app/services/utils.service.ts b/src/src/app/services/utils.service.ts new file mode 100644 index 0000000..2bfade8 --- /dev/null +++ b/src/src/app/services/utils.service.ts @@ -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(a: T, b: T): Partial { + const diff: Partial = {}; + + 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: "$" }, + ]; + } +} diff --git a/src/src/app/shared/map.ts b/src/src/app/shared/map.ts new file mode 100644 index 0000000..bf9f87e --- /dev/null +++ b/src/src/app/shared/map.ts @@ -0,0 +1,97 @@ +import * as L from "leaflet"; +import "leaflet.markercluster"; +import "leaflet-contextmenu"; +import { Place } from "../types/poi"; + +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[]; +} + +export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map { + let southWest = L.latLng(-89.99, -180); + let northEast = L.latLng(89.99, 180); + let bounds = L.latLngBounds(southWest, northEast); + + let _contextMenuItems = contextMenuItems || []; + let map = L.map("map", { + maxBoundsViscosity: 1.0, + zoomControl: false, + contextmenu: true, + contextmenuItems: _contextMenuItems, + attributionControl: false, + } as MapOptions) + .setZoom(10) + .setMaxBounds(bounds); + + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 17, + minZoom: 5, + }).addTo(map); + + return map; +} + +export function assetHoverTooltip(place: Place): string { + let content = `
${place.name}
`; + content += `
${place.category.name}
`; + return content; +} + +export function createClusterGroup(): L.MarkerClusterGroup { + return L.markerClusterGroup({ + chunkedLoading: true, + disableClusteringAtZoom: 11, + showCoverageOnHover: false, + maxClusterRadius: 50, + iconCreateFunction: function (cluster) { + const count = cluster.getChildCount(); + return L.divIcon({ + html: `
${count}
`, + className: "", + iconSize: [40, 40], + }); + }, + }); +} + +export function placeToMarker(place: Place): L.Marker { + let marker: L.Marker; + let options: any = { + riseOnHover: true, + title: place.name, + place_id: place.id, + alt: "", + }; + + marker = new L.Marker([+place.lat, +place.lng], options); + marker.options.icon = L.icon({ + iconUrl: place.image!, + iconSize: [56, 56], + iconAnchor: [28, 28], + shadowSize: [0, 0], + shadowAnchor: [0, 0], + popupAnchor: [0, -12], + className: "image-marker", + }); + + let touchDevice = "ontouchstart" in window; + if (!touchDevice) { + marker.bindTooltip(assetHoverTooltip(place), { + direction: "right", + offset: [24, 0], + className: "class-tooltip", + }); + } + return marker; +} diff --git a/src/src/app/shared/place-box/place-box.component.html b/src/src/app/shared/place-box/place-box.component.html new file mode 100644 index 0000000..dfec2dc --- /dev/null +++ b/src/src/app/shared/place-box/place-box.component.html @@ -0,0 +1,121 @@ +@if (selectedPlace) { +
+
+
+
+ + +

{{ selectedPlace.name }}

+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+
+ Place + {{ selectedPlace.place }} +
+ +
+ Latitude + {{ selectedPlace.lat }} +
+ +
+ Longitude + {{ selectedPlace.lng }} +
+ +
+ Description + {{ selectedPlace.description || '-' }} +
+ +
+ Price + {{ selectedPlace.price || '-' }} {{ currency$ | async }} +
+ +
+ Duration + {{ selectedPlace.duration || '-' }} +
+
+ +
+ {{ selectedPlace.category.name }} + + @if (selectedPlace.allowdog) { + 🐢 + Yes + } @else { + 🐢 + No + } + + @if (selectedPlace.visited) { + Done + } @else { + To do + } +
+
+
+} + + \ No newline at end of file diff --git a/src/src/app/shared/place-box/place-box.component.scss b/src/src/app/shared/place-box/place-box.component.scss new file mode 100644 index 0000000..0c5147a --- /dev/null +++ b/src/src/app/shared/place-box/place-box.component.scss @@ -0,0 +1,25 @@ +.place-box-dialog { + animation: slideYcenteredX 0.3s both; + z-index: 999; + min-height: 100px; + max-height: 800px; + width: 95%; + max-width: 1200px; + background-color: #fff; + position: fixed; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + transition: none; + box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.1); + border-radius: 12px; + display: flex; + align-items: flex-end; + + &-content { + position: relative; + padding: 2rem; + border-radius: 8px 8px 0 0; + width: 100%; + } +} diff --git a/src/src/app/shared/place-box/place-box.component.ts b/src/src/app/shared/place-box/place-box.component.ts new file mode 100644 index 0000000..29d7bdc --- /dev/null +++ b/src/src/app/shared/place-box/place-box.component.ts @@ -0,0 +1,110 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { ButtonModule } from "primeng/button"; +import { MenuModule } from "primeng/menu"; +import { Place } from "../../types/poi"; +import { MenuItem } from "primeng/api"; +import { UtilsService } from "../../services/utils.service"; +import { Observable } from "rxjs"; +import { AsyncPipe } from "@angular/common"; + +@Component({ + selector: "app-place-box", + standalone: true, + imports: [ButtonModule, MenuModule, AsyncPipe], + templateUrl: "./place-box.component.html", + styleUrls: ["./place-box.component.scss"], +}) +export class PlaceBoxComponent { + @Input() selectedPlace: Place | undefined = undefined; + + @Output() editEmitter = new EventEmitter(); + @Output() deleteEmitter = new EventEmitter(); + @Output() visitEmitter = new EventEmitter(); + @Output() favoriteEmitter = new EventEmitter(); + @Output() gpxEmitter = new EventEmitter(); + @Output() closeEmitter = new EventEmitter(); + + menuItems: MenuItem[] = []; + currency$: Observable; + + constructor(private utilsService: UtilsService) { + this.currency$ = this.utilsService.currency$; + + let items = [ + { + label: "Edit", + icon: "pi pi-pencil", + iconClass: "text-blue-500!", + command: () => { + this.editPlace(); + }, + }, + { + label: "Favorite", + icon: "pi pi-star", + iconClass: "text-yellow-500!", + command: () => { + this.favoritePlace(); + }, + }, + { + label: "Mark", + icon: "pi pi-check", + iconClass: "text-green-500!", + command: () => { + this.visitPlace(); + }, + }, + { + label: "Delete", + icon: "pi pi-trash", + iconClass: "text-red-500!", + command: () => { + this.deletePlace(); + }, + }, + ]; + + if (this.selectedPlace?.gpx) { + items.push({ + label: "Display GPX", + icon: "pi pi-compass", + iconClass: "text-gray-500!", + command: () => { + this.displayGPX(); + }, + }); + } + + this.menuItems = [ + { + label: "Place", + items: items, + }, + ]; + } + + visitPlace() { + this.visitEmitter.emit(); + } + + favoritePlace() { + this.favoriteEmitter.emit(); + } + + editPlace() { + this.editEmitter.emit(); + } + + displayGPX() { + this.gpxEmitter.emit(); + } + + deletePlace() { + this.deleteEmitter.emit(); + } + + close() { + this.closeEmitter.emit(); + } +} diff --git a/src/src/app/types/info.ts b/src/src/app/types/info.ts new file mode 100644 index 0000000..b5bf63f --- /dev/null +++ b/src/src/app/types/info.ts @@ -0,0 +1,4 @@ +export interface Info { + version: string; + update?: string; +} diff --git a/src/src/app/types/poi.ts b/src/src/app/types/poi.ts new file mode 100644 index 0000000..135033b --- /dev/null +++ b/src/src/app/types/poi.ts @@ -0,0 +1,26 @@ +export interface Category { + id: number; + name: string; + image_id: number; + image: string; +} + +export interface Place { + id: number; + name: string; + lat: number; + lng: number; + place: string; + category: Category; + category_id?: number; + + gpx?: string; + image?: string; + price?: number; + description?: string; + duration?: number; + allowdog?: boolean; + visited?: boolean; + favorite?: boolean; + imageDefault?: boolean; // Injected in service +} diff --git a/src/src/app/types/settings.ts b/src/src/app/types/settings.ts new file mode 100644 index 0000000..600804f --- /dev/null +++ b/src/src/app/types/settings.ts @@ -0,0 +1,7 @@ +export interface Settings { + username: string; + mapLat: number; + mapLng: number; + currency: string; + do_not_display: string[]; +} diff --git a/src/src/app/types/trip.ts b/src/src/app/types/trip.ts new file mode 100644 index 0000000..5564c98 --- /dev/null +++ b/src/src/app/types/trip.ts @@ -0,0 +1,62 @@ +import { Place } from "./poi"; + +export interface TripBase { + id: number; + name: string; + image?: string; + archived?: boolean; + user: string; + days: number; +} + +export interface Trip { + id: number; + name: string; + image?: string; + archived?: boolean; + user: string; + days: TripDay[]; + + // POST / PUT + places: Place[]; + place_ids: number[]; +} + +export interface TripDay { + id: number; + label: string; + items: TripItem[]; +} + +export interface TripItem { + id: number; + time: string; + text: string; + comment?: string; + place?: Place; + lat?: number; + lng?: number; + price?: number; + day_id: number; + status?: string | TripStatus; +} + +export interface TripStatus { + label: string; + color: string; +} + +export interface FlattenedTripItem { + td_id: number; + td_label: string; + id: number; + time: string; + text: string; + comment?: string; + place?: Place; + price?: number; + lat?: number; + lng?: number; + day_id: number; + status?: TripStatus; +} diff --git a/src/src/index.html b/src/src/index.html new file mode 100644 index 0000000..454c2f8 --- /dev/null +++ b/src/src/index.html @@ -0,0 +1,19 @@ + + + + + + TRIP + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/src/main.ts b/src/src/main.ts new file mode 100644 index 0000000..8882c45 --- /dev/null +++ b/src/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err), +); diff --git a/src/src/mytheme.ts b/src/src/mytheme.ts new file mode 100644 index 0000000..6179ee9 --- /dev/null +++ b/src/src/mytheme.ts @@ -0,0 +1,664 @@ +import Aura from '@primeng/themes/aura'; +import { definePreset } from '@primeng/themes'; + +export const TripThemePreset = definePreset(Aura, { + primitive: { + borderRadius: { + none: '0', + xs: '2px', + sm: '4px', + md: '6px', + lg: '8px', + xl: '12px', + }, + emerald: { + 50: '#ecfdf5', + 100: '#d1fae5', + 200: '#a7f3d0', + 300: '#6ee7b7', + 400: '#34d399', + 500: '#10b981', + 600: '#059669', + 700: '#047857', + 800: '#065f46', + 900: '#064e3b', + 950: '#022c22', + }, + green: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + 950: '#052e16', + }, + lime: { + 50: '#f7fee7', + 100: '#ecfccb', + 200: '#d9f99d', + 300: '#bef264', + 400: '#a3e635', + 500: '#84cc16', + 600: '#65a30d', + 700: '#4d7c0f', + 800: '#3f6212', + 900: '#365314', + 950: '#1a2e05', + }, + red: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + 950: '#450a0a', + }, + orange: { + 50: '#fff7ed', + 100: '#ffedd5', + 200: '#fed7aa', + 300: '#fdba74', + 400: '#fb923c', + 500: '#f97316', + 600: '#ea580c', + 700: '#c2410c', + 800: '#9a3412', + 900: '#7c2d12', + 950: '#431407', + }, + amber: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + 950: '#451a03', + }, + yellow: { + 50: '#fefce8', + 100: '#fef9c3', + 200: '#fef08a', + 300: '#fde047', + 400: '#facc15', + 500: '#eab308', + 600: '#ca8a04', + 700: '#a16207', + 800: '#854d0e', + 900: '#713f12', + 950: '#422006', + }, + teal: { + 50: '#f0fdfa', + 100: '#ccfbf1', + 200: '#99f6e4', + 300: '#5eead4', + 400: '#2dd4bf', + 500: '#14b8a6', + 600: '#0d9488', + 700: '#0f766e', + 800: '#115e59', + 900: '#134e4a', + 950: '#042f2e', + }, + cyan: { + 50: '#ecfeff', + 100: '#cffafe', + 200: '#a5f3fc', + 300: '#67e8f9', + 400: '#22d3ee', + 500: '#06b6d4', + 600: '#0891b2', + 700: '#0e7490', + 800: '#155e75', + 900: '#164e63', + 950: '#083344', + }, + sky: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + 950: '#082f49', + }, + blue: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + 950: '#172554', + }, + indigo: { + 50: '#eef2ff', + 100: '#e0e7ff', + 200: '#c7d2fe', + 300: '#a5b4fc', + 400: '#818cf8', + 500: '#6366f1', + 600: '#4f46e5', + 700: '#4338ca', + 800: '#3730a3', + 900: '#312e81', + 950: '#1e1b4b', + }, + violet: { + 50: '#f5f3ff', + 100: '#ede9fe', + 200: '#ddd6fe', + 300: '#c4b5fd', + 400: '#a78bfa', + 500: '#8b5cf6', + 600: '#7c3aed', + 700: '#6d28d9', + 800: '#5b21b6', + 900: '#4c1d95', + 950: '#2e1065', + }, + purple: { + 50: '#faf5ff', + 100: '#f3e8ff', + 200: '#e9d5ff', + 300: '#d8b4fe', + 400: '#c084fc', + 500: '#a855f7', + 600: '#9333ea', + 700: '#7e22ce', + 800: '#6b21a8', + 900: '#581c87', + 950: '#3b0764', + }, + fuchsia: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + 950: '#4a044e', + }, + pink: { + 50: '#fdf2f8', + 100: '#fce7f3', + 200: '#fbcfe8', + 300: '#f9a8d4', + 400: '#f472b6', + 500: '#ec4899', + 600: '#db2777', + 700: '#be185d', + 800: '#9d174d', + 900: '#831843', + 950: '#500724', + }, + rose: { + 50: '#fff1f2', + 100: '#ffe4e6', + 200: '#fecdd3', + 300: '#fda4af', + 400: '#fb7185', + 500: '#f43f5e', + 600: '#e11d48', + 700: '#be123c', + 800: '#9f1239', + 900: '#881337', + 950: '#4c0519', + }, + slate: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', + }, + gray: { + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', + 950: '#030712', + }, + zinc: { + 50: '#fafafa', + 100: '#f4f4f5', + 200: '#e4e4e7', + 300: '#d4d4d8', + 400: '#a1a1aa', + 500: '#71717a', + 600: '#52525b', + 700: '#3f3f46', + 800: '#27272a', + 900: '#18181b', + 950: '#09090b', + }, + neutral: { + 50: '#fafafa', + 100: '#f5f5f5', + 200: '#e5e5e5', + 300: '#d4d4d4', + 400: '#a3a3a3', + 500: '#737373', + 600: '#525252', + 700: '#404040', + 800: '#262626', + 900: '#171717', + 950: '#0a0a0a', + }, + stone: { + 50: '#fafaf9', + 100: '#f5f5f4', + 200: '#e7e5e4', + 300: '#d6d3d1', + 400: '#a8a29e', + 500: '#78716c', + 600: '#57534e', + 700: '#44403c', + 800: '#292524', + 900: '#1c1917', + 950: '#0c0a09', + }, + }, + semantic: { + transitionDuration: '0.2s', + focusRing: { + width: '1px', + style: 'solid', + color: '{primary.color}', + offset: '2px', + shadow: 'none', + }, + disabledOpacity: '0.6', + iconSize: '1rem', + anchorGutter: '2px', + primary: { + 50: '#f5f6f7', + 100: '#ced1d6', + 200: '#a7adb6', + 300: '#818996', + 400: '#5a6575', + 500: '#334155', + 600: '#2b3748', + 700: '#242e3b', + 800: '#1c242f', + 900: '#141a22', + 950: '#0d1015', + }, + formField: { + paddingX: '0.75rem', + paddingY: '0.5rem', + sm: { + fontSize: '0.875rem', + paddingX: '0.625rem', + paddingY: '0.375rem', + }, + lg: { + fontSize: '1.125rem', + paddingX: '0.875rem', + paddingY: '0.625rem', + }, + borderRadius: '{border.radius.md}', + focusRing: { + width: '0', + style: 'none', + color: 'transparent', + offset: '0', + shadow: 'none', + }, + transitionDuration: '{transition.duration}', + }, + list: { + padding: '0.25rem 0.25rem', + gap: '2px', + header: { + padding: '0.5rem 1rem 0.25rem 1rem', + }, + option: { + padding: '0.5rem 0.75rem', + borderRadius: '{border.radius.sm}', + }, + optionGroup: { + padding: '0.5rem 0.75rem', + fontWeight: '600', + }, + }, + content: { + borderRadius: '{border.radius.md}', + }, + mask: { + transitionDuration: '0.15s', + }, + navigation: { + list: { + padding: '0.25rem 0.25rem', + gap: '2px', + }, + item: { + padding: '0.5rem 0.75rem', + borderRadius: '{border.radius.sm}', + gap: '0.5rem', + }, + submenuLabel: { + padding: '0.5rem 0.75rem', + fontWeight: '600', + }, + submenuIcon: { + size: '0.875rem', + }, + }, + overlay: { + select: { + borderRadius: '{border.radius.md}', + shadow: + '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + }, + popover: { + borderRadius: '{border.radius.md}', + padding: '0.75rem', + shadow: + '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + }, + modal: { + borderRadius: '{border.radius.xl}', + padding: '1.25rem', + shadow: + '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', + }, + navigation: { + shadow: + '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + }, + }, + colorScheme: { + light: { + surface: { + 0: '#ffffff', + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', + }, + primary: { + color: '{primary.500}', + contrastColor: '#ffffff', + hoverColor: '{primary.600}', + activeColor: '{primary.700}', + }, + highlight: { + background: '{primary.50}', + focusBackground: '{primary.100}', + color: '{primary.700}', + focusColor: '{primary.800}', + }, + mask: { + background: 'rgba(0,0,0,0.4)', + color: '{surface.200}', + }, + formField: { + background: '{surface.0}', + disabledBackground: '{surface.200}', + filledBackground: '{surface.50}', + filledHoverBackground: '{surface.50}', + filledFocusBackground: '{surface.50}', + borderColor: '{surface.300}', + hoverBorderColor: '{surface.400}', + focusBorderColor: '{primary.color}', + invalidBorderColor: '{red.400}', + color: '{surface.700}', + disabledColor: '{surface.500}', + placeholderColor: '{surface.500}', + invalidPlaceholderColor: '{red.600}', + floatLabelColor: '{surface.500}', + floatLabelFocusColor: '{primary.600}', + floatLabelActiveColor: '{surface.500}', + floatLabelInvalidColor: '{form.field.invalid.placeholder.color}', + iconColor: '{surface.400}', + shadow: '0 0 #0000, 0 0 #0000, 0 1px 2px 0 rgba(18, 18, 23, 0.05)', + }, + text: { + color: '{surface.700}', + hoverColor: '{surface.800}', + mutedColor: '{surface.500}', + hoverMutedColor: '{surface.600}', + }, + content: { + background: '{surface.0}', + hoverBackground: '{surface.100}', + borderColor: '{surface.200}', + color: '{text.color}', + hoverColor: '{text.hover.color}', + }, + overlay: { + select: { + background: '{surface.0}', + borderColor: '{surface.200}', + color: '{text.color}', + }, + popover: { + background: '{surface.0}', + borderColor: '{surface.200}', + color: '{text.color}', + }, + modal: { + background: '{surface.0}', + borderColor: '{surface.200}', + color: '{text.color}', + }, + }, + list: { + option: { + focusBackground: '{surface.100}', + selectedBackground: '{highlight.background}', + selectedFocusBackground: '{highlight.focus.background}', + color: '{text.color}', + focusColor: '{text.hover.color}', + selectedColor: '{highlight.color}', + selectedFocusColor: '{highlight.focus.color}', + icon: { + color: '{surface.400}', + focusColor: '{surface.500}', + }, + }, + optionGroup: { + background: 'transparent', + color: '{text.muted.color}', + }, + }, + navigation: { + item: { + focusBackground: '{surface.100}', + activeBackground: '{surface.100}', + color: '{text.color}', + focusColor: '{text.hover.color}', + activeColor: '{text.hover.color}', + icon: { + color: '{surface.400}', + focusColor: '{surface.500}', + activeColor: '{surface.500}', + }, + }, + submenuLabel: { + background: 'transparent', + color: '{text.muted.color}', + }, + submenuIcon: { + color: '{surface.400}', + focusColor: '{surface.500}', + activeColor: '{surface.500}', + }, + }, + }, + dark: { + surface: { + 0: '#ffffff', + 50: '#fafafa', + 100: '#f4f4f5', + 200: '#e4e4e7', + 300: '#d4d4d8', + 400: '#a1a1aa', + 500: '#71717a', + 600: '#52525b', + 700: '#3f3f46', + 800: '#27272a', + 900: '#18181b', + 950: '#09090b', + }, + primary: { + color: '{primary.400}', + contrastColor: '{surface.900}', + hoverColor: '{primary.300}', + activeColor: '{primary.200}', + }, + highlight: { + background: 'color-mix(in srgb, {primary.400}, transparent 84%)', + focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)', + color: 'rgba(255,255,255,.87)', + focusColor: 'rgba(255,255,255,.87)', + }, + mask: { + background: 'rgba(0,0,0,0.6)', + color: '{surface.200}', + }, + formField: { + background: '{surface.950}', + disabledBackground: '{surface.700}', + filledBackground: '{surface.800}', + filledHoverBackground: '{surface.800}', + filledFocusBackground: '{surface.800}', + borderColor: '{surface.600}', + hoverBorderColor: '{surface.500}', + focusBorderColor: '{primary.color}', + invalidBorderColor: '{red.300}', + color: '{surface.0}', + disabledColor: '{surface.400}', + placeholderColor: '{surface.400}', + invalidPlaceholderColor: '{red.400}', + floatLabelColor: '{surface.400}', + floatLabelFocusColor: '{primary.color}', + floatLabelActiveColor: '{surface.400}', + floatLabelInvalidColor: '{form.field.invalid.placeholder.color}', + iconColor: '{surface.400}', + shadow: '0 0 #0000, 0 0 #0000, 0 1px 2px 0 rgba(18, 18, 23, 0.05)', + }, + text: { + color: '{surface.0}', + hoverColor: '{surface.0}', + mutedColor: '{surface.400}', + hoverMutedColor: '{surface.300}', + }, + content: { + background: '{surface.900}', + hoverBackground: '{surface.800}', + borderColor: '{surface.700}', + color: '{text.color}', + hoverColor: '{text.hover.color}', + }, + overlay: { + select: { + background: '{surface.900}', + borderColor: '{surface.700}', + color: '{text.color}', + }, + popover: { + background: '{surface.900}', + borderColor: '{surface.700}', + color: '{text.color}', + }, + modal: { + background: '{surface.900}', + borderColor: '{surface.700}', + color: '{text.color}', + }, + }, + list: { + option: { + focusBackground: '{surface.800}', + selectedBackground: '{highlight.background}', + selectedFocusBackground: '{highlight.focus.background}', + color: '{text.color}', + focusColor: '{text.hover.color}', + selectedColor: '{highlight.color}', + selectedFocusColor: '{highlight.focus.color}', + icon: { + color: '{surface.500}', + focusColor: '{surface.400}', + }, + }, + optionGroup: { + background: 'transparent', + color: '{text.muted.color}', + }, + }, + navigation: { + item: { + focusBackground: '{surface.800}', + activeBackground: '{surface.800}', + color: '{text.color}', + focusColor: '{text.hover.color}', + activeColor: '{text.hover.color}', + icon: { + color: '{surface.500}', + focusColor: '{surface.400}', + activeColor: '{surface.400}', + }, + }, + submenuLabel: { + background: 'transparent', + color: '{text.muted.color}', + }, + submenuIcon: { + color: '{surface.500}', + focusColor: '{surface.400}', + activeColor: '{surface.400}', + }, + }, + }, + }, + }, +}); diff --git a/src/src/styles.scss b/src/src/styles.scss new file mode 100644 index 0000000..02f1138 --- /dev/null +++ b/src/src/styles.scss @@ -0,0 +1,169 @@ +@use "primeicons/primeicons.css"; +@plugin 'tailwindcss-primeui'; +@variant dark (&:where(.dark, .dark *)); + +@layer tailwind, primeng; + +@layer tailwind { + @import "tailwindcss"; +} + +* { + scrollbar-width: thin; + scroll-behavior: smooth; + overscroll-behavior: none; + + ::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + ::-webkit-scrollbar-thumb { + background: #999; + border-radius: 20px; + } + + ::-webkit-scrollbar-track { + background: #fff; + border-radius: 20px; + } +} + +html { + font-size: 15px; + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Helvetica, + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol"; + line-height: normal; +} + +@keyframes slideY { + 0% { + opacity: 0; + transform: translateY(20px); + } +} + +.slideY { + animation: slideY 0.3s both; +} + +.class-tooltip { + background: white; + border-radius: 8px; + text-align: center; + padding: 12px; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + width: 200px; + white-space: pre-wrap; +} + +.custom-cluster { + background-color: #4f46e5; + color: white; + border-radius: 9999px; + padding: 0.5rem; + font-weight: 600; + font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: center; + border: 3px solid white; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + transition: transform 0.2s ease; +} + +.leaflet-contextmenu { + display: none; + box-shadow: 0 10px 20px rgba(#404040, 0.15); + -webkit-border-radius: 4px; + border-radius: 8px; + padding: 8px; + background-color: #fff; + cursor: default; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + font-family: "Inter", sans-serif; + font-size: 15px; +} + +.leaflet-contextmenu a.leaflet-contextmenu-item { + display: flex; + align-items: center; + gap: 8px; + color: #222; + font-size: 12px; + line-height: 20px; + text-decoration: none; + border-radius: 8px; + padding: 8px 36px 8px 8px; + + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + cursor: default; + outline: none; +} + +.leaflet-contextmenu a.leaflet-contextmenu-item-disabled { + opacity: 0.5; +} + +.leaflet-contextmenu a.leaflet-contextmenu-item.over { + background-color: #f1f3f7; + border-top: 1px solid #f0f0f0; + border-bottom: 1px solid #f0f0f0; +} + +.leaflet-contextmenu a.leaflet-contextmenu-item-disabled.over { + background-color: inherit; +} + +.leaflet-contextmenu-icon { + display: flex; + align-items: center; + width: 24px; +} + +.leaflet-contextmenu-separator { + border-bottom: 1px solid #ccc; + margin: 5px 0; +} + +.image-marker { + border-radius: 50%; + border: 2px solid #405cf5; + background: white; + object-fit: cover; +} + +.listHover { + .custom-cluster { + background-color: red; + } + &.image-marker { + border: 4px dashed red; + } +} + +@keyframes slideYcenteredX { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(100px); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} diff --git a/src/tailwind.config.js b/src/tailwind.config.js new file mode 100644 index 0000000..ce1d474 --- /dev/null +++ b/src/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,ts}", + ], + theme: { + extend: {}, + }, + plugins: [require('tailwindcss-primeui')] +} \ No newline at end of file diff --git a/src/tsconfig.app.json b/src/tsconfig.app.json new file mode 100644 index 0000000..3775b37 --- /dev/null +++ b/src/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..5525117 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,27 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/src/tsconfig.spec.json b/src/tsconfig.spec.json new file mode 100644 index 0000000..5fb748d --- /dev/null +++ b/src/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}