Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d14879c
docs: design for public viewable binders
hiddenbanana Jul 19, 2026
237351b
docs: implementation plan for public viewable binders
hiddenbanana Jul 19, 2026
9038756
feat(public-binders): add profile handle + public flags data model
hiddenbanana Jul 19, 2026
d5b719d
fix(public-binders): enforce NOT NULL on new boolean columns for upgr…
hiddenbanana Jul 19, 2026
2de712c
feat(public-binders): handle validation + reserved words
hiddenbanana Jul 19, 2026
c434927
feat(public-binders): profile resolution + whitelist serialization
hiddenbanana Jul 19, 2026
0a22d6d
feat(public-binders): unauthenticated public profile + binder API
hiddenbanana Jul 19, 2026
b35f866
feat(public-binders): owner profile controls (handle + toggles)
hiddenbanana Jul 19, 2026
f52cd4a
feat(public-binders): per-binder is_public toggle
hiddenbanana Jul 19, 2026
0f38cd8
feat(public-binders): expose public_handle on leaderboard rows
hiddenbanana Jul 19, 2026
9ec3e89
test(public-binders): assert leaderboard hides handle when profile no…
hiddenbanana Jul 19, 2026
17aa012
feat(public-binders): frontend handle validator + token-less public c…
hiddenbanana Jul 19, 2026
e2081a2
docs: revise Task 9 to pure formatEur test (no DOM test infra on this…
hiddenbanana Jul 19, 2026
ed76d19
feat(public-binders): public profile + binder pages and routes
hiddenbanana Jul 19, 2026
979e252
feat(public-binders): owner controls — settings, binder toggle, leade…
hiddenbanana Jul 19, 2026
1b19bb5
feat(public-binders): cache headers + rate-limit public endpoints
hiddenbanana Jul 19, 2026
ef1c813
fix(public-binders): order binder cards by added_at and show card var…
hiddenbanana Jul 20, 2026
4cc1550
fix(public-binders): sort binder cards by card number, not add-order
hiddenbanana Jul 20, 2026
112d07e
fix(public-binders): shorten public cache TTL to 30s with revalidation
hiddenbanana Jul 20, 2026
1f2f7c1
docs: design for stacked variant tiles in public binder view
hiddenbanana Jul 20, 2026
721116a
fix(public-binders): repair broken VariantPills import
hiddenbanana Jul 25, 2026
53fe7a2
Merge current main for PR 308 review
Git-Romer Jul 26, 2026
4f58d71
feat: harden public profiles and add admin gate
Git-Romer Jul 26, 2026
f6680b9
Polish public profile sharing
Git-Romer Jul 27, 2026
5101a0c
Polish binder card indicators
Git-Romer Jul 27, 2026
1266677
Fix binder legend and collection shine
Git-Romer Jul 27, 2026
e719c36
Use variant-specific card shine
Git-Romer Jul 27, 2026
2f45195
Strengthen variant-specific card shine
Git-Romer Jul 27, 2026
d88f102
Balance variant shine intensity
Git-Romer Jul 27, 2026
a458be7
Improve grouped public card stacks
Git-Romer Jul 27, 2026
b5a7641
Keep public home button fixed across routes
Git-Romer Jul 27, 2026
8d3aab5
Keep public home button clickable
Git-Romer Jul 27, 2026
bf0af90
Harden binder sharing invariants
Git-Romer Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.25.1
1.26.0
29 changes: 27 additions & 2 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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}


Expand Down Expand Up @@ -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}
35 changes: 28 additions & 7 deletions backend/api/binders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -502,24 +504,43 @@ 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:
binder.description = update.description
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)
Expand Down
80 changes: 80 additions & 0 deletions backend/api/profile.py
Original file line number Diff line number Diff line change
@@ -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))
119 changes: 119 additions & 0 deletions backend/api/public.py
Original file line number Diff line number Diff line change
@@ -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))
Loading