diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..a033d56 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +script_location = %(here)s/trip/alembic +prepend_sys_path = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console +qualname = + +[logger_alembic] +level = ERROR +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/trip/__init__.py b/backend/trip/__init__.py index 320141d..0a0a43a 100644 --- a/backend/trip/__init__.py +++ b/backend/trip/__init__.py @@ -1 +1 @@ -__version__ = "1.8.2" +__version__ = "1.9.0" diff --git a/backend/trip/alembic/README.md b/backend/trip/alembic/README.md new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/backend/trip/alembic/README.md @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/backend/trip/alembic/env.py b/backend/trip/alembic/env.py new file mode 100644 index 0000000..cbfd1cd --- /dev/null +++ b/backend/trip/alembic/env.py @@ -0,0 +1,48 @@ +from logging.config import fileConfig + +from alembic import context + +from trip.db.core import get_engine # noqa +from trip.models.models import * # noqa + +target_metadata = SQLModel.metadata # noqa + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name, disable_existing_loggers=False) + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + render_as_batch=True, + literal_binds=True, + transactional_ddl=True, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + transactional_ddl=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/trip/alembic/script.py.mako b/backend/trip/alembic/script.py.mako new file mode 100644 index 0000000..800e1a8 --- /dev/null +++ b/backend/trip/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} + diff --git a/backend/trip/alembic/versions/1cc13f8893ad_category_color.py b/backend/trip/alembic/versions/1cc13f8893ad_category_color.py new file mode 100644 index 0000000..51d9029 --- /dev/null +++ b/backend/trip/alembic/versions/1cc13f8893ad_category_color.py @@ -0,0 +1,33 @@ +"""Category color + +Revision ID: 1cc13f8893ad +Revises: b2ed4bf9c1b2 +Create Date: 2025-08-02 20:12:07.507972 + +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "1cc13f8893ad" +down_revision = "b2ed4bf9c1b2" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("category", schema=None) as batch_op: + batch_op.add_column(sa.Column("color", sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("category", schema=None) as batch_op: + batch_op.drop_column("color") + + # ### end Alembic commands ### diff --git a/backend/trip/alembic/versions/7c0ec2b61abb_user_custom_tile_layer.py b/backend/trip/alembic/versions/7c0ec2b61abb_user_custom_tile_layer.py new file mode 100644 index 0000000..a49c9d9 --- /dev/null +++ b/backend/trip/alembic/versions/7c0ec2b61abb_user_custom_tile_layer.py @@ -0,0 +1,33 @@ +"""User custom tile layer + +Revision ID: 7c0ec2b61abb +Revises: 1cc13f8893ad +Create Date: 2025-08-02 20:17:08.675700 + +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "7c0ec2b61abb" +down_revision = "1cc13f8893ad" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column(sa.Column("tile_layer", sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("tile_layer") + + # ### end Alembic commands ### diff --git a/backend/trip/alembic/versions/b2ed4bf9c1b2_init.py b/backend/trip/alembic/versions/b2ed4bf9c1b2_init.py new file mode 100644 index 0000000..d48dddb --- /dev/null +++ b/backend/trip/alembic/versions/b2ed4bf9c1b2_init.py @@ -0,0 +1,157 @@ +"""Init + +Revision ID: b2ed4bf9c1b2 +Revises: +Create Date: 2025-08-01 20:14:29.749521 + +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b2ed4bf9c1b2" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user", + sa.Column("mapLat", sa.Float(), nullable=False), + sa.Column("mapLng", sa.Float(), nullable=False), + sa.Column("currency", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("do_not_display", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("password", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint("username", name=op.f("pk_user")), + ) + op.create_table( + "image", + sa.Column("filename", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint( + ["user"], ["user.username"], name=op.f("fk_image_user_user"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_image")), + ) + op.create_table( + "category", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("image_id", sa.Integer(), nullable=True), + sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint( + ["image_id"], ["image.id"], name=op.f("fk_category_image_id_image"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user"], ["user.username"], name=op.f("fk_category_user_user"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_category")), + ) + op.create_table( + "trip", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("archived", sa.Boolean(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("image_id", sa.Integer(), nullable=True), + sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint( + ["image_id"], ["image.id"], name=op.f("fk_trip_image_id_image"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user"], ["user.username"], name=op.f("fk_trip_user_user"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_trip")), + ) + op.create_table( + "place", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("lat", sa.Float(), nullable=False), + sa.Column("lng", sa.Float(), nullable=False), + sa.Column("place", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("allowdog", sa.Boolean(), nullable=True), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("price", sa.Float(), nullable=True), + sa.Column("duration", sa.Integer(), nullable=True), + sa.Column("favorite", sa.Boolean(), nullable=True), + sa.Column("visited", sa.Boolean(), nullable=True), + sa.Column("gpx", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("cdate", sa.Date(), nullable=False), + sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("image_id", sa.Integer(), nullable=True), + sa.Column("category_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["category_id"], ["category.id"], name=op.f("fk_place_category_id_category") + ), + sa.ForeignKeyConstraint( + ["image_id"], ["image.id"], name=op.f("fk_place_image_id_image"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user"], ["user.username"], name=op.f("fk_place_user_user"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_place")), + ) + op.create_table( + "tripday", + sa.Column("label", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("trip_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["trip_id"], ["trip.id"], name=op.f("fk_tripday_trip_id_trip"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user"], ["user.username"], name=op.f("fk_tripday_user_user"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_tripday")), + ) + op.create_table( + "tripitem", + sa.Column("time", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("text", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("comment", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("lat", sa.Float(), nullable=True), + sa.Column("price", sa.Float(), nullable=True), + sa.Column("lng", sa.Float(), nullable=True), + sa.Column( + "status", + sa.Enum("PENDING", "CONFIRMED", "CONSTRAINT", "OPTIONAL", name="tripitemstatusenum"), + nullable=True, + ), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("place_id", sa.Integer(), nullable=True), + sa.Column("day_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["day_id"], ["tripday.id"], name=op.f("fk_tripitem_day_id_tripday"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["place_id"], ["place.id"], name=op.f("fk_tripitem_place_id_place")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_tripitem")), + ) + op.create_table( + "tripplacelink", + sa.Column("trip_id", sa.Integer(), nullable=False), + sa.Column("place_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["place_id"], ["place.id"], name=op.f("fk_tripplacelink_place_id_place")), + sa.ForeignKeyConstraint(["trip_id"], ["trip.id"], name=op.f("fk_tripplacelink_trip_id_trip")), + sa.PrimaryKeyConstraint("trip_id", "place_id", name=op.f("pk_tripplacelink")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("tripplacelink") + op.drop_table("tripitem") + op.drop_table("tripday") + op.drop_table("place") + op.drop_table("trip") + op.drop_table("category") + op.drop_table("image") + op.drop_table("user") + # ### end Alembic commands ### diff --git a/backend/trip/alembic/versions/d5fee6ec85c2_user_settings_snake_case.py b/backend/trip/alembic/versions/d5fee6ec85c2_user_settings_snake_case.py new file mode 100644 index 0000000..3e8070d --- /dev/null +++ b/backend/trip/alembic/versions/d5fee6ec85c2_user_settings_snake_case.py @@ -0,0 +1,25 @@ +"""User settings snake case + +Revision ID: d5fee6ec85c2 +Revises: dd7a55d2ae42 +Create Date: 2025-08-03 12:43:33.909182 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d5fee6ec85c2" +down_revision = "dd7a55d2ae42" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TABLE user RENAME COLUMN mapLat TO map_lat;") + op.execute("ALTER TABLE user RENAME COLUMN mapLng TO map_lng;") + + +def downgrade(): + op.execute("ALTER TABLE user RENAME COLUMN map_lat TO mapLat;") + op.execute("ALTER TABLE user RENAME COLUMN map_lng TO mapLng;") diff --git a/backend/trip/alembic/versions/dd7a55d2ae42_user_settings_modes_low_network_dark_.py b/backend/trip/alembic/versions/dd7a55d2ae42_user_settings_modes_low_network_dark_.py new file mode 100644 index 0000000..6e1e689 --- /dev/null +++ b/backend/trip/alembic/versions/dd7a55d2ae42_user_settings_modes_low_network_dark_.py @@ -0,0 +1,31 @@ +"""User settings modes: Low Network, Dark, GPX bubble + +Revision ID: dd7a55d2ae42 +Revises: 7c0ec2b61abb +Create Date: 2025-08-03 08:24:09.825100 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "dd7a55d2ae42" +down_revision = "7c0ec2b61abb" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TABLE user ADD COLUMN mode_low_network BOOLEAN NOT NULL DEFAULT 1;") + op.execute("ALTER TABLE user ADD COLUMN mode_dark BOOLEAN NOT NULL DEFAULT 0;") + op.execute("ALTER TABLE user ADD COLUMN mode_gpx_in_place BOOLEAN NOT NULL DEFAULT 0;") + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("mode_gpx_in_place") + batch_op.drop_column("mode_dark") + batch_op.drop_column("mode_low_network") + + # ### end Alembic commands ### diff --git a/backend/trip/db/core.py b/backend/trip/db/core.py index d680ea6..1f44b59 100644 --- a/backend/trip/db/core.py +++ b/backend/trip/db/core.py @@ -1,6 +1,11 @@ -from sqlalchemy import event +import asyncio +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import event, text from sqlalchemy.engine import Engine -from sqlmodel import Session, SQLModel, create_engine +from sqlmodel import Session, create_engine from ..config import settings from ..models.models import Category @@ -8,6 +13,14 @@ from ..models.models import Category _engine = None +def _db_needs_stamp(engine: Engine) -> bool: + with engine.connect() as conn: + result = conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version';") + ) + return result.fetchone() is None + + def get_engine(): global _engine if not _engine: @@ -25,9 +38,24 @@ def set_sqlite_pragma(dbapi_connection, connection_record): cursor.close() -def init_db(): +async def init_and_migrate_db(): + alembic_cfg = Config("alembic.ini") + sqlite_file = Path(settings.SQLITE_FILE) engine = get_engine() - SQLModel.metadata.create_all(engine) + + if not sqlite_file.exists(): + # DB does not exist, upgrade to head + await asyncio.to_thread(command.upgrade, alembic_cfg, "head") + return + + if _db_needs_stamp(engine): + # DB exists, but Alembic not initialized, we stamp + # b2ed4bf9c1b2 is the revision before Alembic introduction + await asyncio.to_thread(command.stamp, alembic_cfg, "b2ed4bf9c1b2") + + # Alembic already introdcued, classic upgrade + await asyncio.to_thread(command.upgrade, alembic_cfg, "head") + return def init_user_data(session: Session, username: str): diff --git a/backend/trip/main.py b/backend/trip/main.py index e4fd8c5..3bddbd8 100644 --- a/backend/trip/main.py +++ b/backend/trip/main.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request @@ -8,7 +9,7 @@ from starlette.middleware.gzip import GZipMiddleware from . import __version__ from .config import settings -from .db.core import init_db +from .db.core import init_and_migrate_db from .routers import auth, categories, places from .routers import settings as settings_r from .routers import trips @@ -18,7 +19,14 @@ if not Path(settings.FRONTEND_FOLDER).is_dir(): Path(settings.ASSETS_FOLDER).mkdir(parents=True, exist_ok=True) -app = FastAPI() + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_and_migrate_db() + yield + + +app = FastAPI(lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -30,7 +38,6 @@ app.add_middleware( app.add_middleware(GZipMiddleware, minimum_size=1000) - app.include_router(auth.router) app.include_router(categories.router) app.include_router(places.router) @@ -51,10 +58,5 @@ async def not_found_to_spa(request: Request, call_next): 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/models.py b/backend/trip/models/models.py index 96008ec..f4dcdc9 100644 --- a/backend/trip/models/models.py +++ b/backend/trip/models/models.py @@ -66,10 +66,14 @@ class Image(ImageBase, table=True): class UserBase(SQLModel): - mapLat: float = 48.107 - mapLng: float = -2.988 - currency: str = "€" + map_lat: float = settings.DEFAULT_MAP_LAT + map_lng: float = settings.DEFAULT_MAP_LNG + currency: str = settings.DEFAULT_CURRENCY do_not_display: str = "" + tile_layer: str | None = None + mode_low_network: bool | None = True + mode_dark: bool | None = False + mode_gpx_in_place: bool | None = False class User(UserBase, table=True): @@ -78,8 +82,8 @@ class User(UserBase, table=True): class UserUpdate(UserBase): - mapLat: float | None = None - mapLng: float | None = None + map_lat: float | None = None + map_lng: float | None = None currency: str | None = None do_not_display: list[str] | None = None @@ -92,15 +96,20 @@ class UserRead(UserBase): def serialize(cls, obj: User) -> "UserRead": return cls( username=obj.username, - mapLat=obj.mapLat, - mapLng=obj.mapLng, + map_lat=obj.map_lat, + map_lng=obj.map_lng, currency=obj.currency, do_not_display=obj.do_not_display.split(",") if obj.do_not_display else [], + tile_layer=obj.tile_layer if obj.tile_layer else settings.DEFAULT_TILE, + mode_low_network=obj.mode_low_network, + mode_dark=obj.mode_dark, + mode_gpx_in_place=obj.mode_gpx_in_place, ) class CategoryBase(SQLModel): name: str + color: str | None = None class Category(CategoryBase, table=True): @@ -114,17 +123,20 @@ class Category(CategoryBase, table=True): class CategoryCreate(CategoryBase): name: str image: str | None = None + color: str | None = None class CategoryUpdate(CategoryBase): name: str | None = None image: str | None = None + color: str | None = None class CategoryRead(CategoryBase): id: int image: str | None image_id: int | None + color: str @classmethod def serialize(cls, obj: Category) -> "CategoryRead": @@ -133,6 +145,7 @@ class CategoryRead(CategoryBase): name=obj.name, image_id=obj.image_id, image=_prefix_assets_url(obj.image.filename) if obj.image else "/favicon.png", + color=obj.color if obj.color else "#000000", ) @@ -349,6 +362,7 @@ class TripItemUpdate(TripItemBase): time: str | None = None text: str | None = None place: int | None = None + day_id: int | None = None status: TripItemStatusEnum | None = None diff --git a/backend/trip/requirements.txt b/backend/trip/requirements.txt index 4e0b542..7eeec4b 100644 --- a/backend/trip/requirements.txt +++ b/backend/trip/requirements.txt @@ -5,4 +5,5 @@ PyJWT~=2.10 argon2-cffi~=25.1 pydantic_settings~=2.9 Pillow~=11.2 -authlib~=1.6 \ No newline at end of file +authlib~=1.6 +alembic~=1.16 diff --git a/backend/trip/routers/categories.py b/backend/trip/routers/categories.py index 578cdf8..40d59ca 100644 --- a/backend/trip/routers/categories.py +++ b/backend/trip/routers/categories.py @@ -27,7 +27,7 @@ def post_category( session: SessionDep, current_user: Annotated[str, Depends(get_current_username)], ) -> CategoryRead: - new_category = Category(name=category.name, user=current_user) + new_category = Category(name=category.name, color=category.color, user=current_user) if category.image: image_bytes = b64img_decode(category.image) diff --git a/backend/trip/routers/settings.py b/backend/trip/routers/settings.py index 183168c..6bb628b 100644 --- a/backend/trip/routers/settings.py +++ b/backend/trip/routers/settings.py @@ -11,7 +11,8 @@ 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 +from ..utils.utils import (b64e, b64img_decode, check_update, remove_image, + save_image_to_file) router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -100,7 +101,42 @@ async def import_data( select(Category).filter(Category.user == current_user, Category.name == category_name) ).first() if category_exists: - continue # This category label exists + # Update color if present in import data + if category.get("color"): + category_exists.color = category.get("color") + + # Handle image update + if category.get("image_id"): + b64_image = category.get("images", {}).get(str(category.get("image_id"))) + if b64_image: + 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) + + if category_exists.image_id: + old_image = session.get(Image, category_exists.image_id) + try: + remove_image(old_image.filename) + session.delete(old_image) + category_exists.image_id = None + session.flush() + except Exception: + raise HTTPException( + status_code=500, detail="Failed to remove old image during import" + ) + + category_exists.image_id = image.id + + session.add(category_exists) + session.flush() + session.refresh(category_exists) + continue category_data = { key: category[key] for key in category.keys() if key not in {"id", "image", "image_id"} @@ -169,11 +205,11 @@ async def import_data( 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("map_lat"): + db_user.map_lat = settings_data["map_lat"] - if settings_data.get("mapLng"): - db_user.mapLng = settings_data["mapLng"] + if settings_data.get("map_lng"): + db_user.map_lng = settings_data["map_lng"] if settings_data.get("currency"): db_user.currency = settings_data["currency"] diff --git a/src/src/app/components/dashboard/dashboard.component.html b/src/src/app/components/dashboard/dashboard.component.html index 20f37fa..4d774f8 100644 --- a/src/src/app/components/dashboard/dashboard.component.html +++ b/src/src/app/components/dashboard/dashboard.component.html @@ -88,8 +88,8 @@ class="pi pi-eye-slash text-xs"> } - {{ p.category.name }} @@ -160,18 +160,18 @@ About - Settings + Tweaks - Categories + Map - Data + Categories -
+

