✨ Alembic introduction
This commit is contained in:
parent
d5b5ba8c84
commit
a23df2f7b5
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@ -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
|
||||
@ -1 +1 @@
|
||||
__version__ = "1.8.2"
|
||||
__version__ = "1.9.0"
|
||||
|
||||
1
backend/trip/alembic/README.md
Normal file
1
backend/trip/alembic/README.md
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
48
backend/trip/alembic/env.py
Normal file
48
backend/trip/alembic/env.py
Normal file
@ -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()
|
||||
26
backend/trip/alembic/script.py.mako
Normal file
26
backend/trip/alembic/script.py.mako
Normal file
@ -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"}
|
||||
|
||||
33
backend/trip/alembic/versions/1cc13f8893ad_category_color.py
Normal file
33
backend/trip/alembic/versions/1cc13f8893ad_category_color.py
Normal file
@ -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 ###
|
||||
@ -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 ###
|
||||
157
backend/trip/alembic/versions/b2ed4bf9c1b2_init.py
Normal file
157
backend/trip/alembic/versions/b2ed4bf9c1b2_init.py
Normal file
@ -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 ###
|
||||
@ -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;")
|
||||
@ -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 ###
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -6,3 +6,4 @@ argon2-cffi~=25.1
|
||||
pydantic_settings~=2.9
|
||||
Pillow~=11.2
|
||||
authlib~=1.6
|
||||
alembic~=1.16
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -88,8 +88,8 @@
|
||||
class="pi pi-eye-slash text-xs"></i></span>
|
||||
}
|
||||
|
||||
<span
|
||||
class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded flex gap-2 items-center truncate dark:bg-blue-100/85"><i
|
||||
<span [style.color]="p.category.color" [style.background-color]="p.category.color + '1A'"
|
||||
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded flex gap-2 items-center truncate"><i
|
||||
class="pi pi-box text-xs"></i>{{ p.category.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -160,18 +160,18 @@
|
||||
<i class="pi pi-info-circle"></i><span class="font-bold whitespace-nowrap">About</span>
|
||||
</p-tab>
|
||||
<p-tab value="1" class="flex items-center gap-2">
|
||||
<i class="pi pi-map"></i><span class="font-bold whitespace-nowrap">Settings</span>
|
||||
<i class="pi pi-sliders-v"></i><span class="font-bold whitespace-nowrap">Tweaks</span>
|
||||
</p-tab>
|
||||
<p-tab value="2" class="flex items-center gap-2">
|
||||
<i class="pi pi-th-large"></i><span class="font-bold whitespace-nowrap">Categories</span>
|
||||
<i class="pi pi-map"></i><span class="font-bold whitespace-nowrap">Map</span>
|
||||
</p-tab>
|
||||
<p-tab value="3" class="flex items-center gap-2">
|
||||
<i class="pi pi-database"></i><span class="font-bold whitespace-nowrap">Data</span>
|
||||
<i class="pi pi-th-large"></i><span class="font-bold whitespace-nowrap">Categories</span>
|
||||
</p-tab>
|
||||
</p-tablist>
|
||||
<p-tabpanels>
|
||||
<p-tabpanel value="0">
|
||||
<div class="mt-1 flex justify-between align-items">
|
||||
<div class="mt-2 flex justify-between align-items">
|
||||
<h1 class="font-semibold tracking-tight text-xl">About</h1>
|
||||
|
||||
<div class="flex">
|
||||
@ -192,9 +192,9 @@
|
||||
<button class="custom-button orange" (click)="toGithub()">
|
||||
Open Github
|
||||
</button>
|
||||
<span class="text-center flex items-center gap-2 text-gray-400">TRIP {{ this.info?.update }}
|
||||
available on
|
||||
Github</span>
|
||||
<div class="text-center text-gray-400">TRIP {{ this.info?.update }} available on
|
||||
Github. <a [href]="'https://github.com/itskovacs/trip/releases/tag/' + this.info?.update"
|
||||
class="text-blue-500 font-semibold" target="_blank">Changelog</a></div>
|
||||
} @else {
|
||||
<button class="custom-button" (click)="check_update()">
|
||||
Check for updates
|
||||
@ -203,6 +203,12 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-around mt-8 gap-4">
|
||||
<p-button (click)="exportData()" text icon="pi pi-download" label="Export" />
|
||||
<p-button (click)="fileUpload.click()" text icon="pi pi-upload" label="Import" />
|
||||
<input type="file" class="file-input" style="display: none;" (change)="importData($event)" #fileUpload>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">Made with ❤️ in BZH</div>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="1">
|
||||
@ -225,6 +231,17 @@
|
||||
<p-toggleswitch [(ngModel)]="isDarkMode" (onChange)="toggleDarkMode()" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h1 class="font-semibold tracking-tight text-xl">GPX Indication</h1>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">If enabled, display a compass in Place bubble when a
|
||||
GPX is present.</span>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between">
|
||||
<div>Enable GPX indication</div>
|
||||
<p-toggleswitch [(ngModel)]="isGpxInPlaceMode" (onChange)="toggleGpxInPlace()" />
|
||||
</div>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="2">
|
||||
<section [formGroup]="settingsForm">
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div>
|
||||
@ -239,13 +256,18 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
<p-floatlabel variant="in">
|
||||
<input id="mapLat" formControlName="mapLat" pInputText fluid />
|
||||
<label for="mapLat">Lat.</label>
|
||||
<input id="map_lat" formControlName="map_lat" pInputText fluid />
|
||||
<label for="map_lat">Lat.</label>
|
||||
</p-floatlabel>
|
||||
|
||||
<p-floatlabel variant="in">
|
||||
<input id="mapLng" formControlName="mapLng" pInputText fluid />
|
||||
<label for="mapLng">Long.</label>
|
||||
<input id="map_lng" formControlName="map_lng" pInputText fluid />
|
||||
<label for="map_lng">Long.</label>
|
||||
</p-floatlabel>
|
||||
|
||||
<p-floatlabel variant="in" class="col-span-full">
|
||||
<input id="tile_layer" formControlName="tile_layer" pInputText fluid />
|
||||
<label for="tile_layer">Tile Layer</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
|
||||
@ -279,8 +301,7 @@
|
||||
</div>
|
||||
</section>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="2">
|
||||
|
||||
<p-tabpanel value="3">
|
||||
<div class="mt-1 p-2 mb-2 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="font-semibold tracking-tight text-xl">Categories</h1>
|
||||
@ -291,11 +312,13 @@
|
||||
<p-button icon="pi pi-plus" (click)="addCategory()" text />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col">
|
||||
<div class="mt-4 flex flex-col overflow-y-auto">
|
||||
@for (category of categories; track category.id) {
|
||||
<div class="p-3 flex items-center justify-between rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<img [src]="category.image" class="size-8 rounded-full" />{{ category.name }}
|
||||
<img [src]="category.image" style="border: 2px solid" [style.border-color]="category.color"
|
||||
class="size-8 rounded-full" />{{
|
||||
category.name }}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
@ -306,18 +329,6 @@
|
||||
}
|
||||
</div>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel value="3">
|
||||
<div class="mt-1">
|
||||
<h1 class="font-semibold tracking-tight text-xl">Data</h1>
|
||||
<span class="text-xs text-gray-500">You can import/export your data</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-around mt-4 gap-4">
|
||||
<p-button (click)="exportData()" text icon="pi pi-download" label="Export" />
|
||||
<p-button (click)="fileUpload.click()" text icon="pi pi-upload" label="Import" />
|
||||
<input type="file" class="file-input" style="display: none;" (change)="importData($event)" #fileUpload>
|
||||
</div>
|
||||
</p-tabpanel>
|
||||
</p-tabpanels>
|
||||
</p-tabs>
|
||||
</div>
|
||||
|
||||
@ -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();
|
||||
this.apiService.putSettings({ mode_low_network: this.isLowNet }).subscribe({
|
||||
next: (_) => {
|
||||
setTimeout(() => {
|
||||
this.updateMarkersAndClusters();
|
||||
}, 200);
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
<div pFocusTrap class="grid items-center gap-4" [formGroup]="categoryForm">
|
||||
<p-floatlabel variant="in">
|
||||
<div class="flex gap-2 items-center">
|
||||
<p-colorpicker formControlName="color" />
|
||||
|
||||
<p-floatlabel variant="in" class="w-full">
|
||||
<input id="name" formControlName="name" pInputText fluid (keyup.enter)="closeDialog()" />
|
||||
<label for="name">Name</label>
|
||||
</p-floatlabel>
|
||||
</div>
|
||||
|
||||
<input type="file" accept="image/*" #fileInput class="hidden" (change)="onFileSelected($event)" />
|
||||
<div class="grid place-items-center">
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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,6 +37,7 @@ export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map {
|
||||
.setMaxBounds(bounds);
|
||||
|
||||
L.tileLayer(
|
||||
tilelayer ||
|
||||
"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
|
||||
{
|
||||
maxZoom: 17,
|
||||
@ -48,7 +52,7 @@ export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map {
|
||||
|
||||
export function placeHoverTooltip(place: Place): string {
|
||||
let content = `<div class="font-semibold mb-1 truncate" style="font-size:1.1em">${place.name}</div>`;
|
||||
content += `<div><span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded">${place.category.name}</span></div>`;
|
||||
content += `<div><span style="color:${place.category.color}; background:${place.category.color}1A;" class="text-xs font-medium px-2.5 py-0.5 rounded">${place.category.name}</span></div>`;
|
||||
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 = `
|
||||
<div class="flex items-center justify-center relative rounded-full marker-anchor size-14 box-border" style="border: 2px solid ${place.category.color};">
|
||||
<div class="${markerClasses}" style="background-image: url('${markerImage}');"></div>
|
||||
${gpxInBubble && place.gpx ? '<div class="absolute -top-1 -left-1 size-6 flex justify-center items-center bg-white border-2 border-black rounded-full"><i class="pi pi-compass"></i></div>' : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,8 +10,9 @@
|
||||
</h1>
|
||||
|
||||
<div class="hidden md:flex mt-2 gap-1">
|
||||
<span
|
||||
class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300 flex gap-2 items-center truncate"><i
|
||||
<span [style.color]="selectedPlace.category.color"
|
||||
[style.background-color]="selectedPlace.category.color + '1A'"
|
||||
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded flex gap-2 items-center truncate"><i
|
||||
class="pi pi-box text-xs"></i>{{ selectedPlace.category.name }}</span>
|
||||
|
||||
@if (selectedPlace.allowdog) {
|
||||
@ -88,12 +89,13 @@
|
||||
|
||||
<div class="flex flex-col mb-4 max-h-40 overflow-y-auto">
|
||||
<span class="text-gray-500">Description</span>
|
||||
<span class="break-words">{{ selectedPlace.description || '-' }}</span>
|
||||
<span class="break-words" [innerHTML]="(selectedPlace.description || '-') | linkify"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex md:hidden mt-2 justify-center gap-1">
|
||||
<span
|
||||
class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300 flex gap-2 items-center truncate"><i
|
||||
<span [style.color]="selectedPlace.category.color"
|
||||
[style.background-color]="selectedPlace.category.color + '1A'"
|
||||
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded flex gap-2 items-center truncate"><i
|
||||
class="pi pi-box text-xs"></i>{{ selectedPlace.category.name }}</span>
|
||||
|
||||
@if (selectedPlace.allowdog) {
|
||||
|
||||
@ -6,11 +6,12 @@ import { MenuItem } from "primeng/api";
|
||||
import { UtilsService } from "../../services/utils.service";
|
||||
import { Observable } from "rxjs";
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import { LinkifyPipe } from "../linkify.pipe";
|
||||
|
||||
@Component({
|
||||
selector: "app-place-box",
|
||||
standalone: true,
|
||||
imports: [ButtonModule, MenuModule, AsyncPipe],
|
||||
imports: [ButtonModule, MenuModule, AsyncPipe, LinkifyPipe],
|
||||
templateUrl: "./place-box.component.html",
|
||||
styleUrls: ["./place-box.component.scss"],
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ export interface Category {
|
||||
name: string;
|
||||
image_id: number;
|
||||
image: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface Place {
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
export interface Settings {
|
||||
username: string;
|
||||
mapLat: number;
|
||||
mapLng: number;
|
||||
map_lat: number;
|
||||
map_lng: number;
|
||||
currency: string;
|
||||
do_not_display: string[];
|
||||
tile_layer?: string;
|
||||
mode_low_network?: boolean;
|
||||
mode_dark?: boolean;
|
||||
mode_gpx_in_place?: boolean;
|
||||
}
|
||||
|
||||
@ -117,23 +117,12 @@ html {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.image-marker {
|
||||
border-radius: 50%;
|
||||
border: 2px solid #405cf5;
|
||||
background: white;
|
||||
object-fit: cover;
|
||||
|
||||
&.visited {
|
||||
border: 2px solid #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.listHover {
|
||||
.custom-cluster {
|
||||
background-color: red;
|
||||
}
|
||||
&.image-marker {
|
||||
border: 4px dashed red;
|
||||
.marker-anchor {
|
||||
border: 3px dashed red !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user