From d14879cf86a1b402767fe83a5250548b88b06d45 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 19:50:02 +0000 Subject: [PATCH 01/32] docs: design for public viewable binders Public profile (custom handle) exposing owner-shared binders, viewable anonymously and discoverable in-app. Dedicated unauthenticated /api/public namespace with whitelist serializers so purchase price / cost / P&L cannot leak; market values shown only on owner opt-in. Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-19-public-binders-design.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-public-binders-design.md diff --git a/docs/superpowers/specs/2026-07-19-public-binders-design.md b/docs/superpowers/specs/2026-07-19-public-binders-design.md new file mode 100644 index 00000000..7b997b91 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-public-binders-design.md @@ -0,0 +1,176 @@ +# Public Viewable Binders — Design + +**Date:** 2026-07-19 +**Branch:** `feature/public-binders` (cut from `upstream/main`) +**Status:** Design — awaiting review before implementation planning + +## Summary + +Let a user publish a **public profile** identified by a custom handle. On that profile, +individual binders the user has explicitly shared are viewable — by anyone with the link +(no account required) and discoverable by logged-in users from the leaderboard. Purchase +prices, cost basis, and P&L are **never** exposed. Current card market values are shown +only if the profile owner opts in. + +This extends the app's existing cross-user viewing (which today is entirely behind the +login wall via `ProtectedRoutes` and `get_current_user`) with a genuinely public, +unauthenticated surface. + +## Decisions (from brainstorming) + +- **Audience:** both — anonymous link works logged-out, AND logged-in users can discover + public profiles in-app. +- **Control model:** a profile-level "make profile public" switch plus a per-binder + "share publicly" toggle. The public profile lists only the binders the user shared. +- **Money data:** purchase price / cost basis / P&L are never public. Current market value + (per card + binder total) is shown only when the owner enables a per-profile + "show card values" setting. Default off. +- **Identity:** a unique, URL-safe **handle** for the URL (`/u/`); the page shows + the user's `trainer_name` + avatar. The login `username` is never stored in or emitted + by any public response. +- **Architecture:** a dedicated unauthenticated `/api/public/*` namespace with its own + whitelist serializers, kept physically separate from the private endpoints, so private + fields cannot leak by construction. (Rejected: gating the existing private endpoints + with optional auth — mixing trust levels in one handler is exactly the pattern that + produced the earlier `SENSITIVE_ADMIN_KEYS` admin-key leak.) + +## Data model + +No Alembic in this project (`create_all` adds new *tables* only), so each new **column** +needs a hand-written `migrate_*` function in `backend/database.py` +(`ALTER TABLE ... ADD COLUMN ... DEFAULT ...`). All defaults keep existing data private. + +`User` (new columns): +- `public_handle` — `String`, **unique**, nullable. URL slug: lowercase `[a-z0-9-]`, + 3–30 chars, no leading/trailing/double hyphen. `NULL` = no public profile. +- `is_profile_public` — `Boolean`, default `False`. Master switch. +- `public_show_values` — `Boolean`, default `False`. Per-profile "show card market values". + +`Binder` (new column): +- `is_public` — `Boolean`, default `False`. Per-binder share toggle. + +**A profile is live iff** `is_profile_public = True AND public_handle IS NOT NULL`. +**A binder is publicly viewable iff** its owner's profile is live AND `binder.is_public = True`. + +Display name = existing `trainer_name` UserSetting (default `"TRAINER"`). Avatar = +existing `User.avatar_id`. + +### Reserved handles + +`admin`, `api`, `u`, `settings`, `login`, `logout`, `static`, `assets`, `public`, +`me`, `null`, `undefined` (final list finalized during implementation). + +## Backend — `backend/api/public.py` (unauthenticated router) + +Mounted under `/api/public`. No `get_current_user`. Own Pydantic response models that +contain **only** whitelisted fields — private fields are absent from the models, so they +cannot be serialized even by mistake. + +Response models: +- `PublicProfile` — `handle`, `trainer_name`, `avatar_id`, `show_values` (bool), + `binders: list[PublicBinderSummary]`. +- `PublicBinderSummary` — `id`, `name`, `color`, `icon_pokemon_id`, `card_count`, + `unique_card_count`, `total_value` (nullable; present only if `show_values`). +- `PublicBinderDetail` — summary fields + `cards: list[PublicCard]`. +- `PublicCard` — `id`, `name`, `image`, `set_name`, `number`, `rarity`, `quantity`, + `market_value` (nullable; present only if `show_values`). + +Endpoints: +- `GET /api/public/profiles/{handle}` → `PublicProfile`. 404 unless profile is live. + Lists only `is_public` binders belonging to that user. +- `GET /api/public/profiles/{handle}/binders/{binder_id}` → `PublicBinderDetail`. + 404 unless profile is live AND the binder belongs to that user AND `binder.is_public`. + +Owner-facing control endpoints (authenticated). These are profile-shaped, not simple +key/value settings, so they live in a small new authenticated `backend/api/profile.py` +router (mounted at `/api/profile`) rather than being squeezed into the settings key/value +model: +- `PUT /api/profile` → set `public_handle` (validated), `is_profile_public`, + `public_show_values`. +- `GET /api/profile/handle-available?handle=...` → `{available: bool, reason?: str}` for + live availability checks. +- Extend the existing binder update endpoint (`backend/api/binders.py`) to accept + `is_public`. + +Discovery: +- Add `handle` (nullable) + `is_profile_public` to the existing leaderboard row payload + (`backend/api/social.py`) so the frontend can link rows to `/u/`. No standalone + public directory page or endpoint in v1 (YAGNI). + +Market value: reuse `effective_market_price` (Cardmarket EUR) for `market_value` / +`total_value`. Values are EUR-native; public pages display in EUR in v1 (no per-viewer +currency conversion — the viewer may be anonymous with no currency setting). + +## Frontend + +Routing (`frontend/src/App.jsx`): add a **public route group rendered regardless of auth +state**, as a sibling of `ProtectedRoutes`: +- `/u/:handle` → `PublicProfile` +- `/u/:handle/binder/:binderId` → `PublicBinderView` + +These pages call `/api/public/*` with no `Authorization` header and must render fully for a +logged-out visitor (no redirect to login, no calls to protected endpoints). + +New pages: +- `PublicProfile.jsx` — handle → trainer name + avatar + grid of shared binders (counts, + and `total_value` only when `show_values`). Each binder links to its public view. +- `PublicBinderView.jsx` — read-only card grid reusing existing card-tile components + (e.g. `CardItem`), with **no** add/edit/remove affordances. Per-card value shown only + when `show_values`. + +Owner controls: +- `Settings.jsx` — a "Public profile" section: set/edit handle with live availability + check, toggle `is_profile_public`, toggle `public_show_values`, and a copy-to-clipboard + of the public URL. +- Binder UI (`Binders.jsx` / `BinderDetail.jsx`) — a per-binder "Share publicly" toggle, + effective only while the profile is public (with a hint if the profile isn't public yet), + plus a copy-link affordance. +- `Leaderboard.jsx` — rows with a handle link to `/u/`. + +## Privacy, security & error handling + +- Non-public profile or binder → **404, never 403** (do not confirm a private binder exists). +- Disabling a profile or unsharing a binder immediately 404s existing links — no stale + tokens or cached grants. +- Public serializers omit `purchase_price`, cost basis, P&L, condition, notes, `username`, + email, telegram/gemini settings, and internal user ids. +- Handle set-path validates slug format, reserved words, and uniqueness (backed by a unique + constraint; handle races surface as a 409/validation error). +- Public GET responses send `Cache-Control: public, max-age=...`. Data is intentionally + public; no heavy per-IP rate limiting in v1 (noted as a follow-up if scraping becomes an + issue). +- Works in single- or multi-user mode. + +**Known v1 limitations (out of scope):** +- SPA has no SSR, so shared links won't render rich social-preview (OpenGraph) cards. The + link works; the unfurl is plain. Could add per-route meta / prerender later. +- No per-viewer currency conversion on public pages (EUR only). +- No follower/comment/like social features — view-only. + +## Testing + +Backend (`unittest`, run in the backend container — not pytest): +- Public serializers never emit private fields (assert `purchase_price` etc. absent from + the JSON) even when the underlying rows have them populated. +- 404 for: unknown handle, `is_profile_public = False`, non-public binder, and a binder id + that exists but belongs to a different user than the handle. +- `show_values` gating: `market_value`/`total_value` present when on, absent/null when off. +- Handle validation: format rejects, reserved-word rejects, uniqueness conflict. + +Frontend (vitest): +- Handle-validation util (format + reserved). +- `PublicBinderView` renders read-only (no edit controls present). +- Value hiding when `show_values` is off. + +Manual: +- Confirm logged-out access to `/u/` and a shared binder against the real domain + (`https://poke.roberts-clan.site`), and that a private binder / unpublished profile 404s. + +## Out of scope / future + +- Public directory / browse-all-profiles page. +- Social-preview (OG) meta and SSR/prerender. +- Rate limiting / anti-scraping. +- Per-viewer currency on public pages. +- Wishlist binders public sharing (v1 covers collection binders; wishlist sharing can follow + the same pattern if wanted). From 237351be061672ff96958e1a240fbebc7c0aa8e0 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:00:14 +0000 Subject: [PATCH 02/32] docs: implementation plan for public viewable binders 11 TDD tasks: data model + migrations, handle validation, whitelist serialization, unauthenticated public API, owner controls, per-binder toggle, leaderboard discovery, frontend pages/routes + token-less client, owner UI, cache/rate-limit. Backend unittest + frontend vitest throughout. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-19-public-binders.md | 1451 +++++++++++++++++ 1 file changed, 1451 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-public-binders.md diff --git a/docs/superpowers/plans/2026-07-19-public-binders.md b/docs/superpowers/plans/2026-07-19-public-binders.md new file mode 100644 index 00000000..2f455cb2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-public-binders.md @@ -0,0 +1,1451 @@ +# Public Viewable Binders Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user publish a public profile (custom handle) exposing binders they explicitly share, viewable by anonymous visitors and discoverable in-app, with purchase price / cost / P&L never exposed. + +**Architecture:** A dedicated unauthenticated `/api/public/*` router with its own whitelist serializers (private fields physically absent from the response models). Owner controls live in a new authenticated `/api/profile` router plus an `is_public` flag on binders. Frontend adds public routes outside the login wall using a separate axios client that never attaches a token or redirects on 401. + +**Tech Stack:** FastAPI + SQLAlchemy + PostgreSQL (prod) / in-memory SQLite (tests); React + Vite + React Router + axios; slowapi for rate limiting; Vitest + Python `unittest`. + +## Global Constraints + +- **Tests are `unittest`, NOT pytest.** Run backend tests in the backend image with the source bind-mounted: + `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests. -v` +- **Frontend tests/build run in Node 20 container, not the host:** + `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run ` +- **No Alembic.** New columns need idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...` statements in `backend/database.py::_run_migrations` (PostgreSQL). Tests get the columns from `Base.metadata.create_all` via the model definitions. +- **The collection table is named `collection`** (not `collection_items`). `Set.id`/`Card.id` are composite/lang-suffixed; `Card.set_id` is the unsuffixed tcg id. +- **`SessionLocal` is `autoflush=False`.** +- **Never expose private fields publicly:** `purchase_price`, cost basis, P&L, `condition`, notes, `username`, email, telegram/gemini settings, internal user ids. The login `username` in particular must never appear in any `/api/public/*` or profile-public response. +- **Money is EUR-native** on public pages (no per-viewer currency conversion in v1). +- Branch: `feature/public-binders` (already created from `upstream/main`). Design/plan docs under `docs/superpowers/` must not ride into an upstream PR. + +--- + +## File Structure + +**Backend** +- `backend/models.py` — add `User.public_handle`, `User.is_profile_public`, `User.public_show_values`, `Binder.is_public`. +- `backend/database.py` — migrations for the four new columns. +- `backend/services/public_profile.py` *(new)* — handle validation/reserved words, profile resolution, and whitelist serialization. All public logic isolated here. +- `backend/api/public.py` *(new)* — unauthenticated router + public Pydantic response models. +- `backend/api/profile.py` *(new)* — authenticated owner controls (set handle/toggles, availability check). +- `backend/schemas.py` — add `ProfileUpdate`; add `is_public` to `BinderUpdate` and `BinderResponse`. +- `backend/api/binders.py` — persist `is_public` in `update_binder`, include it in `_binder_response`. +- `backend/api/social.py` — include `public_handle` in leaderboard rows. +- `backend/main.py` — mount the two new routers. +- `backend/tests/test_public_binders.py` *(new)* — all backend tests for this feature. + +**Frontend** +- `frontend/src/utils/publicHandle.js` *(new)* — shared handle-format validator. +- `frontend/src/utils/publicHandle.test.js` *(new)*. +- `frontend/src/api/publicClient.js` *(new)* — token-less axios instance + public API calls. +- `frontend/src/api/client.js` — add `updateProfile`, `checkHandleAvailable`, and `is_public` on binder update. +- `frontend/src/pages/PublicProfile.jsx` *(new)*, `frontend/src/pages/PublicBinderView.jsx` *(new)*. +- `frontend/src/pages/PublicBinderView.test.jsx` *(new)*. +- `frontend/src/App.jsx` — public routes outside `ProtectedRoutes`. +- `frontend/src/pages/Settings.jsx` — "Public profile" section. +- `frontend/src/pages/Binders.jsx` — per-binder "Share publicly" toggle. +- `frontend/src/pages/Leaderboard.jsx` — link rows with a handle. + +--- + +## Task 1: Data model + migrations + +**Files:** +- Modify: `backend/models.py` (User class ~line 134, Binder class ~line 205) +- Modify: `backend/database.py` (`_run_migrations` list ~line 58) +- Test: `backend/tests/test_public_binders.py` + +**Interfaces:** +- Produces: `User.public_handle: str|None`, `User.is_profile_public: bool`, `User.public_show_values: bool`, `Binder.is_public: bool`. + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_public_binders.py`: + +```python +import unittest + +try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from database import Base + from models import User, Binder + 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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders -v` +Expected: FAIL — `AttributeError`/`TypeError` (columns don't exist yet). + +- [ ] **Step 3: Add columns to models** + +In `backend/models.py`, inside `class User(Base)` (after `avatar_id`): + +```python + public_handle = Column(String, unique=True, nullable=True) + is_profile_public = Column(Boolean, default=False, nullable=False) + public_show_values = Column(Boolean, default=False, nullable=False) +``` + +Inside `class Binder(Base)` (after `icon_pokemon_id`): + +```python + is_public = Column(Boolean, default=False, nullable=False) +``` + +- [ ] **Step 4: Add migrations** + +In `backend/database.py`, append to the `migrations` list in `_run_migrations`: + +```python + "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", +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/models.py backend/database.py backend/tests/test_public_binders.py +git commit -m "feat(public-binders): add profile handle + public flags data model" +``` + +--- + +## Task 2: Handle validation service + +**Files:** +- Create: `backend/services/public_profile.py` +- Test: `backend/tests/test_public_binders.py` + +**Interfaces:** +- Produces: + - `HANDLE_RE` (compiled), `RESERVED_HANDLES: set[str]` + - `class HandleError(ValueError)` + - `validate_handle(raw: str) -> str` — returns normalized lowercase handle or raises `HandleError(message)` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_public_binders.py`: + +```python +try: + from services.public_profile import validate_handle, HandleError + 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") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.HandleValidationTests -v` +Expected: FAIL — `ModuleNotFoundError`/skip → the file doesn't exist. (If skipped, that itself signals the module is missing; create it in Step 3.) + +- [ ] **Step 3: Create the service** + +Create `backend/services/public_profile.py`: + +```python +import re + +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 + + +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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.HandleValidationTests -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/services/public_profile.py backend/tests/test_public_binders.py +git commit -m "feat(public-binders): handle validation + reserved words" +``` + +--- + +## Task 3: Profile resolution + whitelist serialization + +**Files:** +- Modify: `backend/services/public_profile.py` +- Test: `backend/tests/test_public_binders.py` + +**Interfaces:** +- Consumes: `User`, `Binder`, `BinderCard`, `Card`, `UserSetting` models; `services.card_values.effective_market_price`. +- Produces: + - `is_handle_available(db, handle: str, exclude_user_id: int|None=None) -> bool` + - `get_live_profile(db, handle: str) -> User|None` — returns the user only if `is_profile_public` and handle set. + - `trainer_name_for(db, user) -> str` + - `public_collection_binders(db, user) -> list[Binder]` — this user's `is_public` collection binders. + - `serialize_profile(db, user) -> dict` — keys: `handle, trainer_name, avatar_id, show_values, binders`. + - `serialize_binder_summary(db, binder, show_values: bool) -> dict` — keys: `id, name, color, icon_pokemon_id, card_count, unique_card_count, total_value`. + - `serialize_binder_detail(db, binder, show_values: bool) -> dict` — summary keys + `cards`. + - Each card dict keys: `id, name, image, set_name, number, rarity, quantity, market_value`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_public_binders.py`: + +```python +try: + from services import public_profile as pp + from models import BinderCard, Card, Set, UserSetting + 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, + UserSetting(user_id=1, key="trainer_name", value="Ash K."), + 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 K.") + 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]["quantity"], 2) + self.assertIsNone(detail["cards"][0]["market_value"]) + self.assertIsNone(detail["total_value"]) + + 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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.SerializationTests -v` +Expected: FAIL — functions not defined. + +- [ ] **Step 3: Implement the resolution + serialization** + +Append to `backend/services/public_profile.py`: + +```python +from models import User, Binder, BinderCard, Card, UserSetting +from services.card_values import effective_market_price + +_DEFAULT_TRAINER_NAME = "TRAINER" +_PRICE_FIELD = "price_trend" + + +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 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: + row = db.query(UserSetting).filter( + UserSetting.user_id == user.id, UserSetting.key == "trainer_name" + ).first() + return (row.value if row and row.value else _DEFAULT_TRAINER_NAME) + + +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 _binder_cards(db, binder: Binder) -> list[BinderCard]: + return db.query(BinderCard).filter(BinderCard.binder_id == binder.id).all() + + +def _serialize_card(bc: BinderCard, show_values: bool) -> dict: + card = bc.card + quantity = bc.required_quantity or 1 + value = effective_market_price(card, None, _PRICE_FIELD) if show_values else None + return { + "id": card.id, + "name": card.name, + "image": card.images_small or card.images_large, + "set_name": card.set_ref.name if card.set_ref else None, + "number": card.number, + "rarity": card.rarity, + "quantity": quantity, + "market_value": value, + } + + +def serialize_binder_summary(db, binder: Binder, show_values: bool) -> dict: + cards = _binder_cards(db, binder) + 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, None, _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: + summary = serialize_binder_summary(db, binder, show_values) + cards = _binder_cards(db, binder) + 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) + 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, b, show_values) for b in binders], + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.SerializationTests -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/services/public_profile.py backend/tests/test_public_binders.py +git commit -m "feat(public-binders): profile resolution + whitelist serialization" +``` + +--- + +## Task 4: Public API router + +**Files:** +- Create: `backend/api/public.py` +- Modify: `backend/main.py` (imports ~line 124, mounts ~line 156) +- Test: `backend/tests/test_public_binders.py` + +**Interfaces:** +- Consumes: `services.public_profile`, `database.get_db`. +- Produces (callable directly in tests): `get_public_profile(handle, db)`, `get_public_binder(handle, binder_id, db)`; Pydantic `PublicProfile`, `PublicBinderDetail`, `PublicBinderSummary`, `PublicCard`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_public_binders.py`: + +```python +try: + from fastapi import HTTPException + from api.public import get_public_profile, get_public_binder + API_DEPS = True +except ModuleNotFoundError: + API_DEPS = False + + +@unittest.skipUnless(API_DEPS, "api deps unavailable") +class PublicApiTests(unittest.TestCase): + def _db(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + 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_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_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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.PublicApiTests -v` +Expected: FAIL — `api.public` not found. + +- [ ] **Step 3: Create the router** + +Create `backend/api/public.py`: + +```python +from fastapi import APIRouter, Depends, HTTPException +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 + +router = APIRouter() + + +class PublicCard(BaseModel): + id: str + name: str + image: Optional[str] = None + set_name: Optional[str] = None + number: Optional[str] = None + rarity: 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 PublicBinderDetail(PublicBinderSummary): + cards: List[PublicCard] + + +@router.get("/profiles/{handle}", response_model=PublicProfile) +def get_public_profile(handle: str, db: Session = Depends(get_db)): + user = pp.get_live_profile(db, handle.lower()) + if not user: + raise HTTPException(status_code=404, detail="Profile not found") + 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)): + user = pp.get_live_profile(db, handle.lower()) + if not user: + raise HTTPException(status_code=404, detail="Profile not found") + binder = next((b for b in pp.public_collection_binders(db, user) if b.id == binder_id), None) + if not binder: + raise HTTPException(status_code=404, detail="Binder not found") + return pp.serialize_binder_detail(db, binder, show_values=bool(user.public_show_values)) +``` + +Note: endpoints return the plain whitelisted dict from the serializer; `response_model` filters/validates the schema at FastAPI's serialization layer (extra keys would be dropped), and the serializers already emit only whitelisted keys — belt and suspenders. Returning a dict (not a `Response`) keeps the functions directly callable and subscriptable in the unit tests. The public `Cache-Control` header is added in Task 11 with a test-safe signature. + +- [ ] **Step 4: Mount the router** + +In `backend/main.py`, add `public` to the `from api import ...` line (~124), then after the other `include_router` calls: + +```python +app.include_router(public.router, prefix="/api/public", tags=["public"]) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.PublicApiTests -v` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add backend/api/public.py backend/main.py backend/tests/test_public_binders.py +git commit -m "feat(public-binders): unauthenticated public profile + binder API" +``` + +--- + +## Task 5: Owner control API (profile router) + +**Files:** +- Create: `backend/api/profile.py` +- Modify: `backend/schemas.py` (add `ProfileUpdate`) +- Modify: `backend/main.py` +- Test: `backend/tests/test_public_binders.py` + +**Interfaces:** +- Consumes: `api.auth.get_current_user`, `services.public_profile`, `database.get_db`. +- Produces: `update_profile(payload, db, current_user)`, `handle_available(handle, db, current_user)`; `schemas.ProfileUpdate`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_public_binders.py`: + +```python +try: + from api.profile import update_profile, handle_available + 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): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + 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_set_handle_and_publish(self): + db = self._db() + u = self._user(db) + result = update_profile(ProfileUpdate(public_handle="Ash-K", is_profile_public=True), + db=db, current_user=u) + self.assertEqual(result["public_handle"], "ash-k") + self.assertTrue(result["is_profile_public"]) + + def test_invalid_handle_422(self): + db = self._db() + u = self._user(db) + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(public_handle="a"), db=db, current_user=u) + self.assertEqual(ctx.exception.status_code, 422) + + def test_duplicate_handle_409(self): + db = self._db() + taken = self._user(db, "misty") + update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=taken) + me = self._user(db, "ash") + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=me) + self.assertEqual(ctx.exception.status_code, 409) + + def test_handle_available_check(self): + db = self._db() + u = self._user(db) + self.assertTrue(handle_available("brand-new", db=db, current_user=u)["available"]) + self.assertFalse(handle_available("ADMIN", db=db, current_user=u)["available"]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.ProfileControlTests -v` +Expected: FAIL — `api.profile` not found. + +- [ ] **Step 3: Add the schema** + +In `backend/schemas.py`, add: + +```python +class ProfileUpdate(BaseModel): + public_handle: Optional[str] = None + is_profile_public: Optional[bool] = None + public_show_values: Optional[bool] = None +``` + +- [ ] **Step 4: Create the router** + +Create `backend/api/profile.py`: + +```python +from fastapi import APIRouter, Depends, HTTPException, Query +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 + +router = APIRouter() + + +def _serialize_owner(user: User) -> dict: + return { + "public_handle": user.public_handle, + "is_profile_public": bool(user.is_profile_public), + "public_show_values": bool(user.public_show_values), + } + + +@router.get("/handle-available") +def handle_available(handle: str = Query(...), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + try: + normalized = pp.validate_handle(handle) + except pp.HandleError as exc: + return {"available": False, "reason": str(exc)} + available = pp.is_handle_available(db, normalized, exclude_user_id=current_user.id) + return {"available": available, "reason": None if available else "Handle is taken"} + + +@router.put("/") +def update_profile(payload: ProfileUpdate, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + if payload.public_handle is not None: + try: + normalized = pp.validate_handle(payload.public_handle) + except pp.HandleError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from None + if not pp.is_handle_available(db, normalized, exclude_user_id=current_user.id): + raise HTTPException(status_code=409, detail="Handle is taken") + current_user.public_handle = normalized + if payload.is_profile_public is not None: + current_user.is_profile_public = payload.is_profile_public + if payload.public_show_values is not None: + current_user.public_show_values = payload.public_show_values + db.commit() + db.refresh(current_user) + return _serialize_owner(current_user) +``` + +- [ ] **Step 5: Mount the router** + +In `backend/main.py`, add `profile` to the `from api import ...` line and: + +```python +app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.ProfileControlTests -v` +Expected: PASS (4 tests). + +- [ ] **Step 7: Commit** + +```bash +git add backend/api/profile.py backend/schemas.py backend/main.py backend/tests/test_public_binders.py +git commit -m "feat(public-binders): owner profile controls (handle + toggles)" +``` + +--- + +## Task 6: Binder `is_public` toggle + +**Files:** +- Modify: `backend/schemas.py` (`BinderUpdate` ~234, `BinderResponse` ~256) +- Modify: `backend/api/binders.py` (`_binder_response` ~88, `update_binder` ~491) +- Test: `backend/tests/test_public_binders.py` + +**Interfaces:** +- Consumes: existing `update_binder(binder_id, binder, db, current_user)`. +- Produces: `BinderResponse.is_public: bool`; `update_binder` persists `is_public`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_public_binders.py`: + +```python +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): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + 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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.BinderPublicToggleTests -v` +Expected: FAIL — `BinderUpdate` has no `is_public` / `BinderResponse` has no `is_public`. + +- [ ] **Step 3: Update schemas** + +In `backend/schemas.py`, add `is_public: Optional[bool] = None` to `BinderUpdate`, and `is_public: bool = False` to `BinderResponse`. + +- [ ] **Step 4: Persist and return `is_public`** + +In `backend/api/binders.py`, in `_binder_response(...)` add `is_public=binder.is_public or False,` to the `BinderResponse(...)` call. In `update_binder`, alongside the other `if update.X is not None:` assignments, add: + +```python + if update.is_public is not None: + binder.is_public = update.is_public +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.BinderPublicToggleTests -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/schemas.py backend/api/binders.py backend/tests/test_public_binders.py +git commit -m "feat(public-binders): per-binder is_public toggle" +``` + +--- + +## Task 7: Leaderboard handle exposure (discovery) + +**Files:** +- Modify: `backend/api/social.py` (`_load_user_stats`, the `stats[user.id] = {...}` block ~line 153) +- Test: `backend/tests/test_public_binders.py` + +**Interfaces:** +- Produces: leaderboard row dict gains `public_handle: str|None`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_public_binders.py`: + +```python +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(u) + db.commit() + stats = _load_user_stats(db) + self.assertIn(u.id, stats) + self.assertEqual(stats[u.id]["public_handle"], "ash") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.LeaderboardHandleTests -v` +Expected: FAIL — `KeyError: 'public_handle'`. + +- [ ] **Step 3: Add the field** + +In `backend/api/social.py`, inside the `stats[user.id] = { ... }` dict, add: + +```python + "public_handle": user.public_handle if user.is_profile_public else None, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.LeaderboardHandleTests -v` +Expected: PASS. + +- [ ] **Step 5: Full backend suite regression check** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest discover -s tests -v` +Expected: all pass (existing + new). + +- [ ] **Step 6: Commit** + +```bash +git add backend/api/social.py backend/tests/test_public_binders.py +git commit -m "feat(public-binders): expose public_handle on leaderboard rows" +``` + +--- + +## Task 8: Frontend handle validator + public API client + +**Files:** +- Create: `frontend/src/utils/publicHandle.js` +- Create: `frontend/src/utils/publicHandle.test.js` +- Create: `frontend/src/api/publicClient.js` + +**Interfaces:** +- Produces: `isValidHandleFormat(raw) -> bool`, `normalizeHandle(raw) -> string`; `getPublicProfile(handle)`, `getPublicBinder(handle, binderId)`. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/utils/publicHandle.test.js`: + +```javascript +import { describe, it, expect } from 'vitest' +import { isValidHandleFormat, normalizeHandle } from './publicHandle' + +describe('publicHandle', () => { + it('normalizes case and trims', () => { + expect(normalizeHandle(' Ash-K ')).toBe('ash-k') + }) + it('accepts a valid handle', () => { + expect(isValidHandleFormat('ash-ketchum')).toBe(true) + }) + it('rejects too short', () => { + expect(isValidHandleFormat('ab')).toBe(false) + }) + it('rejects bad chars and edges', () => { + expect(isValidHandleFormat('ash_k')).toBe(false) + expect(isValidHandleFormat('-ash')).toBe(false) + expect(isValidHandleFormat('ash--k')).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/publicHandle.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the validator** + +Create `frontend/src/utils/publicHandle.js`: + +```javascript +const HANDLE_RE = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/ + +export function normalizeHandle(raw) { + return String(raw || '').trim().toLowerCase() +} + +export function isValidHandleFormat(raw) { + const handle = normalizeHandle(raw) + if (handle.length < 3 || handle.length > 30) return false + if (handle.includes('--')) return false + return HANDLE_RE.test(handle) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/publicHandle.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Create the token-less public client** + +Create `frontend/src/api/publicClient.js`: + +```javascript +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 getPublicBinder = (handle, binderId) => + publicApi.get(`/profiles/${encodeURIComponent(handle)}/binders/${binderId}`).then(r => r.data) +``` + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/utils/publicHandle.js frontend/src/utils/publicHandle.test.js frontend/src/api/publicClient.js +git commit -m "feat(public-binders): frontend handle validator + token-less public client" +``` + +--- + +## Task 9: Public pages + routes + +**Files:** +- Create: `frontend/src/pages/PublicProfile.jsx` +- Create: `frontend/src/pages/PublicBinderView.jsx` +- Create: `frontend/src/pages/PublicBinderView.test.jsx` +- Modify: `frontend/src/App.jsx` + +**Interfaces:** +- Consumes: `getPublicProfile`, `getPublicBinder` from `../api/publicClient`. +- Produces: routes `/u/:handle`, `/u/:handle/binder/:binderId` rendered outside `ProtectedRoutes`. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/pages/PublicBinderView.test.jsx`: + +```javascript +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' + +vi.mock('../api/publicClient', () => ({ + getPublicBinder: vi.fn(), +})) +import { getPublicBinder } from '../api/publicClient' +import PublicBinderView from './PublicBinderView' + +function renderAt(path) { + return render( + + + } /> + + + ) +} + +describe('PublicBinderView', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders cards read-only and hides value when null', async () => { + getPublicBinder.mockResolvedValue({ + id: 1, name: 'Starters', card_count: 1, unique_card_count: 1, total_value: null, + cards: [{ id: 'sv1-1_en', name: 'Sprigatito', image: null, set_name: 'SV', number: '1', rarity: 'Common', quantity: 2, market_value: null }], + }) + renderAt('/u/ash/binder/1') + await waitFor(() => expect(screen.getByText('Sprigatito')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /add|edit|remove/i })).toBeNull() + expect(screen.queryByText(/€/)).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/pages/PublicBinderView.test.jsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `PublicBinderView.jsx`** + +Create `frontend/src/pages/PublicBinderView.jsx`: + +```javascript +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { getPublicBinder } from '../api/publicClient' + +function formatEur(value) { + if (value == null) return null + return new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' }).format(value) +} + +export default function PublicBinderView() { + const { handle, binderId } = useParams() + const [binder, setBinder] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + getPublicBinder(handle, binderId) + .then(data => { if (!cancelled) setBinder(data) }) + .catch(() => { if (!cancelled) setError('This binder is not available.') }) + return () => { cancelled = true } + }, [handle, binderId]) + + if (error) return
{error}
+ if (!binder) return
Loading…
+ + return ( +
+ ← {handle} +
+

{binder.name}

+ {binder.total_value != null && ( + {formatEur(binder.total_value)} + )} +
+
+ {binder.cards.map(card => ( +
+ {card.image + ? {card.name} + :
} +
{card.name}
+
+ {card.set_name} · #{card.number}{card.quantity > 1 ? ` · ×${card.quantity}` : ''} +
+ {card.market_value != null && ( +
{formatEur(card.market_value)}
+ )} +
+ ))} +
+
+ ) +} +``` + +- [ ] **Step 4: Create `PublicProfile.jsx`** + +Create `frontend/src/pages/PublicProfile.jsx`: + +```javascript +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { getPublicProfile } from '../api/publicClient' + +function formatEur(value) { + if (value == null) return null + return new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' }).format(value) +} + +export default function PublicProfile() { + const { handle } = useParams() + const [profile, setProfile] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + getPublicProfile(handle) + .then(data => { if (!cancelled) setProfile(data) }) + .catch(() => { if (!cancelled) setError('This profile is not available.') }) + return () => { cancelled = true } + }, [handle]) + + if (error) return
{error}
+ if (!profile) return
Loading…
+ + return ( +
+
+ {profile.avatar_id && ( + + )} +

{profile.trainer_name}

+
+ {profile.binders.length === 0 && ( +

No shared binders yet.

+ )} +
+ {profile.binders.map(binder => ( + +
{binder.name}
+
+ {binder.unique_card_count} cards + {binder.total_value != null ? ` · ${formatEur(binder.total_value)}` : ''} +
+ + ))} +
+
+ ) +} +``` + +- [ ] **Step 5: Wire the routes outside the auth wall** + +In `frontend/src/App.jsx`: add lazy imports near the other page imports: + +```javascript +const PublicProfile = lazy(() => import('./pages/PublicProfile')) +const PublicBinderView = lazy(() => import('./pages/PublicBinderView')) +``` + +Then in the top-level `` (the one containing `/login` and `/*`), add these **before** the `/*` catch-all so they bypass `ProtectedRoutes`: + +```javascript + )} /> + )} /> +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/pages/PublicBinderView.test.jsx` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/pages/PublicProfile.jsx frontend/src/pages/PublicBinderView.jsx frontend/src/pages/PublicBinderView.test.jsx frontend/src/App.jsx +git commit -m "feat(public-binders): public profile + binder pages and routes" +``` + +--- + +## Task 10: Owner controls UI (settings + binder toggle + leaderboard links) + +**Files:** +- Modify: `frontend/src/api/client.js` +- Modify: `frontend/src/pages/Settings.jsx` +- Modify: `frontend/src/pages/Binders.jsx` +- Modify: `frontend/src/pages/Leaderboard.jsx` + +**Interfaces:** +- Consumes: `/api/profile` (PUT + handle-available), `/api/binders/{id}` (PUT with `is_public`), leaderboard `public_handle`. +- Produces: user-facing controls; no new exported types. + +- [ ] **Step 1: Add API client functions** + +In `frontend/src/api/client.js`, add: + +```javascript +export const updateProfile = (data) => api.put('/profile/', data).then(r => r.data) +export const checkHandleAvailable = (handle) => + api.get('/profile/handle-available', { params: { handle } }).then(r => r.data) +export const updateBinder = (id, data) => api.put(`/binders/${id}`, data).then(r => r.data) +``` + +(If `updateBinder` already exists, extend its usage to pass `is_public` rather than redefining.) + +- [ ] **Step 2: Add the "Public profile" settings section** + +In `frontend/src/pages/Settings.jsx`, add a section that: +- Loads current `public_handle`, `is_profile_public`, `public_show_values` from `/api/settings/` (these are now included via the profile columns — if not surfaced there, fetch from a `getMe`-style call; simplest is to read them from the settings payload which already returns user-scoped data). Use local state seeded on mount. +- Renders a handle text input with live availability feedback via `checkHandleAvailable` (debounced 400ms; show "available"/reason). +- Renders toggles for `is_profile_public` and `public_show_values`. +- On save, calls `updateProfile({ public_handle, is_profile_public, public_show_values })`. +- Shows the public URL `${window.location.origin}/u/${handle}` with a copy button when a handle is set and the profile is public. + +Concrete control block to insert (adapt styling to the surrounding page): + +```javascript +// inside Settings component +const [handle, setHandle] = useState('') +const [profilePublic, setProfilePublic] = useState(false) +const [showValues, setShowValues] = useState(false) +const [handleStatus, setHandleStatus] = useState(null) // {available, reason} + +useEffect(() => { + if (!handle) { setHandleStatus(null); return } + const t = setTimeout(() => { + checkHandleAvailable(handle).then(setHandleStatus).catch(() => setHandleStatus(null)) + }, 400) + return () => clearTimeout(t) +}, [handle]) + +const savePublicProfile = async () => { + await updateProfile({ + public_handle: handle || null, + is_profile_public: profilePublic, + public_show_values: showValues, + }) +} +``` + +And JSX (place in a settings card): + +```jsx +
+

Public profile

+ + {handleStatus && ( +

+ {handleStatus.available ? 'Available' : handleStatus.reason} +

+ )} + + + + {profilePublic && handle && ( +
+ {`${window.location.origin}/u/${handle}`} + +
+ )} +
+``` + +Seed `handle/profilePublic/showValues` from the settings payload the page already loads (the profile columns are user-scoped). If the settings endpoint does not return them, add them to `_get_user_settings` in `backend/api/settings.py` (read-only, non-sensitive) so the page can hydrate — but do NOT route their writes through settings; writes go through `/api/profile`. + +- [ ] **Step 3: Add per-binder "Share publicly" toggle** + +In `frontend/src/pages/Binders.jsx`, on each binder card (collection binders only), add a small toggle that calls `updateBinder(binder.id, { is_public: next })` and reflects `binder.is_public`. Show a hint ("Enable your public profile in Settings to share") when the user's profile isn't public. Include a copy-link affordance to `${origin}/u/${handle}/binder/${binder.id}` when both profile and binder are public. + +- [ ] **Step 4: Link leaderboard rows with a handle** + +In `frontend/src/pages/Leaderboard.jsx`, where each row renders, if `row.public_handle` is set, wrap/append a `` (e.g. a small "profile" link/icon). Leave rows without a handle unchanged. + +- [ ] **Step 5: Build to verify no breakage** + +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npm run build` +Expected: build succeeds. + +- [ ] **Step 6: Run frontend tests** + +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run` +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/api/client.js frontend/src/pages/Settings.jsx frontend/src/pages/Binders.jsx frontend/src/pages/Leaderboard.jsx +git commit -m "feat(public-binders): owner controls — settings, binder toggle, leaderboard links" +``` + +--- + +## Task 11: Cache headers + rate limiting + manual verification + +**Files:** +- Modify: `backend/api/public.py` + +**Interfaces:** +- Consumes: `main.py`'s existing slowapi `Limiter` (via the `@limiter.limit` decorator pattern already used in the codebase). + +- [ ] **Step 1: Add the public `Cache-Control` header (test-safe signature)** + +Add a `response: Response = None` keyword param (defaulted, so the direct unit-test calls from Task 4 still work) and set the header. Update both endpoints: + +```python +from fastapi import Response + +@router.get("/profiles/{handle}", response_model=PublicProfile) +def get_public_profile(handle: str, db: Session = Depends(get_db), response: Response = None): + user = pp.get_live_profile(db, handle.lower()) + if not user: + raise HTTPException(status_code=404, detail="Profile not found") + if response is not None: + response.headers["Cache-Control"] = "public, max-age=300" + return pp.serialize_profile(db, user) +``` + +Apply the same `response: Response = None` param + header line to `get_public_binder`. Re-run Task 4's tests to confirm they still pass: +`docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.PublicApiTests -v` + +- [ ] **Step 2: Add a slowapi limit to public endpoints** + +First search the codebase for the existing pattern: `grep -rn "limiter.limit\|@limiter\|Limiter(" backend/`. Match whatever it does exactly. slowapi's decorator requires a `request: Request` parameter on the endpoint. Example shape: + +```python +from fastapi import Request + +@router.get("/profiles/{handle}", response_model=PublicProfile) +@limiter.limit("60/minute") +def get_public_profile(request: Request, handle: str, db: Session = Depends(get_db), response: Response = None): + ... +``` + +Because adding a required `request: Request` positional param changes the signature the Task 4 unit tests call, either (a) pass a stub `request` in those tests, or (b) if the `limiter` instance can't be imported into `public.py` without an import cycle with `main.py`, **skip the decorator** and leave rate limiting as a documented follow-up — the spec permits deferral. Do NOT introduce an import cycle, and do NOT break the Task 4 tests. Prefer moving the `Limiter` instance to a small `backend/services/rate_limit.py` if you want the decorator without the cycle. + +- [ ] **Step 3: Run the full backend suite** + +Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest discover -s tests -v` +Expected: all pass. + +- [ ] **Step 4: Manual verification (logged out)** + +With the feature deployed to a test environment (not asked for here — just the checklist): +- Set a handle, publish the profile, share one binder in the app. +- In a private/incognito window (no token), open `https:///u/` → profile renders, shared binder visible. +- Open the shared binder URL → cards render read-only; values shown only if the toggle is on. +- Open a non-shared binder id under the handle → 404 page/message. +- Unpublish the profile → both URLs now show "not available". + +- [ ] **Step 5: Commit** + +```bash +git add backend/api/public.py +git commit -m "feat(public-binders): cache headers + rate-limit public endpoints" +``` + +--- + +## Self-Review Notes (coverage map) + +- Data model (handle, profile-public, show-values, binder-public) → Task 1. +- Handle validation + reserved words → Task 2 (backend), Task 8 (frontend). +- Whitelist serialization / no private-field leak / show-values gating → Task 3 (+ tests). +- Unauthenticated public API + 404-not-403 + cross-owner guard → Task 4. +- Owner controls (handle set, toggles, availability, 409 on dup) → Task 5. +- Per-binder share toggle → Task 6 (backend), Task 10 (UI). +- In-app discovery via leaderboard → Task 7 (backend), Task 10 (UI links). +- Public pages outside login wall + token-less client → Tasks 8–9. +- Rate limiting (upgraded from spec's "deferred" since slowapi already exists) → Task 11. +- Known v1 exclusions (SSR/OG previews, per-viewer currency, public directory, wishlist sharing) remain out of scope. From 903875651011113f8e4546d7bb66a0f515b21cb9 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:07:10 +0000 Subject: [PATCH 03/32] feat(public-binders): add profile handle + public flags data model Co-Authored-By: Claude Opus 4.8 --- backend/database.py | 6 ++++++ backend/models.py | 4 ++++ backend/tests/test_public_binders.py | 31 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 backend/tests/test_public_binders.py diff --git a/backend/database.py b/backend/database.py index 1f323ac8..1e075aca 100644 --- a/backend/database.py +++ b/backend/database.py @@ -375,6 +375,12 @@ 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", ] for stmt in migrations: try: diff --git a/backend/models.py b/backend/models.py index 19834d59..d28e0407 100644 --- a/backend/models.py +++ b/backend/models.py @@ -140,6 +140,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, unique=True, 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 +211,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) created_at = Column(DateTime, default=func.now()) binder_cards = relationship("BinderCard", back_populates="binder", cascade="all, delete-orphan") diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py new file mode 100644 index 00000000..89bd533d --- /dev/null +++ b/backend/tests/test_public_binders.py @@ -0,0 +1,31 @@ +import unittest + +try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from database import Base + from models import User, Binder + 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) From d5b719d2b1a1af96bccdc1e731272f5fb9612097 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:12:10 +0000 Subject: [PATCH 04/32] fix(public-binders): enforce NOT NULL on new boolean columns for upgraded installs --- backend/database.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/database.py b/backend/database.py index 1e075aca..a32007ac 100644 --- a/backend/database.py +++ b/backend/database.py @@ -381,6 +381,10 @@ def _run_migrations(conn): "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: From 2de712c91b49c281a534304c1fab6c86c3344362 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:14:11 +0000 Subject: [PATCH 05/32] feat(public-binders): handle validation + reserved words Co-Authored-By: Claude Opus 4.8 --- backend/services/public_profile.py | 28 +++++++++++++++++++++++ backend/tests/test_public_binders.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 backend/services/public_profile.py diff --git a/backend/services/public_profile.py b/backend/services/public_profile.py new file mode 100644 index 00000000..a4017fb8 --- /dev/null +++ b/backend/services/public_profile.py @@ -0,0 +1,28 @@ +import re + +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 + + +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 diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 89bd533d..ee408b47 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -29,3 +29,36 @@ def test_new_columns_default_private(self): self.assertFalse(user.is_profile_public) self.assertFalse(user.public_show_values) self.assertFalse(binder.is_public) + + +try: + from services.public_profile import validate_handle, HandleError + 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") From c434927faf92cdde90675deb129ea6108e4d8f16 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:18:50 +0000 Subject: [PATCH 06/32] feat(public-binders): profile resolution + whitelist serialization Co-Authored-By: Claude --- backend/services/public_profile.py | 99 ++++++++++++++++++++++++++++ backend/tests/test_public_binders.py | 71 ++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/backend/services/public_profile.py b/backend/services/public_profile.py index a4017fb8..def90051 100644 --- a/backend/services/public_profile.py +++ b/backend/services/public_profile.py @@ -26,3 +26,102 @@ def validate_handle(raw: str) -> str: if handle in RESERVED_HANDLES: raise HandleError("That handle is reserved") return handle + + +from models import User, Binder, BinderCard, Card, UserSetting +from services.card_values import effective_market_price + +_DEFAULT_TRAINER_NAME = "TRAINER" +_PRICE_FIELD = "price_trend" + + +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 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: + row = db.query(UserSetting).filter( + UserSetting.user_id == user.id, UserSetting.key == "trainer_name" + ).first() + return (row.value if row and row.value else _DEFAULT_TRAINER_NAME) + + +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 _binder_cards(db, binder: Binder) -> list[BinderCard]: + return db.query(BinderCard).filter(BinderCard.binder_id == binder.id).all() + + +def _serialize_card(bc: BinderCard, show_values: bool) -> dict: + card = bc.card + quantity = bc.required_quantity or 1 + value = effective_market_price(card, None, _PRICE_FIELD) if show_values else None + return { + "id": card.id, + "name": card.name, + "image": card.images_small or card.images_large, + "set_name": card.set_ref.name if card.set_ref else None, + "number": card.number, + "rarity": card.rarity, + "quantity": quantity, + "market_value": value, + } + + +def serialize_binder_summary(db, binder: Binder, show_values: bool) -> dict: + cards = _binder_cards(db, binder) + 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, None, _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: + summary = serialize_binder_summary(db, binder, show_values) + cards = _binder_cards(db, binder) + 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) + 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, b, show_values) for b in binders], + } diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index ee408b47..f7827457 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -62,3 +62,74 @@ def test_double_hyphen_rejected(self): def test_reserved_rejected(self): with self.assertRaises(HandleError): validate_handle("admin") + + +try: + from services import public_profile as pp + from models import BinderCard, Card, Set, UserSetting + 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, + UserSetting(user_id=1, key="trainer_name", value="Ash K."), + 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 K.") + 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]["quantity"], 2) + self.assertIsNone(detail["cards"][0]["market_value"]) + self.assertIsNone(detail["total_value"]) + + 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) From 0a22d6dbba9075524c112ce865e7c03774fb1964 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:23:16 +0000 Subject: [PATCH 07/32] feat(public-binders): unauthenticated public profile + binder API Co-Authored-By: Claude --- backend/api/public.py | 61 ++++++++++++++++++++++++++++ backend/main.py | 3 +- backend/tests/test_public_binders.py | 57 ++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 backend/api/public.py diff --git a/backend/api/public.py b/backend/api/public.py new file mode 100644 index 00000000..6b5b3dfb --- /dev/null +++ b/backend/api/public.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, HTTPException +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 + +router = APIRouter() + + +class PublicCard(BaseModel): + id: str + name: str + image: Optional[str] = None + set_name: Optional[str] = None + number: Optional[str] = None + rarity: 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 PublicBinderDetail(PublicBinderSummary): + cards: List[PublicCard] + + +@router.get("/profiles/{handle}", response_model=PublicProfile) +def get_public_profile(handle: str, db: Session = Depends(get_db)): + user = pp.get_live_profile(db, handle.lower()) + if not user: + raise HTTPException(status_code=404, detail="Profile not found") + 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)): + user = pp.get_live_profile(db, handle.lower()) + if not user: + raise HTTPException(status_code=404, detail="Profile not found") + binder = next((b for b in pp.public_collection_binders(db, user) if b.id == binder_id), None) + if not binder: + raise HTTPException(status_code=404, detail="Binder not found") + return pp.serialize_binder_detail(db, binder, show_values=bool(user.public_show_values)) diff --git a/backend/main.py b/backend/main.py index 487e3bed..924804b3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -121,7 +121,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 from api.github import router as github_router from api.recognize import router as recognize_router @@ -170,6 +170,7 @@ 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(github_router, prefix="/api/github", tags=["github"]) diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index f7827457..51b8c835 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -133,3 +133,60 @@ def test_no_private_fields_leak(self): card = detail["cards"][0] for banned in ("purchase_price", "condition", "user_id", "username"): self.assertNotIn(banned, card) + + +try: + from fastapi import HTTPException + from api.public import get_public_profile, get_public_binder + API_DEPS = True +except ModuleNotFoundError: + API_DEPS = False + + +@unittest.skipUnless(API_DEPS, "api deps unavailable") +class PublicApiTests(unittest.TestCase): + def _db(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + 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_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_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) From b35f866c519a94771f41b79585e5eb237792cac7 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:28:23 +0000 Subject: [PATCH 08/32] feat(public-binders): owner profile controls (handle + toggles) Co-Authored-By: Claude --- backend/api/profile.py | 49 +++++++++++++++++++++++++ backend/main.py | 3 +- backend/schemas.py | 6 ++++ backend/tests/test_public_binders.py | 53 ++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 backend/api/profile.py diff --git a/backend/api/profile.py b/backend/api/profile.py new file mode 100644 index 00000000..2b36fb7c --- /dev/null +++ b/backend/api/profile.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +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 + +router = APIRouter() + + +def _serialize_owner(user: User) -> dict: + return { + "public_handle": user.public_handle, + "is_profile_public": bool(user.is_profile_public), + "public_show_values": bool(user.public_show_values), + } + + +@router.get("/handle-available") +def handle_available(handle: str = Query(...), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + try: + normalized = pp.validate_handle(handle) + except pp.HandleError as exc: + return {"available": False, "reason": str(exc)} + available = pp.is_handle_available(db, normalized, exclude_user_id=current_user.id) + return {"available": available, "reason": None if available else "Handle is taken"} + + +@router.put("/") +def update_profile(payload: ProfileUpdate, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + if payload.public_handle is not None: + try: + normalized = pp.validate_handle(payload.public_handle) + except pp.HandleError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from None + if not pp.is_handle_available(db, normalized, exclude_user_id=current_user.id): + raise HTTPException(status_code=409, detail="Handle is taken") + current_user.public_handle = normalized + if payload.is_profile_public is not None: + current_user.is_profile_public = payload.is_profile_public + if payload.public_show_values is not None: + current_user.public_show_values = payload.public_show_values + db.commit() + db.refresh(current_user) + return _serialize_owner(current_user) diff --git a/backend/main.py b/backend/main.py index 924804b3..d3ef926d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -121,7 +121,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, public +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 @@ -171,6 +171,7 @@ async def login_rate_limit(request: Request, call_next): 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/schemas.py b/backend/schemas.py index 603d233d..11e610db 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -475,3 +475,9 @@ class SyncLogResponse(BaseModel): class Config: from_attributes = True + + +class ProfileUpdate(BaseModel): + public_handle: Optional[str] = None + is_profile_public: Optional[bool] = None + public_show_values: Optional[bool] = None diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 51b8c835..4b409480 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -190,3 +190,56 @@ def test_cross_owner_binder_404(self): with self.assertRaises(HTTPException) as ctx: get_public_binder("ash", other.id, db=db) self.assertEqual(ctx.exception.status_code, 404) + + +try: + from api.profile import update_profile, handle_available + 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): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + 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_set_handle_and_publish(self): + db = self._db() + u = self._user(db) + result = update_profile(ProfileUpdate(public_handle="Ash-K", is_profile_public=True), + db=db, current_user=u) + self.assertEqual(result["public_handle"], "ash-k") + self.assertTrue(result["is_profile_public"]) + + def test_invalid_handle_422(self): + db = self._db() + u = self._user(db) + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(public_handle="a"), db=db, current_user=u) + self.assertEqual(ctx.exception.status_code, 422) + + def test_duplicate_handle_409(self): + db = self._db() + taken = self._user(db, "misty") + update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=taken) + me = self._user(db, "ash") + with self.assertRaises(HTTPException) as ctx: + update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=me) + self.assertEqual(ctx.exception.status_code, 409) + + def test_handle_available_check(self): + db = self._db() + u = self._user(db) + self.assertTrue(handle_available("brand-new", db=db, current_user=u)["available"]) + self.assertFalse(handle_available("ADMIN", db=db, current_user=u)["available"]) From f52cd4af1d30517d8c667779ac58b0e3be450e26 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:33:21 +0000 Subject: [PATCH 09/32] feat(public-binders): per-binder is_public toggle - Add is_public field to BinderUpdate schema (Optional[bool] = None) - Add is_public field to BinderResponse schema (bool = False) - Include is_public in _binder_response() constructor - Persist is_public updates in update_binder() - Add BinderPublicToggleTests test class Co-Authored-By: Claude Opus 4.8 --- backend/api/binders.py | 3 +++ backend/schemas.py | 2 ++ backend/tests/test_public_binders.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/backend/api/binders.py b/backend/api/binders.py index 9e3ddab7..5811200f 100644 --- a/backend/api/binders.py +++ b/backend/api/binders.py @@ -97,6 +97,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, ) @@ -520,6 +521,8 @@ def update_binder( 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 update.is_public is not None: + binder.is_public = update.is_public db.commit() db.refresh(binder) diff --git a/backend/schemas.py b/backend/schemas.py index 11e610db..014dad1a 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 diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 4b409480..63e75ec1 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -243,3 +243,31 @@ def test_handle_available_check(self): u = self._user(db) self.assertTrue(handle_available("brand-new", db=db, current_user=u)["available"]) self.assertFalse(handle_available("ADMIN", db=db, current_user=u)["available"]) + + +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): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine)() + + 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) From 0f38cd86a7a0c3edfa5fe24de1c8ceb943aec4d5 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:36:47 +0000 Subject: [PATCH 10/32] feat(public-binders): expose public_handle on leaderboard rows Co-Authored-By: Claude Opus 4.8 --- backend/api/social.py | 1 + backend/tests/test_public_binders.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/backend/api/social.py b/backend/api/social.py index 97035950..2bfaf7b0 100644 --- a/backend/api/social.py +++ b/backend/api/social.py @@ -359,6 +359,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 user.is_profile_public else None, } return stats diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 63e75ec1..65518fa9 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -271,3 +271,25 @@ def test_update_binder_sets_is_public(self): db.commit() resp = update_binder(binder.id, BinderUpdate(is_public=True), db=db, current_user=user) self.assertTrue(resp.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(u) + db.commit() + stats = _load_user_stats(db) + self.assertIn(u.id, stats) + self.assertEqual(stats[u.id]["public_handle"], "ash") From 9ec3e893bf1b0cf2cd73424bad5a04ceda00deb9 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:39:19 +0000 Subject: [PATCH 11/32] test(public-binders): assert leaderboard hides handle when profile not public --- backend/tests/test_public_binders.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 65518fa9..77514640 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -293,3 +293,15 @@ def test_row_includes_public_handle(self): 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(u) + db.commit() + stats = _load_user_stats(db) + self.assertIn(u.id, stats) + self.assertIsNone(stats[u.id]["public_handle"]) From 17aa0123fa7d4633a3a0fae1bb8d809a0773a924 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:41:03 +0000 Subject: [PATCH 12/32] feat(public-binders): frontend handle validator + token-less public client Co-Authored-By: Claude Opus 4.8 --- frontend/src/api/publicClient.js | 15 +++++++++++++++ frontend/src/utils/publicHandle.js | 12 ++++++++++++ frontend/src/utils/publicHandle.test.js | 19 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 frontend/src/api/publicClient.js create mode 100644 frontend/src/utils/publicHandle.js create mode 100644 frontend/src/utils/publicHandle.test.js diff --git a/frontend/src/api/publicClient.js b/frontend/src/api/publicClient.js new file mode 100644 index 00000000..4c0d7640 --- /dev/null +++ b/frontend/src/api/publicClient.js @@ -0,0 +1,15 @@ +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 getPublicBinder = (handle, binderId) => + publicApi.get(`/profiles/${encodeURIComponent(handle)}/binders/${binderId}`).then(r => r.data) diff --git a/frontend/src/utils/publicHandle.js b/frontend/src/utils/publicHandle.js new file mode 100644 index 00000000..39b6e0bf --- /dev/null +++ b/frontend/src/utils/publicHandle.js @@ -0,0 +1,12 @@ +const HANDLE_RE = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/ + +export function normalizeHandle(raw) { + return String(raw || '').trim().toLowerCase() +} + +export function isValidHandleFormat(raw) { + const handle = normalizeHandle(raw) + if (handle.length < 3 || handle.length > 30) return false + if (handle.includes('--')) return false + return HANDLE_RE.test(handle) +} diff --git a/frontend/src/utils/publicHandle.test.js b/frontend/src/utils/publicHandle.test.js new file mode 100644 index 00000000..175d400f --- /dev/null +++ b/frontend/src/utils/publicHandle.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { isValidHandleFormat, normalizeHandle } from './publicHandle' + +describe('publicHandle', () => { + it('normalizes case and trims', () => { + expect(normalizeHandle(' Ash-K ')).toBe('ash-k') + }) + it('accepts a valid handle', () => { + expect(isValidHandleFormat('ash-ketchum')).toBe(true) + }) + it('rejects too short', () => { + expect(isValidHandleFormat('ab')).toBe(false) + }) + it('rejects bad chars and edges', () => { + expect(isValidHandleFormat('ash_k')).toBe(false) + expect(isValidHandleFormat('-ash')).toBe(false) + expect(isValidHandleFormat('ash--k')).toBe(false) + }) +}) From e2081a22c61e6ce9616acd35aa63ef75831868df Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:48:06 +0000 Subject: [PATCH 13/32] docs: revise Task 9 to pure formatEur test (no DOM test infra on this frontend) Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-19-public-binders.md | 109 +++++++++--------- 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/plans/2026-07-19-public-binders.md b/docs/superpowers/plans/2026-07-19-public-binders.md index 2f455cb2..06199bbc 100644 --- a/docs/superpowers/plans/2026-07-19-public-binders.md +++ b/docs/superpowers/plans/2026-07-19-public-binders.md @@ -42,8 +42,8 @@ - `frontend/src/utils/publicHandle.test.js` *(new)*. - `frontend/src/api/publicClient.js` *(new)* — token-less axios instance + public API calls. - `frontend/src/api/client.js` — add `updateProfile`, `checkHandleAvailable`, and `is_public` on binder update. +- `frontend/src/utils/formatEur.js` *(new)* + `frontend/src/utils/formatEur.test.js` *(new)* — shared EUR formatter used by both public pages (returns null when a value is hidden). - `frontend/src/pages/PublicProfile.jsx` *(new)*, `frontend/src/pages/PublicBinderView.jsx` *(new)*. -- `frontend/src/pages/PublicBinderView.test.jsx` *(new)*. - `frontend/src/App.jsx` — public routes outside `ProtectedRoutes`. - `frontend/src/pages/Settings.jsx` — "Public profile" section. - `frontend/src/pages/Binders.jsx` — per-binder "Share publicly" toggle. @@ -1044,62 +1044,65 @@ git commit -m "feat(public-binders): frontend handle validator + token-less publ ## Task 9: Public pages + routes **Files:** +- Create: `frontend/src/utils/formatEur.js` +- Create: `frontend/src/utils/formatEur.test.js` - Create: `frontend/src/pages/PublicProfile.jsx` - Create: `frontend/src/pages/PublicBinderView.jsx` -- Create: `frontend/src/pages/PublicBinderView.test.jsx` - Modify: `frontend/src/App.jsx` +**Environment note (read before starting):** This frontend has NO DOM test infrastructure — no `@testing-library`, no `jsdom`, no vitest `environment` config; the only existing tests are pure-JS (node env). Do NOT add those dependencies. Component rendering is verified here by `npm run build` (a compile/import check) plus the Task 11 manual pass; the vitest unit test covers the shared `formatEur` value-hiding logic that both pages depend on. + **Interfaces:** - Consumes: `getPublicProfile`, `getPublicBinder` from `../api/publicClient`. -- Produces: routes `/u/:handle`, `/u/:handle/binder/:binderId` rendered outside `ProtectedRoutes`. +- Produces: `formatEur(value)` from `../utils/formatEur` (returns null when hidden); routes `/u/:handle`, `/u/:handle/binder/:binderId` rendered outside `ProtectedRoutes`. - [ ] **Step 1: Write the failing test** -Create `frontend/src/pages/PublicBinderView.test.jsx`: +Create `frontend/src/utils/formatEur.test.js`: ```javascript -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter, Routes, Route } from 'react-router-dom' - -vi.mock('../api/publicClient', () => ({ - getPublicBinder: vi.fn(), -})) -import { getPublicBinder } from '../api/publicClient' -import PublicBinderView from './PublicBinderView' - -function renderAt(path) { - return render( - - - } /> - - - ) -} +import { describe, it, expect } from 'vitest' +import { formatEur } from './formatEur' -describe('PublicBinderView', () => { - beforeEach(() => vi.clearAllMocks()) - - it('renders cards read-only and hides value when null', async () => { - getPublicBinder.mockResolvedValue({ - id: 1, name: 'Starters', card_count: 1, unique_card_count: 1, total_value: null, - cards: [{ id: 'sv1-1_en', name: 'Sprigatito', image: null, set_name: 'SV', number: '1', rarity: 'Common', quantity: 2, market_value: null }], - }) - renderAt('/u/ash/binder/1') - await waitFor(() => expect(screen.getByText('Sprigatito')).toBeInTheDocument()) - expect(screen.queryByRole('button', { name: /add|edit|remove/i })).toBeNull() - expect(screen.queryByText(/€/)).toBeNull() +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() }) }) ``` - [ ] **Step 2: Run test to verify it fails** -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/pages/PublicBinderView.test.jsx` -Expected: FAIL — module not found. +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/formatEur.test.js` +Expected: FAIL — `./formatEur` module not found. + +- [ ] **Step 3: Create the shared formatter** + +Create `frontend/src/utils/formatEur.js`: + +```javascript +// 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)) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/formatEur.test.js` +Expected: PASS (3 tests). -- [ ] **Step 3: Create `PublicBinderView.jsx`** +- [ ] **Step 5: Create `PublicBinderView.jsx`** Create `frontend/src/pages/PublicBinderView.jsx`: @@ -1107,11 +1110,7 @@ Create `frontend/src/pages/PublicBinderView.jsx`: import { useEffect, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { getPublicBinder } from '../api/publicClient' - -function formatEur(value) { - if (value == null) return null - return new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' }).format(value) -} +import { formatEur } from '../utils/formatEur' export default function PublicBinderView() { const { handle, binderId } = useParams() @@ -1159,7 +1158,7 @@ export default function PublicBinderView() { } ``` -- [ ] **Step 4: Create `PublicProfile.jsx`** +- [ ] **Step 6: Create `PublicProfile.jsx`** Create `frontend/src/pages/PublicProfile.jsx`: @@ -1167,11 +1166,7 @@ Create `frontend/src/pages/PublicProfile.jsx`: import { useEffect, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { getPublicProfile } from '../api/publicClient' - -function formatEur(value) { - if (value == null) return null - return new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' }).format(value) -} +import { formatEur } from '../utils/formatEur' export default function PublicProfile() { const { handle } = useParams() @@ -1218,7 +1213,7 @@ export default function PublicProfile() { } ``` -- [ ] **Step 5: Wire the routes outside the auth wall** +- [ ] **Step 7: Wire the routes outside the auth wall** In `frontend/src/App.jsx`: add lazy imports near the other page imports: @@ -1234,15 +1229,19 @@ Then in the top-level `` (the one containing `/login` and `/*`), add the )} /> ``` -- [ ] **Step 6: Run test to verify it passes** +- [ ] **Step 8: Build to verify components compile and imports resolve** -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/pages/PublicBinderView.test.jsx` -Expected: PASS. +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npm run build` +Expected: build succeeds. This is the compile/import safety net that replaces a DOM render test (none available in this environment). -- [ ] **Step 7: Commit** +Then run the full util test suite to confirm no regression: +Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run` +Expected: all pass (formatEur + publicHandle). + +- [ ] **Step 9: Commit** ```bash -git add frontend/src/pages/PublicProfile.jsx frontend/src/pages/PublicBinderView.jsx frontend/src/pages/PublicBinderView.test.jsx frontend/src/App.jsx +git add frontend/src/utils/formatEur.js frontend/src/utils/formatEur.test.js frontend/src/pages/PublicProfile.jsx frontend/src/pages/PublicBinderView.jsx frontend/src/App.jsx git commit -m "feat(public-binders): public profile + binder pages and routes" ``` From ed76d19e0fb2e943b562ced9f1ae4f50df285af0 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:49:45 +0000 Subject: [PATCH 14/32] feat(public-binders): public profile + binder pages and routes Adds PublicProfile and PublicBinderView pages consuming the Task 8 publicClient, a shared formatEur(value) helper (returns null for hidden/non-numeric values so callers can conditionally render), and wires /u/:handle and /u/:handle/binder/:binderId as siblings in the top-level Routes, before the /* catch-all, so they render outside ProtectedRoutes for anonymous visitors. Co-Authored-By: Claude --- frontend/src/App.jsx | 4 ++ frontend/src/pages/PublicBinderView.jsx | 49 +++++++++++++++++++++++++ frontend/src/pages/PublicProfile.jsx | 48 ++++++++++++++++++++++++ frontend/src/utils/formatEur.js | 7 ++++ frontend/src/utils/formatEur.test.js | 15 ++++++++ 5 files changed, 123 insertions(+) create mode 100644 frontend/src/pages/PublicBinderView.jsx create mode 100644 frontend/src/pages/PublicProfile.jsx create mode 100644 frontend/src/utils/formatEur.js create mode 100644 frontend/src/utils/formatEur.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7055086a..6d942ba3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -29,6 +29,8 @@ 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')) function RouteLoader() { return ( @@ -172,6 +174,8 @@ export default function App() { )} /> + )} /> + )} /> } /> diff --git a/frontend/src/pages/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx new file mode 100644 index 00000000..566f4d81 --- /dev/null +++ b/frontend/src/pages/PublicBinderView.jsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { getPublicBinder } from '../api/publicClient' +import { formatEur } from '../utils/formatEur' + +export default function PublicBinderView() { + const { handle, binderId } = useParams() + const [binder, setBinder] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + getPublicBinder(handle, binderId) + .then(data => { if (!cancelled) setBinder(data) }) + .catch(() => { if (!cancelled) setError('This binder is not available.') }) + return () => { cancelled = true } + }, [handle, binderId]) + + if (error) return
{error}
+ if (!binder) return
Loading…
+ + return ( +
+ ← {handle} +
+

{binder.name}

+ {binder.total_value != null && ( + {formatEur(binder.total_value)} + )} +
+
+ {binder.cards.map(card => ( +
+ {card.image + ? {card.name} + :
} +
{card.name}
+
+ {card.set_name} · #{card.number}{card.quantity > 1 ? ` · ×${card.quantity}` : ''} +
+ {card.market_value != null && ( +
{formatEur(card.market_value)}
+ )} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/PublicProfile.jsx b/frontend/src/pages/PublicProfile.jsx new file mode 100644 index 00000000..fb11fd45 --- /dev/null +++ b/frontend/src/pages/PublicProfile.jsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import { useParams, Link } from 'react-router-dom' +import { getPublicProfile } from '../api/publicClient' +import { formatEur } from '../utils/formatEur' + +export default function PublicProfile() { + const { handle } = useParams() + const [profile, setProfile] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + getPublicProfile(handle) + .then(data => { if (!cancelled) setProfile(data) }) + .catch(() => { if (!cancelled) setError('This profile is not available.') }) + return () => { cancelled = true } + }, [handle]) + + if (error) return
{error}
+ if (!profile) return
Loading…
+ + return ( +
+
+ {profile.avatar_id && ( + + )} +

{profile.trainer_name}

+
+ {profile.binders.length === 0 && ( +

No shared binders yet.

+ )} +
+ {profile.binders.map(binder => ( + +
{binder.name}
+
+ {binder.unique_card_count} cards + {binder.total_value != null ? ` · ${formatEur(binder.total_value)}` : ''} +
+ + ))} +
+
+ ) +} 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() + }) +}) From 979e252f71843bb5050257b507b2b88de641a61f Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 20:58:28 +0000 Subject: [PATCH 15/32] =?UTF-8?q?feat(public-binders):=20owner=20controls?= =?UTF-8?q?=20=E2=80=94=20settings,=20binder=20toggle,=20leaderboard=20lin?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/profile/ to hydrate the public-profile columns (they live on User, not the settings key/value store), plus client.js helpers, a Settings.jsx "Public Profile" section (handle input with debounced availability check, public/show-values toggles, copy link), a per-binder "Share publicly" toggle in Binders.jsx, and a public-profile link on Leaderboard rows that have a handle. Co-Authored-By: Claude --- backend/api/profile.py | 5 ++ backend/tests/test_public_binders.py | 12 ++- frontend/src/api/client.js | 6 ++ frontend/src/i18n/en.js | 17 +++++ frontend/src/pages/Binders.jsx | 66 +++++++++++++++- frontend/src/pages/Leaderboard.jsx | 18 ++++- frontend/src/pages/Settings.jsx | 108 ++++++++++++++++++++++++++- 7 files changed, 225 insertions(+), 7 deletions(-) diff --git a/backend/api/profile.py b/backend/api/profile.py index 2b36fb7c..ce5e70d9 100644 --- a/backend/api/profile.py +++ b/backend/api/profile.py @@ -18,6 +18,11 @@ def _serialize_owner(user: User) -> dict: } +@router.get("/") +def get_profile(current_user: User = Depends(get_current_user)): + return _serialize_owner(current_user) + + @router.get("/handle-available") def handle_available(handle: str = Query(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 77514640..461c91fe 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -193,7 +193,7 @@ def test_cross_owner_binder_404(self): try: - from api.profile import update_profile, handle_available + from api.profile import update_profile, handle_available, get_profile from schemas import ProfileUpdate PROFILE_DEPS = True except ModuleNotFoundError: @@ -244,6 +244,16 @@ def test_handle_available_check(self): self.assertTrue(handle_available("brand-new", db=db, current_user=u)["available"]) self.assertFalse(handle_available("ADMIN", db=db, current_user=u)["available"]) + def test_get_profile_returns_current_user_values(self): + db = self._db() + u = self._user(db) + update_profile(ProfileUpdate(public_handle="Ash-K", is_profile_public=True, public_show_values=True), + db=db, current_user=u) + result = get_profile(current_user=u) + self.assertEqual(result["public_handle"], "ash-k") + self.assertTrue(result["is_profile_public"]) + self.assertTrue(result["public_show_values"]) + try: from api.binders import update_binder diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index f59d7ee8..4b4a1206 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -82,6 +82,12 @@ 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) +export const checkHandleAvailable = (handle) => + api.get('/profile/handle-available', { params: { handle } }).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/i18n/en.js b/frontend/src/i18n/en.js index c4a66ca8..898209c8 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,17 @@ const en = { sectionData: 'Data', sectionAI: 'AI / Card Scanner', sectionAbout: 'About the App', + sectionPublicProfile: 'Public Profile', + publicHandle: 'Handle', + publicHandleDesc: 'Your public URL slug (letters, numbers, hyphens)', + handleAvailable: 'Available', + handleTaken: 'Handle is taken', + 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', // Settings page row labels multiUserMode: 'Multi-User Mode', multiUserModeDesc: 'Enable login screen and user management', @@ -1144,6 +1160,7 @@ const en = { noTrainers: 'No trainers found.', compare: 'Compare trainers', viewCollection: 'View Collection', + viewPublicProfile: 'View public profile', }, trainerCard: { diff --git a/frontend/src/pages/Binders.jsx b/frontend/src/pages/Binders.jsx index 91ac22cb..378e56e1 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,14 @@ 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 COLLECTION_TABS = [ { to: '/collection', label: t('nav.collection'), icon: Library }, { to: '/binders', label: t('nav.binders'), icon: BookOpen }, @@ -185,6 +193,21 @@ 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: () => toast.error(t('binders.updateFailed')), + }) + + const copyPublicBinderLink = (binderId) => { + const url = `${window.location.origin}/u/${publicHandle}/binder/${binderId}` + navigator.clipboard.writeText(url) + toast.success(t('settings.linkCopied')) + } + return (
@@ -271,6 +294,45 @@ export default function Binders() { {uniqueCount} {uniqueCount === 1 ? t('binders.uniqueCard') : t('binders.uniqueCards')}

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

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

+ )} + {binder.is_public && profileIsPublic && publicHandle && ( + + )} +
+ )}
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/Settings.jsx b/frontend/src/pages/Settings.jsx index 3d15d6f3..f8f91dfc 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, checkHandleAvailable, } from '../api/client' import api from '../api/client' import { useAuth } from '../contexts/AuthContext' @@ -346,6 +347,34 @@ export default function Settings() { queryFn: () => getSetting('gemini_api_key').catch(() => ({ value: '' })), }) + // Public profile + const [publicHandle, setPublicHandle] = useState('') + const [profilePublic, setProfilePublic] = useState(false) + const [publicShowValues, setPublicShowValues] = useState(false) + const [profileDirty, setProfileDirty] = useState(false) + const [handleStatus, setHandleStatus] = useState(null) + + const { data: profileData } = useQuery({ + queryKey: ['profile'], + queryFn: () => getProfile(), + }) + + useEffect(() => { + if (profileData && !profileDirty) { + setPublicHandle(profileData.public_handle || '') + setProfilePublic(!!profileData.is_profile_public) + setPublicShowValues(!!profileData.public_show_values) + } + }, [profileData]) + + useEffect(() => { + if (!publicHandle) { setHandleStatus(null); return } + const timer = setTimeout(() => { + checkHandleAvailable(publicHandle).then(setHandleStatus).catch(() => setHandleStatus(null)) + }, 400) + return () => clearTimeout(timer) + }, [publicHandle]) + const [telegramBotToken, setTelegramBotToken] = useState('') const [telegramBotTokenDirty, setTelegramBotTokenDirty] = useState(false) @@ -444,6 +473,32 @@ export default function Settings() { onError: (err) => toast.error(err.response?.data?.detail || t('common.error')), }) + const profileMutation = useMutation({ + mutationFn: (data) => updateProfile(data), + onSuccess: (data) => { + queryClient.setQueryData(['profile'], data) + queryClient.invalidateQueries({ queryKey: ['leaderboard'] }) + setProfileDirty(false) + toast.success(t('settings.saved')) + }, + onError: (err) => toast.error(err.response?.data?.detail || t('settings.saveFailed')), + }) + + const savePublicProfile = () => { + profileMutation.mutate({ + public_handle: publicHandle || null, + is_profile_public: profilePublic, + public_show_values: publicShowValues, + }) + } + + const publicProfileUrl = publicHandle ? `${window.location.origin}/u/${publicHandle}` : '' + + const copyPublicProfileUrl = () => { + navigator.clipboard.writeText(publicProfileUrl) + toast.success(t('settings.linkCopied')) + } + const isRunning = syncStatus?.is_running || syncStatus?.is_price_sync_running || syncMutation.isPending || allPriceSyncMutation.isPending // Save helper @@ -689,6 +744,57 @@ export default function Settings() { + {/* ── PUBLIC PROFILE ── */} +
+ + + +
+ { setPublicHandle(e.target.value.toLowerCase()); setProfileDirty(true) }} + placeholder="ash-ketchum" + className="input text-xs font-mono w-full" + maxLength={32} + /> + {handleStatus && ( + + {handleStatus.available ? t('settings.handleAvailable') : (handleStatus.reason || t('settings.handleTaken'))} + + )} +
+
+ + { setProfilePublic(val); setProfileDirty(true) }} /> + + + { setPublicShowValues(val); setProfileDirty(true) }} /> + + {profilePublic && publicHandle && ( + + + + )} +
+
+ +
+
+ {/* ── 2. THEME ── */}
From 1b19bb5be3362998a9249046c1941dede1224be0 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sun, 19 Jul 2026 21:03:02 +0000 Subject: [PATCH 16/32] feat(public-binders): cache headers + rate-limit public endpoints Add Cache-Control: public, max-age=300 to both public GET endpoints (get_public_profile, get_public_binder) via a test-safe defaulted response: Response = None param, so Task 4's direct-call unit tests (no response arg) keep passing. Rate limiting: no per-route @limiter.limit decorator added. main.py's existing Limiter(default_limits=["60/minute"]) + SlowAPIMiddleware already applies globally to these routes, and importing that limiter into api/public.py would create an import cycle (main.py imports api.public). Documented via code comment instead. Co-Authored-By: Claude --- backend/api/public.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/api/public.py b/backend/api/public.py index 6b5b3dfb..38182ed6 100644 --- a/backend/api/public.py +++ b/backend/api/public.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy.orm import Session from pydantic import BaseModel from typing import Optional, List @@ -8,6 +8,14 @@ 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 @@ -43,19 +51,23 @@ class PublicBinderDetail(PublicBinderSummary): @router.get("/profiles/{handle}", response_model=PublicProfile) -def get_public_profile(handle: str, db: Session = Depends(get_db)): +def get_public_profile(handle: str, db: Session = Depends(get_db), response: Response = None): user = pp.get_live_profile(db, handle.lower()) if not user: raise HTTPException(status_code=404, detail="Profile not found") + if response is not None: + response.headers["Cache-Control"] = "public, max-age=300" 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)): +def get_public_binder(handle: str, binder_id: int, db: Session = Depends(get_db), response: Response = None): user = pp.get_live_profile(db, handle.lower()) if not user: raise HTTPException(status_code=404, detail="Profile not found") binder = next((b for b in pp.public_collection_binders(db, user) if b.id == binder_id), None) if not binder: raise HTTPException(status_code=404, detail="Binder not found") + if response is not None: + response.headers["Cache-Control"] = "public, max-age=300" return pp.serialize_binder_detail(db, binder, show_values=bool(user.public_show_values)) From ef1c81380dd445a2c06ac19344ae58d3ab6096b2 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Mon, 20 Jul 2026 07:03:34 +0000 Subject: [PATCH 17/32] fix(public-binders): order binder cards by added_at and show card variants Public binder view returned cards in arbitrary DB order and omitted the variant, so cards were unordered and variant prints were indistinguishable (and mispriced when values were shown). - _binder_cards: order by added_at desc (matches owner's binder view) and eager-load card/set/collection_item to avoid N+1 - _serialize_card: expose variant (from the linked collection item) and use it for effective_market_price; summary total_value now variant-correct - PublicCard schema gains variant; PublicBinderView renders VariantPills - tests: variant presence, variant-absent default, added_at ordering Co-Authored-By: Claude Opus 4.8 --- backend/api/public.py | 1 + backend/services/public_profile.py | 26 +++++++++++++++-- backend/tests/test_public_binders.py | 38 +++++++++++++++++++++++++ frontend/src/pages/PublicBinderView.jsx | 8 ++++-- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/backend/api/public.py b/backend/api/public.py index 38182ed6..c0dd63c8 100644 --- a/backend/api/public.py +++ b/backend/api/public.py @@ -24,6 +24,7 @@ class PublicCard(BaseModel): set_name: Optional[str] = None number: Optional[str] = None rarity: Optional[str] = None + variant: Optional[str] = None quantity: int market_value: Optional[float] = None diff --git a/backend/services/public_profile.py b/backend/services/public_profile.py index def90051..b5f1febc 100644 --- a/backend/services/public_profile.py +++ b/backend/services/public_profile.py @@ -28,6 +28,8 @@ def validate_handle(raw: str) -> str: return handle +from sqlalchemy.orm import joinedload + from models import User, Binder, BinderCard, Card, UserSetting from services.card_values import effective_market_price @@ -68,13 +70,30 @@ def public_collection_binders(db, user: User) -> list[Binder]: def _binder_cards(db, binder: Binder) -> list[BinderCard]: - return db.query(BinderCard).filter(BinderCard.binder_id == binder.id).all() + # Order matches the owner's own binder view (api/binders.get_binder_cards): + # newest-added first. Eager-load the card, its set, and the linked collection + # item so per-card variant/value reads don't issue a query each. + return ( + db.query(BinderCard) + .options( + joinedload(BinderCard.card).joinedload(Card.set_ref), + joinedload(BinderCard.collection_item), + ) + .filter(BinderCard.binder_id == binder.id) + .order_by(BinderCard.added_at.desc()) + .all() + ) + + +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 - value = effective_market_price(card, None, _PRICE_FIELD) if show_values else None + variant = _card_variant(bc) + value = effective_market_price(card, variant, _PRICE_FIELD) if show_values else None return { "id": card.id, "name": card.name, @@ -82,6 +101,7 @@ def _serialize_card(bc: BinderCard, show_values: bool) -> dict: "set_name": card.set_ref.name if card.set_ref else None, "number": card.number, "rarity": card.rarity, + "variant": variant, "quantity": quantity, "market_value": value, } @@ -94,7 +114,7 @@ def serialize_binder_summary(db, binder: Binder, show_values: bool) -> dict: total_value = None if show_values: total_value = round(sum( - effective_market_price(bc.card, None, _PRICE_FIELD) * (bc.required_quantity or 1) + effective_market_price(bc.card, _card_variant(bc), _PRICE_FIELD) * (bc.required_quantity or 1) for bc in cards if bc.card ), 2) return { diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 461c91fe..60ee7bec 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -134,6 +134,44 @@ def test_no_private_fields_leak(self): 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") + + 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_added_at_desc(self): + from datetime import datetime, timedelta + db = self._db() + _, binder = self._seed(db) + db.add(Card(id="sv1-2_en", tcg_card_id="sv1-2", name="Floragato", set_id="sv1", + number="2", lang="en", rarity="Common", price_trend=6.0)) + db.commit() + # Existing seed card was added first; add a newer card explicitly. + old = db.query(BinderCard).filter(BinderCard.binder_id == binder.id).first() + old.added_at = datetime(2026, 1, 1) + db.add(BinderCard(binder_id=binder.id, card_id="sv1-2_en", required_quantity=1, + added_at=datetime(2026, 6, 1))) + db.commit() + detail = pp.serialize_binder_detail(db, binder, show_values=False) + names = [c["name"] for c in detail["cards"]] + self.assertEqual(names, ["Floragato", "Sprigatito"]) # newest first + try: from fastapi import HTTPException diff --git a/frontend/src/pages/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx index 566f4d81..9d60e3db 100644 --- a/frontend/src/pages/PublicBinderView.jsx +++ b/frontend/src/pages/PublicBinderView.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { getPublicBinder } from '../api/publicClient' import { formatEur } from '../utils/formatEur' +import VariantPills from '../components/VariantPills' export default function PublicBinderView() { const { handle, binderId } = useParams() @@ -29,8 +30,8 @@ export default function PublicBinderView() { )}
- {binder.cards.map(card => ( -
+ {binder.cards.map((card, i) => ( +
{card.image ? {card.name} :
} @@ -38,6 +39,9 @@ export default function PublicBinderView() {
{card.set_name} · #{card.number}{card.quantity > 1 ? ` · ×${card.quantity}` : ''}
+ {card.variant && ( + + )} {card.market_value != null && (
{formatEur(card.market_value)}
)} From 4cc155060917964e3917fe8161d3e9f9cfa26983 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Mon, 20 Jul 2026 07:13:01 +0000 Subject: [PATCH 18/32] fix(public-binders): sort binder cards by card number, not add-order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public binder listed cards newest-added-first (matching the owner's app view), but collectors expect card-number order. Sort by set, then natural card number (1, 2, 10 — not 1, 10, 2), then variant so same-number prints group. - extract natural_card_number_key into services/card_numbers.py (shared); api/sets.py now imports it instead of a private duplicate - public_profile._binder_cards sorts in Python via that key - test asserts 1, 2, 10 ordering regardless of add order Co-Authored-By: Claude Opus 4.8 --- backend/api/sets.py | 25 ++----------------------- backend/services/card_numbers.py | 21 +++++++++++++++++++++ backend/services/public_profile.py | 20 +++++++++++++++----- backend/tests/test_public_binders.py | 27 +++++++++++++++------------ 4 files changed, 53 insertions(+), 40 deletions(-) diff --git a/backend/api/sets.py b/backend/api/sets.py index b15c275f..119b277b 100644 --- a/backend/api/sets.py +++ b/backend/api/sets.py @@ -16,6 +16,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 +25,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 +236,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/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 index b5f1febc..b2dd81ab 100644 --- a/backend/services/public_profile.py +++ b/backend/services/public_profile.py @@ -31,6 +31,7 @@ def validate_handle(raw: str) -> str: from sqlalchemy.orm import joinedload from models import User, Binder, BinderCard, Card, UserSetting +from services.card_numbers import natural_card_number_key from services.card_values import effective_market_price _DEFAULT_TRAINER_NAME = "TRAINER" @@ -70,19 +71,28 @@ def public_collection_binders(db, user: User) -> list[Binder]: def _binder_cards(db, binder: Binder) -> list[BinderCard]: - # Order matches the owner's own binder view (api/binders.get_binder_cards): - # newest-added first. Eager-load the card, its set, and the linked collection - # item so per-card variant/value reads don't issue a query each. - return ( + # 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) - .order_by(BinderCard.added_at.desc()) .all() ) + return sorted(cards, key=_card_sort_key) + + +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: diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 60ee7bec..1951d4cd 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -155,22 +155,25 @@ def test_serialized_card_variant_defaults_none_without_collection_item(self): detail = pp.serialize_binder_detail(db, binder, show_values=False) self.assertIsNone(detail["cards"][0]["variant"]) - def test_binder_detail_orders_by_added_at_desc(self): - from datetime import datetime, timedelta + def test_binder_detail_orders_by_card_number_naturally(self): + from datetime import datetime db = self._db() - _, binder = self._seed(db) - db.add(Card(id="sv1-2_en", tcg_card_id="sv1-2", name="Floragato", set_id="sv1", - number="2", lang="en", rarity="Common", price_trend=6.0)) + _, 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() - # Existing seed card was added first; add a newer card explicitly. - old = db.query(BinderCard).filter(BinderCard.binder_id == binder.id).first() - old.added_at = datetime(2026, 1, 1) - db.add(BinderCard(binder_id=binder.id, card_id="sv1-2_en", required_quantity=1, - added_at=datetime(2026, 6, 1))) + 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) - names = [c["name"] for c in detail["cards"]] - self.assertEqual(names, ["Floragato", "Sprigatito"]) # newest first + self.assertEqual([c["number"] for c in detail["cards"]], ["1", "2", "10"]) try: From 112d07edc334ddca79c7ef7c490a722625d8dc12 Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Mon, 20 Jul 2026 07:18:48 +0000 Subject: [PATCH 19/32] fix(public-binders): shorten public cache TTL to 30s with revalidation public,max-age=300 pinned a stale browser copy for up to 5 minutes, so owner edits (unshare, value toggle) and card-order/variant fixes appeared not to take effect until the cache expired. Drop to public,max-age=30,must-revalidate so changes surface within seconds while still blunting load bursts. Dedupe the two header assignments into _set_public_cache and pin the value with tests (set on success, absent on 404). Co-Authored-By: Claude Opus 4.8 --- backend/api/public.py | 17 +++++++++++++---- backend/tests/test_public_binders.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/backend/api/public.py b/backend/api/public.py index c0dd63c8..00c6dcd0 100644 --- a/backend/api/public.py +++ b/backend/api/public.py @@ -51,13 +51,23 @@ class PublicBinderDetail(PublicBinderSummary): cards: List[PublicCard] +# Short TTL with revalidation: public pages get light caching to blunt load bursts, +# but owner edits (unshare, reorder, value toggle) and deploys become visible within +# seconds rather than being pinned for minutes by a stale browser copy. +_PUBLIC_CACHE_CONTROL = "public, max-age=30, must-revalidate" + + +def _set_public_cache(response: Response | None) -> None: + if response is not None: + response.headers["Cache-Control"] = _PUBLIC_CACHE_CONTROL + + @router.get("/profiles/{handle}", response_model=PublicProfile) def get_public_profile(handle: str, db: Session = Depends(get_db), response: Response = None): user = pp.get_live_profile(db, handle.lower()) if not user: raise HTTPException(status_code=404, detail="Profile not found") - if response is not None: - response.headers["Cache-Control"] = "public, max-age=300" + _set_public_cache(response) return pp.serialize_profile(db, user) @@ -69,6 +79,5 @@ def get_public_binder(handle: str, binder_id: int, db: Session = Depends(get_db) binder = next((b for b in pp.public_collection_binders(db, user) if b.id == binder_id), None) if not binder: raise HTTPException(status_code=404, detail="Binder not found") - if response is not None: - response.headers["Cache-Control"] = "public, max-age=300" + _set_public_cache(response) return pp.serialize_binder_detail(db, binder, show_values=bool(user.public_show_values)) diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 1951d4cd..d319eee3 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -232,6 +232,24 @@ def test_cross_owner_binder_404(self): 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=30", cc) + self.assertIn("must-revalidate", cc) + + def test_not_found_does_not_set_cache(self): + from fastapi import Response + db = self._db() # no seed → unknown handle + resp = Response() + with self.assertRaises(HTTPException): + get_public_binder("ash", 1, db=db, response=resp) + self.assertNotIn("Cache-Control", resp.headers) + try: from api.profile import update_profile, handle_available, get_profile From 1f2f7c136b6ed25937ee7daae068312aed48385b Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Mon, 20 Jul 2026 07:42:16 +0000 Subject: [PATCH 20/32] docs: design for stacked variant tiles in public binder view Co-Authored-By: Claude Opus 4.8 --- ...0-public-binder-stacked-variants-design.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md diff --git a/docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md b/docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md new file mode 100644 index 00000000..6b1594ce --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md @@ -0,0 +1,50 @@ +# Public binder — stacked variant tiles + +## Goal + +In the public binder view (`/u/:handle/binder/:id`), collapse a card's multiple +prints into a single tile and give it a physical "stack of cards" look whose depth +reflects how many distinct variants are owned. + +## Scope + +Frontend only. The public API (`GET /api/public/profiles/{handle}/binders/{id}`) +already returns one entry per (card, variant) with `variant`, `quantity`, and +per-variant `market_value`, sorted by set → natural card number → variant. No +backend change. + +## Behaviour + +- **Group by card.** `binder.cards` (contiguous per card thanks to server sort) is + grouped by `card.id` into one tile per card carrying an ordered list of prints + `[{ variant, quantity, market_value }]`. +- **Tile content.** Card image, name, `set · #number`, a `VariantPills` row of every + owned print with its ×quantity, and — only when the profile exposes values — the + summed value across the card's prints. +- **Stack depth = distinct variants.** Back-layers rendered = `min(variants − 1, 2)`: + 1 variant → flat tile; 2 variants → 1 layer behind; 3+ → 2 layers (capped). A + single-variant card stays flat even at quantity ×3 (depth follows distinct prints, + not copies). +- **Stack look.** Each back-layer is the same rounded card silhouette, offset a few px + down-right and rotated ~2°, in the card border colour, sitting behind the image. + +## Components + +- `groupCardsByPrint(cards)` — pure util (`frontend/src/utils/`). Input: the flat + `cards` array. Output: ordered array of `{ id, name, image, set_name, number, + prints: [{variant, quantity, market_value}], variantCount, total_value }` where + `total_value` is `null` if every print's `market_value` is null (values hidden), + else the summed `market_value × quantity`. First-seen order preserved. +- `PublicBinderView.jsx` — maps grouped tiles; renders the stack layers + `VariantPills`. + +## Testing + +- Vitest unit tests for `groupCardsByPrint`: grouping/merge, order preservation, + variantCount, value summing, and null-value (hidden) case. +- Visual stack verified via `npm run build` + a look at the live page (this frontend + has no DOM test infra). + +## Out of scope + +Owner-side binder views (unchanged). No new i18n beyond reuse of existing +`variants.*` keys via `VariantPills`. From 721116af3c9352606bbd9c71ce7a56125be5303c Mon Sep 17 00:00:00 2001 From: hiddenbanana Date: Sat, 25 Jul 2026 07:32:07 +0000 Subject: [PATCH 21/32] fix(public-binders): repair broken VariantPills import PublicBinderView imported ../components/VariantPills, which was never added on this branch - build has been failing standalone. VariantPills was unified into CardStateIndicators and merged upstream as part of v1.24.0 (PR #295), so pull that component plus its cardVariants.js dependency straight from upstream/main instead of reintroducing the old deploy-only component. Also brings in groupCardsByPrint (grouped, stacked-tile rendering) already proven on the deploy branch. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-19-public-binders.md | 1450 ----------------- .../specs/2026-07-19-public-binders-design.md | 176 -- ...0-public-binder-stacked-variants-design.md | 50 - frontend/src/pages/PublicBinderView.jsx | 52 +- frontend/src/utils/groupCardsByPrint.js | 34 + frontend/src/utils/groupCardsByPrint.test.js | 57 + 6 files changed, 126 insertions(+), 1693 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-19-public-binders.md delete mode 100644 docs/superpowers/specs/2026-07-19-public-binders-design.md delete mode 100644 docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md create mode 100644 frontend/src/utils/groupCardsByPrint.js create mode 100644 frontend/src/utils/groupCardsByPrint.test.js diff --git a/docs/superpowers/plans/2026-07-19-public-binders.md b/docs/superpowers/plans/2026-07-19-public-binders.md deleted file mode 100644 index 06199bbc..00000000 --- a/docs/superpowers/plans/2026-07-19-public-binders.md +++ /dev/null @@ -1,1450 +0,0 @@ -# Public Viewable Binders Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let a user publish a public profile (custom handle) exposing binders they explicitly share, viewable by anonymous visitors and discoverable in-app, with purchase price / cost / P&L never exposed. - -**Architecture:** A dedicated unauthenticated `/api/public/*` router with its own whitelist serializers (private fields physically absent from the response models). Owner controls live in a new authenticated `/api/profile` router plus an `is_public` flag on binders. Frontend adds public routes outside the login wall using a separate axios client that never attaches a token or redirects on 401. - -**Tech Stack:** FastAPI + SQLAlchemy + PostgreSQL (prod) / in-memory SQLite (tests); React + Vite + React Router + axios; slowapi for rate limiting; Vitest + Python `unittest`. - -## Global Constraints - -- **Tests are `unittest`, NOT pytest.** Run backend tests in the backend image with the source bind-mounted: - `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests. -v` -- **Frontend tests/build run in Node 20 container, not the host:** - `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run ` -- **No Alembic.** New columns need idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...` statements in `backend/database.py::_run_migrations` (PostgreSQL). Tests get the columns from `Base.metadata.create_all` via the model definitions. -- **The collection table is named `collection`** (not `collection_items`). `Set.id`/`Card.id` are composite/lang-suffixed; `Card.set_id` is the unsuffixed tcg id. -- **`SessionLocal` is `autoflush=False`.** -- **Never expose private fields publicly:** `purchase_price`, cost basis, P&L, `condition`, notes, `username`, email, telegram/gemini settings, internal user ids. The login `username` in particular must never appear in any `/api/public/*` or profile-public response. -- **Money is EUR-native** on public pages (no per-viewer currency conversion in v1). -- Branch: `feature/public-binders` (already created from `upstream/main`). Design/plan docs under `docs/superpowers/` must not ride into an upstream PR. - ---- - -## File Structure - -**Backend** -- `backend/models.py` — add `User.public_handle`, `User.is_profile_public`, `User.public_show_values`, `Binder.is_public`. -- `backend/database.py` — migrations for the four new columns. -- `backend/services/public_profile.py` *(new)* — handle validation/reserved words, profile resolution, and whitelist serialization. All public logic isolated here. -- `backend/api/public.py` *(new)* — unauthenticated router + public Pydantic response models. -- `backend/api/profile.py` *(new)* — authenticated owner controls (set handle/toggles, availability check). -- `backend/schemas.py` — add `ProfileUpdate`; add `is_public` to `BinderUpdate` and `BinderResponse`. -- `backend/api/binders.py` — persist `is_public` in `update_binder`, include it in `_binder_response`. -- `backend/api/social.py` — include `public_handle` in leaderboard rows. -- `backend/main.py` — mount the two new routers. -- `backend/tests/test_public_binders.py` *(new)* — all backend tests for this feature. - -**Frontend** -- `frontend/src/utils/publicHandle.js` *(new)* — shared handle-format validator. -- `frontend/src/utils/publicHandle.test.js` *(new)*. -- `frontend/src/api/publicClient.js` *(new)* — token-less axios instance + public API calls. -- `frontend/src/api/client.js` — add `updateProfile`, `checkHandleAvailable`, and `is_public` on binder update. -- `frontend/src/utils/formatEur.js` *(new)* + `frontend/src/utils/formatEur.test.js` *(new)* — shared EUR formatter used by both public pages (returns null when a value is hidden). -- `frontend/src/pages/PublicProfile.jsx` *(new)*, `frontend/src/pages/PublicBinderView.jsx` *(new)*. -- `frontend/src/App.jsx` — public routes outside `ProtectedRoutes`. -- `frontend/src/pages/Settings.jsx` — "Public profile" section. -- `frontend/src/pages/Binders.jsx` — per-binder "Share publicly" toggle. -- `frontend/src/pages/Leaderboard.jsx` — link rows with a handle. - ---- - -## Task 1: Data model + migrations - -**Files:** -- Modify: `backend/models.py` (User class ~line 134, Binder class ~line 205) -- Modify: `backend/database.py` (`_run_migrations` list ~line 58) -- Test: `backend/tests/test_public_binders.py` - -**Interfaces:** -- Produces: `User.public_handle: str|None`, `User.is_profile_public: bool`, `User.public_show_values: bool`, `Binder.is_public: bool`. - -- [ ] **Step 1: Write the failing test** - -Create `backend/tests/test_public_binders.py`: - -```python -import unittest - -try: - from sqlalchemy import create_engine - from sqlalchemy.orm import sessionmaker - from database import Base - from models import User, Binder - 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) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders -v` -Expected: FAIL — `AttributeError`/`TypeError` (columns don't exist yet). - -- [ ] **Step 3: Add columns to models** - -In `backend/models.py`, inside `class User(Base)` (after `avatar_id`): - -```python - public_handle = Column(String, unique=True, nullable=True) - is_profile_public = Column(Boolean, default=False, nullable=False) - public_show_values = Column(Boolean, default=False, nullable=False) -``` - -Inside `class Binder(Base)` (after `icon_pokemon_id`): - -```python - is_public = Column(Boolean, default=False, nullable=False) -``` - -- [ ] **Step 4: Add migrations** - -In `backend/database.py`, append to the `migrations` list in `_run_migrations`: - -```python - "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", -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders -v` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add backend/models.py backend/database.py backend/tests/test_public_binders.py -git commit -m "feat(public-binders): add profile handle + public flags data model" -``` - ---- - -## Task 2: Handle validation service - -**Files:** -- Create: `backend/services/public_profile.py` -- Test: `backend/tests/test_public_binders.py` - -**Interfaces:** -- Produces: - - `HANDLE_RE` (compiled), `RESERVED_HANDLES: set[str]` - - `class HandleError(ValueError)` - - `validate_handle(raw: str) -> str` — returns normalized lowercase handle or raises `HandleError(message)` - -- [ ] **Step 1: Write the failing test** - -Append to `backend/tests/test_public_binders.py`: - -```python -try: - from services.public_profile import validate_handle, HandleError - 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") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.HandleValidationTests -v` -Expected: FAIL — `ModuleNotFoundError`/skip → the file doesn't exist. (If skipped, that itself signals the module is missing; create it in Step 3.) - -- [ ] **Step 3: Create the service** - -Create `backend/services/public_profile.py`: - -```python -import re - -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 - - -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 -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.HandleValidationTests -v` -Expected: PASS (6 tests). - -- [ ] **Step 5: Commit** - -```bash -git add backend/services/public_profile.py backend/tests/test_public_binders.py -git commit -m "feat(public-binders): handle validation + reserved words" -``` - ---- - -## Task 3: Profile resolution + whitelist serialization - -**Files:** -- Modify: `backend/services/public_profile.py` -- Test: `backend/tests/test_public_binders.py` - -**Interfaces:** -- Consumes: `User`, `Binder`, `BinderCard`, `Card`, `UserSetting` models; `services.card_values.effective_market_price`. -- Produces: - - `is_handle_available(db, handle: str, exclude_user_id: int|None=None) -> bool` - - `get_live_profile(db, handle: str) -> User|None` — returns the user only if `is_profile_public` and handle set. - - `trainer_name_for(db, user) -> str` - - `public_collection_binders(db, user) -> list[Binder]` — this user's `is_public` collection binders. - - `serialize_profile(db, user) -> dict` — keys: `handle, trainer_name, avatar_id, show_values, binders`. - - `serialize_binder_summary(db, binder, show_values: bool) -> dict` — keys: `id, name, color, icon_pokemon_id, card_count, unique_card_count, total_value`. - - `serialize_binder_detail(db, binder, show_values: bool) -> dict` — summary keys + `cards`. - - Each card dict keys: `id, name, image, set_name, number, rarity, quantity, market_value`. - -- [ ] **Step 1: Write the failing test** - -Append to `backend/tests/test_public_binders.py`: - -```python -try: - from services import public_profile as pp - from models import BinderCard, Card, Set, UserSetting - 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, - UserSetting(user_id=1, key="trainer_name", value="Ash K."), - 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 K.") - 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]["quantity"], 2) - self.assertIsNone(detail["cards"][0]["market_value"]) - self.assertIsNone(detail["total_value"]) - - 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) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.SerializationTests -v` -Expected: FAIL — functions not defined. - -- [ ] **Step 3: Implement the resolution + serialization** - -Append to `backend/services/public_profile.py`: - -```python -from models import User, Binder, BinderCard, Card, UserSetting -from services.card_values import effective_market_price - -_DEFAULT_TRAINER_NAME = "TRAINER" -_PRICE_FIELD = "price_trend" - - -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 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: - row = db.query(UserSetting).filter( - UserSetting.user_id == user.id, UserSetting.key == "trainer_name" - ).first() - return (row.value if row and row.value else _DEFAULT_TRAINER_NAME) - - -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 _binder_cards(db, binder: Binder) -> list[BinderCard]: - return db.query(BinderCard).filter(BinderCard.binder_id == binder.id).all() - - -def _serialize_card(bc: BinderCard, show_values: bool) -> dict: - card = bc.card - quantity = bc.required_quantity or 1 - value = effective_market_price(card, None, _PRICE_FIELD) if show_values else None - return { - "id": card.id, - "name": card.name, - "image": card.images_small or card.images_large, - "set_name": card.set_ref.name if card.set_ref else None, - "number": card.number, - "rarity": card.rarity, - "quantity": quantity, - "market_value": value, - } - - -def serialize_binder_summary(db, binder: Binder, show_values: bool) -> dict: - cards = _binder_cards(db, binder) - 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, None, _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: - summary = serialize_binder_summary(db, binder, show_values) - cards = _binder_cards(db, binder) - 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) - 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, b, show_values) for b in binders], - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.SerializationTests -v` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add backend/services/public_profile.py backend/tests/test_public_binders.py -git commit -m "feat(public-binders): profile resolution + whitelist serialization" -``` - ---- - -## Task 4: Public API router - -**Files:** -- Create: `backend/api/public.py` -- Modify: `backend/main.py` (imports ~line 124, mounts ~line 156) -- Test: `backend/tests/test_public_binders.py` - -**Interfaces:** -- Consumes: `services.public_profile`, `database.get_db`. -- Produces (callable directly in tests): `get_public_profile(handle, db)`, `get_public_binder(handle, binder_id, db)`; Pydantic `PublicProfile`, `PublicBinderDetail`, `PublicBinderSummary`, `PublicCard`. - -- [ ] **Step 1: Write the failing test** - -Append to `backend/tests/test_public_binders.py`: - -```python -try: - from fastapi import HTTPException - from api.public import get_public_profile, get_public_binder - API_DEPS = True -except ModuleNotFoundError: - API_DEPS = False - - -@unittest.skipUnless(API_DEPS, "api deps unavailable") -class PublicApiTests(unittest.TestCase): - def _db(self): - engine = create_engine("sqlite:///:memory:") - Base.metadata.create_all(engine) - return sessionmaker(bind=engine)() - - 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_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_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) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.PublicApiTests -v` -Expected: FAIL — `api.public` not found. - -- [ ] **Step 3: Create the router** - -Create `backend/api/public.py`: - -```python -from fastapi import APIRouter, Depends, HTTPException -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 - -router = APIRouter() - - -class PublicCard(BaseModel): - id: str - name: str - image: Optional[str] = None - set_name: Optional[str] = None - number: Optional[str] = None - rarity: 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 PublicBinderDetail(PublicBinderSummary): - cards: List[PublicCard] - - -@router.get("/profiles/{handle}", response_model=PublicProfile) -def get_public_profile(handle: str, db: Session = Depends(get_db)): - user = pp.get_live_profile(db, handle.lower()) - if not user: - raise HTTPException(status_code=404, detail="Profile not found") - 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)): - user = pp.get_live_profile(db, handle.lower()) - if not user: - raise HTTPException(status_code=404, detail="Profile not found") - binder = next((b for b in pp.public_collection_binders(db, user) if b.id == binder_id), None) - if not binder: - raise HTTPException(status_code=404, detail="Binder not found") - return pp.serialize_binder_detail(db, binder, show_values=bool(user.public_show_values)) -``` - -Note: endpoints return the plain whitelisted dict from the serializer; `response_model` filters/validates the schema at FastAPI's serialization layer (extra keys would be dropped), and the serializers already emit only whitelisted keys — belt and suspenders. Returning a dict (not a `Response`) keeps the functions directly callable and subscriptable in the unit tests. The public `Cache-Control` header is added in Task 11 with a test-safe signature. - -- [ ] **Step 4: Mount the router** - -In `backend/main.py`, add `public` to the `from api import ...` line (~124), then after the other `include_router` calls: - -```python -app.include_router(public.router, prefix="/api/public", tags=["public"]) -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.PublicApiTests -v` -Expected: PASS (5 tests). - -- [ ] **Step 6: Commit** - -```bash -git add backend/api/public.py backend/main.py backend/tests/test_public_binders.py -git commit -m "feat(public-binders): unauthenticated public profile + binder API" -``` - ---- - -## Task 5: Owner control API (profile router) - -**Files:** -- Create: `backend/api/profile.py` -- Modify: `backend/schemas.py` (add `ProfileUpdate`) -- Modify: `backend/main.py` -- Test: `backend/tests/test_public_binders.py` - -**Interfaces:** -- Consumes: `api.auth.get_current_user`, `services.public_profile`, `database.get_db`. -- Produces: `update_profile(payload, db, current_user)`, `handle_available(handle, db, current_user)`; `schemas.ProfileUpdate`. - -- [ ] **Step 1: Write the failing test** - -Append to `backend/tests/test_public_binders.py`: - -```python -try: - from api.profile import update_profile, handle_available - 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): - engine = create_engine("sqlite:///:memory:") - Base.metadata.create_all(engine) - return sessionmaker(bind=engine)() - - 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_set_handle_and_publish(self): - db = self._db() - u = self._user(db) - result = update_profile(ProfileUpdate(public_handle="Ash-K", is_profile_public=True), - db=db, current_user=u) - self.assertEqual(result["public_handle"], "ash-k") - self.assertTrue(result["is_profile_public"]) - - def test_invalid_handle_422(self): - db = self._db() - u = self._user(db) - with self.assertRaises(HTTPException) as ctx: - update_profile(ProfileUpdate(public_handle="a"), db=db, current_user=u) - self.assertEqual(ctx.exception.status_code, 422) - - def test_duplicate_handle_409(self): - db = self._db() - taken = self._user(db, "misty") - update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=taken) - me = self._user(db, "ash") - with self.assertRaises(HTTPException) as ctx: - update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=me) - self.assertEqual(ctx.exception.status_code, 409) - - def test_handle_available_check(self): - db = self._db() - u = self._user(db) - self.assertTrue(handle_available("brand-new", db=db, current_user=u)["available"]) - self.assertFalse(handle_available("ADMIN", db=db, current_user=u)["available"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.ProfileControlTests -v` -Expected: FAIL — `api.profile` not found. - -- [ ] **Step 3: Add the schema** - -In `backend/schemas.py`, add: - -```python -class ProfileUpdate(BaseModel): - public_handle: Optional[str] = None - is_profile_public: Optional[bool] = None - public_show_values: Optional[bool] = None -``` - -- [ ] **Step 4: Create the router** - -Create `backend/api/profile.py`: - -```python -from fastapi import APIRouter, Depends, HTTPException, Query -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 - -router = APIRouter() - - -def _serialize_owner(user: User) -> dict: - return { - "public_handle": user.public_handle, - "is_profile_public": bool(user.is_profile_public), - "public_show_values": bool(user.public_show_values), - } - - -@router.get("/handle-available") -def handle_available(handle: str = Query(...), db: Session = Depends(get_db), - current_user: User = Depends(get_current_user)): - try: - normalized = pp.validate_handle(handle) - except pp.HandleError as exc: - return {"available": False, "reason": str(exc)} - available = pp.is_handle_available(db, normalized, exclude_user_id=current_user.id) - return {"available": available, "reason": None if available else "Handle is taken"} - - -@router.put("/") -def update_profile(payload: ProfileUpdate, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user)): - if payload.public_handle is not None: - try: - normalized = pp.validate_handle(payload.public_handle) - except pp.HandleError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from None - if not pp.is_handle_available(db, normalized, exclude_user_id=current_user.id): - raise HTTPException(status_code=409, detail="Handle is taken") - current_user.public_handle = normalized - if payload.is_profile_public is not None: - current_user.is_profile_public = payload.is_profile_public - if payload.public_show_values is not None: - current_user.public_show_values = payload.public_show_values - db.commit() - db.refresh(current_user) - return _serialize_owner(current_user) -``` - -- [ ] **Step 5: Mount the router** - -In `backend/main.py`, add `profile` to the `from api import ...` line and: - -```python -app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) -``` - -- [ ] **Step 6: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.ProfileControlTests -v` -Expected: PASS (4 tests). - -- [ ] **Step 7: Commit** - -```bash -git add backend/api/profile.py backend/schemas.py backend/main.py backend/tests/test_public_binders.py -git commit -m "feat(public-binders): owner profile controls (handle + toggles)" -``` - ---- - -## Task 6: Binder `is_public` toggle - -**Files:** -- Modify: `backend/schemas.py` (`BinderUpdate` ~234, `BinderResponse` ~256) -- Modify: `backend/api/binders.py` (`_binder_response` ~88, `update_binder` ~491) -- Test: `backend/tests/test_public_binders.py` - -**Interfaces:** -- Consumes: existing `update_binder(binder_id, binder, db, current_user)`. -- Produces: `BinderResponse.is_public: bool`; `update_binder` persists `is_public`. - -- [ ] **Step 1: Write the failing test** - -Append to `backend/tests/test_public_binders.py`: - -```python -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): - engine = create_engine("sqlite:///:memory:") - Base.metadata.create_all(engine) - return sessionmaker(bind=engine)() - - 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) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.BinderPublicToggleTests -v` -Expected: FAIL — `BinderUpdate` has no `is_public` / `BinderResponse` has no `is_public`. - -- [ ] **Step 3: Update schemas** - -In `backend/schemas.py`, add `is_public: Optional[bool] = None` to `BinderUpdate`, and `is_public: bool = False` to `BinderResponse`. - -- [ ] **Step 4: Persist and return `is_public`** - -In `backend/api/binders.py`, in `_binder_response(...)` add `is_public=binder.is_public or False,` to the `BinderResponse(...)` call. In `update_binder`, alongside the other `if update.X is not None:` assignments, add: - -```python - if update.is_public is not None: - binder.is_public = update.is_public -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.BinderPublicToggleTests -v` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add backend/schemas.py backend/api/binders.py backend/tests/test_public_binders.py -git commit -m "feat(public-binders): per-binder is_public toggle" -``` - ---- - -## Task 7: Leaderboard handle exposure (discovery) - -**Files:** -- Modify: `backend/api/social.py` (`_load_user_stats`, the `stats[user.id] = {...}` block ~line 153) -- Test: `backend/tests/test_public_binders.py` - -**Interfaces:** -- Produces: leaderboard row dict gains `public_handle: str|None`. - -- [ ] **Step 1: Write the failing test** - -Append to `backend/tests/test_public_binders.py`: - -```python -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(u) - db.commit() - stats = _load_user_stats(db) - self.assertIn(u.id, stats) - self.assertEqual(stats[u.id]["public_handle"], "ash") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.LeaderboardHandleTests -v` -Expected: FAIL — `KeyError: 'public_handle'`. - -- [ ] **Step 3: Add the field** - -In `backend/api/social.py`, inside the `stats[user.id] = { ... }` dict, add: - -```python - "public_handle": user.public_handle if user.is_profile_public else None, -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.LeaderboardHandleTests -v` -Expected: PASS. - -- [ ] **Step 5: Full backend suite regression check** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest discover -s tests -v` -Expected: all pass (existing + new). - -- [ ] **Step 6: Commit** - -```bash -git add backend/api/social.py backend/tests/test_public_binders.py -git commit -m "feat(public-binders): expose public_handle on leaderboard rows" -``` - ---- - -## Task 8: Frontend handle validator + public API client - -**Files:** -- Create: `frontend/src/utils/publicHandle.js` -- Create: `frontend/src/utils/publicHandle.test.js` -- Create: `frontend/src/api/publicClient.js` - -**Interfaces:** -- Produces: `isValidHandleFormat(raw) -> bool`, `normalizeHandle(raw) -> string`; `getPublicProfile(handle)`, `getPublicBinder(handle, binderId)`. - -- [ ] **Step 1: Write the failing test** - -Create `frontend/src/utils/publicHandle.test.js`: - -```javascript -import { describe, it, expect } from 'vitest' -import { isValidHandleFormat, normalizeHandle } from './publicHandle' - -describe('publicHandle', () => { - it('normalizes case and trims', () => { - expect(normalizeHandle(' Ash-K ')).toBe('ash-k') - }) - it('accepts a valid handle', () => { - expect(isValidHandleFormat('ash-ketchum')).toBe(true) - }) - it('rejects too short', () => { - expect(isValidHandleFormat('ab')).toBe(false) - }) - it('rejects bad chars and edges', () => { - expect(isValidHandleFormat('ash_k')).toBe(false) - expect(isValidHandleFormat('-ash')).toBe(false) - expect(isValidHandleFormat('ash--k')).toBe(false) - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/publicHandle.test.js` -Expected: FAIL — module not found. - -- [ ] **Step 3: Create the validator** - -Create `frontend/src/utils/publicHandle.js`: - -```javascript -const HANDLE_RE = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/ - -export function normalizeHandle(raw) { - return String(raw || '').trim().toLowerCase() -} - -export function isValidHandleFormat(raw) { - const handle = normalizeHandle(raw) - if (handle.length < 3 || handle.length > 30) return false - if (handle.includes('--')) return false - return HANDLE_RE.test(handle) -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/publicHandle.test.js` -Expected: PASS (4 tests). - -- [ ] **Step 5: Create the token-less public client** - -Create `frontend/src/api/publicClient.js`: - -```javascript -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 getPublicBinder = (handle, binderId) => - publicApi.get(`/profiles/${encodeURIComponent(handle)}/binders/${binderId}`).then(r => r.data) -``` - -- [ ] **Step 6: Commit** - -```bash -git add frontend/src/utils/publicHandle.js frontend/src/utils/publicHandle.test.js frontend/src/api/publicClient.js -git commit -m "feat(public-binders): frontend handle validator + token-less public client" -``` - ---- - -## Task 9: Public pages + routes - -**Files:** -- Create: `frontend/src/utils/formatEur.js` -- Create: `frontend/src/utils/formatEur.test.js` -- Create: `frontend/src/pages/PublicProfile.jsx` -- Create: `frontend/src/pages/PublicBinderView.jsx` -- Modify: `frontend/src/App.jsx` - -**Environment note (read before starting):** This frontend has NO DOM test infrastructure — no `@testing-library`, no `jsdom`, no vitest `environment` config; the only existing tests are pure-JS (node env). Do NOT add those dependencies. Component rendering is verified here by `npm run build` (a compile/import check) plus the Task 11 manual pass; the vitest unit test covers the shared `formatEur` value-hiding logic that both pages depend on. - -**Interfaces:** -- Consumes: `getPublicProfile`, `getPublicBinder` from `../api/publicClient`. -- Produces: `formatEur(value)` from `../utils/formatEur` (returns null when hidden); routes `/u/:handle`, `/u/:handle/binder/:binderId` rendered outside `ProtectedRoutes`. - -- [ ] **Step 1: Write the failing test** - -Create `frontend/src/utils/formatEur.test.js`: - -```javascript -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() - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/formatEur.test.js` -Expected: FAIL — `./formatEur` module not found. - -- [ ] **Step 3: Create the shared formatter** - -Create `frontend/src/utils/formatEur.js`: - -```javascript -// 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)) -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run src/utils/formatEur.test.js` -Expected: PASS (3 tests). - -- [ ] **Step 5: Create `PublicBinderView.jsx`** - -Create `frontend/src/pages/PublicBinderView.jsx`: - -```javascript -import { useEffect, useState } from 'react' -import { useParams, Link } from 'react-router-dom' -import { getPublicBinder } from '../api/publicClient' -import { formatEur } from '../utils/formatEur' - -export default function PublicBinderView() { - const { handle, binderId } = useParams() - const [binder, setBinder] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - getPublicBinder(handle, binderId) - .then(data => { if (!cancelled) setBinder(data) }) - .catch(() => { if (!cancelled) setError('This binder is not available.') }) - return () => { cancelled = true } - }, [handle, binderId]) - - if (error) return
{error}
- if (!binder) return
Loading…
- - return ( -
- ← {handle} -
-

{binder.name}

- {binder.total_value != null && ( - {formatEur(binder.total_value)} - )} -
-
- {binder.cards.map(card => ( -
- {card.image - ? {card.name} - :
} -
{card.name}
-
- {card.set_name} · #{card.number}{card.quantity > 1 ? ` · ×${card.quantity}` : ''} -
- {card.market_value != null && ( -
{formatEur(card.market_value)}
- )} -
- ))} -
-
- ) -} -``` - -- [ ] **Step 6: Create `PublicProfile.jsx`** - -Create `frontend/src/pages/PublicProfile.jsx`: - -```javascript -import { useEffect, useState } from 'react' -import { useParams, Link } from 'react-router-dom' -import { getPublicProfile } from '../api/publicClient' -import { formatEur } from '../utils/formatEur' - -export default function PublicProfile() { - const { handle } = useParams() - const [profile, setProfile] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - getPublicProfile(handle) - .then(data => { if (!cancelled) setProfile(data) }) - .catch(() => { if (!cancelled) setError('This profile is not available.') }) - return () => { cancelled = true } - }, [handle]) - - if (error) return
{error}
- if (!profile) return
Loading…
- - return ( -
-
- {profile.avatar_id && ( - - )} -

{profile.trainer_name}

-
- {profile.binders.length === 0 && ( -

No shared binders yet.

- )} -
- {profile.binders.map(binder => ( - -
{binder.name}
-
- {binder.unique_card_count} cards - {binder.total_value != null ? ` · ${formatEur(binder.total_value)}` : ''} -
- - ))} -
-
- ) -} -``` - -- [ ] **Step 7: Wire the routes outside the auth wall** - -In `frontend/src/App.jsx`: add lazy imports near the other page imports: - -```javascript -const PublicProfile = lazy(() => import('./pages/PublicProfile')) -const PublicBinderView = lazy(() => import('./pages/PublicBinderView')) -``` - -Then in the top-level `` (the one containing `/login` and `/*`), add these **before** the `/*` catch-all so they bypass `ProtectedRoutes`: - -```javascript - )} /> - )} /> -``` - -- [ ] **Step 8: Build to verify components compile and imports resolve** - -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npm run build` -Expected: build succeeds. This is the compile/import safety net that replaces a DOM render test (none available in this environment). - -Then run the full util test suite to confirm no regression: -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run` -Expected: all pass (formatEur + publicHandle). - -- [ ] **Step 9: Commit** - -```bash -git add frontend/src/utils/formatEur.js frontend/src/utils/formatEur.test.js frontend/src/pages/PublicProfile.jsx frontend/src/pages/PublicBinderView.jsx frontend/src/App.jsx -git commit -m "feat(public-binders): public profile + binder pages and routes" -``` - ---- - -## Task 10: Owner controls UI (settings + binder toggle + leaderboard links) - -**Files:** -- Modify: `frontend/src/api/client.js` -- Modify: `frontend/src/pages/Settings.jsx` -- Modify: `frontend/src/pages/Binders.jsx` -- Modify: `frontend/src/pages/Leaderboard.jsx` - -**Interfaces:** -- Consumes: `/api/profile` (PUT + handle-available), `/api/binders/{id}` (PUT with `is_public`), leaderboard `public_handle`. -- Produces: user-facing controls; no new exported types. - -- [ ] **Step 1: Add API client functions** - -In `frontend/src/api/client.js`, add: - -```javascript -export const updateProfile = (data) => api.put('/profile/', data).then(r => r.data) -export const checkHandleAvailable = (handle) => - api.get('/profile/handle-available', { params: { handle } }).then(r => r.data) -export const updateBinder = (id, data) => api.put(`/binders/${id}`, data).then(r => r.data) -``` - -(If `updateBinder` already exists, extend its usage to pass `is_public` rather than redefining.) - -- [ ] **Step 2: Add the "Public profile" settings section** - -In `frontend/src/pages/Settings.jsx`, add a section that: -- Loads current `public_handle`, `is_profile_public`, `public_show_values` from `/api/settings/` (these are now included via the profile columns — if not surfaced there, fetch from a `getMe`-style call; simplest is to read them from the settings payload which already returns user-scoped data). Use local state seeded on mount. -- Renders a handle text input with live availability feedback via `checkHandleAvailable` (debounced 400ms; show "available"/reason). -- Renders toggles for `is_profile_public` and `public_show_values`. -- On save, calls `updateProfile({ public_handle, is_profile_public, public_show_values })`. -- Shows the public URL `${window.location.origin}/u/${handle}` with a copy button when a handle is set and the profile is public. - -Concrete control block to insert (adapt styling to the surrounding page): - -```javascript -// inside Settings component -const [handle, setHandle] = useState('') -const [profilePublic, setProfilePublic] = useState(false) -const [showValues, setShowValues] = useState(false) -const [handleStatus, setHandleStatus] = useState(null) // {available, reason} - -useEffect(() => { - if (!handle) { setHandleStatus(null); return } - const t = setTimeout(() => { - checkHandleAvailable(handle).then(setHandleStatus).catch(() => setHandleStatus(null)) - }, 400) - return () => clearTimeout(t) -}, [handle]) - -const savePublicProfile = async () => { - await updateProfile({ - public_handle: handle || null, - is_profile_public: profilePublic, - public_show_values: showValues, - }) -} -``` - -And JSX (place in a settings card): - -```jsx -
-

Public profile

- - {handleStatus && ( -

- {handleStatus.available ? 'Available' : handleStatus.reason} -

- )} - - - - {profilePublic && handle && ( -
- {`${window.location.origin}/u/${handle}`} - -
- )} -
-``` - -Seed `handle/profilePublic/showValues` from the settings payload the page already loads (the profile columns are user-scoped). If the settings endpoint does not return them, add them to `_get_user_settings` in `backend/api/settings.py` (read-only, non-sensitive) so the page can hydrate — but do NOT route their writes through settings; writes go through `/api/profile`. - -- [ ] **Step 3: Add per-binder "Share publicly" toggle** - -In `frontend/src/pages/Binders.jsx`, on each binder card (collection binders only), add a small toggle that calls `updateBinder(binder.id, { is_public: next })` and reflects `binder.is_public`. Show a hint ("Enable your public profile in Settings to share") when the user's profile isn't public. Include a copy-link affordance to `${origin}/u/${handle}/binder/${binder.id}` when both profile and binder are public. - -- [ ] **Step 4: Link leaderboard rows with a handle** - -In `frontend/src/pages/Leaderboard.jsx`, where each row renders, if `row.public_handle` is set, wrap/append a `` (e.g. a small "profile" link/icon). Leave rows without a handle unchanged. - -- [ ] **Step 5: Build to verify no breakage** - -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npm run build` -Expected: build succeeds. - -- [ ] **Step 6: Run frontend tests** - -Run: `docker run --rm -v "$PWD/frontend":/app -w /app node:20 npx vitest run` -Expected: all pass. - -- [ ] **Step 7: Commit** - -```bash -git add frontend/src/api/client.js frontend/src/pages/Settings.jsx frontend/src/pages/Binders.jsx frontend/src/pages/Leaderboard.jsx -git commit -m "feat(public-binders): owner controls — settings, binder toggle, leaderboard links" -``` - ---- - -## Task 11: Cache headers + rate limiting + manual verification - -**Files:** -- Modify: `backend/api/public.py` - -**Interfaces:** -- Consumes: `main.py`'s existing slowapi `Limiter` (via the `@limiter.limit` decorator pattern already used in the codebase). - -- [ ] **Step 1: Add the public `Cache-Control` header (test-safe signature)** - -Add a `response: Response = None` keyword param (defaulted, so the direct unit-test calls from Task 4 still work) and set the header. Update both endpoints: - -```python -from fastapi import Response - -@router.get("/profiles/{handle}", response_model=PublicProfile) -def get_public_profile(handle: str, db: Session = Depends(get_db), response: Response = None): - user = pp.get_live_profile(db, handle.lower()) - if not user: - raise HTTPException(status_code=404, detail="Profile not found") - if response is not None: - response.headers["Cache-Control"] = "public, max-age=300" - return pp.serialize_profile(db, user) -``` - -Apply the same `response: Response = None` param + header line to `get_public_binder`. Re-run Task 4's tests to confirm they still pass: -`docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest tests.test_public_binders.PublicApiTests -v` - -- [ ] **Step 2: Add a slowapi limit to public endpoints** - -First search the codebase for the existing pattern: `grep -rn "limiter.limit\|@limiter\|Limiter(" backend/`. Match whatever it does exactly. slowapi's decorator requires a `request: Request` parameter on the endpoint. Example shape: - -```python -from fastapi import Request - -@router.get("/profiles/{handle}", response_model=PublicProfile) -@limiter.limit("60/minute") -def get_public_profile(request: Request, handle: str, db: Session = Depends(get_db), response: Response = None): - ... -``` - -Because adding a required `request: Request` positional param changes the signature the Task 4 unit tests call, either (a) pass a stub `request` in those tests, or (b) if the `limiter` instance can't be imported into `public.py` without an import cycle with `main.py`, **skip the decorator** and leave rate limiting as a documented follow-up — the spec permits deferral. Do NOT introduce an import cycle, and do NOT break the Task 4 tests. Prefer moving the `Limiter` instance to a small `backend/services/rate_limit.py` if you want the decorator without the cycle. - -- [ ] **Step 3: Run the full backend suite** - -Run: `docker run --rm -v "$PWD/backend":/app/backend -w /app/backend pokecollector-backend python -m unittest discover -s tests -v` -Expected: all pass. - -- [ ] **Step 4: Manual verification (logged out)** - -With the feature deployed to a test environment (not asked for here — just the checklist): -- Set a handle, publish the profile, share one binder in the app. -- In a private/incognito window (no token), open `https:///u/` → profile renders, shared binder visible. -- Open the shared binder URL → cards render read-only; values shown only if the toggle is on. -- Open a non-shared binder id under the handle → 404 page/message. -- Unpublish the profile → both URLs now show "not available". - -- [ ] **Step 5: Commit** - -```bash -git add backend/api/public.py -git commit -m "feat(public-binders): cache headers + rate-limit public endpoints" -``` - ---- - -## Self-Review Notes (coverage map) - -- Data model (handle, profile-public, show-values, binder-public) → Task 1. -- Handle validation + reserved words → Task 2 (backend), Task 8 (frontend). -- Whitelist serialization / no private-field leak / show-values gating → Task 3 (+ tests). -- Unauthenticated public API + 404-not-403 + cross-owner guard → Task 4. -- Owner controls (handle set, toggles, availability, 409 on dup) → Task 5. -- Per-binder share toggle → Task 6 (backend), Task 10 (UI). -- In-app discovery via leaderboard → Task 7 (backend), Task 10 (UI links). -- Public pages outside login wall + token-less client → Tasks 8–9. -- Rate limiting (upgraded from spec's "deferred" since slowapi already exists) → Task 11. -- Known v1 exclusions (SSR/OG previews, per-viewer currency, public directory, wishlist sharing) remain out of scope. diff --git a/docs/superpowers/specs/2026-07-19-public-binders-design.md b/docs/superpowers/specs/2026-07-19-public-binders-design.md deleted file mode 100644 index 7b997b91..00000000 --- a/docs/superpowers/specs/2026-07-19-public-binders-design.md +++ /dev/null @@ -1,176 +0,0 @@ -# Public Viewable Binders — Design - -**Date:** 2026-07-19 -**Branch:** `feature/public-binders` (cut from `upstream/main`) -**Status:** Design — awaiting review before implementation planning - -## Summary - -Let a user publish a **public profile** identified by a custom handle. On that profile, -individual binders the user has explicitly shared are viewable — by anyone with the link -(no account required) and discoverable by logged-in users from the leaderboard. Purchase -prices, cost basis, and P&L are **never** exposed. Current card market values are shown -only if the profile owner opts in. - -This extends the app's existing cross-user viewing (which today is entirely behind the -login wall via `ProtectedRoutes` and `get_current_user`) with a genuinely public, -unauthenticated surface. - -## Decisions (from brainstorming) - -- **Audience:** both — anonymous link works logged-out, AND logged-in users can discover - public profiles in-app. -- **Control model:** a profile-level "make profile public" switch plus a per-binder - "share publicly" toggle. The public profile lists only the binders the user shared. -- **Money data:** purchase price / cost basis / P&L are never public. Current market value - (per card + binder total) is shown only when the owner enables a per-profile - "show card values" setting. Default off. -- **Identity:** a unique, URL-safe **handle** for the URL (`/u/`); the page shows - the user's `trainer_name` + avatar. The login `username` is never stored in or emitted - by any public response. -- **Architecture:** a dedicated unauthenticated `/api/public/*` namespace with its own - whitelist serializers, kept physically separate from the private endpoints, so private - fields cannot leak by construction. (Rejected: gating the existing private endpoints - with optional auth — mixing trust levels in one handler is exactly the pattern that - produced the earlier `SENSITIVE_ADMIN_KEYS` admin-key leak.) - -## Data model - -No Alembic in this project (`create_all` adds new *tables* only), so each new **column** -needs a hand-written `migrate_*` function in `backend/database.py` -(`ALTER TABLE ... ADD COLUMN ... DEFAULT ...`). All defaults keep existing data private. - -`User` (new columns): -- `public_handle` — `String`, **unique**, nullable. URL slug: lowercase `[a-z0-9-]`, - 3–30 chars, no leading/trailing/double hyphen. `NULL` = no public profile. -- `is_profile_public` — `Boolean`, default `False`. Master switch. -- `public_show_values` — `Boolean`, default `False`. Per-profile "show card market values". - -`Binder` (new column): -- `is_public` — `Boolean`, default `False`. Per-binder share toggle. - -**A profile is live iff** `is_profile_public = True AND public_handle IS NOT NULL`. -**A binder is publicly viewable iff** its owner's profile is live AND `binder.is_public = True`. - -Display name = existing `trainer_name` UserSetting (default `"TRAINER"`). Avatar = -existing `User.avatar_id`. - -### Reserved handles - -`admin`, `api`, `u`, `settings`, `login`, `logout`, `static`, `assets`, `public`, -`me`, `null`, `undefined` (final list finalized during implementation). - -## Backend — `backend/api/public.py` (unauthenticated router) - -Mounted under `/api/public`. No `get_current_user`. Own Pydantic response models that -contain **only** whitelisted fields — private fields are absent from the models, so they -cannot be serialized even by mistake. - -Response models: -- `PublicProfile` — `handle`, `trainer_name`, `avatar_id`, `show_values` (bool), - `binders: list[PublicBinderSummary]`. -- `PublicBinderSummary` — `id`, `name`, `color`, `icon_pokemon_id`, `card_count`, - `unique_card_count`, `total_value` (nullable; present only if `show_values`). -- `PublicBinderDetail` — summary fields + `cards: list[PublicCard]`. -- `PublicCard` — `id`, `name`, `image`, `set_name`, `number`, `rarity`, `quantity`, - `market_value` (nullable; present only if `show_values`). - -Endpoints: -- `GET /api/public/profiles/{handle}` → `PublicProfile`. 404 unless profile is live. - Lists only `is_public` binders belonging to that user. -- `GET /api/public/profiles/{handle}/binders/{binder_id}` → `PublicBinderDetail`. - 404 unless profile is live AND the binder belongs to that user AND `binder.is_public`. - -Owner-facing control endpoints (authenticated). These are profile-shaped, not simple -key/value settings, so they live in a small new authenticated `backend/api/profile.py` -router (mounted at `/api/profile`) rather than being squeezed into the settings key/value -model: -- `PUT /api/profile` → set `public_handle` (validated), `is_profile_public`, - `public_show_values`. -- `GET /api/profile/handle-available?handle=...` → `{available: bool, reason?: str}` for - live availability checks. -- Extend the existing binder update endpoint (`backend/api/binders.py`) to accept - `is_public`. - -Discovery: -- Add `handle` (nullable) + `is_profile_public` to the existing leaderboard row payload - (`backend/api/social.py`) so the frontend can link rows to `/u/`. No standalone - public directory page or endpoint in v1 (YAGNI). - -Market value: reuse `effective_market_price` (Cardmarket EUR) for `market_value` / -`total_value`. Values are EUR-native; public pages display in EUR in v1 (no per-viewer -currency conversion — the viewer may be anonymous with no currency setting). - -## Frontend - -Routing (`frontend/src/App.jsx`): add a **public route group rendered regardless of auth -state**, as a sibling of `ProtectedRoutes`: -- `/u/:handle` → `PublicProfile` -- `/u/:handle/binder/:binderId` → `PublicBinderView` - -These pages call `/api/public/*` with no `Authorization` header and must render fully for a -logged-out visitor (no redirect to login, no calls to protected endpoints). - -New pages: -- `PublicProfile.jsx` — handle → trainer name + avatar + grid of shared binders (counts, - and `total_value` only when `show_values`). Each binder links to its public view. -- `PublicBinderView.jsx` — read-only card grid reusing existing card-tile components - (e.g. `CardItem`), with **no** add/edit/remove affordances. Per-card value shown only - when `show_values`. - -Owner controls: -- `Settings.jsx` — a "Public profile" section: set/edit handle with live availability - check, toggle `is_profile_public`, toggle `public_show_values`, and a copy-to-clipboard - of the public URL. -- Binder UI (`Binders.jsx` / `BinderDetail.jsx`) — a per-binder "Share publicly" toggle, - effective only while the profile is public (with a hint if the profile isn't public yet), - plus a copy-link affordance. -- `Leaderboard.jsx` — rows with a handle link to `/u/`. - -## Privacy, security & error handling - -- Non-public profile or binder → **404, never 403** (do not confirm a private binder exists). -- Disabling a profile or unsharing a binder immediately 404s existing links — no stale - tokens or cached grants. -- Public serializers omit `purchase_price`, cost basis, P&L, condition, notes, `username`, - email, telegram/gemini settings, and internal user ids. -- Handle set-path validates slug format, reserved words, and uniqueness (backed by a unique - constraint; handle races surface as a 409/validation error). -- Public GET responses send `Cache-Control: public, max-age=...`. Data is intentionally - public; no heavy per-IP rate limiting in v1 (noted as a follow-up if scraping becomes an - issue). -- Works in single- or multi-user mode. - -**Known v1 limitations (out of scope):** -- SPA has no SSR, so shared links won't render rich social-preview (OpenGraph) cards. The - link works; the unfurl is plain. Could add per-route meta / prerender later. -- No per-viewer currency conversion on public pages (EUR only). -- No follower/comment/like social features — view-only. - -## Testing - -Backend (`unittest`, run in the backend container — not pytest): -- Public serializers never emit private fields (assert `purchase_price` etc. absent from - the JSON) even when the underlying rows have them populated. -- 404 for: unknown handle, `is_profile_public = False`, non-public binder, and a binder id - that exists but belongs to a different user than the handle. -- `show_values` gating: `market_value`/`total_value` present when on, absent/null when off. -- Handle validation: format rejects, reserved-word rejects, uniqueness conflict. - -Frontend (vitest): -- Handle-validation util (format + reserved). -- `PublicBinderView` renders read-only (no edit controls present). -- Value hiding when `show_values` is off. - -Manual: -- Confirm logged-out access to `/u/` and a shared binder against the real domain - (`https://poke.roberts-clan.site`), and that a private binder / unpublished profile 404s. - -## Out of scope / future - -- Public directory / browse-all-profiles page. -- Social-preview (OG) meta and SSR/prerender. -- Rate limiting / anti-scraping. -- Per-viewer currency on public pages. -- Wishlist binders public sharing (v1 covers collection binders; wishlist sharing can follow - the same pattern if wanted). diff --git a/docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md b/docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md deleted file mode 100644 index 6b1594ce..00000000 --- a/docs/superpowers/specs/2026-07-20-public-binder-stacked-variants-design.md +++ /dev/null @@ -1,50 +0,0 @@ -# Public binder — stacked variant tiles - -## Goal - -In the public binder view (`/u/:handle/binder/:id`), collapse a card's multiple -prints into a single tile and give it a physical "stack of cards" look whose depth -reflects how many distinct variants are owned. - -## Scope - -Frontend only. The public API (`GET /api/public/profiles/{handle}/binders/{id}`) -already returns one entry per (card, variant) with `variant`, `quantity`, and -per-variant `market_value`, sorted by set → natural card number → variant. No -backend change. - -## Behaviour - -- **Group by card.** `binder.cards` (contiguous per card thanks to server sort) is - grouped by `card.id` into one tile per card carrying an ordered list of prints - `[{ variant, quantity, market_value }]`. -- **Tile content.** Card image, name, `set · #number`, a `VariantPills` row of every - owned print with its ×quantity, and — only when the profile exposes values — the - summed value across the card's prints. -- **Stack depth = distinct variants.** Back-layers rendered = `min(variants − 1, 2)`: - 1 variant → flat tile; 2 variants → 1 layer behind; 3+ → 2 layers (capped). A - single-variant card stays flat even at quantity ×3 (depth follows distinct prints, - not copies). -- **Stack look.** Each back-layer is the same rounded card silhouette, offset a few px - down-right and rotated ~2°, in the card border colour, sitting behind the image. - -## Components - -- `groupCardsByPrint(cards)` — pure util (`frontend/src/utils/`). Input: the flat - `cards` array. Output: ordered array of `{ id, name, image, set_name, number, - prints: [{variant, quantity, market_value}], variantCount, total_value }` where - `total_value` is `null` if every print's `market_value` is null (values hidden), - else the summed `market_value × quantity`. First-seen order preserved. -- `PublicBinderView.jsx` — maps grouped tiles; renders the stack layers + `VariantPills`. - -## Testing - -- Vitest unit tests for `groupCardsByPrint`: grouping/merge, order preservation, - variantCount, value summing, and null-value (hidden) case. -- Visual stack verified via `npm run build` + a look at the live page (this frontend - has no DOM test infra). - -## Out of scope - -Owner-side binder views (unchanged). No new i18n beyond reuse of existing -`variants.*` keys via `VariantPills`. diff --git a/frontend/src/pages/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx index 9d60e3db..3f41511c 100644 --- a/frontend/src/pages/PublicBinderView.jsx +++ b/frontend/src/pages/PublicBinderView.jsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { getPublicBinder } from '../api/publicClient' import { formatEur } from '../utils/formatEur' -import VariantPills from '../components/VariantPills' +import { groupCardsByPrint } from '../utils/groupCardsByPrint' +import CardStateIndicators from '../components/CardStateIndicators' export default function PublicBinderView() { const { handle, binderId } = useParams() @@ -20,6 +21,8 @@ export default function PublicBinderView() { if (error) return
{error}
if (!binder) return
Loading…
+ const tiles = groupCardsByPrint(binder.cards) + return (
← {handle} @@ -30,23 +33,38 @@ export default function PublicBinderView() { )}
- {binder.cards.map((card, i) => ( -
- {card.image - ? {card.name} - :
} -
{card.name}
-
- {card.set_name} · #{card.number}{card.quantity > 1 ? ` · ×${card.quantity}` : ''} + {tiles.map(tile => { + // Depth follows distinct prints: 1 layer behind for 2 variants, 2 for 3+. + const backLayers = Math.min(tile.variantCount - 1, 2) + return ( +
+
+ {Array.from({ length: backLayers }).map((_, idx) => { + const depth = idx + 1 + return ( +
+ ) + })} +
+ {tile.image + ? {tile.name} + :
} +
+
+
{tile.name}
+
{tile.set_name} · #{tile.number}
+ + {tile.total_value != null && ( +
{formatEur(tile.total_value)}
+ )}
- {card.variant && ( - - )} - {card.market_value != null && ( -
{formatEur(card.market_value)}
- )} -
- ))} + ) + })}
) diff --git a/frontend/src/utils/groupCardsByPrint.js b/frontend/src/utils/groupCardsByPrint.js new file mode 100644 index 00000000..285641a8 --- /dev/null +++ b/frontend/src/utils/groupCardsByPrint.js @@ -0,0 +1,34 @@ +// 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, + 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..bd65ef84 --- /dev/null +++ b/frontend/src/utils/groupCardsByPrint.test.js @@ -0,0 +1,57 @@ +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', 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].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([]) + }) +}) From 4f58d71ab4a446128ff05bb0e8d3776d5696eaae Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Sun, 26 Jul 2026 19:46:55 +0200 Subject: [PATCH 22/32] feat: harden public profiles and add admin gate --- README.md | 6 +- VERSION | 2 +- backend/api/binders.py | 7 +- backend/api/profile.py | 56 ++++-- backend/api/public.py | 37 +++- backend/api/sets.py | 1 - backend/api/settings.py | 11 +- backend/api/social.py | 4 +- backend/models.py | 7 +- backend/services/public_profile.py | 49 ++++- backend/services/public_profile_feature.py | 12 ++ backend/tests/test_public_binders.py | 216 +++++++++++++++++++-- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 3 +- frontend/src/contexts/AuthContext.jsx | 1 + frontend/src/contexts/SettingsContext.jsx | 91 ++++----- frontend/src/i18n/de.js | 27 +++ frontend/src/i18n/en.js | 14 +- frontend/src/pages/Binders.jsx | 9 +- frontend/src/pages/PublicBinderView.jsx | 71 +++++-- frontend/src/pages/PublicProfile.jsx | 62 +++--- frontend/src/pages/Settings.jsx | 138 ++++++++++--- frontend/src/utils/publicHandle.js | 6 +- frontend/src/utils/publicHandle.test.js | 4 + frontend/src/utils/publicRoutes.js | 2 + frontend/src/utils/publicRoutes.test.js | 15 ++ 27 files changed, 684 insertions(+), 173 deletions(-) create mode 100644 backend/services/public_profile_feature.py create mode 100644 frontend/src/utils/publicRoutes.js create mode 100644 frontend/src/utils/publicRoutes.test.js diff --git a/README.md b/README.md index 927d002c..022a0aed 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 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/binders.py b/backend/api/binders.py index 4e759fdf..474bc20f 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 @@ -521,7 +522,11 @@ def update_binder( 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 update.is_public is not None: + 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") binder.is_public = update.is_public db.commit() diff --git a/backend/api/profile.py b/backend/api/profile.py index ce5e70d9..b2ce23a9 100644 --- a/backend/api/profile.py +++ b/backend/api/profile.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from api.auth import get_current_user @@ -6,26 +7,42 @@ 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 _serialize_owner(user: User) -> dict: +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(user: User, feature_enabled: bool) -> dict: return { "public_handle": user.public_handle, "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(current_user: User = Depends(get_current_user)): - return _serialize_owner(current_user) +def get_profile(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + return _serialize_owner(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.get("/handle-available") def handle_available(handle: str = Query(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + _require_public_profiles_enabled(db) try: normalized = pp.validate_handle(handle) except pp.HandleError as exc: @@ -37,18 +54,31 @@ def handle_available(handle: str = Query(...), db: Session = Depends(get_db), @router.put("/") def update_profile(payload: ProfileUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - if payload.public_handle is not None: - try: - normalized = pp.validate_handle(payload.public_handle) - except pp.HandleError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from None - if not pp.is_handle_available(db, normalized, exclude_user_id=current_user.id): - raise HTTPException(status_code=409, detail="Handle is taken") - current_user.public_handle = normalized + _require_public_profiles_enabled(db) + if "public_handle" in payload.model_fields_set: + if payload.public_handle is None or not payload.public_handle.strip(): + current_user.public_handle = None + current_user.is_profile_public = False + else: + try: + normalized = pp.validate_handle(payload.public_handle) + except pp.HandleError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from None + if not pp.is_handle_available(db, normalized, exclude_user_id=current_user.id): + raise HTTPException(status_code=409, detail="Handle is taken") + current_user.public_handle = normalized if payload.is_profile_public is not None: + if payload.is_profile_public and not current_user.public_handle: + raise HTTPException(status_code=422, detail="A public handle is required before publishing the profile") current_user.is_profile_public = payload.is_profile_public if payload.public_show_values is not None: current_user.public_show_values = payload.public_show_values - db.commit() + try: + db.commit() + except IntegrityError as exc: + db.rollback() + if not _is_public_handle_conflict(exc): + raise + raise HTTPException(status_code=409, detail="Handle is taken") from None db.refresh(current_user) - return _serialize_owner(current_user) + return _serialize_owner(current_user, public_profiles_enabled(db)) diff --git a/backend/api/public.py b/backend/api/public.py index 00c6dcd0..601c02b1 100644 --- a/backend/api/public.py +++ b/backend/api/public.py @@ -5,6 +5,7 @@ from database import get_db from services import public_profile as pp +from services.public_profile_feature import public_profiles_enabled router = APIRouter() @@ -51,10 +52,11 @@ class PublicBinderDetail(PublicBinderSummary): cards: List[PublicCard] -# Short TTL with revalidation: public pages get light caching to blunt load bursts, -# but owner edits (unshare, reorder, value toggle) and deploys become visible within -# seconds rather than being pinned for minutes by a stale browser copy. -_PUBLIC_CACHE_CONTROL = "public, max-age=30, must-revalidate" +# 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: @@ -62,22 +64,41 @@ def _set_public_cache(response: Response | None) -> 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/{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 HTTPException(status_code=404, detail="Profile not found") + 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 HTTPException(status_code=404, detail="Profile not found") - binder = next((b for b in pp.public_collection_binders(db, user) if b.id == binder_id), None) + raise _not_found("Profile not found") + binder = pp.get_public_collection_binder(db, user.id, binder_id) if not binder: - raise HTTPException(status_code=404, detail="Binder not found") + 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 119b277b..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 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 2bfaf7b0..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,7 +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 user.is_profile_public else None, + "public_handle": user.public_handle if sharing_enabled and user.is_profile_public else None, } return stats diff --git a/backend/models.py b/backend/models.py index a300d8b6..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,7 +143,7 @@ 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, unique=True, nullable=True) + 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) diff --git a/backend/services/public_profile.py b/backend/services/public_profile.py index b2dd81ab..953a2811 100644 --- a/backend/services/public_profile.py +++ b/backend/services/public_profile.py @@ -1,4 +1,5 @@ import re +from urllib.parse import quote HANDLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$") @@ -38,6 +39,10 @@ def validate_handle(raw: str) -> str: _PRICE_FIELD = "price_trend" +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: @@ -70,6 +75,15 @@ def public_collection_binders(db, user: User) -> list[Binder]: ).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. @@ -88,6 +102,27 @@ def _binder_cards(db, binder: Binder) -> list[BinderCard]: 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 "" @@ -107,7 +142,7 @@ def _serialize_card(bc: BinderCard, show_values: bool) -> dict: return { "id": card.id, "name": card.name, - "image": card.images_small or card.images_large, + "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, @@ -117,8 +152,8 @@ def _serialize_card(bc: BinderCard, show_values: bool) -> dict: } -def serialize_binder_summary(db, binder: Binder, show_values: bool) -> dict: - cards = _binder_cards(db, binder) +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 @@ -139,8 +174,8 @@ def serialize_binder_summary(db, binder: Binder, show_values: bool) -> dict: def serialize_binder_detail(db, binder: Binder, show_values: bool) -> dict: - summary = serialize_binder_summary(db, binder, show_values) 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 @@ -148,10 +183,14 @@ def serialize_binder_detail(db, binder: Binder, show_values: bool) -> dict: 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, b, show_values) for b in binders], + "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_public_binders.py b/backend/tests/test_public_binders.py index d319eee3..9e12c73e 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -4,7 +4,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from database import Base - from models import User, Binder + from models import User, Binder, Setting DEPS = True except ModuleNotFoundError: DEPS = False @@ -115,10 +115,17 @@ def test_binder_detail_hides_values_when_off(self): _, 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) @@ -186,10 +193,14 @@ def test_binder_detail_orders_by_card_number_naturally(self): @unittest.skipUnless(API_DEPS, "api deps unavailable") class PublicApiTests(unittest.TestCase): - def _db(self): + def _db(self, enabled=True): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) - return sessionmaker(bind=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) @@ -200,6 +211,14 @@ def test_unknown_handle_404(self): 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) @@ -239,16 +258,16 @@ def test_success_sets_short_revalidating_cache(self): resp = Response() get_public_binder("ash", binder.id, db=db, response=resp) cc = resp.headers["Cache-Control"] - self.assertIn("max-age=30", cc) + self.assertIn("max-age=0", cc) self.assertIn("must-revalidate", cc) - def test_not_found_does_not_set_cache(self): + 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): + with self.assertRaises(HTTPException) as ctx: get_public_binder("ash", 1, db=db, response=resp) - self.assertNotIn("Cache-Control", resp.headers) + self.assertEqual(ctx.exception.headers["Cache-Control"], "no-store") try: @@ -261,10 +280,14 @@ def test_not_found_does_not_set_cache(self): @unittest.skipUnless(PROFILE_DEPS, "profile api deps unavailable") class ProfileControlTests(unittest.TestCase): - def _db(self): + def _db(self, enabled=True): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) - return sessionmaker(bind=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) @@ -297,6 +320,64 @@ def test_duplicate_handle_409(self): update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=me) self.assertEqual(ctx.exception.status_code, 409) + 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(public_handle="racing"), 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(public_handle="racing"), db=db, current_user=u) + rollback.assert_called_once() + + def test_handle_can_be_cleared_and_profile_becomes_private(self): + db = self._db() + u = self._user(db) + update_profile(ProfileUpdate(public_handle="ash-k", is_profile_public=True), db=db, current_user=u) + result = update_profile(ProfileUpdate(public_handle=None), db=db, current_user=u) + self.assertIsNone(result["public_handle"]) + self.assertFalse(result["is_profile_public"]) + + def test_profile_cannot_be_published_without_handle(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) + + 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(public_handle="ash-k"), db=db, current_user=u) + self.assertEqual(ctx.exception.status_code, 403) + def test_handle_available_check(self): db = self._db() u = self._user(db) @@ -308,7 +389,7 @@ def test_get_profile_returns_current_user_values(self): u = self._user(db) update_profile(ProfileUpdate(public_handle="Ash-K", is_profile_public=True, public_show_values=True), db=db, current_user=u) - result = get_profile(current_user=u) + result = get_profile(db=db, current_user=u) self.assertEqual(result["public_handle"], "ash-k") self.assertTrue(result["is_profile_public"]) self.assertTrue(result["public_show_values"]) @@ -324,10 +405,14 @@ def test_get_profile_returns_current_user_values(self): @unittest.skipUnless(BINDER_DEPS, "binder api deps unavailable") class BinderPublicToggleTests(unittest.TestCase): - def _db(self): + def _db(self, enabled=True): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) - return sessionmaker(bind=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() @@ -341,6 +426,44 @@ def test_update_binder_sets_is_public(self): 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_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 @@ -357,7 +480,7 @@ def test_row_includes_public_handle(self): 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.add_all([u, Setting(key="public_profiles_enabled", value="true")]) db.commit() stats = _load_user_stats(db) self.assertIn(u.id, stats) @@ -369,8 +492,73 @@ def test_row_hides_handle_when_profile_not_public(self): 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(u) + 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/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/api/client.js b/frontend/src/api/client.js index ee4a7534..a1c52db5 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' } } 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..e85d0756 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,21 @@ 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 Handles und Freigaben bleiben gespeichert, sind aber nicht erreichbar.', + publicHandle: 'Handle', + publicHandleDesc: 'Dein öffentlicher URL-Name. Beim Veröffentlichen sind Trainername, Avatar, Handle und ausgewählte Sammlungs-Binder für jeden mit dem Link sichtbar.', + handleAvailable: 'Verfügbar', + handleTaken: 'Handle ist bereits vergeben', + handleInvalid: 'Nutze 3–30 Kleinbuchstaben, Zahlen oder einzelne Bindestriche', + 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', // Settings page row labels multiUserMode: 'Mehrspieler-Modus', multiUserModeDesc: 'Login-Bildschirm und Benutzerverwaltung aktivieren', @@ -867,6 +886,14 @@ const de = { min1440: 'Alle 24 Stunden', }, + publicProfiles: { + 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', diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index 7d0028fb..e8261dd5 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -707,10 +707,14 @@ const en = { 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 handles and sharing choices are preserved but unavailable.', publicHandle: 'Handle', - publicHandleDesc: 'Your public URL slug (letters, numbers, hyphens)', + publicHandleDesc: 'Your public URL slug. Publishing exposes your trainer name, avatar, handle, and selected collection binders to anyone with the link.', handleAvailable: 'Available', handleTaken: 'Handle is taken', + handleInvalid: 'Use 3–30 lowercase letters, numbers, or single hyphens', publicProfileToggle: 'Make my profile public', publicProfileToggleDesc: 'Anyone with the link can view your public binders', publicShowValues: 'Show card market values', @@ -883,6 +887,14 @@ const en = { min1440: 'Every 24 hours', }, + publicProfiles: { + 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', diff --git a/frontend/src/pages/Binders.jsx b/frontend/src/pages/Binders.jsx index 378e56e1..a33373bf 100644 --- a/frontend/src/pages/Binders.jsx +++ b/frontend/src/pages/Binders.jsx @@ -155,6 +155,7 @@ export default function Binders() { }) 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 }, @@ -181,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({ @@ -199,7 +200,7 @@ export default function Binders() { toast.success(t('binders.publicUpdated')) queryClient.invalidateQueries({ queryKey: ['binders'] }) }, - onError: () => toast.error(t('binders.updateFailed')), + onError: (error) => toast.error(error.response?.data?.detail || t('binders.updateFailed')), }) const copyPublicBinderLink = (binderId) => { @@ -294,7 +295,7 @@ export default function Binders() { {uniqueCount} {uniqueCount === 1 ? t('binders.uniqueCard') : t('binders.uniqueCards')}

)} - {!isWishlist && ( + {!isWishlist && publicProfilesEnabled && (
e.stopPropagation()}> {profileIsPublic ? (
@@ -306,6 +307,8 @@ export default function Binders() { type="button" onClick={() => publicToggleMutation.mutate({ id: binder.id, is_public: !binder.is_public })} disabled={publicToggleMutation.isPending} + aria-label={t('binders.sharePublicly')} + aria-pressed={!!binder.is_public} className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${ binder.is_public ? 'bg-brand-red' : 'bg-bg-elevated border border-border' }`} diff --git a/frontend/src/pages/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx index 3f41511c..88325f19 100644 --- a/frontend/src/pages/PublicBinderView.jsx +++ b/frontend/src/pages/PublicBinderView.jsx @@ -3,41 +3,77 @@ import { useParams, Link } from 'react-router-dom' import { getPublicBinder } from '../api/publicClient' import { formatEur } from '../utils/formatEur' import { groupCardsByPrint } from '../utils/groupCardsByPrint' -import CardStateIndicators from '../components/CardStateIndicators' +import { getOwnedVariants, VARIANT_PILL_META } from '../utils/cardVariants' +import { useSettings } from '../contexts/SettingsContext' + +function PublicVariantPills({ prints, t }) { + const variants = getOwnedVariants(prints) + return ( +
+ {variants.map(({ variant, quantity }) => { + const meta = VARIANT_PILL_META[variant] + const translated = t(`variants.${variant}`) + const label = translated === `variants.${variant}` ? variant : translated + const title = quantity > 1 ? `${label} ×${quantity}` : label + return ( + + {meta?.code || variant.slice(0, 3).toUpperCase()} + {quantity > 1 && ×{quantity}} + + ) + })} +
+ ) +} export default function PublicBinderView() { const { handle, binderId } = useParams() const [binder, setBinder] = useState(null) const [error, setError] = useState(null) + const { t } = useSettings() useEffect(() => { let cancelled = false + setBinder(null) + setError(null) getPublicBinder(handle, binderId) .then(data => { if (!cancelled) setBinder(data) }) - .catch(() => { if (!cancelled) setError('This binder is not available.') }) + .catch(() => { if (!cancelled) setError(true) }) return () => { cancelled = true } }, [handle, binderId]) - if (error) return
{error}
- if (!binder) return
Loading…
+ if (error) return
{t('publicProfiles.binderUnavailable')}
+ if (!binder) return
{t('common.loading')}
const tiles = groupCardsByPrint(binder.cards) return ( -
- ← {handle} -
-

{binder.name}

- {binder.total_value != null && ( - {formatEur(binder.total_value)} - )} -
-
+
+
+ ← @{handle} +
+
+

{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)} + )} +
+
{tiles.map(tile => { // Depth follows distinct prints: 1 layer behind for 2 variants, 2 for 3+. const backLayers = Math.min(tile.variantCount - 1, 2) return ( -
+
{Array.from({ length: backLayers }).map((_, idx) => { const depth = idx + 1 @@ -58,14 +94,15 @@ export default function PublicBinderView() {
{tile.name}
{tile.set_name} · #{tile.number}
- + {tile.total_value != null && (
{formatEur(tile.total_value)}
)} -
+ ) })} +
-
+ ) } diff --git a/frontend/src/pages/PublicProfile.jsx b/frontend/src/pages/PublicProfile.jsx index fb11fd45..766a86de 100644 --- a/frontend/src/pages/PublicProfile.jsx +++ b/frontend/src/pages/PublicProfile.jsx @@ -2,47 +2,59 @@ 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('This profile is not available.') }) + .catch(() => { if (!cancelled) setError(true) }) return () => { cancelled = true } }, [handle]) - if (error) return
{error}
- if (!profile) return
Loading…
+ if (error) return
{t('publicProfiles.profileUnavailable')}
+ if (!profile) return
{t('common.loading')}
return ( -
-
- {profile.avatar_id && ( - +
+
+
+ {profile.avatar_id && ( + + )} +
+

{t('publicProfiles.publicCollection')}

+

{profile.trainer_name}

+

@{profile.handle}

+
+
+ {profile.binders.length === 0 && ( +
+ {t('publicProfiles.noSharedBinders')} +
)} -

{profile.trainer_name}

+
+ {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)}` : ''} +
+ + ))} +
- {profile.binders.length === 0 && ( -

No shared binders yet.

- )} -
- {profile.binders.map(binder => ( - -
{binder.name}
-
- {binder.unique_card_count} cards - {binder.total_value != null ? ` · ${formatEur(binder.total_value)}` : ''} -
- - ))} -
-
+ ) } diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index f8f91dfc..57ca091b 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -21,6 +21,7 @@ import toast from 'react-hot-toast' import { TCGDEX_LANGUAGES, normalizeTcgdexLanguageCsv, tcgdexLanguageLabel } from '../utils/tcgdexLanguages' import { APP_LANGUAGES } from '../utils/appLanguages' import { invalidateTcgdexFilterLanguages } from '../utils/queryInvalidation' +import { isValidHandleFormat, normalizeHandle } from '../utils/publicHandle' // ─── Sub-components ─────────────────────────────────────────────────────────── @@ -63,13 +64,17 @@ function SettingsRow({ label, description, children, last }) { ) } -function Toggle({ value, onChange }) { +function Toggle({ value, onChange, label, disabled = false }) { return ( -
- +
} + } {/* ── 2. THEME ── */}
@@ -837,6 +915,7 @@ export default function Settings() { > { try { await setAuthMode(val) @@ -1019,18 +1098,21 @@ export default function Settings() { handleAdminBooleanSettingToggle('tcgdex_digital_sets_enabled', val)} /> handleAdminBooleanSettingToggle('cross_language_price_fallback', val)} /> handleAdminBooleanSettingToggle('cross_language_image_fallback', val)} /> @@ -1113,7 +1195,7 @@ export default function Settings() { > {t('settings.debugLogDownload')} - +
)} @@ -1299,7 +1381,7 @@ export default function Settings() { label={t('settings.priceAlerts')} description={t('settings.priceAlertsDesc')} > - + {priceAlertsEnabled && ( 30) return false if (handle.includes('--')) return false - return HANDLE_RE.test(handle) + return HANDLE_RE.test(handle) && !RESERVED_HANDLES.has(handle) } diff --git a/frontend/src/utils/publicHandle.test.js b/frontend/src/utils/publicHandle.test.js index 175d400f..6aabc0d7 100644 --- a/frontend/src/utils/publicHandle.test.js +++ b/frontend/src/utils/publicHandle.test.js @@ -16,4 +16,8 @@ describe('publicHandle', () => { expect(isValidHandleFormat('-ash')).toBe(false) expect(isValidHandleFormat('ash--k')).toBe(false) }) + it('rejects routes and reserved words', () => { + expect(isValidHandleFormat('admin')).toBe(false) + expect(isValidHandleFormat('settings')).toBe(false) + }) }) 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..af7f55e2 --- /dev/null +++ b/frontend/src/utils/publicRoutes.test.js @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { isPublicSharePath } from './publicRoutes' + +describe('isPublicSharePath', () => { + it('recognizes public profile and binder routes', () => { + 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) + }) +}) From f6680b9856f39f7f830214f78aa433b3c5a5b97f Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Mon, 27 Jul 2026 13:29:30 +0200 Subject: [PATCH 23/32] Polish public profile sharing --- README.md | 2 +- backend/api/auth.py | 29 +++- backend/api/profile.py | 60 ++++--- backend/api/public.py | 15 ++ backend/main.py | 17 ++ backend/schemas.py | 1 - backend/services/public_profile.py | 128 +++++++++++++- backend/tests/test_auth_admin_safety.py | 50 +++++- backend/tests/test_public_binders.py | 157 +++++++++++++----- backend/tests/test_public_cache_policy.py | 77 +++++++++ frontend/src/App.jsx | 2 + frontend/src/api/client.js | 2 - frontend/src/api/publicClient.js | 3 + .../src/components/CardStateIndicators.jsx | 19 ++- .../components/CardStateIndicators.test.js | 12 ++ frontend/src/components/PublicHomeButton.jsx | 26 +++ frontend/src/i18n/de.js | 17 +- frontend/src/i18n/en.js | 17 +- frontend/src/pages/PublicBinderView.jsx | 78 +++++---- frontend/src/pages/PublicDirectory.jsx | 78 +++++++++ frontend/src/pages/PublicProfile.jsx | 6 +- frontend/src/pages/Settings.jsx | 80 +++------ frontend/src/utils/groupCardsByPrint.js | 2 + frontend/src/utils/groupCardsByPrint.test.js | 3 +- frontend/src/utils/publicHandle.js | 16 -- frontend/src/utils/publicHandle.test.js | 23 --- frontend/src/utils/publicRoutes.test.js | 1 + 27 files changed, 682 insertions(+), 239 deletions(-) create mode 100644 backend/tests/test_public_cache_policy.py create mode 100644 frontend/src/components/PublicHomeButton.jsx create mode 100644 frontend/src/pages/PublicDirectory.jsx delete mode 100644 frontend/src/utils/publicHandle.js delete mode 100644 frontend/src/utils/publicHandle.test.js diff --git a/README.md b/README.md index 022a0aed..ff561a44 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ 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 individually shared collection binders and opt-in market values +- 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 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/profile.py b/backend/api/profile.py index b2ce23a9..32ef4cb1 100644 --- a/backend/api/profile.py +++ b/backend/api/profile.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -20,9 +20,20 @@ def _is_public_handle_conflict(exc: IntegrityError) -> bool: return "public_handle" in str(original).lower() -def _serialize_owner(user: User, feature_enabled: bool) -> dict: +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 { - "public_handle": user.public_handle, + "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, @@ -31,7 +42,7 @@ def _serialize_owner(user: User, feature_enabled: bool) -> dict: @router.get("/") def get_profile(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - return _serialize_owner(current_user, public_profiles_enabled(db)) + return _serialize_owner(db, current_user, public_profiles_enabled(db)) def _require_public_profiles_enabled(db: Session) -> None: @@ -39,38 +50,23 @@ def _require_public_profiles_enabled(db: Session) -> None: raise HTTPException(status_code=403, detail="Public profiles are disabled by the administrator") -@router.get("/handle-available") -def handle_available(handle: str = Query(...), db: Session = Depends(get_db), - current_user: User = Depends(get_current_user)): - _require_public_profiles_enabled(db) - try: - normalized = pp.validate_handle(handle) - except pp.HandleError as exc: - return {"available": False, "reason": str(exc)} - available = pp.is_handle_available(db, normalized, exclude_user_id=current_user.id) - return {"available": available, "reason": None if available else "Handle is taken"} - - @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 "public_handle" in payload.model_fields_set: - if payload.public_handle is None or not payload.public_handle.strip(): - current_user.public_handle = None - current_user.is_profile_public = False - else: - try: - normalized = pp.validate_handle(payload.public_handle) - except pp.HandleError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from None - if not pp.is_handle_available(db, normalized, exclude_user_id=current_user.id): - raise HTTPException(status_code=409, detail="Handle is taken") - current_user.public_handle = normalized if payload.is_profile_public is not None: - if payload.is_profile_public and not current_user.public_handle: - raise HTTPException(status_code=422, detail="A public handle is required before publishing the profile") 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: @@ -79,6 +75,6 @@ def update_profile(payload: ProfileUpdate, db: Session = Depends(get_db), db.rollback() if not _is_public_handle_conflict(exc): raise - raise HTTPException(status_code=409, detail="Handle is taken") from None + raise HTTPException(status_code=409, detail="Another public profile already uses this trainer name") from None db.refresh(current_user) - return _serialize_owner(current_user, public_profiles_enabled(db)) + return _serialize_owner(db, current_user, public_profiles_enabled(db)) diff --git a/backend/api/public.py b/backend/api/public.py index 601c02b1..0542a9e7 100644 --- a/backend/api/public.py +++ b/backend/api/public.py @@ -25,6 +25,7 @@ class PublicCard(BaseModel): 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 @@ -48,6 +49,13 @@ class PublicProfile(BaseModel): 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] @@ -81,6 +89,13 @@ def _not_found(detail: str) -> HTTPException: ) +@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) diff --git a/backend/main.py b/backend/main.py index d3ef926d..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 ["*"], diff --git a/backend/schemas.py b/backend/schemas.py index 014dad1a..e2c95d74 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -480,6 +480,5 @@ class Config: class ProfileUpdate(BaseModel): - public_handle: Optional[str] = None is_profile_public: Optional[bool] = None public_show_values: Optional[bool] = None diff --git a/backend/services/public_profile.py b/backend/services/public_profile.py index 953a2811..eb377b98 100644 --- a/backend/services/public_profile.py +++ b/backend/services/public_profile.py @@ -1,6 +1,14 @@ 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 = { @@ -13,6 +21,10 @@ 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() @@ -29,14 +41,26 @@ def validate_handle(raw: str) -> str: return handle -from sqlalchemy.orm import joinedload - -from models import User, Binder, BinderCard, Card, UserSetting -from services.card_numbers import natural_card_number_key -from services.card_values import effective_market_price +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: @@ -50,6 +74,62 @@ def is_handle_available(db, handle: str, exclude_user_id: int | None = None) -> 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 @@ -61,10 +141,39 @@ def get_live_profile(db, handle: str) -> User | None: def trainer_name_for(db, user: User) -> str: - row = db.query(UserSetting).filter( - UserSetting.user_id == user.id, UserSetting.key == "trainer_name" - ).first() - return (row.value if row and row.value else _DEFAULT_TRAINER_NAME) + 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]: @@ -146,6 +255,7 @@ def _serialize_card(bc: BinderCard, show_values: bool) -> dict: "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, 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 index 9e12c73e..64f8c6bd 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -32,7 +32,12 @@ def test_new_columns_default_private(self): try: - from services.public_profile import validate_handle, HandleError + 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 @@ -63,10 +68,21 @@ 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, UserSetting + from models import BinderCard, Card, Set PP_DEPS = True except ModuleNotFoundError: PP_DEPS = False @@ -84,7 +100,6 @@ def _seed(self, db, *, profile_public=True, binder_public=True, show_values=Fals public_handle="ash", is_profile_public=profile_public, public_show_values=show_values) db.add_all([ user, - UserSetting(user_id=1, key="trainer_name", value="Ash K."), 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", @@ -107,7 +122,7 @@ 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 K.") + self.assertEqual(data["trainer_name"], "ash") self.assertEqual(data["binders"], []) def test_binder_detail_hides_values_when_off(self): @@ -155,6 +170,7 @@ def test_serialized_card_includes_variant(self): 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() @@ -185,7 +201,7 @@ def test_binder_detail_orders_by_card_number_naturally(self): try: from fastapi import HTTPException - from api.public import get_public_profile, get_public_binder + from api.public import get_public_profile, get_public_binder, list_public_profiles API_DEPS = True except ModuleNotFoundError: API_DEPS = False @@ -233,6 +249,31 @@ def test_public_profile_returns_binders(self): 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) @@ -271,7 +312,7 @@ def test_not_found_explicitly_disables_caching(self): try: - from api.profile import update_profile, handle_available, get_profile + from api.profile import update_profile, get_profile from schemas import ProfileUpdate PROFILE_DEPS = True except ModuleNotFoundError: @@ -296,29 +337,35 @@ def _user(self, db, username="ash"): db.refresh(u) return u - def test_set_handle_and_publish(self): + def test_publish_uses_trainer_name_handle(self): db = self._db() - u = self._user(db) - result = update_profile(ProfileUpdate(public_handle="Ash-K", is_profile_public=True), - db=db, current_user=u) - self.assertEqual(result["public_handle"], "ash-k") + 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_handle_422(self): + def test_invalid_trainer_name_422(self): db = self._db() - u = self._user(db) + u = self._user(db, "🔥") with self.assertRaises(HTTPException) as ctx: - update_profile(ProfileUpdate(public_handle="a"), db=db, current_user=u) + 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_handle_409(self): + def test_duplicate_derived_handle_409(self): db = self._db() - taken = self._user(db, "misty") - update_profile(ProfileUpdate(public_handle="star"), db=db, current_user=taken) - me = self._user(db, "ash") + 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(public_handle="star"), db=db, current_user=me) + 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 @@ -335,7 +382,7 @@ def test_concurrent_unique_constraint_is_returned_as_409(self): patch.object(db, "rollback") as rollback, ): with self.assertRaises(HTTPException) as ctx: - update_profile(ProfileUpdate(public_handle="racing"), db=db, current_user=u) + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) self.assertEqual(ctx.exception.status_code, 409) rollback.assert_called_once() @@ -351,49 +398,77 @@ def test_unrelated_integrity_error_is_not_mislabeled_as_handle_conflict(self): patch.object(db, "rollback") as rollback, ): with self.assertRaises(IntegrityError): - update_profile(ProfileUpdate(public_handle="racing"), db=db, current_user=u) + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) rollback.assert_called_once() - def test_handle_can_be_cleared_and_profile_becomes_private(self): + def test_disabling_profile_releases_stored_handle(self): db = self._db() u = self._user(db) - update_profile(ProfileUpdate(public_handle="ash-k", is_profile_public=True), db=db, current_user=u) - result = update_profile(ProfileUpdate(public_handle=None), db=db, current_user=u) - self.assertIsNone(result["public_handle"]) + 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_cannot_be_published_without_handle(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) - 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(public_handle="ash-k"), db=db, current_user=u) + update_profile(ProfileUpdate(is_profile_public=True), db=db, current_user=u) self.assertEqual(ctx.exception.status_code, 403) - def test_handle_available_check(self): - db = self._db() - u = self._user(db) - self.assertTrue(handle_available("brand-new", db=db, current_user=u)["available"]) - self.assertFalse(handle_available("ADMIN", db=db, current_user=u)["available"]) - def test_get_profile_returns_current_user_values(self): db = self._db() - u = self._user(db) - update_profile(ProfileUpdate(public_handle="Ash-K", is_profile_public=True, public_show_values=True), + 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 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/src/App.jsx b/frontend/src/App.jsx index 6d942ba3..93db52e1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,6 +31,7 @@ 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 ( @@ -174,6 +175,7 @@ export default function App() { )} /> + )} /> )} /> )} /> } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index a1c52db5..edcc5283 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -86,8 +86,6 @@ export const getTcgdexFilterLanguages = () => api.get('/settings/tcgdex-filter-l // 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) -export const checkHandleAvailable = (handle) => - api.get('/profile/handle-available', { params: { handle } }).then(r => r.data) // Cards export const searchCards = (params) => api.get('/cards/search', { params }) diff --git a/frontend/src/api/publicClient.js b/frontend/src/api/publicClient.js index 4c0d7640..74d0f9a2 100644 --- a/frontend/src/api/publicClient.js +++ b/frontend/src/api/publicClient.js @@ -11,5 +11,8 @@ const publicApi = axios.create({ 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/CardStateIndicators.jsx b/frontend/src/components/CardStateIndicators.jsx index bd5f18e6..db68943a 100644 --- a/frontend/src/components/CardStateIndicators.jsx +++ b/frontend/src/components/CardStateIndicators.jsx @@ -44,7 +44,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 +68,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..ea77de6a 100644 --- a/frontend/src/components/CardStateIndicators.test.js +++ b/frontend/src/components/CardStateIndicators.test.js @@ -47,4 +47,16 @@ 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') + }) }) diff --git a/frontend/src/components/PublicHomeButton.jsx b/frontend/src/components/PublicHomeButton.jsx new file mode 100644 index 00000000..2067a6f5 --- /dev/null +++ b/frontend/src/components/PublicHomeButton.jsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router-dom' +import { useSettings } from '../contexts/SettingsContext' + +export default function PublicHomeButton() { + const { t } = useSettings() + + return ( + + + + + + + ) +} diff --git a/frontend/src/i18n/de.js b/frontend/src/i18n/de.js index e85d0756..0c066fc6 100644 --- a/frontend/src/i18n/de.js +++ b/frontend/src/i18n/de.js @@ -708,12 +708,9 @@ const de = { 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 Handles und Freigaben bleiben gespeichert, sind aber nicht erreichbar.', - publicHandle: 'Handle', - publicHandleDesc: 'Dein öffentlicher URL-Name. Beim Veröffentlichen sind Trainername, Avatar, Handle und ausgewählte Sammlungs-Binder für jeden mit dem Link sichtbar.', - handleAvailable: 'Verfügbar', - handleTaken: 'Handle ist bereits vergeben', - handleInvalid: 'Nutze 3–30 Kleinbuchstaben, Zahlen oder einzelne Bindestriche', + 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', @@ -887,6 +884,14 @@ const de = { }, 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.', diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index e8261dd5..6fa15422 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -709,12 +709,9 @@ const en = { 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 handles and sharing choices are preserved but unavailable.', - publicHandle: 'Handle', - publicHandleDesc: 'Your public URL slug. Publishing exposes your trainer name, avatar, handle, and selected collection binders to anyone with the link.', - handleAvailable: 'Available', - handleTaken: 'Handle is taken', - handleInvalid: 'Use 3–30 lowercase letters, numbers, or single hyphens', + 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', @@ -888,6 +885,14 @@ const en = { }, 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.', diff --git a/frontend/src/pages/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx index 88325f19..3d6e9784 100644 --- a/frontend/src/pages/PublicBinderView.jsx +++ b/frontend/src/pages/PublicBinderView.jsx @@ -1,40 +1,20 @@ 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 { getOwnedVariants, VARIANT_PILL_META } from '../utils/cardVariants' import { useSettings } from '../contexts/SettingsContext' - -function PublicVariantPills({ prints, t }) { - const variants = getOwnedVariants(prints) - return ( -
- {variants.map(({ variant, quantity }) => { - const meta = VARIANT_PILL_META[variant] - const translated = t(`variants.${variant}`) - const label = translated === `variants.${variant}` ? variant : translated - const title = quantity > 1 ? `${label} ×${quantity}` : label - return ( - - {meta?.code || variant.slice(0, 3).toUpperCase()} - {quantity > 1 && ×{quantity}} - - ) - })} -
- ) -} +import CardStateIndicators, { CardStateLegend } from '../components/CardStateIndicators' +import PublicHomeButton from '../components/PublicHomeButton' +import { getCardRarityEffectClass } from '../utils/cardRarity' 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(() => { @@ -47,15 +27,18 @@ export default function PublicBinderView() { return () => { cancelled = true } }, [handle, binderId]) - if (error) return
{t('publicProfiles.binderUnavailable')}
- if (!binder) return
{t('common.loading')}
+ if (error) return <>
{t('publicProfiles.binderUnavailable')}
+ if (!binder) return <>
{t('common.loading')}
const tiles = groupCardsByPrint(binder.cards) return (
+
- ← @{handle} + + {t('publicProfiles.backToProfile')} +

{t('publicProfiles.sharedBinder')}

@@ -68,6 +51,31 @@ export default function PublicBinderView() { {formatEur(binder.total_value)} )}
+ +
+ +
+ {badgeLegendOpen && ( +
+

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

+ +
+ )} +
{tiles.map(tile => { // Depth follows distinct prints: 1 layer behind for 2 variants, 2 for 3+. @@ -86,15 +94,23 @@ export default function PublicBinderView() { /> ) })} -
+
{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 index 766a86de..36b6e25f 100644 --- a/frontend/src/pages/PublicProfile.jsx +++ b/frontend/src/pages/PublicProfile.jsx @@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom' import { getPublicProfile } from '../api/publicClient' import { formatEur } from '../utils/formatEur' import { useSettings } from '../contexts/SettingsContext' +import PublicHomeButton from '../components/PublicHomeButton' export default function PublicProfile() { const { handle } = useParams() @@ -20,11 +21,12 @@ export default function PublicProfile() { return () => { cancelled = true } }, [handle]) - if (error) return
{t('publicProfiles.profileUnavailable')}
- if (!profile) return
{t('common.loading')}
+ if (error) return <>
{t('publicProfiles.profileUnavailable')}
+ if (!profile) return <>
{t('common.loading')}
return (
+
{profile.avatar_id && ( diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 57ca091b..c9bb789d 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -8,7 +8,7 @@ import { getSetting, setSetting, getTelegramStatus, saveSettings, setAuthMode, getUsers, createUser, updateUser, deleteUser, changePassword, changeAvatar, changeUsername, getContributors, getSupporters, getRescueDonations, getCustomMatches, downloadDebugLog, - getProfile, updateProfile, checkHandleAvailable, + getProfile, updateProfile, } from '../api/client' import api from '../api/client' import { useAuth } from '../contexts/AuthContext' @@ -21,7 +21,6 @@ import toast from 'react-hot-toast' import { TCGDEX_LANGUAGES, normalizeTcgdexLanguageCsv, tcgdexLanguageLabel } from '../utils/tcgdexLanguages' import { APP_LANGUAGES } from '../utils/appLanguages' import { invalidateTcgdexFilterLanguages } from '../utils/queryInvalidation' -import { isValidHandleFormat, normalizeHandle } from '../utils/publicHandle' // ─── Sub-components ─────────────────────────────────────────────────────────── @@ -354,11 +353,9 @@ export default function Settings() { }) // Public profile - const [publicHandle, setPublicHandle] = useState('') const [profilePublic, setProfilePublic] = useState(false) const [publicShowValues, setPublicShowValues] = useState(false) const [profileDirty, setProfileDirty] = useState(false) - const [handleStatus, setHandleStatus] = useState(null) const [publicFeatureSaving, setPublicFeatureSaving] = useState(false) const { data: profileData } = useQuery({ @@ -368,30 +365,11 @@ export default function Settings() { useEffect(() => { if (profileData && !profileDirty) { - setPublicHandle(profileData.public_handle || '') setProfilePublic(!!profileData.is_profile_public) setPublicShowValues(!!profileData.public_show_values) } }, [profileData, profileDirty]) - useEffect(() => { - const normalized = normalizeHandle(publicHandle) - if (!publicProfilesEnabled || !normalized || !isValidHandleFormat(normalized)) { - setHandleStatus(null) - return - } - let cancelled = false - const timer = setTimeout(() => { - checkHandleAvailable(normalized) - .then(status => { if (!cancelled) setHandleStatus(status) }) - .catch(() => { if (!cancelled) setHandleStatus(null) }) - }, 400) - return () => { - cancelled = true - clearTimeout(timer) - } - }, [publicHandle, publicProfilesEnabled]) - const [telegramBotToken, setTelegramBotToken] = useState('') const [telegramBotTokenDirty, setTelegramBotTokenDirty] = useState(false) @@ -495,10 +473,8 @@ export default function Settings() { onSuccess: (data) => { queryClient.setQueryData(['profile'], data) queryClient.invalidateQueries({ queryKey: ['leaderboard'] }) - setPublicHandle(data.public_handle || '') setProfilePublic(!!data.is_profile_public) setPublicShowValues(!!data.public_show_values) - setHandleStatus(null) setProfileDirty(false) toast.success(t('settings.saved')) }, @@ -506,20 +482,16 @@ export default function Settings() { }) const savePublicProfile = () => { - const normalized = normalizeHandle(publicHandle) profileMutation.mutate({ - public_handle: normalized || null, - is_profile_public: normalized ? profilePublic : false, + is_profile_public: profilePublic, public_show_values: publicShowValues, }) } - const normalizedPublicHandle = normalizeHandle(publicHandle) - const publicProfileUrl = normalizedPublicHandle ? `${window.location.origin}/u/${normalizedPublicHandle}` : '' - - const publicHandleInvalid = Boolean(normalizedPublicHandle) && !isValidHandleFormat(normalizedPublicHandle) - const publicHandleTaken = Boolean(handleStatus && !handleStatus.available) - const publicProfileSaveDisabled = profileMutation.isPending || publicHandleInvalid || publicHandleTaken + const publicHandle = profileData?.public_handle || '' + const publicHandleError = profileData?.public_handle_error || '' + const publicProfileUrl = publicHandle ? `${window.location.origin}/u/${publicHandle}` : '' + const publicProfileSaveDisabled = profileMutation.isPending || (profilePublic && !publicHandle) const handlePublicProfilesToggle = async (enabled) => { setPublicFeatureSaving(true) @@ -681,10 +653,12 @@ export default function Settings() { mutationFn: (username) => changeUsername(username), onSuccess: (updatedUser) => { updateCurrentUser(updatedUser) + queryClient.invalidateQueries({ queryKey: ['profile'] }) + queryClient.invalidateQueries({ queryKey: ['leaderboard'] }) setEditingUsername(false) toast.success(t('common.saved')) }, - onError: () => toast.error(t('common.error')), + onError: (error) => toast.error(error.response?.data?.detail || t('common.error')), }) const handleAvatarSelect = (avatarId) => { @@ -808,29 +782,19 @@ export default function Settings() { /> )} - {publicProfilesEnabled && -
- { - const next = e.target.value.toLowerCase() - setPublicHandle(next) - if (!normalizeHandle(next)) setProfilePublic(false) - setProfileDirty(true) - }} - placeholder="ash-ketchum" - className="input text-xs font-mono w-full" - maxLength={30} - /> - {publicHandleInvalid && ( - - {t('settings.handleInvalid')} + {publicProfilesEnabled && +
+ + {profileData?.trainer_name || user?.username || t('settings.username')} + + {publicProfileUrl && ( + + {publicProfileUrl} )} - {handleStatus && ( - - {handleStatus.available ? t('settings.handleAvailable') : (handleStatus.reason || t('settings.handleTaken'))} + {publicHandleError && ( + + {publicHandleError} )}
@@ -842,14 +806,14 @@ export default function Settings() { label={t('settings.publicProfileToggle')} />
} - {publicProfilesEnabled && + {publicProfilesEnabled && { setPublicShowValues(val); setProfileDirty(true) }} label={t('settings.publicShowValues')} /> } - {publicProfilesEnabled && profilePublic && normalizedPublicHandle && ( + {publicProfilesEnabled && profilePublic && publicHandle && (
)} + {isCollection && cards.length > 0 && ( + <> +
+ +
+ {badgeLegendOpen && ( +
+

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

+ +
+ + 2x + + + {t('binderTypes.amountInBinder')} + +
+
+ )} + + )} + {cards.length > 0 && (
-
-
- ) -} - // ─── CollectionEditModal ──────────────────────────────────────────────────── // Opens when clicking any card in the collection. Allows editing + deleting. function CollectionEditModal({ item, onClose }) { @@ -1320,7 +1241,6 @@ export default function Collection() { className="aspect-[2.5/3.5] relative rounded-xl overflow-hidden flex-shrink-0" > -
{(() => { From e719c36567b042531b52639e18d71185b76c49f9 Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Mon, 27 Jul 2026 15:27:46 +0200 Subject: [PATCH 26/32] Use variant-specific card shine --- frontend/src/components/CardItem.jsx | 12 +- frontend/src/components/CardListItem.jsx | 8 +- frontend/src/components/RarityBorder.jsx | 16 -- frontend/src/index.css | 146 +++++++++++++------ frontend/src/pages/BinderDetail.jsx | 14 +- frontend/src/pages/CardSearch.jsx | 6 +- frontend/src/pages/Collection.jsx | 15 +- frontend/src/pages/PublicBinderView.jsx | 4 +- frontend/src/pages/SetDetail.jsx | 6 +- frontend/src/pages/Trades.jsx | 9 +- frontend/src/pages/UserCollection.jsx | 3 +- frontend/src/utils/cardRarity.js | 43 ------ frontend/src/utils/cardRarity.test.js | 37 ----- frontend/src/utils/cardVariantEffect.js | 60 ++++++++ frontend/src/utils/cardVariantEffect.test.js | 70 +++++++++ 15 files changed, 275 insertions(+), 174 deletions(-) delete mode 100644 frontend/src/components/RarityBorder.jsx delete mode 100644 frontend/src/utils/cardRarity.js delete mode 100644 frontend/src/utils/cardRarity.test.js create mode 100644 frontend/src/utils/cardVariantEffect.js create mode 100644 frontend/src/utils/cardVariantEffect.test.js 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/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/index.css b/frontend/src/index.css index 0d899c24..6217d75d 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.20) 45%, + rgba(255,230,100,0.15) 52%, + rgba(245,200,66,0.20) 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 8px rgba(245,200,66,0.2); } -.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.25) 50%, + rgba(147,210,255,0.15) 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 8px rgba(99,179,237,0.25); } -@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.20) 42%, + rgba(196,181,253,0.15) 50%, + rgba(167,139,250,0.20) 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 10px rgba(167,139,250,0.25); } -/* 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.25) 50%, + rgba(110,231,183,0.15) 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 8px rgba(52,211,153,0.2); } -.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.25) 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 05804a8c..b4495289 100644 --- a/frontend/src/pages/BinderDetail.jsx +++ b/frontend/src/pages/BinderDetail.jsx @@ -12,7 +12,7 @@ import { normalizeSearchText, textIncludes } from '../utils/textSearch' import { tcgdexLanguageLabel } from '../utils/tcgdexLanguages' import { invalidateCardState, invalidateTcgdexFilterLanguages } from '../utils/queryInvalidation' import CardStateIndicators, { CardStateLegend } from '../components/CardStateIndicators' -import { getCardRarityEffectClass } from '../utils/cardRarity' +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' @@ -631,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) ? ( @@ -641,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 && ( -
+
)} @@ -761,7 +761,7 @@ export default function BinderDetail() { return ( setSelectedCard(card)}> -
+
{resolveCardImageUrl(card) ? ( {card.name} (selectMode ? toggleSelected(card) : setSelectedCard(card))} > -
+
{imgSrc ? {card.name} :
@@ -662,7 +662,7 @@ export default function CardSearch() { } {selectMode && (
(
{cardImage && ( - {card?.name} +
+ {card?.name} +
)}
@@ -1229,16 +1231,14 @@ 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)} >
@@ -1339,7 +1339,7 @@ export default function Collection() { >
-
+
@@ -1444,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/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx index 4bbae9fb..c49829a7 100644 --- a/frontend/src/pages/PublicBinderView.jsx +++ b/frontend/src/pages/PublicBinderView.jsx @@ -8,7 +8,7 @@ import { groupCardsByPrint } from '../utils/groupCardsByPrint' import { useSettings } from '../contexts/SettingsContext' import CardStateIndicators, { CardStateLegend } from '../components/CardStateIndicators' import PublicHomeButton from '../components/PublicHomeButton' -import { getCardRarityEffectClass } from '../utils/cardRarity' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' export default function PublicBinderView() { const { handle, binderId } = useParams() @@ -96,7 +96,7 @@ export default function PublicBinderView() { })}
{tile.image ? {tile.name} 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/Trades.jsx b/frontend/src/pages/Trades.jsx index 147a8196..969dfd00 100644 --- a/frontend/src/pages/Trades.jsx +++ b/frontend/src/pages/Trades.jsx @@ -17,6 +17,7 @@ import { CustomCardModal } from '../components/CardItem' import { useSettings } from '../contexts/SettingsContext' import { CARD_VARIANTS, getDefaultVariantOrNull } from '../utils/cardVariants' import { resolveCardImageUrl } from '../utils/imageUrl' +import { getCardVariantEffectClass } from '../utils/cardVariantEffect' import { getEffectiveCardPrice, priceFieldFromPrimary } from '../utils/prices' import { formatMoneyInputValue, parseMoneyInputValue } from '../utils/moneyInput' import { invalidateCardState, invalidateTcgdexFilterLanguages } from '../utils/queryInvalidation' @@ -135,10 +136,10 @@ function TradeHealthBar({ outgoingValue, incomingValue, scoreOutgoingValue, miss ) } -function MiniCardRow({ card, meta, value, rightAction }) { +function MiniCardRow({ card, variant, meta, value, rightAction }) { return (
-
+
@@ -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/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..56d061d9 --- /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 = ['reverse', 'special', 'holo', 'firstEdition', '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('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' + if (normalized.includes('first edition') || normalized.includes('1st edition')) return 'firstEdition' + 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..9db8821a --- /dev/null +++ b/frontend/src/utils/cardVariantEffect.test.js @@ -0,0 +1,70 @@ +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'], + ['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: 'Special Illustration Rare' }, { variant: 'Holo' }], 'special'], + [[{ variant: 'Holo' }, { variant: 'First Edition' }], 'holo'], + [[{ variant: 'First Edition' }, { variant: 'Unknown Foil' }], 'firstEdition'], + ])('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('') + }) +}) From 2f451950a0be2fdfbc8a0852af6dd88883a26052 Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Mon, 27 Jul 2026 15:53:11 +0200 Subject: [PATCH 27/32] Strengthen variant-specific card shine --- frontend/src/index.css | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 6217d75d..94b852bb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -507,45 +507,45 @@ input:-webkit-autofill:focus { --card-variant-shine: linear-gradient( 105deg, transparent 25%, - rgba(245,200,66,0.20) 45%, - rgba(255,230,100,0.15) 52%, - rgba(245,200,66,0.20) 58%, + rgba(245,200,66,0.8) 45%, + rgba(255,230,100,0.6) 52%, + rgba(245,200,66,0.8) 58%, transparent 75% ); --card-variant-duration: 3.2s; - border-color: rgba(245,200,66,0.4) !important; + border-color: rgba(245,200,66,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 8px rgba(245,200,66,0.2); + 0 0 14px 3px rgba(245,200,66,0.55); } .card-variant-reverse { --card-variant-shine: linear-gradient( 105deg, transparent 30%, - rgba(99,179,237,0.25) 50%, - rgba(147,210,255,0.15) 55%, + rgba(99,179,237,0.7) 50%, + rgba(147,210,255,0.5) 55%, transparent 70% ); --card-variant-animation: card-variant-shimmer-reverse; --card-variant-duration: 2.8s; - border-color: rgba(99,179,237,0.5) !important; + border-color: rgba(99,179,237,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(99,179,237,0.25); + 0 0 12px 2px rgba(99,179,237,0.5); } .card-variant-special { --card-variant-shine: linear-gradient( 105deg, transparent 20%, - rgba(167,139,250,0.20) 42%, - rgba(196,181,253,0.15) 50%, - rgba(167,139,250,0.20) 58%, + rgba(167,139,250,0.7) 42%, + rgba(196,181,253,0.5) 50%, + rgba(167,139,250,0.7) 58%, transparent 78% ); --card-variant-duration: 4s; @@ -554,31 +554,31 @@ input:-webkit-autofill:focus { var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow, 0 0 #0000), - 0 0 10px rgba(167,139,250,0.25); + 0 0 16px 4px rgba(167,139,250,0.55); } .card-variant-first-edition { --card-variant-shine: linear-gradient( 105deg, transparent 30%, - rgba(52,211,153,0.25) 50%, - rgba(110,231,183,0.15) 55%, + rgba(52,211,153,0.7) 50%, + rgba(110,231,183,0.5) 55%, transparent 70% ); --card-variant-duration: 3.5s; - border-color: rgba(52,211,153,0.5) !important; + border-color: rgba(52,211,153,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(52,211,153,0.2); + 0 0 12px 2px rgba(52,211,153,0.5); } .card-variant-generic { --card-variant-shine: linear-gradient( 105deg, transparent 30%, - rgba(255,255,255,0.25) 50%, + rgba(255,255,255,0.4) 50%, transparent 70% ); --card-variant-duration: 3s; From d88f1027943a182871e28ec296e1e0bc328921e0 Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Mon, 27 Jul 2026 16:00:42 +0200 Subject: [PATCH 28/32] Balance variant shine intensity --- frontend/src/index.css | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 94b852bb..e6172f97 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -507,45 +507,45 @@ input:-webkit-autofill:focus { --card-variant-shine: linear-gradient( 105deg, transparent 25%, - rgba(245,200,66,0.8) 45%, - rgba(255,230,100,0.6) 52%, - rgba(245,200,66,0.8) 58%, + 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.5) !important; + 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 14px 3px rgba(245,200,66,0.55); + 0 0 9px rgba(245,200,66,0.24); } .card-variant-reverse { --card-variant-shine: linear-gradient( 105deg, transparent 30%, - rgba(99,179,237,0.7) 50%, - rgba(147,210,255,0.5) 55%, + rgba(99,179,237,0.32) 50%, + rgba(147,210,255,0.20) 55%, transparent 70% ); --card-variant-animation: card-variant-shimmer-reverse; --card-variant-duration: 2.8s; - border-color: rgba(99,179,237,0.4) !important; + 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 12px 2px rgba(99,179,237,0.5); + 0 0 9px rgba(99,179,237,0.28); } .card-variant-special { --card-variant-shine: linear-gradient( 105deg, transparent 20%, - rgba(167,139,250,0.7) 42%, - rgba(196,181,253,0.5) 50%, - rgba(167,139,250,0.7) 58%, + 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; @@ -554,31 +554,31 @@ input:-webkit-autofill:focus { var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow, 0 0 #0000), - 0 0 16px 4px rgba(167,139,250,0.55); + 0 0 11px rgba(167,139,250,0.28); } .card-variant-first-edition { --card-variant-shine: linear-gradient( 105deg, transparent 30%, - rgba(52,211,153,0.7) 50%, - rgba(110,231,183,0.5) 55%, + 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.4) !important; + 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 2px rgba(52,211,153,0.5); + 0 0 9px rgba(52,211,153,0.24); } .card-variant-generic { --card-variant-shine: linear-gradient( 105deg, transparent 30%, - rgba(255,255,255,0.4) 50%, + rgba(255,255,255,0.3) 50%, transparent 70% ); --card-variant-duration: 3s; From a458be793e53ac88843491ee1f39ab19c5d4c265 Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Mon, 27 Jul 2026 16:15:15 +0200 Subject: [PATCH 29/32] Improve grouped public card stacks --- frontend/src/pages/PublicBinderView.jsx | 16 ++++++++++++---- frontend/src/utils/cardVariantEffect.js | 4 ++-- frontend/src/utils/cardVariantEffect.test.js | 8 ++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/PublicBinderView.jsx b/frontend/src/pages/PublicBinderView.jsx index c49829a7..5d82c985 100644 --- a/frontend/src/pages/PublicBinderView.jsx +++ b/frontend/src/pages/PublicBinderView.jsx @@ -80,18 +80,26 @@ export default function PublicBinderView() { {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 (
+ className="absolute inset-0 overflow-hidden rounded border border-border bg-bg-secondary shadow-sm" + style={{ + transform: `translate(${depth * layerOffset}px, ${depth * layerOffset}px) rotate(${depth * 2}deg)`, + zIndex: backLayers - idx, + }} + > + {tile.image && ( + + )} +
) })}
String(variant || '') .normalize('NFD') @@ -24,6 +24,7 @@ const getVariantName = (value) => ( 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') @@ -32,7 +33,6 @@ const getVariantEffect = (variant) => { || normalized.includes('shiny') ) return 'special' if (normalized.includes('holo')) return 'holo' - if (normalized.includes('first edition') || normalized.includes('1st edition')) return 'firstEdition' return 'generic' } diff --git a/frontend/src/utils/cardVariantEffect.test.js b/frontend/src/utils/cardVariantEffect.test.js index 9db8821a..e64cde39 100644 --- a/frontend/src/utils/cardVariantEffect.test.js +++ b/frontend/src/utils/cardVariantEffect.test.js @@ -10,6 +10,7 @@ describe('getCardVariantEffectType', () => { ['Reverse Holo', 'reverse'], ['First Edition', 'firstEdition'], ['1st Edition', 'firstEdition'], + ['First Edition Holo', 'firstEdition'], ['Alt Art', 'special'], ['Illustration Rare', 'special'], ['Special Illustration Rare', 'special'], @@ -28,9 +29,12 @@ describe('getCardVariantEffectType', () => { }) it.each([ - [[{ variant: 'Special Illustration Rare' }, { variant: 'Holo' }], 'special'], - [[{ variant: 'Holo' }, { variant: 'First Edition' }], 'holo'], + [[{ 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) }) From b5a76410bb975c273a31a71f43f05171f0e8e6cf Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Mon, 27 Jul 2026 16:28:28 +0200 Subject: [PATCH 30/32] Keep public home button fixed across routes --- frontend/src/App.jsx | 20 ++++++++++++++++---- frontend/src/components/PublicHomeButton.jsx | 3 ++- frontend/src/pages/PublicBinderView.jsx | 6 ++---- frontend/src/pages/PublicProfile.jsx | 6 ++---- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 93db52e1..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')) @@ -112,6 +113,15 @@ function lazyRoute(element) { return }>{element} } +function PublicRoutes() { + return ( + <> + + + + ) +} + function ProtectedRoutes() { const { user, loading, multiUser } = useAuth() @@ -175,9 +185,11 @@ export default function App() { )} /> - )} /> - )} /> - )} /> + }> + )} /> + )} /> + )} /> + } /> diff --git a/frontend/src/components/PublicHomeButton.jsx b/frontend/src/components/PublicHomeButton.jsx index 2067a6f5..130fdbf5 100644 --- a/frontend/src/components/PublicHomeButton.jsx +++ b/frontend/src/components/PublicHomeButton.jsx @@ -8,8 +8,9 @@ export default function PublicHomeButton() { { cancelled = true } }, [handle, binderId]) - if (error) return <>
{t('publicProfiles.binderUnavailable')}
- if (!binder) return <>
{t('common.loading')}
+ if (error) return
{t('publicProfiles.binderUnavailable')}
+ if (!binder) return
{t('common.loading')}
const tiles = groupCardsByPrint(binder.cards) return (
-
{t('publicProfiles.backToProfile')} diff --git a/frontend/src/pages/PublicProfile.jsx b/frontend/src/pages/PublicProfile.jsx index 36b6e25f..766a86de 100644 --- a/frontend/src/pages/PublicProfile.jsx +++ b/frontend/src/pages/PublicProfile.jsx @@ -3,7 +3,6 @@ import { useParams, Link } from 'react-router-dom' import { getPublicProfile } from '../api/publicClient' import { formatEur } from '../utils/formatEur' import { useSettings } from '../contexts/SettingsContext' -import PublicHomeButton from '../components/PublicHomeButton' export default function PublicProfile() { const { handle } = useParams() @@ -21,12 +20,11 @@ export default function PublicProfile() { return () => { cancelled = true } }, [handle]) - if (error) return <>
{t('publicProfiles.profileUnavailable')}
- if (!profile) return <>
{t('common.loading')}
+ if (error) return
{t('publicProfiles.profileUnavailable')}
+ if (!profile) return
{t('common.loading')}
return (
-
{profile.avatar_id && ( From 8d3aab583801d5145a8252db4d166cafb09a6f94 Mon Sep 17 00:00:00 2001 From: Git-Romer Date: Mon, 27 Jul 2026 16:59:29 +0200 Subject: [PATCH 31/32] Keep public home button clickable --- frontend/src/components/PublicHomeButton.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/PublicHomeButton.jsx b/frontend/src/components/PublicHomeButton.jsx index 130fdbf5..74d8f83d 100644 --- a/frontend/src/components/PublicHomeButton.jsx +++ b/frontend/src/components/PublicHomeButton.jsx @@ -8,9 +8,10 @@ export default function PublicHomeButton() { Date: Mon, 27 Jul 2026 17:58:30 +0200 Subject: [PATCH 32/32] Harden binder sharing invariants --- backend/api/binders.py | 35 ++++++++++----- backend/tests/test_public_binders.py | 66 ++++++++++++++++++++++++++++ frontend/src/i18n/de.js | 1 + frontend/src/i18n/en.js | 1 + frontend/src/pages/Binders.jsx | 10 +++-- frontend/src/pages/Settings.jsx | 10 +++-- 6 files changed, 106 insertions(+), 17 deletions(-) diff --git a/backend/api/binders.py b/backend/api/binders.py index 474bc20f..8bed72b6 100644 --- a/backend/api/binders.py +++ b/backend/api/binders.py @@ -504,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: @@ -511,22 +531,15 @@ 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: - 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") binder.is_public = update.is_public db.commit() diff --git a/backend/tests/test_public_binders.py b/backend/tests/test_public_binders.py index 64f8c6bd..7120e072 100644 --- a/backend/tests/test_public_binders.py +++ b/backend/tests/test_public_binders.py @@ -527,6 +527,72 @@ def test_public_toggle_requires_a_boolean(self): 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) diff --git a/frontend/src/i18n/de.js b/frontend/src/i18n/de.js index d8fe40bf..3280c946 100644 --- a/frontend/src/i18n/de.js +++ b/frontend/src/i18n/de.js @@ -717,6 +717,7 @@ const de = { 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', diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index 35a22a48..3f600e0e 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -718,6 +718,7 @@ const en = { 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', diff --git a/frontend/src/pages/Binders.jsx b/frontend/src/pages/Binders.jsx index a33373bf..4296d775 100644 --- a/frontend/src/pages/Binders.jsx +++ b/frontend/src/pages/Binders.jsx @@ -203,10 +203,14 @@ export default function Binders() { onError: (error) => toast.error(error.response?.data?.detail || t('binders.updateFailed')), }) - const copyPublicBinderLink = (binderId) => { + const copyPublicBinderLink = async (binderId) => { const url = `${window.location.origin}/u/${publicHandle}/binder/${binderId}` - navigator.clipboard.writeText(url) - toast.success(t('settings.linkCopied')) + try { + await navigator.clipboard.writeText(url) + toast.success(t('settings.linkCopied')) + } catch { + toast.error(t('settings.linkCopyFailed')) + } } return ( diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index c9bb789d..0b975ebb 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -510,9 +510,13 @@ export default function Settings() { } } - const copyPublicProfileUrl = () => { - navigator.clipboard.writeText(publicProfileUrl) - toast.success(t('settings.linkCopied')) + const copyPublicProfileUrl = async () => { + try { + await navigator.clipboard.writeText(publicProfileUrl) + toast.success(t('settings.linkCopied')) + } catch { + toast.error(t('settings.linkCopyFailed')) + } } const isRunning = syncStatus?.is_running || syncStatus?.is_price_sync_running || syncMutation.isPending || allPriceSyncMutation.isPending