Alembic introduction

This commit is contained in:
itskovacs 2025-08-05 19:01:20 +02:00
parent d5b5ba8c84
commit a23df2f7b5
27 changed files with 656 additions and 147 deletions

36
backend/alembic.ini Normal file
View 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

View File

@ -1 +1 @@
__version__ = "1.8.2" __version__ = "1.9.0"

View File

@ -0,0 +1 @@
Generic single-database configuration.

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

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

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

View File

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

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

View File

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

View File

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

View File

@ -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 sqlalchemy.engine import Engine
from sqlmodel import Session, SQLModel, create_engine from sqlmodel import Session, create_engine
from ..config import settings from ..config import settings
from ..models.models import Category from ..models.models import Category
@ -8,6 +13,14 @@ from ..models.models import Category
_engine = None _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(): def get_engine():
global _engine global _engine
if not _engine: if not _engine:
@ -25,9 +38,24 @@ def set_sqlite_pragma(dbapi_connection, connection_record):
cursor.close() 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() 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): def init_user_data(session: Session, username: str):

View File

@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@ -8,7 +9,7 @@ from starlette.middleware.gzip import GZipMiddleware
from . import __version__ from . import __version__
from .config import settings 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 auth, categories, places
from .routers import settings as settings_r from .routers import settings as settings_r
from .routers import trips 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) 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -30,7 +38,6 @@ app.add_middleware(
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(categories.router) app.include_router(categories.router)
app.include_router(places.router) app.include_router(places.router)
@ -51,10 +58,5 @@ async def not_found_to_spa(request: Request, call_next):
return response return response
@app.on_event("startup")
def startup_event():
init_db()
app.mount("/api/assets", StaticFiles(directory=settings.ASSETS_FOLDER), name="static") app.mount("/api/assets", StaticFiles(directory=settings.ASSETS_FOLDER), name="static")
app.mount("/", StaticFiles(directory=settings.FRONTEND_FOLDER, html=True), name="frontend") app.mount("/", StaticFiles(directory=settings.FRONTEND_FOLDER, html=True), name="frontend")

View File

@ -66,10 +66,14 @@ class Image(ImageBase, table=True):
class UserBase(SQLModel): class UserBase(SQLModel):
mapLat: float = 48.107 map_lat: float = settings.DEFAULT_MAP_LAT
mapLng: float = -2.988 map_lng: float = settings.DEFAULT_MAP_LNG
currency: str = "" currency: str = settings.DEFAULT_CURRENCY
do_not_display: str = "" 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): class User(UserBase, table=True):
@ -78,8 +82,8 @@ class User(UserBase, table=True):
class UserUpdate(UserBase): class UserUpdate(UserBase):
mapLat: float | None = None map_lat: float | None = None
mapLng: float | None = None map_lng: float | None = None
currency: str | None = None currency: str | None = None
do_not_display: list[str] | None = None do_not_display: list[str] | None = None
@ -92,15 +96,20 @@ class UserRead(UserBase):
def serialize(cls, obj: User) -> "UserRead": def serialize(cls, obj: User) -> "UserRead":
return cls( return cls(
username=obj.username, username=obj.username,
mapLat=obj.mapLat, map_lat=obj.map_lat,
mapLng=obj.mapLng, map_lng=obj.map_lng,
currency=obj.currency, currency=obj.currency,
do_not_display=obj.do_not_display.split(",") if obj.do_not_display else [], 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): class CategoryBase(SQLModel):
name: str name: str
color: str | None = None
class Category(CategoryBase, table=True): class Category(CategoryBase, table=True):
@ -114,17 +123,20 @@ class Category(CategoryBase, table=True):
class CategoryCreate(CategoryBase): class CategoryCreate(CategoryBase):
name: str name: str
image: str | None = None image: str | None = None
color: str | None = None
class CategoryUpdate(CategoryBase): class CategoryUpdate(CategoryBase):
name: str | None = None name: str | None = None
image: str | None = None image: str | None = None
color: str | None = None
class CategoryRead(CategoryBase): class CategoryRead(CategoryBase):
id: int id: int
image: str | None image: str | None
image_id: int | None image_id: int | None
color: str
@classmethod @classmethod
def serialize(cls, obj: Category) -> "CategoryRead": def serialize(cls, obj: Category) -> "CategoryRead":
@ -133,6 +145,7 @@ class CategoryRead(CategoryBase):
name=obj.name, name=obj.name,
image_id=obj.image_id, image_id=obj.image_id,
image=_prefix_assets_url(obj.image.filename) if obj.image else "/favicon.png", 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 time: str | None = None
text: str | None = None text: str | None = None
place: int | None = None place: int | None = None
day_id: int | None = None
status: TripItemStatusEnum | None = None status: TripItemStatusEnum | None = None

View File

@ -5,4 +5,5 @@ PyJWT~=2.10
argon2-cffi~=25.1 argon2-cffi~=25.1
pydantic_settings~=2.9 pydantic_settings~=2.9
Pillow~=11.2 Pillow~=11.2
authlib~=1.6 authlib~=1.6
alembic~=1.16

View File

@ -27,7 +27,7 @@ def post_category(
session: SessionDep, session: SessionDep,
current_user: Annotated[str, Depends(get_current_username)], current_user: Annotated[str, Depends(get_current_username)],
) -> CategoryRead: ) -> 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: if category.image:
image_bytes = b64img_decode(category.image) image_bytes = b64img_decode(category.image)

