{t('publicProfiles.sharedBinder')}
+{binder.name}
++ {binder.unique_card_count} {binder.unique_card_count === 1 ? t('binders.uniqueCard') : t('binders.uniqueCards')} +
++ {t('setDetail.badgeLegend')} +
+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)
-      [](https://ko-fi.com/gillesromer)
+      [](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).

@@ -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
+ {t('setDetail.badgeLegend')} +
+{card.name}
{card.price_market > 0 ? ( @@ -722,20 +805,6 @@ export default function BinderDetail() {{t('binderTypes.noPriceDataShort')}
)}+ {t('binders.enablePublicProfileHint')} +
+ )} + {binder.is_public && profileIsPublic && publicHandle && ( + + )} +{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 && ({t('publicProfiles.sharedBinder')}
++ {binder.unique_card_count} {binder.unique_card_count === 1 ? t('binders.uniqueCard') : t('binders.uniqueCards')} +
++ {t('setDetail.badgeLegend')} +
+{t('publicProfiles.directoryEyebrow')}
+{t('publicProfiles.directoryDesc')}
+@{profile.handle}
+
+
{t('publicProfiles.publicCollection')}
+@{profile.handle}
+