diff --git a/README.md b/README.md index 927d002c..ff561a44 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ Be kind. Be clear. Assume good intent. Keep feedback constructive. - πŸ‘€ **Creator:** [Gilles Romer](https://romerg.de/) - βœ‰οΈ **Contact:** [info@romerg.de](mailto:info@romerg.de) -![Version](https://img.shields.io/badge/version-v1.25.1-e3000b?style=flat-square) ![Dark Theme](https://img.shields.io/badge/theme-dark-1a1a2e?style=flat-square) ![TCGdex](https://img.shields.io/badge/card%20data-TCGdex-e3000b?style=flat-square) ![Docker](https://img.shields.io/badge/deploy-Docker-2496ed?style=flat-square) ![FastAPI](https://img.shields.io/badge/backend-FastAPI-009688?style=flat-square) ![React](https://img.shields.io/badge/frontend-React%2018-61dafb?style=flat-square) [![Ko-fi](https://img.shields.io/badge/support-Ko--fi-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white)](https://ko-fi.com/gillesromer) +![Version](https://img.shields.io/badge/version-v1.26.0-e3000b?style=flat-square) ![Dark Theme](https://img.shields.io/badge/theme-dark-1a1a2e?style=flat-square) ![TCGdex](https://img.shields.io/badge/card%20data-TCGdex-e3000b?style=flat-square) ![Docker](https://img.shields.io/badge/deploy-Docker-2496ed?style=flat-square) ![FastAPI](https://img.shields.io/badge/backend-FastAPI-009688?style=flat-square) ![React](https://img.shields.io/badge/frontend-React%2018-61dafb?style=flat-square) [![Ko-fi](https://img.shields.io/badge/support-Ko--fi-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white)](https://ko-fi.com/gillesromer) -**Current version:** `v1.25.1` Β· Releases are tracked on the [GitHub Releases page](https://github.com/Git-Romer/pokecollector/releases). +**Current version:** `v1.26.0` Β· Releases are tracked on the [GitHub Releases page](https://github.com/Git-Romer/pokecollector/releases). ![WebApp Preview](preview-homescreen.png) @@ -87,6 +87,8 @@ Be kind. Be clear. Assume good intent. Keep feedback constructive. ### πŸ† Social & Community - Leaderboard, trainer comparison, and achievements in multi-user mode - View other trainers' collections from the Leaderboard +- Optional public trainer profiles with trainer-name URLs, a public directory, individually shared collection binders, and opt-in market values +- Admin-controlled public sharing switch, disabled by default on new and upgraded installations - Community section in Settings with GitHub contributors and Ko-fi supporters ### 🎨 UX & Localization diff --git a/VERSION b/VERSION index d905a6d1..5ff8c4f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.25.1 +1.26.0 diff --git a/backend/api/auth.py b/backend/api/auth.py index adc42ded..265ec8ea 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -3,6 +3,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError from pydantic import BaseModel +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from database import get_db, get_setting, save_setting @@ -70,6 +71,20 @@ def ensure_keeps_active_admin(db: Session, user: User, data: UpdateUserRequest): raise HTTPException(status_code=400, detail="At least one active admin account is required") +def _sync_public_handle_for_username(db: Session, user: User, username: str) -> None: + from services import public_profile as pp + + if not user.is_profile_public: + user.public_handle = None + return + try: + pp.assign_public_handle(db, user, trainer_name=username) + except pp.HandleConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from None + except pp.HandleError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from None + + def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: if not token: multi = get_setting("multi_user_mode") @@ -217,6 +232,7 @@ def update_user( raise HTTPException(status_code=404, detail="User not found") ensure_keeps_active_admin(db, user, data) if data.username is not None: + _sync_public_handle_for_username(db, user, data.username) user.username = data.username if data.password is not None: user.hashed_password = hash_password(data.password) @@ -226,7 +242,11 @@ def update_user( user.is_active = data.is_active if field_was_set(data, "avatar_id"): user.avatar_id = data.avatar_id - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="Trainer name or public URL is already taken") from None return {"id": user.id, "username": user.username, "role": user.role, "is_active": user.is_active, "avatar_id": user.avatar_id} @@ -308,6 +328,11 @@ def change_username(data: dict, current_user: User = Depends(get_current_user), existing = db.query(User).filter(User.username == new_username, User.id != current_user.id).first() if existing: raise HTTPException(status_code=409, detail="Username already taken") + _sync_public_handle_for_username(db, current_user, new_username) current_user.username = new_username - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="Trainer name or public URL is already taken") from None return {"id": current_user.id, "username": current_user.username, "role": current_user.role, "avatar_id": current_user.avatar_id} diff --git a/backend/api/binders.py b/backend/api/binders.py index 28b45a45..8bed72b6 100644 --- a/backend/api/binders.py +++ b/backend/api/binders.py @@ -17,6 +17,7 @@ from services.binder_csv import BINDER_CSV_DUPLICATE_QUANTITY_ERROR, combine_binder_required_quantity from services.wishlist_missing import plan_missing_wishlist_additions from services.tcgdex_languages import SUPPORTED_TCGDEX_LANGUAGES, is_supported_tcgdex_language, normalize_tcgdex_language +from services.public_profile_feature import public_profiles_enabled import datetime import csv import io @@ -97,6 +98,7 @@ def _binder_response(binder: Binder, card_count: int = 0, unique_card_count: int created_at=binder.created_at, card_count=card_count, unique_card_count=unique_card_count, + is_public=binder.is_public or False, ) @@ -502,6 +504,26 @@ def update_binder( if not binder: raise HTTPException(status_code=404, detail="Binder not found") + current_type = binder.binder_type or "collection" + requested_type = ( + (update.binder_type or "collection") + if update.binder_type is not None + else current_type + ) + type_changed = requested_type != current_type + if type_changed: + has_cards = db.query(BinderCard.id).filter(BinderCard.binder_id == binder_id).first() is not None + if has_cards: + raise HTTPException(status_code=400, detail="Binder type cannot be changed after cards are added") + + if "is_public" in update.model_fields_set: + if not public_profiles_enabled(db): + raise HTTPException(status_code=403, detail="Public profiles are disabled by the administrator") + if update.is_public is None: + raise HTTPException(status_code=422, detail="Public sharing must be true or false") + if update.is_public and requested_type != "collection": + raise HTTPException(status_code=422, detail="Only collection binders can be shared publicly") + if update.name is not None: binder.name = update.name if update.description is not None: @@ -509,17 +531,16 @@ def update_binder( if update.color is not None: binder.color = update.color if update.binder_type is not None: - requested_type = update.binder_type or "collection" - current_type = binder.binder_type or "collection" - if requested_type != current_type: - has_cards = db.query(BinderCard.id).filter(BinderCard.binder_id == binder_id).first() is not None - if has_cards: - raise HTTPException(status_code=400, detail="Binder type cannot be changed after cards are added") - binder.binder_type = update.binder_type + binder.binder_type = requested_type + if type_changed: + # A type conversion always requires a fresh sharing decision. + binder.is_public = False if "format" in update.model_fields_set: binder.format = _clean_binder_format(update.format) if "icon_pokemon_id" in update.model_fields_set: binder.icon_pokemon_id = update.icon_pokemon_id + if "is_public" in update.model_fields_set: + binder.is_public = update.is_public db.commit() db.refresh(binder) diff --git a/backend/api/profile.py b/backend/api/profile.py new file mode 100644 index 00000000..32ef4cb1 --- /dev/null +++ b/backend/api/profile.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from api.auth import get_current_user +from database import get_db +from models import User +from schemas import ProfileUpdate +from services import public_profile as pp +from services.public_profile_feature import public_profiles_enabled + +router = APIRouter() + + +def _is_public_handle_conflict(exc: IntegrityError) -> bool: + original = getattr(exc, "orig", None) + constraint = getattr(getattr(original, "diag", None), "constraint_name", None) + if constraint in {"ix_users_public_handle", "users_public_handle_key"}: + return True + return "public_handle" in str(original).lower() + + +def _serialize_owner(db: Session, user: User, feature_enabled: bool) -> dict: + handle = None + handle_error = None + try: + handle = pp.public_handle_from_trainer_name(user.username) + if not pp.is_handle_available(db, handle, exclude_user_id=user.id): + handle_error = "Another public profile already uses this trainer name" + handle = None + except pp.HandleError as exc: + handle_error = str(exc) + return { + "trainer_name": user.username, + "public_handle": handle, + "public_handle_error": handle_error, + "is_profile_public": bool(user.is_profile_public), + "public_show_values": bool(user.public_show_values), + "feature_enabled": feature_enabled, + } + + +@router.get("/") +def get_profile(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + return _serialize_owner(db, current_user, public_profiles_enabled(db)) + + +def _require_public_profiles_enabled(db: Session) -> None: + if not public_profiles_enabled(db): + raise HTTPException(status_code=403, detail="Public profiles are disabled by the administrator") + + +@router.put("/") +def update_profile(payload: ProfileUpdate, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + _require_public_profiles_enabled(db) + if payload.is_profile_public is not None: + current_user.is_profile_public = payload.is_profile_public + if current_user.is_profile_public: + try: + pp.assign_public_handle(db, current_user) + except pp.HandleConflictError as exc: + db.rollback() + raise HTTPException(status_code=409, detail=str(exc)) from None + except pp.HandleError as exc: + db.rollback() + raise HTTPException(status_code=422, detail=str(exc)) from None + else: + current_user.public_handle = None + if payload.public_show_values is not None: + current_user.public_show_values = payload.public_show_values + try: + db.commit() + except IntegrityError as exc: + db.rollback() + if not _is_public_handle_conflict(exc): + raise + raise HTTPException(status_code=409, detail="Another public profile already uses this trainer name") from None + db.refresh(current_user) + return _serialize_owner(db, current_user, public_profiles_enabled(db)) diff --git a/backend/api/public.py b/backend/api/public.py new file mode 100644 index 00000000..0542a9e7 --- /dev/null +++ b/backend/api/public.py @@ -0,0 +1,119 @@ +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional, List + +from database import get_db +from services import public_profile as pp +from services.public_profile_feature import public_profiles_enabled + +router = APIRouter() + +# Rate limiting: these public GET endpoints are already covered by the global +# SlowAPI default_limits=["60/minute"] configured on the app-wide `limiter` in +# main.py (see Limiter(... default_limits=["60/minute"]) + SlowAPIMiddleware). +# No per-route @limiter.limit(...) is added here on purpose: main.py imports +# api.public (via the router), so importing `limiter` back from main.py would +# create an import cycle. The global default already applies to every route, +# including these, so a per-route decorator would just be redundant. + + +class PublicCard(BaseModel): + id: str + name: str + image: Optional[str] = None + set_name: Optional[str] = None + number: Optional[str] = None + rarity: Optional[str] = None + lang: Optional[str] = None + variant: Optional[str] = None + quantity: int + market_value: Optional[float] = None + + +class PublicBinderSummary(BaseModel): + id: int + name: str + color: Optional[str] = None + icon_pokemon_id: Optional[int] = None + card_count: int + unique_card_count: int + total_value: Optional[float] = None + + +class PublicProfile(BaseModel): + handle: str + trainer_name: str + avatar_id: Optional[int] = None + show_values: bool + binders: List[PublicBinderSummary] + + +class PublicProfileSummary(BaseModel): + handle: str + trainer_name: str + avatar_id: Optional[int] = None + binder_count: int + + +class PublicBinderDetail(PublicBinderSummary): + cards: List[PublicCard] + + +# Public sharing can be disabled globally or per owner. Require revalidation so a +# previously opened profile cannot remain visible from a browser cache after either +# control is switched off. Reverse proxies may still validate their stored response. +_PUBLIC_CACHE_CONTROL = "public, max-age=0, must-revalidate" +_PUBLIC_NOT_FOUND_HEADERS = {"Cache-Control": "no-store"} + + +def _set_public_cache(response: Response | None) -> None: + if response is not None: + response.headers["Cache-Control"] = _PUBLIC_CACHE_CONTROL + + +def _require_public_profiles_enabled(db: Session) -> None: + if not public_profiles_enabled(db): + raise HTTPException( + status_code=404, + detail="Profile not found", + headers=_PUBLIC_NOT_FOUND_HEADERS, + ) + + +def _not_found(detail: str) -> HTTPException: + return HTTPException( + status_code=404, + detail=detail, + headers=_PUBLIC_NOT_FOUND_HEADERS, + ) + + +@router.get("/profiles", response_model=List[PublicProfileSummary]) +def list_public_profiles(db: Session = Depends(get_db), response: Response = None): + _require_public_profiles_enabled(db) + _set_public_cache(response) + return pp.public_profile_directory(db) + + +@router.get("/profiles/{handle}", response_model=PublicProfile) +def get_public_profile(handle: str, db: Session = Depends(get_db), response: Response = None): + _require_public_profiles_enabled(db) + user = pp.get_live_profile(db, handle.lower()) + if not user: + raise _not_found("Profile not found") + _set_public_cache(response) + return pp.serialize_profile(db, user) + + +@router.get("/profiles/{handle}/binders/{binder_id}", response_model=PublicBinderDetail) +def get_public_binder(handle: str, binder_id: int, db: Session = Depends(get_db), response: Response = None): + _require_public_profiles_enabled(db) + user = pp.get_live_profile(db, handle.lower()) + if not user: + raise _not_found("Profile not found") + binder = pp.get_public_collection_binder(db, user.id, binder_id) + if not binder: + raise _not_found("Binder not found") + _set_public_cache(response) + return pp.serialize_binder_detail(db, binder, show_values=bool(user.public_show_values)) diff --git a/backend/api/sets.py b/backend/api/sets.py index b15c275f..c68dfec8 100644 --- a/backend/api/sets.py +++ b/backend/api/sets.py @@ -1,4 +1,3 @@ -import re from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -16,6 +15,7 @@ build_missing_language_cards_for_set, missing_language_fallback_enabled, ) +from services.card_numbers import natural_card_number_key from services.card_upsert import upsert_card from services.card_visibility import get_configured_sync_languages, visible_set_filter from services.digital_sets import digital_sets_enabled @@ -24,33 +24,11 @@ router = APIRouter() -_NATURAL_SORT_RE = re.compile(r"(\d+|\D+)") - class MarkSetsSeenRequest(BaseModel): set_ids: Optional[List[str]] = None -def _natural_card_number_key(number: Optional[str]) -> tuple: - """Sort card numbers naturally while preserving alphanumeric formats. - - Examples: - - 1, 2, 10 instead of 1, 10, 2 - - 001, 002, 010 still sort correctly - - 74, 74a, 74b and H04 are handled without converting the display value - """ - if number is None: - return ((2, ""),) - - parts = [] - for part in _NATURAL_SORT_RE.findall(str(number).strip()): - if part.isdigit(): - parts.append((0, int(part), len(part), part)) - else: - parts.append((1, part.casefold())) - return tuple(parts) or ((2, ""),) - - def _refresh_sets(db: Session, display_lang: str): """Refresh sets from TCGdex API and store in DB. @@ -257,7 +235,7 @@ def query_set_cards(): db.rollback() cards = query_set_cards() - cards.sort(key=lambda card: _natural_card_number_key(card.number)) + cards.sort(key=lambda card: natural_card_number_key(card.number)) # Get exact owned collection rows so the UI can safely remove/decrement the # right variant/condition instead of treating ownership as a single boolean. diff --git a/backend/api/settings.py b/backend/api/settings.py index d81b625e..90034236 100644 --- a/backend/api/settings.py +++ b/backend/api/settings.py @@ -17,6 +17,7 @@ parse_frankfurter_v2_rate, ) from services.card_visibility import get_visible_filter_languages +from services.public_profile_feature import PUBLIC_PROFILES_SETTING_KEY from services.tcgdex_languages import ( DEFAULT_TCGDEX_SYNC_LANGUAGES, supported_tcgdex_language_payload, @@ -39,6 +40,7 @@ "tcgdex_sync_languages", "debug_mode", "cross_language_price_fallback", "cross_language_image_fallback", DIGITAL_SETS_SETTING_KEY, + PUBLIC_PROFILES_SETTING_KEY, } DEFAULT_SETTINGS = { @@ -60,6 +62,7 @@ "cross_language_price_fallback": "true", "cross_language_image_fallback": "true", "debug_mode": "false", + PUBLIC_PROFILES_SETTING_KEY: "false", } @@ -73,7 +76,11 @@ def _normalize_tcgdex_sync_languages(value) -> str: def _coerce_setting_value(key: str, value) -> str: if key == "tcgdex_sync_languages": return _normalize_tcgdex_sync_languages(value) - if key in {"debug_mode", "cross_language_price_fallback", "cross_language_image_fallback", DIGITAL_SETS_SETTING_KEY}: + if key in { + "debug_mode", "cross_language_price_fallback", + "cross_language_image_fallback", DIGITAL_SETS_SETTING_KEY, + PUBLIC_PROFILES_SETTING_KEY, + }: return "true" if str(value).lower() in {"true", "1", "yes", "on"} else "false" return str(value) @@ -164,6 +171,8 @@ def update_settings(data: dict, db: Session = Depends(get_db), current_user: Use coerced_value = _coerce_setting_value(key, value) if key in ADMIN_ONLY_KEYS: if current_user.role != "admin": + if key == PUBLIC_PROFILES_SETTING_KEY: + raise HTTPException(status_code=403, detail="Admin only") continue row = db.query(Setting).filter(Setting.key == key).first() if row: diff --git a/backend/api/social.py b/backend/api/social.py index 97035950..a2c907d3 100644 --- a/backend/api/social.py +++ b/backend/api/social.py @@ -10,6 +10,7 @@ from services.card_values import effective_market_price, normalize_price_field from services.card_visibility import visible_card_filter from services.digital_sets import digital_sets_enabled +from services.public_profile_feature import public_profiles_enabled router = APIRouter() @@ -191,6 +192,7 @@ def _card_payload(card: Card | None): def _load_user_stats(db: Session, user_ids: list[int] | None = None, price_field: str = "price_trend"): price_field = normalize_price_field(price_field) + sharing_enabled = public_profiles_enabled(db) def _get_price(row): return effective_market_price(row, getattr(row, "variant", None), price_field) @@ -359,6 +361,7 @@ def _get_price(row): "sold_products_count": sold_product_counts.get(user.id, 0), "positive_pnl_flag": 1 if pnl > 0 else 0, "illustration_rare_flag": 1 if has_illustration_rare else 0, + "public_handle": user.public_handle if sharing_enabled and user.is_profile_public else None, } return stats diff --git a/backend/database.py b/backend/database.py index 8d901b2b..95ae9524 100644 --- a/backend/database.py +++ b/backend/database.py @@ -379,6 +379,16 @@ def _run_migrations(conn): AND sets.tcg_set_id = cards.set_id AND sets.lang = cards.lang )""", + # Public binders feature: user profile sharing settings + "ALTER TABLE users ADD COLUMN IF NOT EXISTS public_handle VARCHAR", + "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_public_handle ON users (public_handle)", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_profile_public BOOLEAN DEFAULT FALSE", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS public_show_values BOOLEAN DEFAULT FALSE", + "ALTER TABLE binders ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE", + # Enforce NOT NULL on new boolean columns for upgraded installs (ADD COLUMN with DEFAULT backfills existing rows first) + "ALTER TABLE users ALTER COLUMN is_profile_public SET NOT NULL", + "ALTER TABLE users ALTER COLUMN public_show_values SET NOT NULL", + "ALTER TABLE binders ALTER COLUMN is_public SET NOT NULL", ] for stmt in migrations: try: diff --git a/backend/main.py b/backend/main.py index 487e3bed..3ccfa763 100644 --- a/backend/main.py +++ b/backend/main.py @@ -56,6 +56,14 @@ async def lifespan(app: FastAPI): db = SessionLocal() try: bootstrap_admin(db) + from services.public_profile import migrate_public_profile_handles + public_handle_migration = migrate_public_profile_handles(db) + if public_handle_migration["migrated"] or public_handle_migration["disabled"]: + logger.info( + "Public trainer-name URL migration updated %s profiles and disabled %s invalid/conflicting profiles", + public_handle_migration["migrated"], + public_handle_migration["disabled"], + ) from models import Setting from services.debug_logging import configure_debug_logging debug_setting = db.query(Setting).filter(Setting.key == "debug_mode").first() @@ -86,6 +94,15 @@ async def lifespan(app: FastAPI): app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(SlowAPIMiddleware) + +@app.middleware("http") +async def prevent_failed_public_response_caching(request: Request, call_next): + response = await call_next(request) + if request.url.path.startswith("/api/public") and response.status_code >= 400: + response.headers["Cache-Control"] = "no-store" + return response + + app.add_middleware( CORSMiddleware, allow_origins=os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else ["*"], @@ -121,7 +138,7 @@ async def debug_request_logging(request: Request, call_next): return response # Include routers -from api import auth, cards, collection, sets, wishlist, binders, dashboard, analytics, sync, products, trades, export, backup, settings, images, social, pokedex +from api import auth, cards, collection, sets, wishlist, binders, dashboard, analytics, sync, products, trades, export, backup, settings, images, social, pokedex, public, profile from api.github import router as github_router from api.recognize import router as recognize_router @@ -170,6 +187,8 @@ async def login_rate_limit(request: Request, call_next): app.include_router(images.router, prefix="/api/images", tags=["images"]) app.include_router(social.router, prefix="/api/social", tags=["social"]) app.include_router(pokedex.router, prefix="/api/pokedex", tags=["pokedex"]) +app.include_router(public.router, prefix="/api/public", tags=["public"]) +app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) app.include_router(github_router, prefix="/api/github", tags=["github"]) diff --git a/backend/models.py b/backend/models.py index b39eeca2..5ee15d33 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,6 +1,6 @@ from sqlalchemy import ( Column, String, Integer, Float, DateTime, Date, Boolean, - CheckConstraint, ForeignKey, Text, JSON, UniqueConstraint, LargeBinary + CheckConstraint, ForeignKey, Text, JSON, UniqueConstraint, LargeBinary, Index ) from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -133,6 +133,9 @@ class Card(Base): class User(Base): __tablename__ = "users" + __table_args__ = ( + Index("ix_users_public_handle", "public_handle", unique=True), + ) id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String, unique=True, nullable=False) @@ -140,6 +143,9 @@ class User(Base): role = Column(String, default="trainer") # "admin" or "trainer" is_active = Column(Boolean, default=True) avatar_id = Column(Integer, nullable=True) # Pokemon number (1-151) for avatar sprite + public_handle = Column(String, nullable=True) + is_profile_public = Column(Boolean, default=False, nullable=False) + public_show_values = Column(Boolean, default=False, nullable=False) must_change_password = Column(Boolean, default=False) created_at = Column(DateTime, default=func.now()) @@ -208,6 +214,7 @@ class Binder(Base): binder_type = Column(String, default="collection") # "collection" or "wishlist" format = Column(String, nullable=True) # "Standard", "Expanded", "Unlimited", "Casual" icon_pokemon_id = Column(Integer, nullable=True) + is_public = Column(Boolean, default=False, nullable=False) auto_owned_set_id = Column(String, nullable=True) created_at = Column(DateTime, default=func.now()) diff --git a/backend/schemas.py b/backend/schemas.py index 603d233d..e2c95d74 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -238,6 +238,7 @@ class BinderUpdate(BaseModel): binder_type: Optional[str] = None format: Optional[str] = None icon_pokemon_id: Optional[int] = None + is_public: Optional[bool] = None class BinderCardUpdate(BaseModel): @@ -264,6 +265,7 @@ class BinderResponse(BaseModel): created_at: Optional[datetime] = None card_count: int = 0 unique_card_count: int = 0 + is_public: bool = False class Config: from_attributes = True @@ -475,3 +477,8 @@ class SyncLogResponse(BaseModel): class Config: from_attributes = True + + +class ProfileUpdate(BaseModel): + is_profile_public: Optional[bool] = None + public_show_values: Optional[bool] = None diff --git a/backend/services/card_numbers.py b/backend/services/card_numbers.py index 33554884..fa21d9eb 100644 --- a/backend/services/card_numbers.py +++ b/backend/services/card_numbers.py @@ -2,6 +2,27 @@ from typing import Optional _DIGITS_RE = re.compile(r"^\d+$") +_NATURAL_SORT_RE = re.compile(r"(\d+|\D+)") + + +def natural_card_number_key(number: Optional[str]) -> tuple: + """Sort card numbers naturally while preserving alphanumeric formats. + + Examples: + - 1, 2, 10 instead of 1, 10, 2 + - 001, 002, 010 still sort correctly + - 74, 74a, 74b and H04 are handled without converting the display value + """ + if number is None: + return ((2, ""),) + + parts = [] + for part in _NATURAL_SORT_RE.findall(str(number).strip()): + if part.isdigit(): + parts.append((0, int(part), len(part), part)) + else: + parts.append((1, part.casefold())) + return tuple(parts) or ((2, ""),) def normalize_card_number(value: object) -> str: diff --git a/backend/services/public_profile.py b/backend/services/public_profile.py new file mode 100644 index 00000000..eb377b98 --- /dev/null +++ b/backend/services/public_profile.py @@ -0,0 +1,306 @@ +import re +import unicodedata +from urllib.parse import quote + +from sqlalchemy import func +from sqlalchemy.orm import joinedload + +from models import User, Binder, BinderCard, Card, Setting +from services.card_numbers import natural_card_number_key +from services.card_values import effective_market_price + +HANDLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$") + +RESERVED_HANDLES = { + "admin", "api", "u", "settings", "login", "logout", "static", "assets", + "public", "profile", "me", "null", "undefined", "app", "www", +} + + +class HandleError(ValueError): + pass + + +class HandleConflictError(HandleError): + pass + + +def validate_handle(raw: str) -> str: + """Normalize and validate a public handle. Return the normalized handle or raise HandleError.""" + handle = (raw or "").strip().lower() + if not handle: + raise HandleError("Handle is required") + if len(handle) < 3 or len(handle) > 30: + raise HandleError("Handle must be 3–30 characters") + if "--" in handle: + raise HandleError("Handle cannot contain consecutive hyphens") + if not HANDLE_RE.match(handle): + raise HandleError("Handle may use lowercase letters, numbers and hyphens, and cannot start or end with a hyphen") + if handle in RESERVED_HANDLES: + raise HandleError("That handle is reserved") + return handle + + +def public_handle_from_trainer_name(raw: str) -> str: + """Create an ASCII URL handle, normalizing common Latin diacritics.""" + ascii_name = ( + unicodedata.normalize("NFKD", raw or "") + .encode("ascii", "ignore") + .decode("ascii") + .lower() + ) + handle = re.sub(r"[^a-z0-9]+", "-", ascii_name).strip("-") + handle = handle[:30].rstrip("-") + try: + return validate_handle(handle) + except HandleError as exc: + raise HandleError( + "Trainer name must produce a 3–30 character public URL using Latin letters or numbers" + ) from exc + +_DEFAULT_TRAINER_NAME = "TRAINER" +_PRICE_FIELD = "price_trend" +_TRAINER_NAME_HANDLE_MIGRATION_KEY = "public_trainer_name_handles_migrated" + + +def _public_card_image_url(card_id: str) -> str: + return f"/api/images/card/{quote(card_id, safe='')}/small" + + +def is_handle_available(db, handle: str, exclude_user_id: int | None = None) -> bool: + query = db.query(User.id).filter(User.public_handle == handle) + if exclude_user_id is not None: + query = query.filter(User.id != exclude_user_id) + return query.first() is None + + +def assign_public_handle(db, user: User, trainer_name: str | None = None) -> str: + """Assign the derived trainer-name handle, rejecting reserved or conflicting URLs.""" + handle = public_handle_from_trainer_name(trainer_name if trainer_name is not None else user.username) + if not is_handle_available(db, handle, exclude_user_id=user.id): + raise HandleConflictError("Another public profile already uses this trainer name") + user.public_handle = handle + return handle + + +def migrate_public_profile_handles(db) -> dict: + """Replace editable legacy handles with trainer-name handles on upgrade. + + Invalid or colliding profiles are disabled rather than exposed under a stale + URL. Users can fix their trainer name and opt in again from Settings. + """ + migration_marker = db.query(Setting).filter( + Setting.key == _TRAINER_NAME_HANDLE_MIGRATION_KEY + ).first() + if migration_marker and str(migration_marker.value).lower() == "true": + return {"migrated": 0, "disabled": 0} + + public_users = db.query(User).filter( + User.is_profile_public.is_(True) + ).order_by(User.id.asc()).all() + previous_handles = {user.id: user.public_handle for user in public_users} + db.query(User).update({User.public_handle: None}, synchronize_session="fetch") + db.flush() + + migrated = 0 + disabled = 0 + claimed: set[str] = set() + for user in public_users: + try: + handle = public_handle_from_trainer_name(user.username) + except HandleError: + user.public_handle = None + user.is_profile_public = False + disabled += 1 + continue + if handle in claimed: + user.public_handle = None + user.is_profile_public = False + disabled += 1 + continue + claimed.add(handle) + user.public_handle = handle + if previous_handles.get(user.id) != handle: + migrated += 1 + if migration_marker: + migration_marker.value = "true" + else: + db.add(Setting(key=_TRAINER_NAME_HANDLE_MIGRATION_KEY, value="true")) + db.commit() + return {"migrated": migrated, "disabled": disabled} + + +def get_live_profile(db, handle: str) -> User | None: + if not handle: + return None + return db.query(User).filter( + User.public_handle == handle, + User.is_profile_public.is_(True), + User.is_active.is_(True), + ).first() + + +def trainer_name_for(db, user: User) -> str: + return user.username or _DEFAULT_TRAINER_NAME + + +def public_profile_directory(db) -> list[dict]: + rows = ( + db.query( + User, + func.count(Binder.id).label("binder_count"), + ) + .outerjoin( + Binder, + (Binder.user_id == User.id) + & (Binder.is_public.is_(True)) + & (Binder.binder_type == "collection"), + ) + .filter( + User.is_profile_public.is_(True), + User.is_active.is_(True), + ) + .group_by(User.id) + .order_by(User.username.asc(), User.id.asc()) + .all() + ) + return [ + { + "handle": user.public_handle, + "trainer_name": trainer_name_for(db, user), + "avatar_id": user.avatar_id, + "binder_count": int(binder_count or 0), + } + for user, binder_count in rows + if user.public_handle + ] + + +def public_collection_binders(db, user: User) -> list[Binder]: + return db.query(Binder).filter( + Binder.user_id == user.id, + Binder.is_public.is_(True), + Binder.binder_type == "collection", + ).order_by(Binder.created_at.asc()).all() + + +def get_public_collection_binder(db, user_id: int, binder_id: int) -> Binder | None: + return db.query(Binder).filter( + Binder.id == binder_id, + Binder.user_id == user_id, + Binder.is_public.is_(True), + Binder.binder_type == "collection", + ).first() + + +def _binder_cards(db, binder: Binder) -> list[BinderCard]: + # Present cards in natural collector order: by set, then card number + # (1, 2, 10 β€” not 1, 10, 2), then variant so same-number prints stay grouped. + # Natural number ordering can't be expressed in SQL, so sort in Python; + # eager-load the card, its set, and the linked collection item so per-card + # number/variant/value reads don't issue a query each. + cards = ( + db.query(BinderCard) + .options( + joinedload(BinderCard.card).joinedload(Card.set_ref), + joinedload(BinderCard.collection_item), + ) + .filter(BinderCard.binder_id == binder.id) + .all() + ) + return sorted(cards, key=_card_sort_key) + + +def _binder_cards_for_binders(db, binders: list[Binder]) -> dict[int, list[BinderCard]]: + binder_ids = [binder.id for binder in binders] + if not binder_ids: + return {} + rows = ( + db.query(BinderCard) + .options( + joinedload(BinderCard.card).joinedload(Card.set_ref), + joinedload(BinderCard.collection_item), + ) + .filter(BinderCard.binder_id.in_(binder_ids)) + .all() + ) + grouped = {binder_id: [] for binder_id in binder_ids} + for row in rows: + grouped[row.binder_id].append(row) + for binder_id in grouped: + grouped[binder_id].sort(key=_card_sort_key) + return grouped + + +def _card_sort_key(bc: BinderCard) -> tuple: + card = bc.card + set_id = (card.set_id or "") if card else "" + number_key = natural_card_number_key(card.number if card else None) + return (set_id, number_key, _card_variant(bc) or "") + + +def _card_variant(bc: BinderCard) -> str | None: + return bc.collection_item.variant if bc.collection_item else None + + +def _serialize_card(bc: BinderCard, show_values: bool) -> dict: + card = bc.card + quantity = bc.required_quantity or 1 + variant = _card_variant(bc) + value = effective_market_price(card, variant, _PRICE_FIELD) if show_values else None + return { + "id": card.id, + "name": card.name, + "image": _public_card_image_url(card.id), + "set_name": card.set_ref.name if card.set_ref else None, + "number": card.number, + "rarity": card.rarity, + "lang": card.lang, + "variant": variant, + "quantity": quantity, + "market_value": value, + } + + +def serialize_binder_summary(db, binder: Binder, show_values: bool, cards: list[BinderCard] | None = None) -> dict: + cards = _binder_cards(db, binder) if cards is None else cards + unique = {bc.card_id for bc in cards} + total_count = sum((bc.required_quantity or 1) for bc in cards) + total_value = None + if show_values: + total_value = round(sum( + effective_market_price(bc.card, _card_variant(bc), _PRICE_FIELD) * (bc.required_quantity or 1) + for bc in cards if bc.card + ), 2) + return { + "id": binder.id, + "name": binder.name, + "color": binder.color, + "icon_pokemon_id": binder.icon_pokemon_id, + "card_count": total_count, + "unique_card_count": len(unique), + "total_value": total_value, + } + + +def serialize_binder_detail(db, binder: Binder, show_values: bool) -> dict: + cards = _binder_cards(db, binder) + summary = serialize_binder_summary(db, binder, show_values, cards=cards) + summary["cards"] = [_serialize_card(bc, show_values) for bc in cards if bc.card] + return summary + + +def serialize_profile(db, user: User) -> dict: + show_values = bool(user.public_show_values) + binders = public_collection_binders(db, user) + cards_by_binder = _binder_cards_for_binders(db, binders) + return { + "handle": user.public_handle, + "trainer_name": trainer_name_for(db, user), + "avatar_id": user.avatar_id, + "show_values": show_values, + "binders": [ + serialize_binder_summary(db, binder, show_values, cards=cards_by_binder.get(binder.id, [])) + for binder in binders + ], + } diff --git a/backend/services/public_profile_feature.py b/backend/services/public_profile_feature.py new file mode 100644 index 00000000..776abda5 --- /dev/null +++ b/backend/services/public_profile_feature.py @@ -0,0 +1,12 @@ +from models import Setting + + +PUBLIC_PROFILES_SETTING_KEY = "public_profiles_enabled" + + +def public_profiles_enabled(db) -> bool: + """Return the global public-sharing state. Missing means safely disabled.""" + row = db.query(Setting.value).filter( + Setting.key == PUBLIC_PROFILES_SETTING_KEY + ).first() + return bool(row and str(row[0]).lower() == "true") diff --git a/backend/tests/test_auth_admin_safety.py b/backend/tests/test_auth_admin_safety.py index f5e0f48b..1edf8f88 100644 --- a/backend/tests/test_auth_admin_safety.py +++ b/backend/tests/test_auth_admin_safety.py @@ -5,7 +5,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker - from api.auth import UpdateUserRequest, update_user + from api.auth import UpdateUserRequest, change_username, update_user from database import Base from models import User @@ -109,6 +109,54 @@ def test_can_update_only_active_admin_without_removing_admin_access(self): self.assertEqual(self.admin.role, "admin") self.assertTrue(self.admin.is_active) + def test_public_profile_url_tracks_self_service_trainer_name_change(self): + self.admin.username = "Owner" + self.admin.public_handle = "owner" + self.admin.is_profile_public = True + self.db.commit() + + result = change_username({"username": "Owner Name"}, current_user=self.admin, db=self.db) + + self.assertEqual(result["username"], "Owner Name") + self.db.refresh(self.admin) + self.assertEqual(self.admin.public_handle, "owner-name") + + def test_admin_user_edit_keeps_public_profile_url_in_sync(self): + trainer = User( + username="Misty", + hashed_password="x", + role="trainer", + is_active=True, + public_handle="misty", + is_profile_public=True, + ) + self.db.add(trainer) + self.db.commit() + + update_user( + trainer.id, + UpdateUserRequest(username="Misty Waterflower"), + current_user=self.admin, + db=self.db, + ) + + self.db.refresh(trainer) + self.assertEqual(trainer.public_handle, "misty-waterflower") + + def test_invalid_public_trainer_name_change_is_rejected(self): + self.admin.username = "Owner" + self.admin.public_handle = "owner" + self.admin.is_profile_public = True + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + change_username({"username": "πŸ”₯πŸ”₯"}, current_user=self.admin, db=self.db) + + self.assertEqual(exc.exception.status_code, 422) + self.db.refresh(self.admin) + self.assertEqual(self.admin.username, "Owner") + self.assertEqual(self.admin.public_handle, "owner") + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py new file mode 100644 index 00000000..7120e072 --- /dev/null +++ b/backend/tests/test_public_binders.py @@ -0,0 +1,705 @@ +import unittest + +try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from database import Base + from models import User, Binder, Setting + DEPS = True +except ModuleNotFoundError: + DEPS = False + + +@unittest.skipUnless(DEPS, "SQLAlchemy not installed in this lightweight test environment") +class PublicBindersModelTests(unittest.TestCase): + def _db(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + def test_new_columns_default_private(self): + db = self._db() + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + binder = Binder(name="Binder", binder_type="collection") + db.add_all([user, binder]) + db.commit() + db.refresh(user) + db.refresh(binder) + self.assertIsNone(user.public_handle) + self.assertFalse(user.is_profile_public) + self.assertFalse(user.public_show_values) + self.assertFalse(binder.is_public) + + +try: + from services.public_profile import ( + HandleError, + migrate_public_profile_handles, + public_handle_from_trainer_name, + validate_handle, + ) + SERVICE_DEPS = True +except ModuleNotFoundError: + SERVICE_DEPS = False + + +@unittest.skipUnless(SERVICE_DEPS, "service deps unavailable") +class HandleValidationTests(unittest.TestCase): + def test_valid_handle_is_normalized(self): + self.assertEqual(validate_handle(" Ash-Ketchum "), "ash-ketchum") + + def test_too_short_rejected(self): + with self.assertRaises(HandleError): + validate_handle("ab") + + def test_bad_chars_rejected(self): + with self.assertRaises(HandleError): + validate_handle("ash_ketchum") + + def test_leading_hyphen_rejected(self): + with self.assertRaises(HandleError): + validate_handle("-ash") + + def test_double_hyphen_rejected(self): + with self.assertRaises(HandleError): + validate_handle("ash--ketchum") + + def test_reserved_rejected(self): + with self.assertRaises(HandleError): + validate_handle("admin") + + def test_trainer_name_normalizes_common_latin_diacritics(self): + self.assertEqual(public_handle_from_trainer_name(" Gilles RomΓ©r! "), "gilles-romer") + + def test_problematic_trainer_name_is_rejected(self): + with self.assertRaises(HandleError): + public_handle_from_trainer_name("πŸ”₯") + + def test_reserved_trainer_name_is_rejected(self): + with self.assertRaises(HandleError): + public_handle_from_trainer_name("Admin") + + +try: + from services import public_profile as pp + from models import BinderCard, Card, Set + PP_DEPS = True +except ModuleNotFoundError: + PP_DEPS = False + + +@unittest.skipUnless(PP_DEPS, "service deps unavailable") +class SerializationTests(unittest.TestCase): + def _db(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + def _seed(self, db, *, profile_public=True, binder_public=True, show_values=False): + user = User(username="ash", hashed_password="x", role="trainer", is_active=True, + public_handle="ash", is_profile_public=profile_public, public_show_values=show_values) + db.add_all([ + user, + Set(id="sv1_en", tcg_set_id="sv1", name="Scarlet & Violet", lang="en", total=1), + Card(id="sv1-1_en", tcg_card_id="sv1-1", name="Sprigatito", set_id="sv1", + number="1", lang="en", rarity="Common", images_small="https://img/s.webp", + price_trend=5.0), + ]) + db.commit() + binder = Binder(name="Starters", user_id=user.id, binder_type="collection", is_public=binder_public) + db.add(binder) + db.commit() + db.add(BinderCard(binder_id=binder.id, card_id="sv1-1_en", required_quantity=2)) + db.commit() + return user, binder + + def test_get_live_profile_requires_public(self): + db = self._db() + self._seed(db, profile_public=False) + self.assertIsNone(pp.get_live_profile(db, "ash")) + + def test_serialize_profile_lists_only_public_binders(self): + db = self._db() + user, _ = self._seed(db, binder_public=False) + data = pp.serialize_profile(db, user) + self.assertEqual(data["trainer_name"], "ash") + self.assertEqual(data["binders"], []) + + def test_binder_detail_hides_values_when_off(self): + db = self._db() + _, binder = self._seed(db, show_values=False) + detail = pp.serialize_binder_detail(db, binder, show_values=False) + self.assertEqual(detail["cards"][0]["name"], "Sprigatito") + self.assertEqual(detail["cards"][0]["image"], "/api/images/card/sv1-1_en/small") + self.assertEqual(detail["cards"][0]["quantity"], 2) + self.assertIsNone(detail["cards"][0]["market_value"]) + self.assertIsNone(detail["total_value"]) + + def test_public_card_proxy_url_encodes_custom_identifiers(self): + self.assertEqual( + pp._public_card_image_url("custom card#1"), + "/api/images/card/custom%20card%231/small", + ) + + def test_binder_detail_shows_values_when_on(self): + db = self._db() + _, binder = self._seed(db, show_values=True) + detail = pp.serialize_binder_detail(db, binder, show_values=True) + self.assertEqual(detail["cards"][0]["market_value"], 5.0) + self.assertEqual(detail["total_value"], 10.0) # 5.0 * qty 2 + + def test_no_private_fields_leak(self): + db = self._db() + _, binder = self._seed(db, show_values=True) + detail = pp.serialize_binder_detail(db, binder, show_values=True) + card = detail["cards"][0] + for banned in ("purchase_price", "condition", "user_id", "username"): + self.assertNotIn(banned, card) + + def test_serialized_card_includes_variant(self): + from models import CollectionItem + db = self._db() + _, binder = self._seed(db) + # Link the binder card to a collection item carrying a variant. + item = CollectionItem(card_id="sv1-1_en", user_id=1, quantity=2, + variant="Reverse Holo", condition="NM") + db.add(item) + db.commit() + bc = db.query(BinderCard).filter(BinderCard.binder_id == binder.id).first() + bc.collection_item_id = item.id + db.commit() + detail = pp.serialize_binder_detail(db, binder, show_values=False) + self.assertEqual(detail["cards"][0]["variant"], "Reverse Holo") + self.assertEqual(detail["cards"][0]["lang"], "en") + + def test_serialized_card_variant_defaults_none_without_collection_item(self): + db = self._db() + _, binder = self._seed(db) # seed BinderCard has no collection_item_id + detail = pp.serialize_binder_detail(db, binder, show_values=False) + self.assertIsNone(detail["cards"][0]["variant"]) + + def test_binder_detail_orders_by_card_number_naturally(self): + from datetime import datetime + db = self._db() + _, binder = self._seed(db) # seed card is number "1", added first + # Add cards whose numbers, sorted as strings, would interleave wrongly + # (10 before 2), and whose add order is the reverse of number order. + db.add_all([ + Card(id="sv1-10_en", tcg_card_id="sv1-10", name="Ten", set_id="sv1", + number="10", lang="en", rarity="Common", price_trend=1.0), + Card(id="sv1-2_en", tcg_card_id="sv1-2", name="Two", set_id="sv1", + number="2", lang="en", rarity="Common", price_trend=1.0), + ]) + db.commit() + db.query(BinderCard).filter(BinderCard.binder_id == binder.id).first().added_at = datetime(2026, 1, 1) + db.add(BinderCard(binder_id=binder.id, card_id="sv1-10_en", required_quantity=1, added_at=datetime(2026, 2, 1))) + db.add(BinderCard(binder_id=binder.id, card_id="sv1-2_en", required_quantity=1, added_at=datetime(2026, 3, 1))) + db.commit() + detail = pp.serialize_binder_detail(db, binder, show_values=False) + self.assertEqual([c["number"] for c in detail["cards"]], ["1", "2", "10"]) + + +try: + from fastapi import HTTPException + from api.public import get_public_profile, get_public_binder, list_public_profiles + API_DEPS = True +except ModuleNotFoundError: + API_DEPS = False + + +@unittest.skipUnless(API_DEPS, "api deps unavailable") +class PublicApiTests(unittest.TestCase): + def _db(self, enabled=True): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + if enabled: + db.add(Setting(key="public_profiles_enabled", value="true")) + db.commit() + return db + + def _seed(self, db, **kw): + return SerializationTests()._seed(db, **kw) + + def test_unknown_handle_404(self): + db = self._db() + with self.assertRaises(HTTPException) as ctx: + get_public_profile("nobody", db=db) + self.assertEqual(ctx.exception.status_code, 404) + + def test_feature_is_disabled_when_setting_is_missing(self): + db = self._db(enabled=False) + self._seed(db) + with self.assertRaises(HTTPException) as ctx: + get_public_profile("ash", db=db) + self.assertEqual(ctx.exception.status_code, 404) + self.assertEqual(ctx.exception.headers["Cache-Control"], "no-store") + + def test_private_profile_404(self): + db = self._db() + self._seed(db, profile_public=False) + with self.assertRaises(HTTPException) as ctx: + get_public_profile("ash", db=db) + self.assertEqual(ctx.exception.status_code, 404) + + def test_public_profile_returns_binders(self): + db = self._db() + self._seed(db) + result = get_public_profile("ash", db=db) + self.assertEqual(result["handle"], "ash") + self.assertEqual(len(result["binders"]), 1) + + def test_directory_lists_only_live_public_profiles_with_public_binder_counts(self): + db = self._db() + self._seed(db) + private = User(username="misty", hashed_password="x", role="trainer", is_active=True, + public_handle="misty", is_profile_public=False) + inactive = User(username="brock", hashed_password="x", role="trainer", is_active=False, + public_handle="brock", is_profile_public=True) + db.add_all([private, inactive]) + db.commit() + result = list_public_profiles(db=db) + self.assertEqual(result, [{ + "handle": "ash", + "trainer_name": "ash", + "avatar_id": None, + "binder_count": 1, + }]) + + def test_directory_excludes_public_wishlist_binders(self): + db = self._db() + user, _ = self._seed(db) + db.add(Binder(name="Wishlist", user_id=user.id, binder_type="wishlist", is_public=True)) + db.commit() + result = list_public_profiles(db=db) + self.assertEqual(result[0]["binder_count"], 1) + + def test_private_binder_404(self): + db = self._db() + _, binder = self._seed(db, binder_public=False) + with self.assertRaises(HTTPException) as ctx: + get_public_binder("ash", binder.id, db=db) + self.assertEqual(ctx.exception.status_code, 404) + + def test_cross_owner_binder_404(self): + db = self._db() + self._seed(db) + # A public binder id that belongs to a different (nonexistent) handle path + other = Binder(name="Other", user_id=999, binder_type="collection", is_public=True) + db.add(other) + db.commit() + with self.assertRaises(HTTPException) as ctx: + get_public_binder("ash", other.id, db=db) + self.assertEqual(ctx.exception.status_code, 404) + + def test_success_sets_short_revalidating_cache(self): + from fastapi import Response + db = self._db() + _, binder = self._seed(db) + resp = Response() + get_public_binder("ash", binder.id, db=db, response=resp) + cc = resp.headers["Cache-Control"] + self.assertIn("max-age=0", cc) + self.assertIn("must-revalidate", cc) + + def test_not_found_explicitly_disables_caching(self): + from fastapi import Response + db = self._db() # no seed β†’ unknown handle + resp = Response() + with self.assertRaises(HTTPException) as ctx: + get_public_binder("ash", 1, db=db, response=resp) + self.assertEqual(ctx.exception.headers["Cache-Control"], "no-store") + + +try: + from api.profile import update_profile, get_profile + from schemas import ProfileUpdate + PROFILE_DEPS = True +except ModuleNotFoundError: + PROFILE_DEPS = False + + +@unittest.skipUnless(PROFILE_DEPS, "profile api deps unavailable") +class ProfileControlTests(unittest.TestCase): + def _db(self, enabled=True): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + if enabled: + db.add(Setting(key="public_profiles_enabled", value="true")) + db.commit() + return db + + def _user(self, db, username="ash"): + u = User(username=username, hashed_password="x", role="trainer", is_active=True) + db.add(u) + db.commit() + db.refresh(u) + return u + + def test_publish_uses_trainer_name_handle(self): + db = self._db() + u = self._user(db, "Ash Ketchum") + result = update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) + self.assertEqual(result["public_handle"], "ash-ketchum") + self.assertEqual(result["trainer_name"], "Ash Ketchum") + self.assertTrue(result["is_profile_public"]) + + def test_invalid_trainer_name_422(self): + db = self._db() + u = self._user(db, "πŸ”₯") + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) + self.assertEqual(ctx.exception.status_code, 422) + db.refresh(u) + self.assertFalse(u.is_profile_public) + self.assertIsNone(u.public_handle) + + def test_duplicate_derived_handle_409(self): + db = self._db() + taken = self._user(db, "Misty Star") + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=taken) + me = self._user(db, "Misty-Star") + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=me) + self.assertEqual(ctx.exception.status_code, 409) + db.refresh(me) + self.assertFalse(me.is_profile_public) + self.assertIsNone(me.public_handle) + + def test_concurrent_unique_constraint_is_returned_as_409(self): + from sqlalchemy.exc import IntegrityError + from unittest.mock import patch + db = self._db() + u = self._user(db) + with ( + patch.object(pp, "is_handle_available", return_value=True), + patch.object( + db, + "commit", + side_effect=IntegrityError("insert", {}, Exception("duplicate public_handle")), + ), + patch.object(db, "rollback") as rollback, + ): + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) + self.assertEqual(ctx.exception.status_code, 409) + rollback.assert_called_once() + + def test_unrelated_integrity_error_is_not_mislabeled_as_handle_conflict(self): + from sqlalchemy.exc import IntegrityError + from unittest.mock import patch + db = self._db() + u = self._user(db) + failure = IntegrityError("insert", {}, Exception("unrelated check constraint")) + with ( + patch.object(pp, "is_handle_available", return_value=True), + patch.object(db, "commit", side_effect=failure), + patch.object(db, "rollback") as rollback, + ): + with self.assertRaises(IntegrityError): + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) + rollback.assert_called_once() + + def test_disabling_profile_releases_stored_handle(self): + db = self._db() + u = self._user(db) + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) + result = update_profile(ProfileUpdate(is_profile_public=False), db=db, current_user=u) + db.refresh(u) + self.assertIsNone(u.public_handle) + self.assertEqual(result["public_handle"], "ash") + self.assertFalse(result["is_profile_public"]) + + def test_profile_controls_are_read_only_while_feature_disabled(self): + db = self._db(enabled=False) + u = self._user(db) + result = get_profile(db=db, current_user=u) + self.assertFalse(result["feature_enabled"]) + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) + self.assertEqual(ctx.exception.status_code, 403) + + def test_get_profile_returns_current_user_values(self): + db = self._db() + u = self._user(db, "Ash K") + update_profile(ProfileUpdate(is_profile_public=True, public_show_values=True), + db=db, current_user=u) + result = get_profile(db=db, current_user=u) + self.assertEqual(result["public_handle"], "ash-k") + self.assertEqual(result["trainer_name"], "Ash K") + self.assertIsNone(result["public_handle_error"]) + self.assertTrue(result["is_profile_public"]) + self.assertTrue(result["public_show_values"]) + + def test_get_profile_explains_invalid_trainer_name(self): + db = self._db() + u = self._user(db, "πŸ”₯") + result = get_profile(db=db, current_user=u) + self.assertIsNone(result["public_handle"]) + self.assertIn("Trainer name", result["public_handle_error"]) + + +@unittest.skipUnless(SERVICE_DEPS, "service deps unavailable") +class PublicHandleMigrationTests(unittest.TestCase): + def test_migration_replaces_legacy_handles_and_disables_collisions(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + first = User(username="Ash K", hashed_password="x", role="trainer", is_active=True, + public_handle="custom-one", is_profile_public=True) + collision = User(username="Ash-K", hashed_password="x", role="trainer", is_active=True, + public_handle="custom-two", is_profile_public=True) + private = User(username="Misty", hashed_password="x", role="trainer", is_active=True, + public_handle="legacy", is_profile_public=False) + db.add_all([first, collision, private]) + db.commit() + result = migrate_public_profile_handles(db) + db.refresh(first) + db.refresh(collision) + db.refresh(private) + self.assertEqual(result, {"migrated": 1, "disabled": 1}) + self.assertEqual(first.public_handle, "ash-k") + self.assertFalse(collision.is_profile_public) + self.assertIsNone(collision.public_handle) + self.assertIsNone(private.public_handle) + + repeat = migrate_public_profile_handles(db) + db.refresh(first) + self.assertEqual(repeat, {"migrated": 0, "disabled": 0}) + self.assertEqual(first.public_handle, "ash-k") + + +try: + from api.binders import update_binder + from schemas import BinderUpdate + BINDER_DEPS = True +except ModuleNotFoundError: + BINDER_DEPS = False + + +@unittest.skipUnless(BINDER_DEPS, "binder api deps unavailable") +class BinderPublicToggleTests(unittest.TestCase): + def _db(self, enabled=True): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + if enabled: + db.add(Setting(key="public_profiles_enabled", value="true")) + db.commit() + return db + + def test_update_binder_sets_is_public(self): + db = self._db() + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + db.refresh(user) + binder = Binder(name="B", user_id=user.id, binder_type="collection") + db.add(binder) + db.commit() + resp = update_binder(binder.id, BinderUpdate(is_public=True), db=db, current_user=user) + self.assertTrue(resp.is_public) + + def test_public_toggle_is_rejected_while_feature_disabled(self): + db = self._db(enabled=False) + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + binder = Binder(name="B", user_id=user.id, binder_type="collection", is_public=True) + db.add(binder) + db.commit() + with self.assertRaises(HTTPException) as ctx: + update_binder(binder.id, BinderUpdate(is_public=False), db=db, current_user=user) + self.assertEqual(ctx.exception.status_code, 403) + db.refresh(binder) + self.assertTrue(binder.is_public) + + def test_public_toggle_requires_a_boolean(self): + db = self._db() + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + binder = Binder(name="B", user_id=user.id, binder_type="collection") + db.add(binder) + db.commit() + with self.assertRaises(HTTPException) as ctx: + update_binder(binder.id, BinderUpdate(is_public=None), db=db, current_user=user) + self.assertEqual(ctx.exception.status_code, 422) + + def test_wishlist_binder_cannot_be_published(self): + db = self._db() + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + binder = Binder(name="Wishlist", user_id=user.id, binder_type="wishlist") + db.add(binder) + db.commit() + with self.assertRaises(HTTPException) as ctx: + update_binder(binder.id, BinderUpdate(is_public=True), db=db, current_user=user) + self.assertEqual(ctx.exception.status_code, 422) + db.refresh(binder) + self.assertFalse(binder.is_public) + + def test_public_collection_becomes_private_when_changed_to_wishlist(self): + db = self._db() + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + binder = Binder( + name="Collection", user_id=user.id, binder_type="collection", is_public=True + ) + db.add(binder) + db.commit() + response = update_binder( + binder.id, BinderUpdate(binder_type="wishlist"), db=db, current_user=user + ) + self.assertEqual(response.binder_type, "wishlist") + self.assertFalse(response.is_public) + + def test_flagged_wishlist_becomes_private_when_changed_to_collection(self): + db = self._db() + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + binder = Binder( + name="Legacy", user_id=user.id, binder_type="wishlist", is_public=True + ) + db.add(binder) + db.commit() + response = update_binder( + binder.id, BinderUpdate(binder_type="collection"), db=db, current_user=user + ) + self.assertEqual(response.binder_type, "collection") + self.assertFalse(response.is_public) + + def test_combined_wishlist_conversion_and_publication_is_rejected(self): + db = self._db() + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + binder = Binder(name="Collection", user_id=user.id, binder_type="collection") + db.add(binder) + db.commit() + with self.assertRaises(HTTPException) as ctx: + update_binder( + binder.id, + BinderUpdate(binder_type="wishlist", is_public=True), + db=db, + current_user=user, + ) + self.assertEqual(ctx.exception.status_code, 422) + db.refresh(binder) + self.assertEqual(binder.binder_type, "collection") + self.assertFalse(binder.is_public) + + def test_unrelated_binder_edit_still_works_while_feature_disabled(self): + db = self._db(enabled=False) + user = User(username="ash", hashed_password="x", role="trainer", is_active=True) + db.add(user) + db.commit() + binder = Binder(name="Before", user_id=user.id, binder_type="collection", is_public=True) + db.add(binder) + db.commit() + response = update_binder(binder.id, BinderUpdate(name="After"), db=db, current_user=user) + self.assertEqual(response.name, "After") + self.assertTrue(response.is_public) + + +try: + from api.social import _load_user_stats + SOCIAL_DEPS = True +except ModuleNotFoundError: + SOCIAL_DEPS = False + + +@unittest.skipUnless(SOCIAL_DEPS, "social deps unavailable") +class LeaderboardHandleTests(unittest.TestCase): + def test_row_includes_public_handle(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + u = User(username="ash", hashed_password="x", role="trainer", is_active=True, + public_handle="ash", is_profile_public=True) + db.add_all([u, Setting(key="public_profiles_enabled", value="true")]) + db.commit() + stats = _load_user_stats(db) + self.assertIn(u.id, stats) + self.assertEqual(stats[u.id]["public_handle"], "ash") + + def test_row_hides_handle_when_profile_not_public(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + u = User(username="ghost", hashed_password="x", role="trainer", is_active=True, + public_handle="ghost", is_profile_public=False) + db.add_all([u, Setting(key="public_profiles_enabled", value="true")]) + db.commit() + stats = _load_user_stats(db) + self.assertIn(u.id, stats) + self.assertIsNone(stats[u.id]["public_handle"]) + + def test_row_hides_handle_when_feature_disabled(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + u = User(username="ash", hashed_password="x", role="trainer", is_active=True, + public_handle="ash", is_profile_public=True) + db.add(u) + db.commit() + self.assertIsNone(_load_user_stats(db)[u.id]["public_handle"]) + + +try: + from api.settings import _get_user_settings, set_setting, update_settings + SETTINGS_DEPS = True +except ModuleNotFoundError: + SETTINGS_DEPS = False + + +@unittest.skipUnless(SETTINGS_DEPS, "settings api deps unavailable") +class PublicProfilesGlobalSettingTests(unittest.TestCase): + def _db(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + def _user(self, db, username, role): + user = User(username=username, hashed_password="x", role=role, is_active=True) + db.add(user) + db.commit() + db.refresh(user) + return user + + def test_global_feature_defaults_disabled(self): + db = self._db() + admin = self._user(db, "admin-user", "admin") + self.assertEqual(_get_user_settings(db, admin.id)["public_profiles_enabled"], "false") + + def test_admin_can_enable_global_feature(self): + db = self._db() + admin = self._user(db, "admin-user", "admin") + result = set_setting( + "public_profiles_enabled", {"value": "true"}, db=db, current_user=admin + ) + self.assertEqual(result["value"], "true") + self.assertEqual(_get_user_settings(db, admin.id)["public_profiles_enabled"], "true") + + def test_trainer_cannot_change_global_feature(self): + db = self._db() + trainer = self._user(db, "trainer-user", "trainer") + with self.assertRaises(HTTPException) as ctx: + set_setting( + "public_profiles_enabled", {"value": "true"}, db=db, current_user=trainer + ) + self.assertEqual(ctx.exception.status_code, 403) + + def test_trainer_cannot_change_global_feature_through_bulk_settings(self): + db = self._db() + trainer = self._user(db, "trainer-user", "trainer") + with self.assertRaises(HTTPException) as ctx: + update_settings( + {"public_profiles_enabled": "true"}, db=db, current_user=trainer + ) + self.assertEqual(ctx.exception.status_code, 403) + self.assertEqual(_get_user_settings(db, trainer.id)["public_profiles_enabled"], "false") diff --git a/backend/tests/test_public_cache_policy.py b/backend/tests/test_public_cache_policy.py new file mode 100644 index 00000000..b969e0be --- /dev/null +++ b/backend/tests/test_public_cache_policy.py @@ -0,0 +1,77 @@ +import asyncio +import unittest + +try: + from starlette.requests import Request + from starlette.responses import Response + + from main import prevent_failed_public_response_caching + + DEPS_AVAILABLE = True +except ModuleNotFoundError: + DEPS_AVAILABLE = False + + +def _request(path: str) -> Request: + return Request({ + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 1234), + "server": ("testserver", 80), + }) + + +@unittest.skipUnless(DEPS_AVAILABLE, "Backend dependencies are not installed") +class PublicCachePolicyTests(unittest.TestCase): + def test_every_failed_public_api_response_is_no_store(self): + async def call_next(_request): + return Response(status_code=422) + + response = asyncio.run( + prevent_failed_public_response_caching( + _request("/api/public/profiles/ash/binders/not-an-id"), + call_next, + ) + ) + self.assertEqual(response.headers["Cache-Control"], "no-store") + + def test_non_public_failures_are_not_changed(self): + async def call_next(_request): + return Response(status_code=422) + + response = asyncio.run( + prevent_failed_public_response_caching( + _request("/api/settings/"), + call_next, + ) + ) + self.assertNotIn("Cache-Control", response.headers) + + def test_successful_public_cache_policy_is_preserved(self): + async def call_next(_request): + return Response( + status_code=200, + headers={"Cache-Control": "public, max-age=0, must-revalidate"}, + ) + + response = asyncio.run( + prevent_failed_public_response_caching( + _request("/api/public/profiles"), + call_next, + ) + ) + self.assertEqual( + response.headers["Cache-Control"], + "public, max-age=0, must-revalidate", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b5eacb0..bb774cd6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pokemon-tcg-collection", - "version": "1.25.1", + "version": "1.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pokemon-tcg-collection", - "version": "1.25.1", + "version": "1.26.0", "dependencies": { "@tanstack/react-query": "^5.18.0", "axios": "^1.18.0", diff --git a/frontend/package.json b/frontend/package.json index 928d9012..c2625ecb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "pokemon-tcg-collection", "private": true, - "version": "1.25.1", + "version": "1.26.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7055086a..052a40b7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom' import { Suspense, lazy, useState } from 'react' import { useMutation } from '@tanstack/react-query' import PokeBallLoader from './components/PokeBallLoader' @@ -7,6 +7,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext' import { forceChangePassword } from './api/client' import Layout from './components/Layout' import { useSettings } from './contexts/SettingsContext' +import PublicHomeButton from './components/PublicHomeButton' const HomeScreen = lazy(() => import('./pages/HomeScreen')) const Dashboard = lazy(() => import('./pages/Dashboard')) @@ -29,6 +30,9 @@ const Leaderboard = lazy(() => import('./pages/Leaderboard')) const Compare = lazy(() => import('./pages/Compare')) const Achievements = lazy(() => import('./pages/Achievements')) const UserCollection = lazy(() => import('./pages/UserCollection')) +const PublicProfile = lazy(() => import('./pages/PublicProfile')) +const PublicBinderView = lazy(() => import('./pages/PublicBinderView')) +const PublicDirectory = lazy(() => import('./pages/PublicDirectory')) function RouteLoader() { return ( @@ -109,6 +113,15 @@ function lazyRoute(element) { return }>{element} } +function PublicRoutes() { + return ( + <> + + + + ) +} + function ProtectedRoutes() { const { user, loading, multiUser } = useAuth() @@ -172,6 +185,11 @@ export default function App() { )} /> + }> + )} /> + )} /> + )} /> + } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 1f7cfb66..edcc5283 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,4 +1,5 @@ import axios from 'axios' +import { isPublicSharePath } from '../utils/publicRoutes' const api = axios.create({ baseURL: '/api', @@ -23,7 +24,7 @@ api.interceptors.response.use( const token = localStorage.getItem('token') localStorage.removeItem('token') localStorage.removeItem('user') - if (token && window.location.pathname !== '/login') { + if (token && window.location.pathname !== '/login' && !isPublicSharePath(window.location.pathname)) { window.location.href = '/login' } } @@ -82,6 +83,10 @@ export const getApiErrorMessage = (error, fallback = 'Request failed') => { // Settings export const getTcgdexFilterLanguages = () => api.get('/settings/tcgdex-filter-languages').then(r => r.data) +// Public profile (owner controls) +export const getProfile = () => api.get('/profile/').then(r => r.data) +export const updateProfile = (data) => api.put('/profile/', data).then(r => r.data) + // Cards export const searchCards = (params) => api.get('/cards/search', { params }) export const getCard = (id) => api.get(`/cards/${id}`) diff --git a/frontend/src/api/publicClient.js b/frontend/src/api/publicClient.js new file mode 100644 index 00000000..74d0f9a2 --- /dev/null +++ b/frontend/src/api/publicClient.js @@ -0,0 +1,18 @@ +import axios from 'axios' + +// A separate instance from api/client.js: NO Authorization header and NO 401->/login +// redirect, so anonymous visitors on public pages are never bounced to the login screen. +const publicApi = axios.create({ + baseURL: '/api/public', + timeout: 30000, + headers: { 'Content-Type': 'application/json' }, +}) + +export const getPublicProfile = (handle) => + publicApi.get(`/profiles/${encodeURIComponent(handle)}`).then(r => r.data) + +export const getPublicProfiles = () => + publicApi.get('/profiles').then(r => r.data) + +export const getPublicBinder = (handle, binderId) => + publicApi.get(`/profiles/${encodeURIComponent(handle)}/binders/${binderId}`).then(r => r.data) diff --git a/frontend/src/components/CardItem.jsx b/frontend/src/components/CardItem.jsx index ddb84089..38fff387 100644 --- a/frontend/src/components/CardItem.jsx +++ b/frontend/src/components/CardItem.jsx @@ -18,7 +18,7 @@ import { invalidateCardState, invalidateTcgdexFilterLanguages } from '../utils/q import { parseMoneyInputValue } from '../utils/moneyInput' import { cardmarketLinks } from '../utils/cardmarket' import CardStateIndicators from './CardStateIndicators' -import { getCardRarityEffectClass } from '../utils/cardRarity' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' function askWishlistQuantity(t, defaultQuantity = 1) { const initialQuantity = Math.max(1, Math.min(99, parseInt(defaultQuantity, 10) || 1)) @@ -426,13 +426,13 @@ export const CardItem = memo(function CardItem({ card, showActions = true, onAdd ?? card.price_trend) const rarityColor = RARITY_COLORS[cardRarity] || 'text-text-secondary' - const rarityEffectClass = getCardRarityEffectClass(cardRarity, card.lang || card.set_ref?.lang) + const variantEffectClass = getCardVariantEffectClass(card) const { ref: tiltRef, onMouseMove: tiltMove, onMouseLeave: tiltLeave } = useTilt(10) if (compact) { return (
setShowModal(true)} onMouseMove={tiltMove} onMouseLeave={tiltLeave}> -
+
{cardImage ? ( {cardName} @@ -449,7 +449,7 @@ export const CardItem = memo(function CardItem({ card, showActions = true, onAdd return ( <>
setShowModal(true)} onMouseMove={tiltMove} onMouseLeave={tiltLeave}> -
+
{cardImage ? ( {cardName} @@ -709,9 +709,9 @@ export function CardModal({ card, onClose, onEdit, defaultLang = 'en', ownedItem
-
+
{cardImage ? ( - {card.name} + {card.name} ) : (
{t('common.noImage')} diff --git a/frontend/src/components/CardListItem.jsx b/frontend/src/components/CardListItem.jsx index 6b7ada64..fb5bd03e 100644 --- a/frontend/src/components/CardListItem.jsx +++ b/frontend/src/components/CardListItem.jsx @@ -1,5 +1,6 @@ import CardImage from './CardImage' import clsx from 'clsx' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' /** * CardListItem β€” Reusable card row for Collection, Wishlist, Search results, etc. @@ -17,6 +18,7 @@ import clsx from 'clsx' * onClick {fn} β€” makes row clickable * rightAction {node} β€” optional right-side action element (e.g. delete button) * className {string} + * variantEffectSource {string|object|array} β€” exact/grouped variant data for shine */ export default function CardListItem({ image, @@ -28,6 +30,7 @@ export default function CardListItem({ onClick, rightAction, className = '', + variantEffectSource = null, }) { return (
e.key === 'Enter' && onClick(e) : undefined} > {/* Card thumbnail */} -
+
diff --git a/frontend/src/components/CardStateIndicators.jsx b/frontend/src/components/CardStateIndicators.jsx index bd5f18e6..0a2b6116 100644 --- a/frontend/src/components/CardStateIndicators.jsx +++ b/frontend/src/components/CardStateIndicators.jsx @@ -21,7 +21,15 @@ export const getCardState = (card = {}, showOwnership = true, showWishlist = tru } /** Reusable, non-positioned ownership and wishlist indicators for card art. */ -export default function CardStateIndicators({ card, compact = false, showOwnership = true, showWishlist = true, className = '' }) { +export default function CardStateIndicators({ + card, + compact = false, + showOwnership = true, + showWishlist = true, + showQuantity = true, + alwaysShowQuantity = false, + className = '', +}) { const { t } = useSettings() const { variants, genericOwned, wishlisted } = getCardState(card, showOwnership, showWishlist) if (!variants.length && !genericOwned && !wishlisted) return null @@ -32,10 +40,11 @@ export default function CardStateIndicators({ card, compact = false, showOwnersh const Icon = VARIANT_ICONS[variant] const meta = VARIANT_PILL_META[variant] const label = translatedVariantLabel(t, variant) - const title = quantity > 1 ? `${label} Γ—${quantity}` : label + const quantityVisible = showQuantity && (alwaysShowQuantity || quantity > 1) + const title = quantityVisible ? `${label} Γ—${quantity}` : label return {Icon ? : (meta?.code || variant.slice(0, 3).toUpperCase())} - {quantity > 1 && Γ—{quantity}} + {quantityVisible && Γ—{quantity}} })} {genericOwned && } @@ -44,7 +53,12 @@ export default function CardStateIndicators({ card, compact = false, showOwnersh
} -export function CardStateLegend({ className = '' }) { +export function CardStateLegend({ + className = '', + showOwnershipFallback = true, + showWishlist = true, + showQuantity = true, +}) { const { t } = useSettings() return ( @@ -63,30 +77,30 @@ export function CardStateLegend({ className = '' }) {
) })} -
+ {showOwnershipFallback &&
{t('setDetail.ownedVariantUnknown')} -
-
+
} + {showWishlist &&
{t('nav.wishlist')} -
-
+
} + {showQuantity &&
Γ—2 {t('setDetail.badgeQuantity')} -
+
}
) } diff --git a/frontend/src/components/CardStateIndicators.test.js b/frontend/src/components/CardStateIndicators.test.js index 6230d2d8..73ec913f 100644 --- a/frontend/src/components/CardStateIndicators.test.js +++ b/frontend/src/components/CardStateIndicators.test.js @@ -1,7 +1,7 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -import { CardStateLegend, getCardState } from './CardStateIndicators' +import CardStateIndicators, { CardStateLegend, getCardState } from './CardStateIndicators' vi.mock('../contexts/SettingsContext', () => ({ useSettings: () => ({ @@ -47,4 +47,57 @@ describe('CardStateLegend', () => { expect(markup).toContain(label) } }) + + it('can show the public binder subset without private-state markers', () => { + const markup = renderToStaticMarkup(createElement(CardStateLegend, { + showOwnershipFallback: false, + showWishlist: false, + })) + + expect(markup).toContain('Reverse Holo') + expect(markup).toContain('Quantity owned') + expect(markup).not.toContain('Owned (variant unknown)') + expect(markup).not.toContain('Wishlist') + }) + + it('can explain private binder variants without duplicating its separate amount badge', () => { + const markup = renderToStaticMarkup(createElement(CardStateLegend, { + showOwnershipFallback: false, + showWishlist: false, + showQuantity: false, + })) + + for (const label of ['Normal', 'Holo', 'Reverse Holo', 'First Edition']) { + expect(markup).toContain(label) + } + expect(markup).not.toContain('Quantity owned') + expect(markup).not.toContain('Γ—2') + }) +}) + +describe('CardStateIndicators', () => { + it('can show every grouped variant quantity, including one copy', () => { + const markup = renderToStaticMarkup(createElement(CardStateIndicators, { + card: { + owned_variants: [ + { variant: 'Normal', quantity: 1 }, + { variant: 'Reverse Holo', quantity: 2 }, + ], + }, + alwaysShowQuantity: true, + })) + + expect(markup).toContain('Normal Γ—1') + expect(markup).toContain('Reverse Holo Γ—2') + }) + + it('can hide quantities when a separate amount badge is present', () => { + const markup = renderToStaticMarkup(createElement(CardStateIndicators, { + card: { owned_variants: [{ variant: 'Normal', quantity: 3 }] }, + showQuantity: false, + })) + + expect(markup).toContain('aria-label="Normal"') + expect(markup).not.toContain('Γ—3') + }) }) diff --git a/frontend/src/components/PublicHomeButton.jsx b/frontend/src/components/PublicHomeButton.jsx new file mode 100644 index 00000000..74d8f83d --- /dev/null +++ b/frontend/src/components/PublicHomeButton.jsx @@ -0,0 +1,28 @@ +import { Link } from 'react-router-dom' +import { useSettings } from '../contexts/SettingsContext' + +export default function PublicHomeButton() { + const { t } = useSettings() + + return ( + + + + + + + ) +} diff --git a/frontend/src/components/RarityBorder.jsx b/frontend/src/components/RarityBorder.jsx deleted file mode 100644 index 91d5062e..00000000 --- a/frontend/src/components/RarityBorder.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { getCardRarityEffectClass } from '../utils/cardRarity' - -// Wraps a card image div and adds rarity-based visual effects -// rarity: string from the card data (e.g. "Rare Holo", "Ultra Rare", "Secret Rare", "Common", etc.) -export default function RarityBorder({ rarity = '', language = 'en', children, className = '' }) { - const rarityClass = getCardRarityEffectClass(rarity, language) - - return ( -
- {children} -
- ) -} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index c658f912..300bb9c0 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -38,6 +38,7 @@ export function AuthProvider({ children }) { localStorage.setItem('user', JSON.stringify(currentUser)) }) } + setUser(null) }) .catch(() => { localStorage.removeItem('token') diff --git a/frontend/src/contexts/SettingsContext.jsx b/frontend/src/contexts/SettingsContext.jsx index b5783d1b..c6c69b2f 100644 --- a/frontend/src/contexts/SettingsContext.jsx +++ b/frontend/src/contexts/SettingsContext.jsx @@ -1,50 +1,31 @@ import { createContext, useContext, useState, useEffect, useCallback } from 'react' -import de from '../i18n/de' import en from '../i18n/en' -import zh from '../i18n/zh' -import zhCn from '../i18n/zhCn' -import sv from '../i18n/sv' -import fr from '../i18n/fr' -import nl from '../i18n/nl' -import es from '../i18n/es' -import esMx from '../i18n/esMx' -import it from '../i18n/it' -import pt from '../i18n/pt' -import ptBr from '../i18n/ptBr' -import ptPt from '../i18n/ptPt' -import pl from '../i18n/pl' -import ru from '../i18n/ru' -import ja from '../i18n/ja' -import ko from '../i18n/ko' -import id from '../i18n/id' -import th from '../i18n/th' -import zhTw from '../i18n/zhTw' import { priceFieldFromPrimary } from '../utils/prices' import { normalizeTcgdexLanguageCsv } from '../utils/tcgdexLanguages' import { useAuth } from './AuthContext' -const translations = { - de, - en, - zh, - 'zh-cn': zhCn, - sv, - fr, - nl, - es, - 'es-mx': esMx, - it, - pt, - 'pt-br': ptBr, - 'pt-pt': ptPt, - pl, - ru, - ja, - ko, - id, - th, - 'zh-tw': zhTw, +const TRANSLATION_LOADERS = { + de: () => import('../i18n/de'), + zh: () => import('../i18n/zh'), + 'zh-cn': () => import('../i18n/zhCn'), + sv: () => import('../i18n/sv'), + fr: () => import('../i18n/fr'), + nl: () => import('../i18n/nl'), + es: () => import('../i18n/es'), + 'es-mx': () => import('../i18n/esMx'), + it: () => import('../i18n/it'), + pt: () => import('../i18n/pt'), + 'pt-br': () => import('../i18n/ptBr'), + 'pt-pt': () => import('../i18n/ptPt'), + pl: () => import('../i18n/pl'), + ru: () => import('../i18n/ru'), + ja: () => import('../i18n/ja'), + ko: () => import('../i18n/ko'), + id: () => import('../i18n/id'), + th: () => import('../i18n/th'), + 'zh-tw': () => import('../i18n/zhTw'), } +const SUPPORTED_LANGUAGES = new Set(['en', ...Object.keys(TRANSLATION_LOADERS)]) const DEFAULT_SETTINGS = { language: 'en', @@ -57,6 +38,7 @@ const DEFAULT_SETTINGS = { set_overview_filters: '{}', hidden_set_ids: '[]', debug_mode: 'false', + public_profiles_enabled: 'false', } const LANGUAGE_STORAGE_KEY = 'app_language' @@ -67,14 +49,14 @@ const LANGUAGE_STORAGE_KEY = 'app_language' function readCachedLanguage() { try { const cached = localStorage.getItem(LANGUAGE_STORAGE_KEY) - return cached && translations[cached] ? cached : null + return cached && SUPPORTED_LANGUAGES.has(cached) ? cached : null } catch { return null } } function cacheLanguage(language) { - if (!language || !translations[language]) return + if (!language || !SUPPORTED_LANGUAGES.has(language)) return try { localStorage.setItem(LANGUAGE_STORAGE_KEY, language) } catch { @@ -96,6 +78,7 @@ export function SettingsProvider({ children }) { const [exchangeRateReady, setExchangeRateReady] = useState(true) const [exchangeRateCurrency, setExchangeRateCurrency] = useState('EUR') const [usdToEurRate, setUsdToEurRate] = useState(0.91) + const [loadedTranslations, setLoadedTranslations] = useState({ en }) // Load settings from backend once auth mode is known. Single-user mode has no // token, but the backend still auto-authenticates the bootstrap admin. @@ -133,6 +116,25 @@ export function SettingsProvider({ children }) { }) }, [authLoading, multiUser, user?.id]) + const lang = settings.language || DEFAULT_SETTINGS.language + useEffect(() => { + if (lang === 'en' || loadedTranslations[lang]) return + const loader = TRANSLATION_LOADERS[lang] + if (!loader) return + + let cancelled = false + loader() + .then(module => { + if (!cancelled) { + setLoadedTranslations(previous => ({ ...previous, [lang]: module.default })) + } + }) + .catch(() => { + // English remains available if a language chunk cannot be loaded. + }) + return () => { cancelled = true } + }, [lang, loadedTranslations]) + // Fetch exchange rates through the backend to avoid browser CORS/redirect issues. // Most app prices are stored in EUR; TCGPlayer prices are stored in USD and need the inverse path. useEffect(() => { @@ -207,8 +209,7 @@ export function SettingsProvider({ children }) { } }, [settings, multiUser]) - const lang = settings.language || DEFAULT_SETTINGS.language - const msgs = translations[lang] || translations.en + const msgs = loadedTranslations[lang] || en // Translation helper const t = useCallback((path) => { @@ -220,7 +221,7 @@ export function SettingsProvider({ children }) { } if (val === undefined) { // Fallback to English - let fallback = translations.en + let fallback = en for (const part of parts) { fallback = fallback?.[part] if (fallback === undefined) break diff --git a/frontend/src/i18n/de.js b/frontend/src/i18n/de.js index ff841819..3280c946 100644 --- a/frontend/src/i18n/de.js +++ b/frontend/src/i18n/de.js @@ -441,6 +441,10 @@ const de = { createFailed: 'Fehler beim Erstellen', updateFailed: 'Fehler beim Aktualisieren', deleteConfirm: 'Binder lΓΆschen?', + sharePublicly: 'Γ–ffentlich teilen', + publicUpdated: 'Freigabe aktualisiert', + enablePublicProfileHint: 'Aktiviere dein ΓΆffentliches Profil in den Einstellungen', + copyPublicLink: 'Γ–ffentlichen Link kopieren', }, // Analytics @@ -701,6 +705,19 @@ const de = { sectionData: 'Daten', sectionAI: 'KI / Karten-Scanner', sectionAbout: 'Über die App', + sectionPublicProfile: 'Γ–ffentliches Profil', + publicProfilesGlobal: 'Γ–ffentliche Profile und geteilte Binder', + publicProfilesGlobalEnabledDesc: 'FΓΌr diese Installation aktiviert. Benutzer kΓΆnnen ein Profil und ausgewΓ€hlte Sammlungs-Binder verΓΆffentlichen.', + publicProfilesGlobalDisabledDesc: 'FΓΌr diese Installation deaktiviert. Bestehende Profil- und Binderfreigaben bleiben gespeichert, sind aber nicht erreichbar.', + publicTrainerName: 'Γ–ffentlicher Trainername', + publicTrainerNameDesc: 'Verwendet deinen Trainernamen oben und erstellt automatisch eine sichere ΓΆffentliche URL. Eine NamensΓ€nderung Γ€ndert auch die URL.', + publicProfileToggle: 'Mein Profil verΓΆffentlichen', + publicProfileToggleDesc: 'Jeder mit dem Link kann deine ΓΆffentlichen Binder ansehen', + publicShowValues: 'Kartenmarktwerte anzeigen', + publicShowValuesDesc: 'GeschΓ€tzte Preise in deinem ΓΆffentlichen Profil anzeigen', + publicProfileLink: 'Γ–ffentlicher Link', + linkCopied: 'Link kopiert', + linkCopyFailed: 'Link konnte nicht kopiert werden', // Settings page row labels multiUserMode: 'Mehrspieler-Modus', multiUserModeDesc: 'Login-Bildschirm und Benutzerverwaltung aktivieren', @@ -867,6 +884,22 @@ const de = { min1440: 'Alle 24 Stunden', }, + publicProfiles: { + directory: 'Γ–ffentliche Sammlungen', + directoryEyebrow: 'PokΓ©Collector-Community', + directoryDesc: 'Entdecke Trainer, die ihr Profil und ausgewΓ€hlte Sammlungs-Binder verΓΆffentlicht haben.', + directoryUnavailable: 'Γ–ffentliche Sammlungen sind nicht verfΓΌgbar.', + noPublicProfiles: 'Noch kein Trainer hat ein Profil verΓΆffentlicht.', + sharedBinderCount: 'geteilter Binder', + sharedBindersCount: 'geteilte Binder', + backToProfile: 'ZurΓΌck zum Profil', + publicCollection: 'Γ–ffentliche Sammlung', + sharedBinder: 'Geteilter Binder', + noSharedBinders: 'Noch keine geteilten Binder.', + profileUnavailable: 'Dieses Profil ist nicht verfΓΌgbar.', + binderUnavailable: 'Dieser Binder ist nicht verfΓΌgbar.', + }, + // Period selector period: { label: 'Zeitraum', @@ -1073,6 +1106,18 @@ const de = { allStatuses: 'Alle Status', ownedComplete: 'VollstΓ€ndig vorhanden', missingCards: 'Fehlende Karten', + sortBy: 'Binderkarten sortieren', + amountInBinder: 'Exemplare in diesem Binder', + sort: { + recent: 'Zuletzt hinzugefΓΌgt', + number: 'Sammlernummer', + name_asc: 'Name: A-Z', + name_desc: 'Name: Z-A', + price_desc: 'Preis: absteigend', + price_asc: 'Preis: aufsteigend', + quantity_desc: 'Anzahl: absteigend', + variant_asc: 'Variante', + }, requiredInBinder: 'Im Binder benΓΆtigt', marketPrice: 'Marktpreis', addToWishlist: 'Zur Wunschliste hinzufΓΌgen', diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index b84ea0d3..3f600e0e 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -94,6 +94,7 @@ const en = { no: 'No', ok: 'OK', close: 'Close', + copy: 'Copy', new: 'NEW', seen: 'Seen', all: 'All', @@ -441,6 +442,10 @@ const en = { createFailed: 'Failed to create binder', updateFailed: 'Failed to update binder', deleteConfirm: 'Delete binder?', + sharePublicly: 'Share publicly', + publicUpdated: 'Sharing setting updated', + enablePublicProfileHint: 'Enable your public profile in Settings to share', + copyPublicLink: 'Copy public link', }, // Analytics @@ -701,6 +706,19 @@ const en = { sectionData: 'Data', sectionAI: 'AI / Card Scanner', sectionAbout: 'About the App', + sectionPublicProfile: 'Public Profile', + publicProfilesGlobal: 'Public profiles and shared binders', + publicProfilesGlobalEnabledDesc: 'Enabled for this installation. Users can publish a profile and selected collection binders.', + publicProfilesGlobalDisabledDesc: 'Disabled for this installation. Existing profile and binder sharing choices are preserved but unavailable.', + publicTrainerName: 'Public trainer name', + publicTrainerNameDesc: 'Uses your trainer name above and automatically creates a safe public URL. Change the trainer name to change the URL.', + publicProfileToggle: 'Make my profile public', + publicProfileToggleDesc: 'Anyone with the link can view your public binders', + publicShowValues: 'Show card market values', + publicShowValuesDesc: 'Include estimated prices on your public profile', + publicProfileLink: 'Public link', + linkCopied: 'Link copied', + linkCopyFailed: 'Could not copy the link', // Settings page row labels multiUserMode: 'Multi-User Mode', multiUserModeDesc: 'Enable login screen and user management', @@ -867,6 +885,22 @@ const en = { min1440: 'Every 24 hours', }, + publicProfiles: { + directory: 'Public collections', + directoryEyebrow: 'PokΓ©Collector community', + directoryDesc: 'Browse trainers who chose to publish their profile and selected collection binders.', + directoryUnavailable: 'Public collections are not available.', + noPublicProfiles: 'No trainers have published a profile yet.', + sharedBinderCount: 'shared binder', + sharedBindersCount: 'shared binders', + backToProfile: 'Back to profile', + publicCollection: 'Public collection', + sharedBinder: 'Shared binder', + noSharedBinders: 'No shared binders yet.', + profileUnavailable: 'This profile is not available.', + binderUnavailable: 'This binder is not available.', + }, + // Period selector period: { label: 'Period', @@ -1082,6 +1116,18 @@ const en = { allStatuses: 'All statuses', ownedComplete: 'Owned complete', missingCards: 'Missing cards', + sortBy: 'Sort binder cards', + amountInBinder: 'Copies in this binder', + sort: { + recent: 'Recently added', + number: 'Collector number', + name_asc: 'Name: A-Z', + name_desc: 'Name: Z-A', + price_desc: 'Price: high to low', + price_asc: 'Price: low to high', + quantity_desc: 'Quantity: high to low', + variant_asc: 'Variant', + }, requiredInBinder: 'Required in binder', marketPrice: 'Market price', addToWishlist: 'Add to wishlist', @@ -1156,6 +1202,7 @@ const en = { noTrainers: 'No trainers found.', compare: 'Compare trainers', viewCollection: 'View Collection', + viewPublicProfile: 'View public profile', }, trainerCard: { diff --git a/frontend/src/index.css b/frontend/src/index.css index 0d899c24..e6172f97 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -483,73 +483,127 @@ input:-webkit-autofill:focus { pointer-events: none; } -/* Holo card effect β€” for rare+ cards in collection grid */ -.card-holo { +/* One shared, variant-driven card shine system. */ +.card-variant-effect { + position: relative; +} + +.card-variant-effect::after { + content: ''; + position: absolute; + top: -20%; + left: 0; + z-index: 2; + width: 60%; + height: 140%; + background: var(--card-variant-shine); + animation: var(--card-variant-animation, card-variant-shimmer) var(--card-variant-duration, 3s) ease-in-out infinite; + border-radius: inherit; + mix-blend-mode: screen; + pointer-events: none; +} + +.card-variant-holo { + --card-variant-shine: linear-gradient( + 105deg, + transparent 25%, + rgba(245,200,66,0.28) 45%, + rgba(255,230,100,0.20) 52%, + rgba(245,200,66,0.28) 58%, + transparent 75% + ); + --card-variant-duration: 3.2s; border-color: rgba(245,200,66,0.4) !important; box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow, 0 0 #0000), - 0 0 8px rgba(245,200,66,0.2), - 0 2px 8px rgba(0,0,0,0.4); + 0 0 9px rgba(245,200,66,0.24); } -.card-holo::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 135deg, - transparent 25%, - rgba(255,255,255,0.06) 45%, - rgba(255,255,255,0.1) 50%, - rgba(255,255,255,0.06) 55%, - transparent 75% + +.card-variant-reverse { + --card-variant-shine: linear-gradient( + 105deg, + transparent 30%, + rgba(99,179,237,0.32) 50%, + rgba(147,210,255,0.20) 55%, + transparent 70% ); - background-size: 200% 200%; - animation: holo-shift 4s ease-in-out infinite; - border-radius: inherit; - pointer-events: none; + --card-variant-animation: card-variant-shimmer-reverse; + --card-variant-duration: 2.8s; + border-color: rgba(99,179,237,0.5) !important; + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow, 0 0 #0000), + 0 0 9px rgba(99,179,237,0.28); } -@keyframes holo-shift { - 0%, 100% { background-position: 0% 0%; } - 50% { background-position: 100% 100%; } + +.card-variant-special { + --card-variant-shine: linear-gradient( + 105deg, + transparent 20%, + rgba(167,139,250,0.28) 42%, + rgba(196,181,253,0.20) 50%, + rgba(167,139,250,0.28) 58%, + transparent 78% + ); + --card-variant-duration: 4s; + border-color: rgba(167,139,250,0.5) !important; + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow, 0 0 #0000), + 0 0 11px rgba(167,139,250,0.28); } -/* Secret rare / rainbow β€” for ultra rare cards */ -.card-secret { - border-color: rgba(180,100,255,0.5) !important; +.card-variant-first-edition { + --card-variant-shine: linear-gradient( + 105deg, + transparent 30%, + rgba(52,211,153,0.32) 50%, + rgba(110,231,183,0.20) 55%, + transparent 70% + ); + --card-variant-duration: 3.5s; + border-color: rgba(52,211,153,0.5) !important; box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow, 0 0 #0000), - 0 0 12px rgba(150,80,255,0.25); + 0 0 9px rgba(52,211,153,0.24); } -.card-secret::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 135deg, - rgba(255,100,150,0.08), - rgba(100,150,255,0.08), - rgba(255,200,100,0.08), - rgba(100,255,180,0.08) + +.card-variant-generic { + --card-variant-shine: linear-gradient( + 105deg, + transparent 30%, + rgba(255,255,255,0.3) 50%, + transparent 70% ); - background-size: 400% 400%; - animation: rainbow-shift 6s ease infinite; - border-radius: inherit; - pointer-events: none; + --card-variant-duration: 3s; + border-color: rgba(255,255,255,0.22) !important; } -@keyframes rainbow-shift { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } + +@keyframes card-variant-shimmer { + 0% { transform: translateX(-100%) rotate(25deg); opacity: 0; } + 15% { opacity: 0.7; } + 50% { opacity: 0.5; } + 85% { opacity: 0.7; } + 100% { transform: translateX(200%) rotate(25deg); opacity: 0; } +} + +@keyframes card-variant-shimmer-reverse { + 0% { transform: translateX(220%) rotate(-20deg); opacity: 0; } + 20% { opacity: 0.6; } + 80% { opacity: 0.4; } + 100% { transform: translateX(-120%) rotate(-20deg); opacity: 0; } } @media (prefers-reduced-motion: reduce) { - .card-holo::after, - .card-secret::after { + .card-variant-effect::after { animation: none; + opacity: 0.16; } } diff --git a/frontend/src/pages/BinderDetail.jsx b/frontend/src/pages/BinderDetail.jsx index 5f5e5b38..b4495289 100644 --- a/frontend/src/pages/BinderDetail.jsx +++ b/frontend/src/pages/BinderDetail.jsx @@ -1,7 +1,7 @@ import { useState, useMemo, useRef, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, Plus, Trash2, Package, Star, Download, Upload, X, Heart, Minus } from 'lucide-react' +import { ArrowLeft, Plus, Trash2, Package, Star, Download, Upload, X, Heart, Minus, HelpCircle } from 'lucide-react' import { getBinderCards, removeCardFromBinder, removeBinderEntry, addCardToBinder, addCollectionItemToBinder, searchCards, getCollection, updateBinderEntry, getBinderEntryEquivalentPrints, getBinderPrintOptimization, applyBinderPrintOptimization, switchBinderEntryCard, addBinderEntryToWishlist, addBinderCardsToWishlist, importBinderCsv, exportBinderCsv, getApiErrorMessage } from '../api/client' import { useSettings } from '../contexts/SettingsContext' import toast from 'react-hot-toast' @@ -11,6 +11,9 @@ import { cardNumberMatches } from '../utils/cardNumbers' import { normalizeSearchText, textIncludes } from '../utils/textSearch' import { tcgdexLanguageLabel } from '../utils/tcgdexLanguages' import { invalidateCardState, invalidateTcgdexFilterLanguages } from '../utils/queryInvalidation' +import CardStateIndicators, { CardStateLegend } from '../components/CardStateIndicators' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' +import { BINDER_SORT_OPTIONS, sortBinderCards } from '../utils/binderCards' const SPRITE_BASE_URL = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated' const CONDITIONS = ['Mint', 'NM', 'LP', 'MP', 'HP'] @@ -143,6 +146,8 @@ export default function BinderDetail() { const [binderFilterSet, setBinderFilterSet] = useState('') const [binderFilterStatus, setBinderFilterStatus] = useState('') const [binderFilterQuery, setBinderFilterQuery] = useState('') + const [binderSortBy, setBinderSortBy] = useState('recent') + const [badgeLegendOpen, setBadgeLegendOpen] = useState(false) const [selectedCard, setSelectedCard] = useState(null) const [showCsvImportModal, setShowCsvImportModal] = useState(false) const [showPrintOptimizer, setShowPrintOptimizer] = useState(false) @@ -397,14 +402,14 @@ export default function BinderDetail() { 0 ) const allPrintOptimizationsSelected = printOptimizationRecommendations.length > 0 && selectedPrintOptimizationCount === printOptimizationRecommendations.length - const visibleCards = cards.filter(card => { + const visibleCards = sortBinderCards(cards.filter(card => { const query = normalizeSearchText(binderFilterQuery) if (query && ![card.name, card.set_name, card.set_id, card.number].some(value => textIncludes(value, query))) return false if (binderFilterSet && (card.set_name || card.set_id) !== binderFilterSet) return false if (binderFilterStatus === 'owned' && (card.missing_quantity || 0) > 0) return false if (binderFilterStatus === 'missing' && (card.missing_quantity || 0) === 0) return false return true - }) + }), binderSortBy, { isWishlist }) const changeRequiredQuantity = (card, delta) => { const next = Math.max(1, Math.min(99, (card.required_quantity || 1) + delta)) @@ -626,7 +631,7 @@ export default function BinderDetail() { const unavailable = unavailableCollectionItemIds.has(item.id) return (
!alreadyAdded && !unavailable && addCollectionItemMutation.mutate(item.id)} title={`${card.name}${item.variant ? ` (${item.variant})` : ''} Β· ${item.quantity}x`}> {resolveCardImageUrl(card) ? ( @@ -636,19 +641,19 @@ export default function BinderDetail() { {card.name}
)} -
{item.quantity}x
+
{item.quantity}x
{(item.variant || item.condition) && ( -
+
{[item.variant || 'Normal', item.condition].filter(Boolean).join(' Β· ')}
)} {unavailable && !alreadyAdded && ( -
+
{t('binderTypes.alreadyUsed')}
)} {!alreadyAdded && !unavailable && ( -
+
)} @@ -665,8 +670,47 @@ export default function BinderDetail() {
)} + {isCollection && cards.length > 0 && ( + <> +
+ +
+ {badgeLegendOpen && ( +
+

+ {t('setDetail.badgeLegend')} +

+ +
+ + 2x + + + {t('binderTypes.amountInBinder')} + +
+
+ )} + + )} + {cards.length > 0 && ( -
+
{t('binderTypes.ownedComplete')} +
)} @@ -705,15 +761,42 @@ export default function BinderDetail() { return ( setSelectedCard(card)}> - {resolveCardImageUrl(card) ? ( - {card.name} - ) : ( -
- {card.name} -
- )} +
+ {resolveCardImageUrl(card) ? ( + {card.name} + ) : ( +
+ {card.name} +
+ )} + + {isWishlist && ( +
+ {(card.owned_quantity || 0) >= (card.required_quantity || 1) ? `βœ“ ${card.owned_quantity || 0}/${card.required_quantity || 1}` : `${card.owned_quantity || 0}/${card.required_quantity || 1}`} +
+ )} + + {!isWishlist && card.in_collection && ( +
+ {card.quantity}x +
+ )} + + {isCollection && card.variant && ( + + )} +
+

{card.name}

{card.price_market > 0 ? ( @@ -722,20 +805,6 @@ export default function BinderDetail() {

{t('binderTypes.noPriceDataShort')}

)}
- - {isWishlist && ( -
- {(card.owned_quantity || 0) >= (card.required_quantity || 1) ? `βœ“ ${card.owned_quantity || 0}/${card.required_quantity || 1}` : `${card.owned_quantity || 0}/${card.required_quantity || 1}`} -
- )} - - {!isWishlist && card.in_collection && ( -
- {card.quantity}x -
- )}
) })} diff --git a/frontend/src/pages/Binders.jsx b/frontend/src/pages/Binders.jsx index 91ac22cb..4296d775 100644 --- a/frontend/src/pages/Binders.jsx +++ b/frontend/src/pages/Binders.jsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' -import { Plus, Trash2, Edit2, BookOpen, Star, Package, Check, X, Library, Heart } from 'lucide-react' -import { getBinders, createBinder, updateBinder, deleteBinder, getWishlist } from '../api/client' +import { Plus, Trash2, Edit2, BookOpen, Star, Package, Check, X, Library, Heart, Globe, Lock, Copy } from 'lucide-react' +import { getBinders, createBinder, updateBinder, deleteBinder, getWishlist, getProfile } from '../api/client' import { useSettings } from '../contexts/SettingsContext' import TabNav from '../components/TabNav' import AvatarPicker from '../components/AvatarPicker' @@ -148,6 +148,15 @@ export default function Binders() { staleTime: 60000, }) + const { data: profileData } = useQuery({ + queryKey: ['profile'], + queryFn: () => getProfile(), + staleTime: 60000, + }) + const profileIsPublic = !!profileData?.is_profile_public + const publicHandle = profileData?.public_handle + const publicProfilesEnabled = !!profileData?.feature_enabled + const COLLECTION_TABS = [ { to: '/collection', label: t('nav.collection'), icon: Library }, { to: '/binders', label: t('nav.binders'), icon: BookOpen }, @@ -173,7 +182,7 @@ export default function Binders() { invalidateTcgdexFilterLanguages(queryClient) setEditingId(null) }, - onError: () => toast.error(t('binders.updateFailed')), + onError: (error) => toast.error(error.response?.data?.detail || t('binders.updateFailed')), }) const deleteMutation = useMutation({ @@ -185,6 +194,25 @@ export default function Binders() { }, }) + const publicToggleMutation = useMutation({ + mutationFn: ({ id, is_public }) => updateBinder(id, { is_public }), + onSuccess: () => { + toast.success(t('binders.publicUpdated')) + queryClient.invalidateQueries({ queryKey: ['binders'] }) + }, + onError: (error) => toast.error(error.response?.data?.detail || t('binders.updateFailed')), + }) + + const copyPublicBinderLink = async (binderId) => { + const url = `${window.location.origin}/u/${publicHandle}/binder/${binderId}` + try { + await navigator.clipboard.writeText(url) + toast.success(t('settings.linkCopied')) + } catch { + toast.error(t('settings.linkCopyFailed')) + } + } + return (
@@ -271,6 +299,47 @@ export default function Binders() { {uniqueCount} {uniqueCount === 1 ? t('binders.uniqueCard') : t('binders.uniqueCards')}

)} + {!isWishlist && publicProfilesEnabled && ( +
e.stopPropagation()}> + {profileIsPublic ? ( +
+ + {binder.is_public ? : } + {t('binders.sharePublicly')} + + +
+ ) : ( +

+ {t('binders.enablePublicProfileHint')} +

+ )} + {binder.is_public && profileIsPublic && publicHandle && ( + + )} +
+ )}
diff --git a/frontend/src/pages/CardSearch.jsx b/frontend/src/pages/CardSearch.jsx index f6371ebb..54e10ac6 100644 --- a/frontend/src/pages/CardSearch.jsx +++ b/frontend/src/pages/CardSearch.jsx @@ -16,7 +16,7 @@ import { useVisibleTcgdexLanguages } from '../hooks/useVisibleTcgdexLanguages' import TcgdexLanguageSelect from '../components/TcgdexLanguageSelect' import { normalizeTcgdexLanguage, tcgdexLanguageBadgeClass, tcgdexLanguageLabel } from '../utils/tcgdexLanguages' import { invalidateCardState, invalidateTcgdexFilterLanguages } from '../utils/queryInvalidation' -import { getCardRarityEffectClass } from '../utils/cardRarity' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' function TiltCardWrapper({ children, className, onClick }) { const { ref, onMouseMove, onMouseEnter, onMouseLeave } = useTilt(12) @@ -653,7 +653,7 @@ export default function CardSearch() { className={`card-3d group relative ${selectMode && isSelected ? 'ring-2 ring-brand-red rounded-xl' : ''}`} onClick={() => (selectMode ? toggleSelected(card) : setSelectedCard(card))} > -
+
{imgSrc ? {card.name} :
@@ -662,7 +662,7 @@ export default function CardSearch() { } {selectMode && (
-
-
- ) -} - // ─── CollectionEditModal ──────────────────────────────────────────────────── // Opens when clicking any card in the collection. Allows editing + deleting. function CollectionEditModal({ item, onClose }) { @@ -557,7 +478,9 @@ function CollectionEditModal({ item, onClose }) { const renderCardHeader = () => (
{cardImage && ( - {card?.name} +
+ {card?.name} +
)}
@@ -1308,19 +1231,16 @@ export default function Collection() {
{filtered.map(item => { const card = item.card - const rarityClass = getCardRarityEffectClass(card?.rarity, card?.lang || card?.set_ref?.lang) - return ( setEditingCollectionItem(item)} >
-
{(() => { @@ -1419,7 +1339,7 @@ export default function Collection() { >
-
+
@@ -1524,6 +1444,7 @@ export default function Collection() { value={marketPrice > 0 ? formatPrice(marketPrice) : '-'} valueSecondary={pnl !== null ? `${pnl >= 0 ? '+' : ''}${formatPrice(pnl)}` : undefined} onClick={() => setEditingCollectionItem(item)} + variantEffectSource={item.variant} /> ) })} diff --git a/frontend/src/pages/Leaderboard.jsx b/frontend/src/pages/Leaderboard.jsx index be995bad..fe0cf3bc 100644 --- a/frontend/src/pages/Leaderboard.jsx +++ b/frontend/src/pages/Leaderboard.jsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' -import { Navigate, useNavigate } from 'react-router-dom' +import { Navigate, useNavigate, Link } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { ArrowUpDown, Trophy, Award } from 'lucide-react' +import { ArrowUpDown, Trophy, Award, Globe } from 'lucide-react' import { getLeaderboard } from '../api/client' import { useSettings } from '../contexts/SettingsContext' import { useAuth } from '../contexts/AuthContext' @@ -114,7 +114,19 @@ export default function Leaderboard() {
-

{trainer.username}

+
+

{trainer.username}

+ {trainer.public_handle && ( + e.stopPropagation()} + title={t('leaderboard.viewPublicProfile')} + className="text-text-muted hover:text-brand-red transition-colors" + > + + + )} +

{trainer.role}

{trainer.user_id !== currentUser?.id && (
diff --git a/frontend/src/pages/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx new file mode 100644 index 00000000..6e8a0df1 --- /dev/null +++ b/frontend/src/pages/PublicBinderView.jsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { ArrowLeft, HelpCircle } from 'lucide-react' +import clsx from 'clsx' +import { getPublicBinder } from '../api/publicClient' +import { formatEur } from '../utils/formatEur' +import { groupCardsByPrint } from '../utils/groupCardsByPrint' +import { useSettings } from '../contexts/SettingsContext' +import CardStateIndicators, { CardStateLegend } from '../components/CardStateIndicators' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' + +export default function PublicBinderView() { + const { handle, binderId } = useParams() + const [binder, setBinder] = useState(null) + const [error, setError] = useState(null) + const [badgeLegendOpen, setBadgeLegendOpen] = useState(false) + const { t } = useSettings() + + useEffect(() => { + let cancelled = false + setBinder(null) + setError(null) + getPublicBinder(handle, binderId) + .then(data => { if (!cancelled) setBinder(data) }) + .catch(() => { if (!cancelled) setError(true) }) + return () => { cancelled = true } + }, [handle, binderId]) + + if (error) return
{t('publicProfiles.binderUnavailable')}
+ if (!binder) return
{t('common.loading')}
+ + const tiles = groupCardsByPrint(binder.cards) + + return ( +
+
+ + {t('publicProfiles.backToProfile')} + +
+
+

{t('publicProfiles.sharedBinder')}

+

{binder.name}

+

+ {binder.unique_card_count} {binder.unique_card_count === 1 ? t('binders.uniqueCard') : t('binders.uniqueCards')} +

+
+ {binder.total_value != null && ( + {formatEur(binder.total_value)} + )} +
+ +
+ +
+ {badgeLegendOpen && ( +
+

+ {t('setDetail.badgeLegend')} +

+ +
+ )} + +
+ {tiles.map(tile => { + // Depth follows distinct prints: 1 layer behind for 2 variants, 2 for 3+. + const backLayers = Math.min(tile.variantCount - 1, 2) + const layerOffset = 8 + return ( +
+
+ {Array.from({ length: backLayers }).map((_, idx) => { + const depth = idx + 1 + return ( +
+ {tile.image && ( + + )} +
+ ) + })} +
+ {tile.image + ? {tile.name} + :
} + +
+
+
{tile.name}
+
{tile.set_name} Β· #{tile.number}
+ {tile.total_value != null && ( +
{formatEur(tile.total_value)}
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/pages/PublicDirectory.jsx b/frontend/src/pages/PublicDirectory.jsx new file mode 100644 index 00000000..ed0cf988 --- /dev/null +++ b/frontend/src/pages/PublicDirectory.jsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { BookOpen } from 'lucide-react' +import { getPublicProfiles } from '../api/publicClient' +import PokeBallLoader from '../components/PokeBallLoader' +import { useSettings } from '../contexts/SettingsContext' + +function PublicAvatar({ profile }) { + if (profile.avatar_id) { + return ( + + ) + } + return +} + +export default function PublicDirectory() { + const [profiles, setProfiles] = useState(null) + const [error, setError] = useState(false) + const { t } = useSettings() + + useEffect(() => { + let cancelled = false + getPublicProfiles() + .then(data => { if (!cancelled) setProfiles(data) }) + .catch(() => { if (!cancelled) setError(true) }) + return () => { cancelled = true } + }, []) + + if (error) { + return
{t('publicProfiles.directoryUnavailable')}
+ } + if (!profiles) { + return
+ } + + return ( +
+
+
+

{t('publicProfiles.directoryEyebrow')}

+

{t('publicProfiles.directory')}

+

{t('publicProfiles.directoryDesc')}

+
+ + {profiles.length === 0 ? ( +
+ {t('publicProfiles.noPublicProfiles')} +
+ ) : ( +
+ {profiles.map(profile => ( + + +
+

{profile.trainer_name}

+

@{profile.handle}

+

+ + {profile.binder_count} {profile.binder_count === 1 ? t('publicProfiles.sharedBinderCount') : t('publicProfiles.sharedBindersCount')} +

+
+ + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/PublicProfile.jsx b/frontend/src/pages/PublicProfile.jsx new file mode 100644 index 00000000..766a86de --- /dev/null +++ b/frontend/src/pages/PublicProfile.jsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { getPublicProfile } from '../api/publicClient' +import { formatEur } from '../utils/formatEur' +import { useSettings } from '../contexts/SettingsContext' + +export default function PublicProfile() { + const { handle } = useParams() + const [profile, setProfile] = useState(null) + const [error, setError] = useState(null) + const { t } = useSettings() + + useEffect(() => { + let cancelled = false + setProfile(null) + setError(null) + getPublicProfile(handle) + .then(data => { if (!cancelled) setProfile(data) }) + .catch(() => { if (!cancelled) setError(true) }) + return () => { cancelled = true } + }, [handle]) + + if (error) return
{t('publicProfiles.profileUnavailable')}
+ if (!profile) return
{t('common.loading')}
+ + return ( +
+
+
+ {profile.avatar_id && ( + + )} +
+

{t('publicProfiles.publicCollection')}

+

{profile.trainer_name}

+

@{profile.handle}

+
+
+ {profile.binders.length === 0 && ( +
+ {t('publicProfiles.noSharedBinders')} +
+ )} +
+ {profile.binders.map(binder => ( + +
{binder.name}
+
+ {binder.unique_card_count} {binder.unique_card_count === 1 ? t('binders.card') : t('binders.cards')} + {binder.total_value != null ? ` Β· ${formatEur(binder.total_value)}` : ''} +
+ + ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/SetDetail.jsx b/frontend/src/pages/SetDetail.jsx index 42e018dd..72b02459 100644 --- a/frontend/src/pages/SetDetail.jsx +++ b/frontend/src/pages/SetDetail.jsx @@ -16,7 +16,7 @@ import TcgdexLanguageSelect from '../components/TcgdexLanguageSelect' import { invalidateCardState, invalidateTcgdexFilterLanguages } from '../utils/queryInvalidation' import MoneyInput from '../components/MoneyInput' import { parseMoneyInputValue } from '../utils/moneyInput' -import { getCardRarityEffectClass } from '../utils/cardRarity' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' const CONDITIONS = ['Mint', 'NM', 'LP', 'MP', 'HP'] @@ -571,7 +571,7 @@ export default function SetDetail() { tabIndex={0} className={clsx( 'relative group rounded-lg overflow-hidden transition-all duration-200', - getCardRarityEffectClass(card.rarity, card.lang || setLang), + getCardVariantEffectClass(card), card.owned ? 'ring-2 ring-green/50 hover:ring-green cursor-pointer' : 'opacity-60 hover:opacity-90 ring-1 ring-brand-red/30 hover:ring-brand-red/60 cursor-pointer' @@ -600,7 +600,7 @@ export default function SetDetail() {
-
+
#{card.number}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 3d15d6f3..0b975ebb 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,13 +1,14 @@ import { useState, useRef, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Crown, RefreshCw, Download, Upload, Plus, Pencil, Trash2, User, UserCheck, UserX, Zap } from 'lucide-react' +import { Crown, RefreshCw, Download, Upload, Plus, Pencil, Trash2, User, UserCheck, UserX, Zap, Copy } from 'lucide-react' import { getSyncStatus, triggerSync, triggerAllPriceSync, rescheduleFullSync, reschedulePriceSync, downloadBackup, restoreBackup, exportCSV, getSetting, setSetting, getTelegramStatus, saveSettings, setAuthMode, getUsers, createUser, updateUser, deleteUser, changePassword, changeAvatar, changeUsername, getContributors, getSupporters, getRescueDonations, getCustomMatches, downloadDebugLog, + getProfile, updateProfile, } from '../api/client' import api from '../api/client' import { useAuth } from '../contexts/AuthContext' @@ -62,13 +63,17 @@ function SettingsRow({ label, description, children, last }) { ) } -function Toggle({ value, onChange }) { +function Toggle({ value, onChange, label, disabled = false }) { return ( + + )} + + {publicProfilesEnabled &&
+ +
} + } + {/* ── 2. THEME ── */}
@@ -731,6 +883,7 @@ export default function Settings() { > { try { await setAuthMode(val) @@ -913,18 +1066,21 @@ export default function Settings() { handleAdminBooleanSettingToggle('tcgdex_digital_sets_enabled', val)} /> handleAdminBooleanSettingToggle('cross_language_price_fallback', val)} /> handleAdminBooleanSettingToggle('cross_language_image_fallback', val)} /> @@ -1007,7 +1163,7 @@ export default function Settings() { > {t('settings.debugLogDownload')} - +
)} @@ -1193,7 +1349,7 @@ export default function Settings() { label={t('settings.priceAlerts')} description={t('settings.priceAlertsDesc')} > - + {priceAlertsEnabled && ( -
+
@@ -160,6 +161,7 @@ function DraftItem({ item, side, onUpdate, onRemove, t, formatPrice, exchangeRat
diff --git a/frontend/src/pages/UserCollection.jsx b/frontend/src/pages/UserCollection.jsx index 421c2ac1..a0c6f095 100644 --- a/frontend/src/pages/UserCollection.jsx +++ b/frontend/src/pages/UserCollection.jsx @@ -8,6 +8,7 @@ import { useSettings } from '../contexts/SettingsContext' import { resolveCardImageUrl } from '../utils/imageUrl' import { CardModal } from '../components/CardItem' import CardImage from '../components/CardImage' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' import FallbackBadges from '../components/FallbackBadges' import { getEffectiveCardPrice } from '../utils/prices' import { TCGDEX_LANGUAGES } from '../utils/tcgdexLanguages' @@ -207,7 +208,7 @@ export default function UserCollection() { className="cursor-pointer group" onClick={() => setSelectedCard(card)} > -
+
diff --git a/frontend/src/utils/binderCards.js b/frontend/src/utils/binderCards.js new file mode 100644 index 00000000..f15fe8dc --- /dev/null +++ b/frontend/src/utils/binderCards.js @@ -0,0 +1,72 @@ +const compareText = (left, right) => String(left || '').localeCompare( + String(right || ''), + undefined, + { numeric: true, sensitivity: 'base' }, +) + +const cardQuantity = (card, isWishlist) => Number( + isWishlist + ? (card?.required_quantity ?? 0) + : (card?.quantity ?? card?.owned_quantity ?? 0), +) || 0 + +const cardPrice = (card) => { + const price = Number(card?.price_market) + return Number.isFinite(price) && price > 0 ? price : null +} + +const collectorOrder = (left, right) => ( + compareText(left?.set_name || left?.set_id, right?.set_name || right?.set_id) + || compareText(left?.number, right?.number) + || compareText(left?.variant, right?.variant) + || compareText(left?.binder_card_id || left?.id, right?.binder_card_id || right?.id) +) + +export const BINDER_SORT_OPTIONS = [ + 'recent', + 'number', + 'name_asc', + 'name_desc', + 'price_desc', + 'price_asc', + 'quantity_desc', + 'variant_asc', +] + +export function sortBinderCards(cards, sortBy = 'recent', { isWishlist = false } = {}) { + const sorted = [...(cards || [])] + if (sortBy === 'recent') return sorted + + sorted.sort((left, right) => { + if (sortBy === 'number') return collectorOrder(left, right) + + if (sortBy === 'name_asc' || sortBy === 'name_desc') { + const comparison = compareText(left?.name, right?.name) + return (sortBy === 'name_desc' ? -comparison : comparison) || collectorOrder(left, right) + } + + if (sortBy === 'price_desc' || sortBy === 'price_asc') { + const leftPrice = cardPrice(left) + const rightPrice = cardPrice(right) + if (leftPrice == null && rightPrice != null) return 1 + if (leftPrice != null && rightPrice == null) return -1 + if (leftPrice == null && rightPrice == null) return collectorOrder(left, right) + const comparison = sortBy === 'price_desc' ? rightPrice - leftPrice : leftPrice - rightPrice + return comparison || collectorOrder(left, right) + } + + if (sortBy === 'quantity_desc') { + return cardQuantity(right, isWishlist) - cardQuantity(left, isWishlist) + || collectorOrder(left, right) + } + + if (sortBy === 'variant_asc') { + return compareText(left?.variant || 'Normal', right?.variant || 'Normal') + || collectorOrder(left, right) + } + + return 0 + }) + + return sorted +} diff --git a/frontend/src/utils/binderCards.test.js b/frontend/src/utils/binderCards.test.js new file mode 100644 index 00000000..b704632d --- /dev/null +++ b/frontend/src/utils/binderCards.test.js @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { sortBinderCards } from './binderCards' + +const card = (overrides = {}) => ({ + binder_card_id: 1, + name: 'Kokuna', + set_name: 'Wachsendes Chaos', + number: '2', + variant: 'Normal', + quantity: 1, + price_market: 1, + ...overrides, +}) + +describe('sortBinderCards', () => { + it('preserves the API order for the recent option', () => { + const cards = [card({ name: 'Second' }), card({ name: 'First' })] + expect(sortBinderCards(cards, 'recent').map(item => item.name)).toEqual(['Second', 'First']) + expect(sortBinderCards(cards, 'recent')).not.toBe(cards) + }) + + it('uses natural collector ordering across sets and variants', () => { + const cards = [ + card({ set_name: 'Set B', number: '1' }), + card({ set_name: 'Set A', number: '10' }), + card({ set_name: 'Set A', number: '2', variant: 'Reverse Holo' }), + card({ set_name: 'Set A', number: '2', variant: 'Normal' }), + ] + + expect(sortBinderCards(cards, 'number').map(item => `${item.set_name}-${item.number}-${item.variant}`)).toEqual([ + 'Set A-2-Normal', + 'Set A-2-Reverse Holo', + 'Set A-10-Normal', + 'Set B-1-Normal', + ]) + }) + + it('sorts by name, price, quantity, and variant with deterministic fallbacks', () => { + const cards = [ + card({ name: 'Vulpix', number: '8', variant: 'Reverse Holo', quantity: 1, price_market: 4 }), + card({ name: 'Kokuna', number: '2', variant: 'Normal', quantity: 3, price_market: 2 }), + card({ name: 'Fynx', number: '11', variant: 'Holo', quantity: 2, price_market: 7 }), + ] + + expect(sortBinderCards(cards, 'name_asc').map(item => item.name)).toEqual(['Fynx', 'Kokuna', 'Vulpix']) + expect(sortBinderCards(cards, 'name_desc').map(item => item.name)).toEqual(['Vulpix', 'Kokuna', 'Fynx']) + expect(sortBinderCards(cards, 'price_desc').map(item => item.name)).toEqual(['Fynx', 'Vulpix', 'Kokuna']) + expect(sortBinderCards(cards, 'price_asc').map(item => item.name)).toEqual(['Kokuna', 'Vulpix', 'Fynx']) + expect(sortBinderCards(cards, 'quantity_desc').map(item => item.name)).toEqual(['Kokuna', 'Fynx', 'Vulpix']) + expect(sortBinderCards(cards, 'variant_asc').map(item => item.variant)).toEqual(['Holo', 'Normal', 'Reverse Holo']) + }) + + it('sorts wishlist quantities by required copies instead of owned copies', () => { + const cards = [ + card({ name: 'Needs one', quantity: 0, owned_quantity: 0, required_quantity: 1 }), + card({ name: 'Needs four', quantity: 0, owned_quantity: 0, required_quantity: 4 }), + ] + + expect(sortBinderCards(cards, 'quantity_desc', { isWishlist: true }).map(item => item.name)).toEqual([ + 'Needs four', + 'Needs one', + ]) + }) + + it('places missing prices last and uses binder card ids to break exact ties', () => { + const cards = [ + card({ binder_card_id: 12, name: 'Missing price', price_market: 0 }), + card({ binder_card_id: 11, name: 'Known price', price_market: 2 }), + card({ binder_card_id: 10, name: 'Known price', price_market: 2 }), + ] + + expect(sortBinderCards(cards, 'price_asc').map(item => item.binder_card_id)).toEqual([10, 11, 12]) + expect(sortBinderCards(cards, 'price_desc').map(item => item.binder_card_id)).toEqual([10, 11, 12]) + }) +}) diff --git a/frontend/src/utils/cardRarity.js b/frontend/src/utils/cardRarity.js deleted file mode 100644 index ef82e303..00000000 --- a/frontend/src/utils/cardRarity.js +++ /dev/null @@ -1,43 +0,0 @@ -const SECRET_MARKERS = { - de: ['hyperselten', 'hyper selten', 'versteckt selten'], - en: ['secret rare', 'rainbow rare', 'hyper rare'], - es: ['rara secreta', 'rara hiper', 'mega hiper rara', 'rara ultra variocolor'], - fr: ['magnifique rare', 'hyper rare', 'mega hyper rare'], - it: ['segreto rara', 'rara iper', 'mega iper raro'], - pt: ['rare secreta', 'rara secreta', 'hiper rara', 'mega hiper raro'], -} - -const RARE_MARKERS = { - de: ['selten', 'atemberaubend', 'vollkunsttrainer'], - en: ['rare', 'full art trainer'], - es: ['rara', 'increibles', 'entrenador de arte completo'], - fr: ['rare', 'magnifique', 'dresseur full art'], - it: ['rara', 'raro', 'ultrarara', 'policrome', 'allenatore d arte completa'], - pt: ['rara', 'raro', 'raras', 'arte completa de treinador'], -} - -const normalizeText = (value) => String(value || '') - .normalize('NFD') - .replace(/\p{Diacritic}/gu, '') - .toLowerCase() - .replace(/[^\p{Letter}\p{Number}]+/gu, ' ') - .trim() - -const baseLanguage = (language) => { - const normalized = String(language || 'en').toLowerCase().replace(/_/g, '-') - return normalized.split('-')[0] || 'en' -} - -const includesMarker = (value, markers) => markers.some(marker => value.includes(marker)) - -export function getCardRarityEffectClass(rarity = '', language = 'en') { - const normalizedRarity = normalizeText(rarity) - const lang = baseLanguage(language) - const secretMarkers = SECRET_MARKERS[lang] || SECRET_MARKERS.en - const rareMarkers = RARE_MARKERS[lang] || RARE_MARKERS.en - - if (includesMarker(normalizedRarity, secretMarkers)) return 'card-secret' - if (includesMarker(normalizedRarity, rareMarkers)) return 'card-holo' - - return '' -} diff --git a/frontend/src/utils/cardRarity.test.js b/frontend/src/utils/cardRarity.test.js deleted file mode 100644 index b36f54ba..00000000 --- a/frontend/src/utils/cardRarity.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getCardRarityEffectClass } from './cardRarity' - -describe('getCardRarityEffectClass', () => { - it.each([ - ['Rare Holo', 'en', 'card-holo'], - ['Ultra Rare', 'en', 'card-holo'], - ['Full Art Trainer', 'en', 'card-holo'], - ['Illustration Rare', 'en', 'card-holo'], - ['Hyper Rare', 'en', 'card-secret'], - ['Secret Rare', 'en', 'card-secret'], - ['Rainbow Rare', 'en', 'card-secret'], - ['Selten', 'de', 'card-holo'], - ['Atemberaubend', 'de', 'card-holo'], - ['Vollkunsttrainer', 'de', 'card-holo'], - ['Versteckt Selten', 'de', 'card-secret'], - ['Magnifique rare', 'fr', 'card-secret'], - ['Magnifique', 'fr', 'card-holo'], - ['Dresseur Full Art', 'fr', 'card-holo'], - ['Rare', 'fr', 'card-holo'], - ['Rara Secreta', 'es', 'card-secret'], - ['Entrenador de arte completo', 'es', 'card-holo'], - ['Rara IlustraciΓ³n', 'es-mx', 'card-holo'], - ['Segreto rara', 'it', 'card-secret'], - ["Allenatore d'arte completa", 'it', 'card-holo'], - ['Rara illustrazione', 'it', 'card-holo'], - ['Hiper rara', 'pt-br', 'card-secret'], - ['Arte Completa de Treinador', 'pt', 'card-holo'], - ['Rara Holo', 'pt', 'card-holo'], - ['Rare', 'ja', 'card-holo'], - ['Common', 'ko', ''], - ['Common', 'en', ''], - [null, 'en', ''], - ])('maps %s (%s) to %s', (rarity, language, expected) => { - expect(getCardRarityEffectClass(rarity, language)).toBe(expected) - }) -}) diff --git a/frontend/src/utils/cardVariantEffect.js b/frontend/src/utils/cardVariantEffect.js new file mode 100644 index 00000000..ea0a43da --- /dev/null +++ b/frontend/src/utils/cardVariantEffect.js @@ -0,0 +1,60 @@ +const EFFECT_CLASS = { + holo: 'card-variant-effect card-variant-holo', + reverse: 'card-variant-effect card-variant-reverse', + special: 'card-variant-effect card-variant-special', + firstEdition: 'card-variant-effect card-variant-first-edition', + generic: 'card-variant-effect card-variant-generic', +} + +// Grouped tiles can represent several prints but must never stack animations. +// Pick one stable representative effect while badges retain the full breakdown. +const EFFECT_PRIORITY = ['firstEdition', 'special', 'reverse', 'holo', 'generic'] + +const normalizeVariant = (variant) => String(variant || '') + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^\p{Letter}\p{Number}]+/gu, ' ') + .trim() + +const getVariantName = (value) => ( + typeof value === 'string' ? value : value?.variant +) + +const getVariantEffect = (variant) => { + const normalized = normalizeVariant(variant) + if (!normalized || normalized === 'normal') return null + if (normalized.includes('first edition') || normalized.includes('1st edition')) return 'firstEdition' + if (normalized.includes('reverse')) return 'reverse' + if ( + normalized.includes('alt art') + || normalized.includes('illustration rare') + || normalized.includes('special illustration') + || normalized.includes('shiny') + ) return 'special' + if (normalized.includes('holo')) return 'holo' + return 'generic' +} + +const getVariants = (source) => { + if (Array.isArray(source)) return source.map(getVariantName) + if (typeof source === 'string') return [source] + if (!source || typeof source !== 'object') return [] + if ( + Object.prototype.hasOwnProperty.call(source, 'variant') + && normalizeVariant(source.variant) + ) return [source.variant] + if (Array.isArray(source.owned_variants)) return source.owned_variants.map(getVariantName) + if (Array.isArray(source.owned_items)) return source.owned_items.map(getVariantName) + return [] +} + +export function getCardVariantEffectType(source) { + const effects = new Set(getVariants(source).map(getVariantEffect).filter(Boolean)) + return EFFECT_PRIORITY.find(effect => effects.has(effect)) || null +} + +export function getCardVariantEffectClass(source) { + const effect = getCardVariantEffectType(source) + return effect ? EFFECT_CLASS[effect] : '' +} diff --git a/frontend/src/utils/cardVariantEffect.test.js b/frontend/src/utils/cardVariantEffect.test.js new file mode 100644 index 00000000..e64cde39 --- /dev/null +++ b/frontend/src/utils/cardVariantEffect.test.js @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { getCardVariantEffectClass, getCardVariantEffectType } from './cardVariantEffect' + +describe('getCardVariantEffectType', () => { + it.each([ + ['Normal', null], + [null, null], + ['Holo', 'holo'], + ['Holo Rare', 'holo'], + ['Reverse Holo', 'reverse'], + ['First Edition', 'firstEdition'], + ['1st Edition', 'firstEdition'], + ['First Edition Holo', 'firstEdition'], + ['Alt Art', 'special'], + ['Illustration Rare', 'special'], + ['Special Illustration Rare', 'special'], + ['Shiny', 'special'], + ['Unknown Foil', 'generic'], + ])('maps %s to %s', (variant, expected) => { + expect(getCardVariantEffectType(variant)).toBe(expected) + }) + + it('uses one deterministic effect for grouped prints', () => { + expect(getCardVariantEffectType([ + { variant: 'Normal', quantity: 2 }, + { variant: 'Holo', quantity: 1 }, + { variant: 'Reverse Holo', quantity: 1 }, + ])).toBe('reverse') + }) + + it.each([ + [[{ variant: 'First Edition' }, { variant: 'Special Illustration Rare' }], 'firstEdition'], + [[{ variant: 'Special Illustration Rare' }, { variant: 'Reverse Holo' }], 'special'], + [[{ variant: 'Reverse Holo' }, { variant: 'Holo' }], 'reverse'], + [[{ variant: 'Holo' }, { variant: 'Unknown Foil' }], 'holo'], + [[{ variant: 'First Edition' }, { variant: 'Unknown Foil' }], 'firstEdition'], + [[{ variant: 'Unknown Foil' }, { variant: 'Normal' }], 'generic'], + ])('applies grouped priority to %j', (variants, expected) => { + expect(getCardVariantEffectType(variants)).toBe(expected) + }) + + it('reads detailed owned variants and ignores generic ownership totals', () => { + expect(getCardVariantEffectType({ + owned: true, + owned_quantity: 3, + owned_variants: [{ variant: 'Holo', quantity: 1 }], + })).toBe('holo') + expect(getCardVariantEffectType({ owned: true, owned_quantity: 3 })).toBe(null) + }) + + it('supports owned-items payloads', () => { + expect(getCardVariantEffectType({ + owned_items: [{ variant: 'First Edition', quantity: 1 }], + })).toBe('firstEdition') + }) + + it('falls back to owned variants when a payload has an empty exact variant', () => { + expect(getCardVariantEffectType({ + variant: null, + owned_variants: [{ variant: 'Reverse Holo', quantity: 1 }], + })).toBe('reverse') + }) +}) + +describe('getCardVariantEffectClass', () => { + it('returns both the shared base and selected variant class', () => { + expect(getCardVariantEffectClass('Reverse Holo')) + .toBe('card-variant-effect card-variant-reverse') + }) + + it('returns no animation class for Normal', () => { + expect(getCardVariantEffectClass('Normal')).toBe('') + }) +}) diff --git a/frontend/src/utils/formatEur.js b/frontend/src/utils/formatEur.js new file mode 100644 index 00000000..de651c82 --- /dev/null +++ b/frontend/src/utils/formatEur.js @@ -0,0 +1,7 @@ +// Shared EUR formatter for the public pages. Returns null when there is nothing to +// show (null/undefined/non-numeric) so a caller can conditionally render β€” a market +// value hidden by the owner arrives as null from the API and stays hidden in the UI. +export function formatEur(value) { + if (value == null || Number.isNaN(Number(value))) return null + return new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' }).format(Number(value)) +} diff --git a/frontend/src/utils/formatEur.test.js b/frontend/src/utils/formatEur.test.js new file mode 100644 index 00000000..0a9da164 --- /dev/null +++ b/frontend/src/utils/formatEur.test.js @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { formatEur } from './formatEur' + +describe('formatEur', () => { + it('returns null for null/undefined so callers can hide the value', () => { + expect(formatEur(null)).toBeNull() + expect(formatEur(undefined)).toBeNull() + }) + it('formats a number as EUR', () => { + expect(formatEur(10)).toBe('€10.00') + }) + it('returns null for non-numeric input', () => { + expect(formatEur('abc')).toBeNull() + }) +}) diff --git a/frontend/src/utils/groupCardsByPrint.js b/frontend/src/utils/groupCardsByPrint.js new file mode 100644 index 00000000..ee9bb695 --- /dev/null +++ b/frontend/src/utils/groupCardsByPrint.js @@ -0,0 +1,36 @@ +// Collapse a public binder's flat card list (one entry per print) into one tile +// per card, carrying every owned variant. The API already returns entries sorted +// by set -> card number -> variant, so a card's prints arrive contiguously; we +// preserve first-seen order for both cards and their prints. +export function groupCardsByPrint(cards) { + const byId = new Map() + for (const c of cards || []) { + let tile = byId.get(c.id) + if (!tile) { + tile = { + id: c.id, + name: c.name, + image: c.image, + set_name: c.set_name, + number: c.number, + rarity: c.rarity, + lang: c.lang, + prints: [], + } + byId.set(c.id, tile) + } + tile.prints.push({ + variant: c.variant, + quantity: c.quantity, + market_value: c.market_value, + }) + } + + return [...byId.values()].map(tile => { + const priced = tile.prints.filter(p => p.market_value != null) + const total_value = priced.length + ? priced.reduce((sum, p) => sum + p.market_value * (p.quantity || 1), 0) + : null + return { ...tile, variantCount: tile.prints.length, total_value } + }) +} diff --git a/frontend/src/utils/groupCardsByPrint.test.js b/frontend/src/utils/groupCardsByPrint.test.js new file mode 100644 index 00000000..eab2029d --- /dev/null +++ b/frontend/src/utils/groupCardsByPrint.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { groupCardsByPrint } from './groupCardsByPrint' + +const card = (over = {}) => ({ + id: 'sv1-1', name: 'Sprigatito', image: 'a.webp', set_name: 'S&V', + number: '1', rarity: 'Rare', lang: 'en', variant: 'Normal', quantity: 1, market_value: null, ...over, +}) + +describe('groupCardsByPrint', () => { + it('merges variants of the same card into one tile', () => { + const out = groupCardsByPrint([ + card({ variant: 'Normal', quantity: 2 }), + card({ variant: 'Reverse Holo', quantity: 1 }), + ]) + expect(out).toHaveLength(1) + expect(out[0].id).toBe('sv1-1') + expect(out[0]).toMatchObject({ rarity: 'Rare', lang: 'en' }) + expect(out[0].variantCount).toBe(2) + expect(out[0].prints).toEqual([ + { variant: 'Normal', quantity: 2, market_value: null }, + { variant: 'Reverse Holo', quantity: 1, market_value: null }, + ]) + }) + + it('keeps distinct cards as separate tiles in first-seen order', () => { + const out = groupCardsByPrint([ + card({ id: 'sv1-2', name: 'Floragato', number: '2' }), + card({ id: 'sv1-1', name: 'Sprigatito', number: '1' }), + ]) + expect(out.map(t => t.id)).toEqual(['sv1-2', 'sv1-1']) + }) + + it('single-variant card has variantCount 1', () => { + const out = groupCardsByPrint([card({ quantity: 3 })]) + expect(out[0].variantCount).toBe(1) + }) + + it('sums value across prints as market_value * quantity', () => { + const out = groupCardsByPrint([ + card({ variant: 'Normal', quantity: 2, market_value: 5 }), + card({ variant: 'Reverse Holo', quantity: 1, market_value: 8 }), + ]) + expect(out[0].total_value).toBe(18) // 5*2 + 8*1 + }) + + it('total_value is null when values are hidden (all market_value null)', () => { + const out = groupCardsByPrint([ + card({ variant: 'Normal', market_value: null }), + card({ variant: 'Reverse Holo', market_value: null }), + ]) + expect(out[0].total_value).toBeNull() + }) + + it('returns [] for empty or missing input', () => { + expect(groupCardsByPrint([])).toEqual([]) + expect(groupCardsByPrint(undefined)).toEqual([]) + }) +}) diff --git a/frontend/src/utils/publicRoutes.js b/frontend/src/utils/publicRoutes.js new file mode 100644 index 00000000..1c0b01b1 --- /dev/null +++ b/frontend/src/utils/publicRoutes.js @@ -0,0 +1,2 @@ +export const isPublicSharePath = (pathname = '') => + pathname === '/u' || pathname.startsWith('/u/') diff --git a/frontend/src/utils/publicRoutes.test.js b/frontend/src/utils/publicRoutes.test.js new file mode 100644 index 00000000..df24881e --- /dev/null +++ b/frontend/src/utils/publicRoutes.test.js @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { isPublicSharePath } from './publicRoutes' + +describe('isPublicSharePath', () => { + it('recognizes public profile and binder routes', () => { + expect(isPublicSharePath('/u')).toBe(true) + expect(isPublicSharePath('/u/ash')).toBe(true) + expect(isPublicSharePath('/u/ash/binder/12')).toBe(true) + }) + + it('does not classify protected or login routes as public', () => { + expect(isPublicSharePath('/login')).toBe(false) + expect(isPublicSharePath('/settings')).toBe(false) + expect(isPublicSharePath('/users')).toBe(false) + }) +})