View File

@ -11,7 +11,8 @@ from ..deps import SessionDep, get_current_username
from ..models.models import (Category, CategoryRead, Image, Place, PlaceRead, from ..models.models import (Category, CategoryRead, Image, Place, PlaceRead,
Trip, TripDay, TripItem, TripRead, User, UserRead, Trip, TripDay, TripItem, TripRead, User, UserRead,
UserUpdate) 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"]) 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) select(Category).filter(Category.user == current_user, Category.name == category_name)
).first() ).first()
if category_exists: 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 = { category_data = {
key: category[key] for key in category.keys() if key not in {"id", "image", "image_id"} 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) db_user = session.get(User, current_user)
if data.get("settings"): if data.get("settings"):
settings_data = data["settings"] settings_data = data["settings"]
if settings_data.get("mapLat"): if settings_data.get("map_lat"):
db_user.mapLat = settings_data["mapLat"] db_user.map_lat = settings_data["map_lat"]
if settings_data.get("mapLng"): if settings_data.get("map_lng"):
db_user.mapLng = settings_data["mapLng"] db_user.map_lng = settings_data["map_lng"]
if settings_data.get("currency"): if settings_data.get("currency"):
db_user.currency = settings_data["currency"] db_user.currency = settings_data["currency"]

View File

@ -88,8 +88,8 @@
class="pi pi-eye-slash text-xs"></i></span> class="pi pi-eye-slash text-xs"></i></span>
} }
<span <span [style.color]="p.category.color" [style.background-color]="p.category.color + '1A'"
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 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> class="pi pi-box text-xs"></i>{{ p.category.name }}</span>
</div> </div>
</div> </div>
@ -160,18 +160,18 @@
<i class="pi pi-info-circle"></i><span class="font-bold whitespace-nowrap">About</span> <i class="pi pi-info-circle"></i><span class="font-bold whitespace-nowrap">About</span>
</p-tab> </p-tab>
<p-tab value="1" class="flex items-center gap-2"> <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>
<p-tab value="2" class="flex items-center gap-2"> <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>
<p-tab value="3" class="flex items-center gap-2"> <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-tab>
</p-tablist> </p-tablist>
<p-tabpanels> <p-tabpanels>
<p-tabpanel value="0"> <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> <h1 class="font-semibold tracking-tight text-xl">About</h1>
<div class="flex"> <div class="flex">
@ -192,9 +192,9 @@
<button class="custom-button orange" (click)="toGithub()"> <button class="custom-button orange" (click)="toGithub()">
Open Github Open Github
</button> </button>
<span class="text-center flex items-center gap-2 text-gray-400">TRIP {{ this.info?.update }} <div class="text-center text-gray-400">TRIP {{ this.info?.update }} available on
available on Github. <a [href]="'https://github.com/itskovacs/trip/releases/tag/' + this.info?.update"
Github</span> class="text-blue-500 font-semibold" target="_blank">Changelog</a></div>
} @else { } @else {
<button class="custom-button" (click)="check_update()"> <button class="custom-button" (click)="check_update()">
Check for updates Check for updates
@ -203,6 +203,12 @@
} }
</div> </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> <div class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">Made with ❤️ in BZH</div>
</p-tabpanel> </p-tabpanel>
<p-tabpanel value="1"> <p-tabpanel value="1">
@ -225,6 +231,17 @@
<p-toggleswitch [(ngModel)]="isDarkMode" (onChange)="toggleDarkMode()" /> <p-toggleswitch [(ngModel)]="isDarkMode" (onChange)="toggleDarkMode()" />
</div> </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"> <section [formGroup]="settingsForm">
<div class="mt-4 flex justify-between items-center"> <div class="mt-4 flex justify-between items-center">
<div> <div>
@ -239,13 +256,18 @@
<div class="grid grid-cols-2 gap-4 mt-4"> <div class="grid grid-cols-2 gap-4 mt-4">
<p-floatlabel variant="in"> <p-floatlabel variant="in">
<input id="mapLat" formControlName="mapLat" pInputText fluid /> <input id="map_lat" formControlName="map_lat" pInputText fluid />
<label for="mapLat">Lat.</label> <label for="map_lat">Lat.</label>
</p-floatlabel> </p-floatlabel>
<p-floatlabel variant="in"> <p-floatlabel variant="in">
<input id="mapLng" formControlName="mapLng" pInputText fluid /> <input id="map_lng" formControlName="map_lng" pInputText fluid />
<label for="mapLng">Long.</label> <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> </p-floatlabel>
</div> </div>
@ -279,8 +301,7 @@
</div> </div>
</section> </section>
</p-tabpanel> </p-tabpanel>
<p-tabpanel value="2"> <p-tabpanel value="3">
<div class="mt-1 p-2 mb-2 flex justify-between items-center"> <div class="mt-1 p-2 mb-2 flex justify-between items-center">
<div> <div>
<h1 class="font-semibold tracking-tight text-xl">Categories</h1> <h1 class="font-semibold tracking-tight text-xl">Categories</h1>
@ -291,11 +312,13 @@
<p-button icon="pi pi-plus" (click)="addCategory()" text /> <p-button icon="pi pi-plus" (click)="addCategory()" text />
</div> </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) { @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="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"> <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>
<div class="flex gap-4"> <div class="flex gap-4">
@ -306,18 +329,6 @@
} }
</div> </div>
</p-tabpanel> </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-tabpanels>
</p-tabs> </p-tabs>
</div> </div>