About

@@ -192,9 +192,9 @@ - TRIP {{ this.info?.update }} - available on - Github +
TRIP {{ this.info?.update }} available on + Github. Changelog
} @else {
+
+ + + +
+
Made with ❤️ in BZH
@@ -225,6 +231,17 @@
+
+

GPX Indication

+ If enabled, display a compass in Place bubble when a + GPX is present. +
+
+
Enable GPX indication
+ +
+ +
@@ -239,13 +256,18 @@
- - + + - - + + + + + + +
@@ -279,8 +301,7 @@
- - +

Categories

@@ -291,11 +312,13 @@
-
+
@for (category of categories; track category.id) {
- {{ category.name }} + {{ + category.name }}
@@ -306,18 +329,6 @@ }
- -
-

Data

- You can import/export your data -
- -
- - - -
-
diff --git a/src/src/app/components/dashboard/dashboard.component.ts b/src/src/app/components/dashboard/dashboard.component.ts index a43f9a7..eefc445 100644 --- a/src/src/app/components/dashboard/dashboard.component.ts +++ b/src/src/app/components/dashboard/dashboard.component.ts @@ -81,6 +81,7 @@ export class DashboardComponent implements AfterViewInit { info: Info | undefined; isLowNet: boolean = false; isDarkMode: boolean = false; + isGpxInPlaceMode: boolean = false; viewSettings = false; viewFilters = false; @@ -115,11 +116,9 @@ export class DashboardComponent implements AfterViewInit { private fb: FormBuilder, ) { this.currencySigns = this.utilsService.currencySigns(); - this.isLowNet = this.utilsService.isLowNet; - this.isDarkMode = this.utilsService.isDarkMode; this.settingsForm = this.fb.group({ - mapLat: [ + map_lat: [ "", { validators: [ @@ -128,7 +127,7 @@ export class DashboardComponent implements AfterViewInit { ], }, ], - mapLng: [ + map_lng: [ "", { validators: [ @@ -141,6 +140,7 @@ export class DashboardComponent implements AfterViewInit { ], currency: ["", Validators.required], do_not_display: [], + tile_layer: ["", Validators.required], }); this.apiService.getInfo().subscribe({ @@ -180,7 +180,6 @@ export class DashboardComponent implements AfterViewInit { } ngAfterViewInit(): void { - this.initMap(); combineLatest({ categories: this.apiService.getCategories(), places: this.apiService.getPlaces(), @@ -188,18 +187,18 @@ export class DashboardComponent implements AfterViewInit { }) .pipe( tap(({ categories, places, settings }) => { + this.settings = settings; + this.initMap(); + 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.isLowNet = !!settings.mode_low_network; + this.isDarkMode = !!settings.mode_dark; + this.isGpxInPlaceMode = !!settings.mode_gpx_in_place; + if (this.isDarkMode) this.utilsService.toggleDarkMode(); 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 }), @@ -208,6 +207,8 @@ export class DashboardComponent implements AfterViewInit { } initMap(): void { + if (!this.settings) return; + let contentMenuItems = [ { text: "Add Point of Interest", @@ -217,7 +218,12 @@ export class DashboardComponent implements AfterViewInit { }, }, ]; - this.map = createMap(contentMenuItems); + this.map = createMap(contentMenuItems, this.settings?.tile_layer); + this.map.setView(L.latLng(this.settings.map_lat, this.settings.map_lng)); + this.map.on("moveend zoomend", () => { + this.setVisibleMarkers(); + }); + this.markerClusterGroup = createClusterGroup().addTo(this.map); } setVisibleMarkers() { @@ -255,14 +261,31 @@ export class DashboardComponent implements AfterViewInit { } toggleLowNet() { - this.utilsService.toggleLowNet(); - setTimeout(() => { - this.updateMarkersAndClusters(); - }, 200); + this.apiService.putSettings({ mode_low_network: this.isLowNet }).subscribe({ + next: (_) => { + setTimeout(() => { + this.updateMarkersAndClusters(); + }, 100); + }, + }); } toggleDarkMode() { - this.utilsService.toggleDarkMode(); + this.apiService.putSettings({ mode_dark: this.isDarkMode }).subscribe({ + next: (_) => { + this.utilsService.toggleDarkMode(); + }, + }); + } + + toggleGpxInPlace() { + this.apiService + .putSettings({ mode_gpx_in_place: this.isGpxInPlaceMode }) + .subscribe({ + next: (_) => { + this.updateMarkersAndClusters(); + }, + }); } get filteredPlaces(): Place[] { @@ -285,7 +308,12 @@ export class DashboardComponent implements AfterViewInit { } placeToMarker(place: Place): L.Marker { - let marker = placeToMarker(place, this.isLowNet); + let marker = placeToMarker( + place, + this.isLowNet, + place.visited, + this.isGpxInPlaceMode, + ); marker .on("click", (e) => { this.selectedPlace = place; @@ -596,7 +624,7 @@ export class DashboardComponent implements AfterViewInit { setMapCenterToCurrent() { let latlng: L.LatLng = this.map.getCenter(); - this.settingsForm.patchValue({ mapLat: latlng.lat, mapLng: latlng.lng }); + this.settingsForm.patchValue({ map_lat: latlng.lat, map_lng: latlng.lng }); this.settingsForm.markAsDirty(); } @@ -636,7 +664,13 @@ export class DashboardComponent implements AfterViewInit { updateSettings() { this.apiService.putSettings(this.settingsForm.value).subscribe({ next: (settings) => { + const refreshMap = this.settings!.tile_layer != settings.tile_layer; this.settings = settings; + if (refreshMap) { + this.map.remove(); + this.initMap(); + this.updateMarkersAndClusters(); + } this.resetFilters(); this.toggleSettings(); }, 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 index 7a9fdd0..ce10865 100644 --- 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 @@ -1,8 +1,12 @@
- - - - +
+ + + + + + +
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 index 2841efa..9a196e7 100644 --- 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 @@ -10,6 +10,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog"; import { FloatLabelModule } from "primeng/floatlabel"; import { InputTextModule } from "primeng/inputtext"; import { FocusTrapModule } from "primeng/focustrap"; +import { ColorPickerModule } from "primeng/colorpicker"; @Component({ selector: "app-category-create-modal", @@ -17,6 +18,7 @@ import { FocusTrapModule } from "primeng/focustrap"; FloatLabelModule, InputTextModule, ButtonModule, + ColorPickerModule, ReactiveFormsModule, FocusTrapModule, ], @@ -37,6 +39,15 @@ export class CategoryCreateModalComponent { this.categoryForm = this.fb.group({ id: -1, name: ["", Validators.required], + color: [ + "#000000", + { + validators: [ + Validators.required, + Validators.pattern("\#[abcdefABCDEF0-9]{6}"), + ], + }, + ], image: null, }); diff --git a/src/src/app/services/utils.service.ts b/src/src/app/services/utils.service.ts index a428766..45c8410 100644 --- a/src/src/app/services/utils.service.ts +++ b/src/src/app/services/utils.service.ts @@ -4,53 +4,24 @@ import { TripStatus } from "../types/trip"; import { ApiService } from "./api.service"; import { map } from "rxjs"; -const DISABLE_LOWNET = "TRIP_DISABLE_LOWNET"; -const DARK = "DARKMODE"; - @Injectable({ providedIn: "root", }) export class UtilsService { private apiService = inject(ApiService); currency$ = this.apiService.settings$.pipe(map((s) => s?.currency ?? "€")); - public isLowNet: boolean = true; - public isDarkMode: boolean = false; - constructor(private ngMessageService: MessageService) { - this.isLowNet = !localStorage.getItem(DISABLE_LOWNET); - this.isDarkMode = !!localStorage.getItem(DARK); - if (this.isDarkMode) this.renderDarkMode(); - } + constructor(private ngMessageService: MessageService) {} toGithubTRIP() { window.open("https://github.com/itskovacs/trip", "_blank"); } - toggleLowNet() { - if (this.isLowNet) { - localStorage.setItem(DISABLE_LOWNET, "1"); - } else { - localStorage.removeItem(DISABLE_LOWNET); - } - this.isLowNet = !this.isLowNet; - } - - renderDarkMode() { + toggleDarkMode() { const element = document.querySelector("html"); element?.classList.toggle("dark"); } - toggleDarkMode() { - if (this.isDarkMode) { - localStorage.removeItem(DARK); - this.isDarkMode = false; - } else { - localStorage.setItem(DARK, "1"); - this.isDarkMode = true; - } - this.renderDarkMode(); - } - get statuses(): TripStatus[] { return [ { label: "pending", color: "#3258A8" }, diff --git a/src/src/app/shared/map.ts b/src/src/app/shared/map.ts index 3471772..21e461f 100644 --- a/src/src/app/shared/map.ts +++ b/src/src/app/shared/map.ts @@ -18,7 +18,10 @@ export interface MarkerOptions extends L.MarkerOptions { contextmenuItems: ContextMenuItem[]; } -export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map { +export function createMap( + contextMenuItems?: ContextMenuItem[], + tilelayer?: string, +): L.Map { let southWest = L.latLng(-89.99, -180); let northEast = L.latLng(89.99, 180); let bounds = L.latLngBounds(southWest, northEast); @@ -34,7 +37,8 @@ export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map { .setMaxBounds(bounds); L.tileLayer( - "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", + tilelayer || + "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", { maxZoom: 17, minZoom: 3, @@ -48,7 +52,7 @@ export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map { export function placeHoverTooltip(place: Place): string { let content = `
${place.name}
`; - content += `
${place.category.name}
`; + content += `
${place.category.name}
`; return content; } @@ -99,6 +103,7 @@ export function placeToMarker( place: Place, isLowNet: boolean = true, grayscale: boolean = false, + gpxInBubble: boolean = false, ): L.Marker { let marker: L.Marker; let options: any = { @@ -108,30 +113,35 @@ export function placeToMarker( alt: "", }; - marker = new L.Marker([+place.lat, +place.lng], options); - const markerImage = isLowNet ? place.category.image : (place.image ?? place.category.image); - let markerClasses = place.visited ? "image-marker visited" : "image-marker"; + let markerClasses = "w-full h-full rounded-full bg-center bg-cover bg-white"; if (grayscale) markerClasses += " grayscale"; + const iconHtml = ` +
+
+ ${gpxInBubble && place.gpx ? '
' : ""} +
+ `; - marker.options.icon = L.icon({ - iconUrl: markerImage, + const icon = L.divIcon({ + html: iconHtml, iconSize: [56, 56], - iconAnchor: [28, 28], - shadowSize: [0, 0], - shadowAnchor: [0, 0], - popupAnchor: [0, -12], - className: markerClasses, + className: "", + }); + + marker = new L.Marker([+place.lat, +place.lng], { + ...options, + icon, }); let touchDevice = "ontouchstart" in window; if (!touchDevice) { marker.bindTooltip(placeHoverTooltip(place), { direction: "right", - offset: [24, 0], + offset: [28, 0], className: "class-tooltip", }); } diff --git a/src/src/app/shared/place-box/place-box.component.html b/src/src/app/shared/place-box/place-box.component.html index c856825..3b703a8 100644 --- a/src/src/app/shared/place-box/place-box.component.html +++ b/src/src/app/shared/place-box/place-box.component.html @@ -10,8 +10,9 @@