View File

@ -81,6 +81,7 @@ export class DashboardComponent implements AfterViewInit {
info: Info | undefined; info: Info | undefined;
isLowNet: boolean = false; isLowNet: boolean = false;
isDarkMode: boolean = false; isDarkMode: boolean = false;
isGpxInPlaceMode: boolean = false;
viewSettings = false; viewSettings = false;
viewFilters = false; viewFilters = false;
@ -115,11 +116,9 @@ export class DashboardComponent implements AfterViewInit {
private fb: FormBuilder, private fb: FormBuilder,
) { ) {
this.currencySigns = this.utilsService.currencySigns(); this.currencySigns = this.utilsService.currencySigns();
this.isLowNet = this.utilsService.isLowNet;
this.isDarkMode = this.utilsService.isDarkMode;
this.settingsForm = this.fb.group({ this.settingsForm = this.fb.group({
mapLat: [ map_lat: [
"", "",
{ {
validators: [ validators: [
@ -128,7 +127,7 @@ export class DashboardComponent implements AfterViewInit {
], ],
}, },
], ],
mapLng: [ map_lng: [
"", "",
{ {
validators: [ validators: [
@ -141,6 +140,7 @@ export class DashboardComponent implements AfterViewInit {
], ],
currency: ["", Validators.required], currency: ["", Validators.required],
do_not_display: [], do_not_display: [],
tile_layer: ["", Validators.required],
}); });
this.apiService.getInfo().subscribe({ this.apiService.getInfo().subscribe({
@ -180,7 +180,6 @@ export class DashboardComponent implements AfterViewInit {
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.initMap();
combineLatest({ combineLatest({
categories: this.apiService.getCategories(), categories: this.apiService.getCategories(),
places: this.apiService.getPlaces(), places: this.apiService.getPlaces(),
@ -188,18 +187,18 @@ export class DashboardComponent implements AfterViewInit {
}) })
.pipe( .pipe(
tap(({ categories, places, settings }) => { tap(({ categories, places, settings }) => {
this.settings = settings;
this.initMap();
this.categories = categories; this.categories = categories;
this.activeCategories = new Set(categories.map((c) => c.name)); this.activeCategories = new Set(categories.map((c) => c.name));
this.settings = settings; this.isLowNet = !!settings.mode_low_network;
this.map.setView(L.latLng(settings.mapLat, +settings.mapLng)); this.isDarkMode = !!settings.mode_dark;
this.isGpxInPlaceMode = !!settings.mode_gpx_in_place;
if (this.isDarkMode) this.utilsService.toggleDarkMode();
this.resetFilters(); this.resetFilters();
this.map.on("moveend zoomend", () => {
this.setVisibleMarkers();
});
this.markerClusterGroup = createClusterGroup().addTo(this.map);
this.places.push(...places); 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 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 { initMap(): void {
if (!this.settings) return;
let contentMenuItems = [ let contentMenuItems = [
{ {
text: "Add Point of Interest", 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() { setVisibleMarkers() {
@ -255,14 +261,31 @@ export class DashboardComponent implements AfterViewInit {
} }
toggleLowNet() { toggleLowNet() {
this.utilsService.toggleLowNet(); this.apiService.putSettings({ mode_low_network: this.isLowNet }).subscribe({
setTimeout(() => { next: (_) => {
this.updateMarkersAndClusters(); setTimeout(() => {
}, 200); this.updateMarkersAndClusters();
}, 100);
},
});
} }
toggleDarkMode() { 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[] { get filteredPlaces(): Place[] {
@ -285,7 +308,12 @@ export class DashboardComponent implements AfterViewInit {
} }
placeToMarker(place: Place): L.Marker { placeToMarker(place: Place): L.Marker {
let marker = placeToMarker(place, this.isLowNet); let marker = placeToMarker(
place,
this.isLowNet,
place.visited,
this.isGpxInPlaceMode,
);
marker marker
.on("click", (e) => { .on("click", (e) => {
this.selectedPlace = place; this.selectedPlace = place;
@ -596,7 +624,7 @@ export class DashboardComponent implements AfterViewInit {
setMapCenterToCurrent() { setMapCenterToCurrent() {
let latlng: L.LatLng = this.map.getCenter(); 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(); this.settingsForm.markAsDirty();
} }
@ -636,7 +664,13 @@ export class DashboardComponent implements AfterViewInit {
updateSettings() { updateSettings() {
this.apiService.putSettings(this.settingsForm.value).subscribe({ this.apiService.putSettings(this.settingsForm.value).subscribe({
next: (settings) => { next: (settings) => {
const refreshMap = this.settings!.tile_layer != settings.tile_layer;
this.settings = settings; this.settings = settings;
if (refreshMap) {
this.map.remove();
this.initMap();
this.updateMarkersAndClusters();
}
this.resetFilters(); this.resetFilters();
this.toggleSettings(); this.toggleSettings();
}, },

View File

@ -1,8 +1,12 @@
<div pFocusTrap class="grid items-center gap-4" [formGroup]="categoryForm"> <div pFocusTrap class="grid items-center gap-4" [formGroup]="categoryForm">
<p-floatlabel variant="in"> <div class="flex gap-2 items-center">
<input id="name" formControlName="name" pInputText fluid (keyup.enter)="closeDialog()" /> <p-colorpicker formControlName="color" />
<label for="name">Name</label>
</p-floatlabel> <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)" /> <input type="file" accept="image/*" #fileInput class="hidden" (change)="onFileSelected($event)" />
<div class="grid place-items-center"> <div class="grid place-items-center">

View File

@ -10,6 +10,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
import { FloatLabelModule } from "primeng/floatlabel"; import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext"; import { InputTextModule } from "primeng/inputtext";
import { FocusTrapModule } from "primeng/focustrap"; import { FocusTrapModule } from "primeng/focustrap";
import { ColorPickerModule } from "primeng/colorpicker";
@Component({ @Component({
selector: "app-category-create-modal", selector: "app-category-create-modal",
@ -17,6 +18,7 @@ import { FocusTrapModule } from "primeng/focustrap";
FloatLabelModule, FloatLabelModule,
InputTextModule, InputTextModule,
ButtonModule, ButtonModule,
ColorPickerModule,
ReactiveFormsModule, ReactiveFormsModule,
FocusTrapModule, FocusTrapModule,
], ],
@ -37,6 +39,15 @@ export class CategoryCreateModalComponent {
this.categoryForm = this.fb.group({ this.categoryForm = this.fb.group({
id: -1, id: -1,
name: ["", Validators.required], name: ["", Validators.required],
color: [
"#000000",
{
validators: [
Validators.required,
Validators.pattern("\#[abcdefABCDEF0-9]{6}"),
],
},
],
image: null, image: null,
}); });

View File

@ -4,53 +4,24 @@ import { TripStatus } from "../types/trip";
import { ApiService } from "./api.service"; import { ApiService } from "./api.service";
import { map } from "rxjs"; import { map } from "rxjs";
const DISABLE_LOWNET = "TRIP_DISABLE_LOWNET";
const DARK = "DARKMODE";
@Injectable({ @Injectable({
providedIn: "root", providedIn: "root",
}) })
export class UtilsService { export class UtilsService {
private apiService = inject(ApiService); private apiService = inject(ApiService);
currency$ = this.apiService.settings$.pipe(map((s) => s?.currency ?? "€")); currency$ = this.apiService.settings$.pipe(map((s) => s?.currency ?? "€"));
public isLowNet: boolean = true;
public isDarkMode: boolean = false;
constructor(private ngMessageService: MessageService) { constructor(private ngMessageService: MessageService) {}
this.isLowNet = !localStorage.getItem(DISABLE_LOWNET);
this.isDarkMode = !!localStorage.getItem(DARK);
if (this.isDarkMode) this.renderDarkMode();
}
toGithubTRIP() { toGithubTRIP() {
window.open("https://github.com/itskovacs/trip", "_blank"); window.open("https://github.com/itskovacs/trip", "_blank");
} }
toggleLowNet() { toggleDarkMode() {
if (this.isLowNet) {
localStorage.setItem(DISABLE_LOWNET, "1");
} else {
localStorage.removeItem(DISABLE_LOWNET);
}
this.isLowNet = !this.isLowNet;
}
renderDarkMode() {
const element = document.querySelector("html"); const element = document.querySelector("html");
element?.classList.toggle("dark"); 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[] { get statuses(): TripStatus[] {
return [ return [
{ label: "pending", color: "#3258A8" }, { label: "pending", color: "#3258A8" },

View File

@ -18,7 +18,10 @@ export interface MarkerOptions extends L.MarkerOptions {
contextmenuItems: ContextMenuItem[]; 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 southWest = L.latLng(-89.99, -180);
let northEast = L.latLng(89.99, 180); let northEast = L.latLng(89.99, 180);
let bounds = L.latLngBounds(southWest, northEast); let bounds = L.latLngBounds(southWest, northEast);
@ -34,7 +37,8 @@ export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map {
.setMaxBounds(bounds); .setMaxBounds(bounds);
L.tileLayer( 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, maxZoom: 17,
minZoom: 3, minZoom: 3,
@ -48,7 +52,7 @@ export function createMap(contextMenuItems?: ContextMenuItem[]): L.Map {
export function placeHoverTooltip(place: Place): string { export function placeHoverTooltip(place: Place): string {
let content = `<div class="font-semibold mb-1 truncate" style="font-size:1.1em">${place.name}</div>`; 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; return content;
} }
@ -99,6 +103,7 @@ export function placeToMarker(
place: Place, place: Place,
isLowNet: boolean = true, isLowNet: boolean = true,
grayscale: boolean = false, grayscale: boolean = false,
gpxInBubble: boolean = false,
): L.Marker { ): L.Marker {
let marker: L.Marker; let marker: L.Marker;
let options: any = { let options: any = {
@ -108,30 +113,35 @@ export function placeToMarker(
alt: "", alt: "",
}; };
marker = new L.Marker([+place.lat, +place.lng], options);
const markerImage = isLowNet const markerImage = isLowNet
? place.category.image ? place.category.image
: (place.image ?? 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"; 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({ const icon = L.divIcon({
iconUrl: markerImage, html: iconHtml,
iconSize: [56, 56], iconSize: [56, 56],
iconAnchor: [28, 28], className: "",
shadowSize: [0, 0], });
shadowAnchor: [0, 0],
popupAnchor: [0, -12], marker = new L.Marker([+place.lat, +place.lng], {
className: markerClasses, ...options,
icon,
}); });
let touchDevice = "ontouchstart" in window; let touchDevice = "ontouchstart" in window;
if (!touchDevice) { if (!touchDevice) {
marker.bindTooltip(placeHoverTooltip(place), { marker.bindTooltip(placeHoverTooltip(place), {
direction: "right", direction: "right",
offset: [24, 0], offset: [28, 0],
className: "class-tooltip", className: "class-tooltip",
}); });
} }

View File

@ -10,8 +10,9 @@
</h1> </h1>
<div class="hidden md:flex mt-2 gap-1"> <div class="hidden md:flex mt-2 gap-1">
<span <span [style.color]="selectedPlace.category.color"
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 [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> class="pi pi-box text-xs"></i>{{ selectedPlace.category.name }}</span>
@if (selectedPlace.allowdog) { @if (selectedPlace.allowdog) {
@ -88,12 +89,13 @@
<div class="flex flex-col mb-4 max-h-40 overflow-y-auto"> <div class="flex flex-col mb-4 max-h-40 overflow-y-auto">
<span class="text-gray-500">Description</span> <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>
<div class="flex md:hidden mt-2 justify-center gap-1"> <div class="flex md:hidden mt-2 justify-center gap-1">
<span <span [style.color]="selectedPlace.category.color"
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 [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> class="pi pi-box text-xs"></i>{{ selectedPlace.category.name }}</span>
@if (selectedPlace.allowdog) { @if (selectedPlace.allowdog) {

View File

@ -6,11 +6,12 @@ import { MenuItem } from "primeng/api";
import { UtilsService } from "../../services/utils.service"; import { UtilsService } from "../../services/utils.service";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { AsyncPipe } from "@angular/common"; import { AsyncPipe } from "@angular/common";
import { LinkifyPipe } from "../linkify.pipe";
@Component({ @Component({
selector: "app-place-box", selector: "app-place-box",
standalone: true, standalone: true,
imports: [ButtonModule, MenuModule, AsyncPipe], imports: [ButtonModule, MenuModule, AsyncPipe, LinkifyPipe],
templateUrl: "./place-box.component.html", templateUrl: "./place-box.component.html",
styleUrls: ["./place-box.component.scss"], styleUrls: ["./place-box.component.scss"],
}) })

View File

@ -3,6 +3,7 @@ export interface Category {
name: string; name: string;
image_id: number; image_id: number;
image: string; image: string;
color?: string;
} }
export interface Place { export interface Place {

View File

@ -1,7 +1,11 @@
export interface Settings { export interface Settings {
username: string; username: string;
mapLat: number; map_lat: number;
mapLng: number; map_lng: number;
currency: string; currency: string;
do_not_display: string[]; do_not_display: string[];
tile_layer?: string;
mode_low_network?: boolean;
mode_dark?: boolean;
mode_gpx_in_place?: boolean;
} }

View File

@ -117,23 +117,12 @@ html {
width: 24px; width: 24px;
} }
.image-marker {
border-radius: 50%;
border: 2px solid #405cf5;
background: white;
object-fit: cover;
&.visited {
border: 2px solid #9ca3af;
}
}
.listHover { .listHover {
.custom-cluster { .custom-cluster {
background-color: red; background-color: red;
} }
&.image-marker { .marker-anchor {
border: 4px dashed red; border: 3px dashed red !important;
} }
} }