mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 14:06:31 +01:00
feat: add pool size badges, sorting, and optional sections to theme picker
This commit is contained in:
parent
0149fc2df9
commit
da7d94158f
12 changed files with 465 additions and 86 deletions
|
|
@ -51,6 +51,7 @@ SHOW_THEME_QUALITY_BADGES=1 # dockerhub: SHOW_THEME_QUALITY_BADGES="1" (
|
|||
SHOW_THEME_POOL_BADGES=1 # dockerhub: SHOW_THEME_POOL_BADGES="1" (show pool size badges in theme catalog)
|
||||
SHOW_THEME_POPULARITY_BADGES=1 # dockerhub: SHOW_THEME_POPULARITY_BADGES="1" (show popularity badges in theme catalog)
|
||||
SHOW_THEME_FILTERS=1 # dockerhub: SHOW_THEME_FILTERS="1" (show filter dropdowns/chips in theme catalog)
|
||||
# THEME_POOL_SECTIONS=1 # dockerhub: THEME_POOL_SECTIONS="0" (1=group themes by pool size sections: Vast/Large/Moderate/Small/Tiny; 0=flat sorted list)
|
||||
ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
|
||||
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
||||
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- **Deck Builder Theme Selection**: Enhanced theme picker with pool size indicators, smart sorting, and optional grouping
|
||||
- **Pool Size Badges**: Numeric card count displayed on all theme chips (recommended + general)
|
||||
- **Smart Sorting**: Themes automatically sorted by pool size (descending), then alphabetically
|
||||
- **Visual Separator**: Clear separation between recommended and general themes with section headers
|
||||
- **Pool Size Sections**: Optional grouping of themes into Vast/Large/Moderate/Small/Tiny sections, controlled by `THEME_POOL_SECTIONS` environment variable (default: off)
|
||||
- **Popup Wizard Parity**: New Deck modal has full feature parity with the legacy builder (badges, sorting, sections)
|
||||
- **Partner-Aware Sections**: When a partner commander is selected, partner themes are bucketed into the correct pool size section rather than appended flat
|
||||
- **Pool Size Tooltips**: Section headers and the "All Available Themes" label include tooltips explaining what the card count badge means and the tier thresholds
|
||||
- **Badge Styling**: Muted, compact pool size badges integrated seamlessly into chip design
|
||||
- **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality`
|
||||
- **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor)
|
||||
- **Catalog Statistics**: Total themes, average quality score displayed prominently
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- **Deck Builder Theme Selection**: Enhanced theme picker with pool size indicators, smart sorting, and optional grouping
|
||||
- **Pool Size Badges**: Numeric card count displayed on all theme chips (recommended + general)
|
||||
- **Smart Sorting**: Themes automatically sorted by pool size (descending), then alphabetically
|
||||
- **Visual Separator**: Clear separation between recommended and general themes with section headers
|
||||
- **Pool Size Sections**: Optional grouping of themes into Vast/Large/Moderate/Small/Tiny sections, controlled by `THEME_POOL_SECTIONS` environment variable (default: off)
|
||||
- **Popup Wizard Parity**: New Deck modal has full feature parity with the legacy builder (badges, sorting, sections)
|
||||
- **Partner-Aware Sections**: When a partner commander is selected, partner themes are bucketed into the correct pool size section rather than appended flat
|
||||
- **Pool Size Tooltips**: Section headers and the "All Available Themes" label include tooltips explaining what the card count badge means and the tier thresholds
|
||||
- **Badge Styling**: Muted, compact pool size badges integrated seamlessly into chip design
|
||||
- **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality`
|
||||
- **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor)
|
||||
- **Catalog Statistics**: Total themes, average quality score displayed prominently
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ def card_image_url(card_name: str, size: str = "normal") -> str:
|
|||
|
||||
templates.env.filters["card_image"] = card_image_url
|
||||
|
||||
# Add slugify filter for theme slugification (R21 M2)
|
||||
from .services.theme_catalog_loader import slugify as _slugify
|
||||
templates.env.filters["slugify"] = _slugify
|
||||
|
||||
# Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...})
|
||||
# and reorder to the new signature TemplateResponse(request, name, {...}).
|
||||
# Prevents DeprecationWarning noise in tests without touching all call sites.
|
||||
|
|
@ -179,6 +183,7 @@ SHOW_THEME_QUALITY_BADGES = _as_bool(os.getenv("SHOW_THEME_QUALITY_BADGES"), Tru
|
|||
SHOW_THEME_POOL_BADGES = _as_bool(os.getenv("SHOW_THEME_POOL_BADGES"), True)
|
||||
SHOW_THEME_POPULARITY_BADGES = _as_bool(os.getenv("SHOW_THEME_POPULARITY_BADGES"), True)
|
||||
SHOW_THEME_FILTERS = _as_bool(os.getenv("SHOW_THEME_FILTERS"), True)
|
||||
THEME_POOL_SECTIONS = _as_bool(os.getenv("THEME_POOL_SECTIONS"), False) # R21: Group themes by pool size
|
||||
WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider'
|
||||
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), True)
|
||||
ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from ..app import (
|
|||
WEB_IDEALS_UI,
|
||||
ENABLE_BATCH_BUILD,
|
||||
DEFAULT_THEME_MATCH_MODE,
|
||||
THEME_POOL_SECTIONS,
|
||||
)
|
||||
from ..services.build_utils import (
|
||||
step5_ctx_from_result,
|
||||
|
|
@ -36,6 +37,7 @@ from .build_partners import (
|
|||
_partner_ui_context,
|
||||
_resolve_partner_selection,
|
||||
)
|
||||
from .build_wizard import _prepare_step2_theme_data, _section_themes_by_pool_size # R21: Pool size data
|
||||
from ..services import custom_theme_manager as theme_mgr
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -173,9 +175,20 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
info = orch.commander_select(name)
|
||||
if not info.get("ok"):
|
||||
return HTMLResponse(f'<div class="muted">Commander not found: {name}</div>')
|
||||
tags = orch.tags_for_commander(info["name"]) or []
|
||||
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
|
||||
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
|
||||
tags_raw = orch.tags_for_commander(info["name"]) or []
|
||||
recommended_raw = orch.recommended_tags_for_commander(info["name"]) if tags_raw else []
|
||||
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags_raw else {}
|
||||
|
||||
# R21: Load pool size data and sort themes
|
||||
tags, recommended, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
|
||||
|
||||
# R21: Section themes by pool size if enabled
|
||||
tag_sections = []
|
||||
recommended_sections = []
|
||||
if THEME_POOL_SECTIONS:
|
||||
tag_sections = _section_themes_by_pool_size(tags, pool_size)
|
||||
recommended_sections = _section_themes_by_pool_size(recommended, pool_size)
|
||||
|
||||
exclusion_detail = lookup_commander_detail(info["name"])
|
||||
# Render tags slot content and OOB commander preview simultaneously
|
||||
# Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer)
|
||||
|
|
@ -189,10 +202,15 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
"commander": {"name": info["name"], "exclusion": exclusion_detail},
|
||||
"tags": tags,
|
||||
"recommended": recommended,
|
||||
"pool_size": pool_size, # R21: Pool size data for badges
|
||||
"use_sections": THEME_POOL_SECTIONS, # R21: Flag for template
|
||||
"tag_sections": tag_sections, # R21: Sectioned themes
|
||||
"recommended_sections": recommended_sections, # R21: Sectioned recommendations
|
||||
"recommended_reasons": recommended_reasons,
|
||||
"gc_commander": is_gc,
|
||||
"brackets": orch.bracket_options(),
|
||||
}
|
||||
|
||||
ctx.update(
|
||||
_partner_ui_context(
|
||||
info["name"],
|
||||
|
|
@ -220,6 +238,10 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
seen.add(key)
|
||||
merged_tags.append(token)
|
||||
ctx["tags"] = merged_tags
|
||||
|
||||
# R21: Re-section merged tags if sectioning enabled
|
||||
if THEME_POOL_SECTIONS:
|
||||
ctx["tag_sections"] = _section_themes_by_pool_size(merged_tags, pool_size)
|
||||
|
||||
# Deduplicate recommended: remove any that are already in partner_tags
|
||||
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from fastapi import APIRouter, Request, Form, Query
|
|||
from fastapi.responses import HTMLResponse
|
||||
from typing import Any
|
||||
|
||||
from ..app import templates, ENABLE_PARTNER_MECHANICS
|
||||
from ..app import templates, ENABLE_PARTNER_MECHANICS, THEME_POOL_SECTIONS
|
||||
from ..services.build_utils import (
|
||||
step5_base_ctx,
|
||||
step5_ctx_from_result,
|
||||
|
|
@ -30,6 +30,7 @@ from ..services.build_utils import (
|
|||
)
|
||||
from ..services import orchestrator as orch
|
||||
from ..services.tasks import get_session, new_sid
|
||||
from ..services.theme_catalog_loader import load_index, slugify
|
||||
from deck_builder import builder_constants as bc
|
||||
from ..services.combo_utils import detect_all as _detect_all
|
||||
from .build_partners import _partner_ui_context, _resolve_partner_selection
|
||||
|
|
@ -79,6 +80,71 @@ def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -
|
|||
)
|
||||
|
||||
|
||||
def _prepare_step2_theme_data(tags: list[str], recommended: list[str]) -> tuple[list[str], list[str], dict[str, int]]:
|
||||
"""
|
||||
Load pool size data and sort themes for Step 2 display (R21).
|
||||
|
||||
Returns:
|
||||
Tuple of (sorted_tags, sorted_recommended, pool_size_dict)
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load theme pool size data (R21 M1)
|
||||
try:
|
||||
theme_index = load_index()
|
||||
pool_size_by_slug = theme_index.pool_size_by_slug
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load theme index for pool sizes: {e}")
|
||||
pool_size_by_slug = {}
|
||||
|
||||
# Sort themes by pool size (descending), then alphabetically (R21 M1)
|
||||
def sort_by_pool_size(theme_list: list[str]) -> list[str]:
|
||||
"""Sort themes by pool size (desc), then alphabetically."""
|
||||
return sorted(
|
||||
theme_list,
|
||||
key=lambda t: (-pool_size_by_slug.get(slugify(t), 0), t.lower())
|
||||
)
|
||||
|
||||
tags_sorted = sort_by_pool_size(tags)
|
||||
recommended_sorted = sort_by_pool_size(recommended)
|
||||
|
||||
return tags_sorted, recommended_sorted, pool_size_by_slug
|
||||
|
||||
|
||||
def _section_themes_by_pool_size(themes: list[str], pool_size: dict[str, int]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Group themes into sections by pool size (R21 enhancement).
|
||||
|
||||
Thresholds:
|
||||
- Vast: 1000+
|
||||
- Large: 500-999
|
||||
- Moderate: 200-499
|
||||
- Small: 50-199
|
||||
- Tiny: <50
|
||||
|
||||
Returns:
|
||||
List of section dicts with 'label' and 'themes' keys
|
||||
"""
|
||||
sections = [
|
||||
{"label": "Vast", "min": 1000, "max": 9999999, "themes": []},
|
||||
{"label": "Large", "min": 500, "max": 999, "themes": []},
|
||||
{"label": "Moderate", "min": 200, "max": 499, "themes": []},
|
||||
{"label": "Small", "min": 50, "max": 199, "themes": []},
|
||||
{"label": "Tiny", "min": 0, "max": 49, "themes": []},
|
||||
]
|
||||
|
||||
for theme in themes:
|
||||
theme_pool = pool_size.get(slugify(theme), 0)
|
||||
for section in sections:
|
||||
if section["min"] <= theme_pool <= section["max"]:
|
||||
section["themes"].append(theme)
|
||||
break
|
||||
|
||||
# Remove empty sections
|
||||
return [s for s in sections if s["themes"]]
|
||||
|
||||
|
||||
def _current_builder_summary(sess: dict) -> Any | None:
|
||||
"""Get current builder's deck summary."""
|
||||
try:
|
||||
|
|
@ -148,16 +214,32 @@ async def build_step1_search(
|
|||
sess["last_step"] = 2
|
||||
commander_name = res.get("name")
|
||||
gc_flag = commander_name in getattr(bc, 'GAME_CHANGERS', [])
|
||||
|
||||
tags_raw = orch.tags_for_commander(commander_name)
|
||||
recommended_raw = orch.recommended_tags_for_commander(commander_name)
|
||||
tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
|
||||
|
||||
# R21: Section themes by pool size if enabled
|
||||
tag_sections = []
|
||||
recommended_sections = []
|
||||
if THEME_POOL_SECTIONS:
|
||||
tag_sections = _section_themes_by_pool_size(tags_sorted, pool_size)
|
||||
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"commander": res,
|
||||
"tags": orch.tags_for_commander(commander_name),
|
||||
"recommended": orch.recommended_tags_for_commander(commander_name),
|
||||
"tags": tags_sorted,
|
||||
"recommended": recommended_sorted,
|
||||
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name),
|
||||
"brackets": orch.bracket_options(),
|
||||
"gc_commander": gc_flag,
|
||||
"selected_bracket": (3 if gc_flag else None),
|
||||
"clear_persisted": True,
|
||||
"pool_size": pool_size,
|
||||
"use_sections": THEME_POOL_SECTIONS,
|
||||
"tag_sections": tag_sections,
|
||||
"recommended_sections": recommended_sections,
|
||||
}
|
||||
context.update(
|
||||
_partner_ui_context(
|
||||
|
|
@ -251,11 +333,23 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
|
|||
is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', []))
|
||||
except Exception:
|
||||
is_gc = False
|
||||
|
||||
tags_raw = orch.tags_for_commander(res["name"])
|
||||
recommended_raw = orch.recommended_tags_for_commander(res["name"])
|
||||
tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
|
||||
|
||||
# R21: Section themes by pool size if enabled
|
||||
tag_sections = []
|
||||
recommended_sections = []
|
||||
if THEME_POOL_SECTIONS:
|
||||
tag_sections = _section_themes_by_pool_size(tags_sorted, pool_size)
|
||||
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"commander": res,
|
||||
"tags": orch.tags_for_commander(res["name"]),
|
||||
"recommended": orch.recommended_tags_for_commander(res["name"]),
|
||||
"tags": tags_sorted,
|
||||
"recommended": recommended_sorted,
|
||||
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
|
||||
"brackets": orch.bracket_options(),
|
||||
"gc_commander": is_gc,
|
||||
|
|
@ -263,6 +357,10 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
|
|||
# Signal that this navigation came from a fresh commander confirmation,
|
||||
# so the Step 2 UI should clear any localStorage theme persistence.
|
||||
"clear_persisted": True,
|
||||
"pool_size": pool_size,
|
||||
"use_sections": THEME_POOL_SECTIONS,
|
||||
"tag_sections": tag_sections,
|
||||
"recommended_sections": recommended_sections,
|
||||
}
|
||||
context.update(
|
||||
_partner_ui_context(
|
||||
|
|
@ -340,11 +438,23 @@ async def build_step2_get(request: Request) -> HTMLResponse:
|
|||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}")
|
||||
|
||||
# Load theme pool size data and sort themes (R21 M1)
|
||||
tags_raw = orch.tags_for_commander(commander)
|
||||
recommended_raw = orch.recommended_tags_for_commander(commander)
|
||||
tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
|
||||
|
||||
# R21 Enhancement: Section themes by pool size if enabled
|
||||
tag_sections = []
|
||||
recommended_sections = []
|
||||
if THEME_POOL_SECTIONS:
|
||||
tag_sections = _section_themes_by_pool_size(tags_sorted, pool_size)
|
||||
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"commander": {"name": commander},
|
||||
"tags": tags,
|
||||
"recommended": orch.recommended_tags_for_commander(commander),
|
||||
"tags": tags_sorted,
|
||||
"recommended": recommended_sorted,
|
||||
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
|
||||
"brackets": orch.bracket_options(),
|
||||
"primary_tag": selected[0] if len(selected) > 0 else "",
|
||||
|
|
@ -356,6 +466,10 @@ async def build_step2_get(request: Request) -> HTMLResponse:
|
|||
# If there are no server-side tags for this commander, let the client clear any persisted ones
|
||||
# to avoid themes sticking between fresh runs.
|
||||
"clear_persisted": False if selected else True,
|
||||
"pool_size": pool_size, # R21 M1: Pass pool size data to template
|
||||
"use_sections": THEME_POOL_SECTIONS, # R21: Flag for template
|
||||
"tag_sections": tag_sections, # R21: Sectioned themes
|
||||
"recommended_sections": recommended_sections, # R21: Sectioned recommendations
|
||||
}
|
||||
context.update(
|
||||
_partner_ui_context(
|
||||
|
|
@ -375,7 +489,9 @@ async def build_step2_get(request: Request) -> HTMLResponse:
|
|||
if partner_tags:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
context["tags"] = partner_tags
|
||||
# Re-sort partner tags by pool size using helper (R21 M1)
|
||||
partner_tags_sorted, _, _ = _prepare_step2_theme_data(partner_tags, [])
|
||||
context["tags"] = partner_tags_sorted
|
||||
# Deduplicate recommended tags: remove any that are already in partner_tags
|
||||
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
|
||||
original_recommended = context.get("recommended", [])
|
||||
|
|
@ -383,12 +499,14 @@ async def build_step2_get(request: Request) -> HTMLResponse:
|
|||
tag for tag in original_recommended
|
||||
if str(tag).strip().casefold() not in partner_tags_lower
|
||||
]
|
||||
# Re-sort deduplicated recommended tags using helper (R21 M1)
|
||||
dedup_sorted, _, _ = _prepare_step2_theme_data(deduplicated_recommended, [])
|
||||
logger.info(
|
||||
f"Step2: partner_tags={len(partner_tags)}, "
|
||||
f"original_recommended={len(original_recommended)}, "
|
||||
f"deduplicated_recommended={len(deduplicated_recommended)}"
|
||||
)
|
||||
context["recommended"] = deduplicated_recommended
|
||||
context["recommended"] = dedup_sorted
|
||||
resp = templates.TemplateResponse("build/_step2.html", context)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -436,11 +554,22 @@ async def build_step2_submit(
|
|||
sel_br = None
|
||||
if is_gc and (sel_br is None or sel_br < 3):
|
||||
sel_br = 3
|
||||
|
||||
recommended_raw = orch.recommended_tags_for_commander(commander)
|
||||
available_tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(available_tags, recommended_raw)
|
||||
|
||||
# R21: Section themes by pool size if enabled
|
||||
tag_sections = []
|
||||
recommended_sections = []
|
||||
if THEME_POOL_SECTIONS:
|
||||
tag_sections = _section_themes_by_pool_size(available_tags_sorted, pool_size)
|
||||
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"commander": {"name": commander},
|
||||
"tags": available_tags,
|
||||
"recommended": orch.recommended_tags_for_commander(commander),
|
||||
"tags": available_tags_sorted,
|
||||
"recommended": recommended_sorted,
|
||||
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
|
||||
"brackets": orch.bracket_options(),
|
||||
"error": "Please choose a primary theme.",
|
||||
|
|
@ -450,6 +579,10 @@ async def build_step2_submit(
|
|||
"selected_bracket": sel_br,
|
||||
"tag_mode": (tag_mode or "AND"),
|
||||
"gc_commander": is_gc,
|
||||
"pool_size": pool_size,
|
||||
"use_sections": THEME_POOL_SECTIONS,
|
||||
"tag_sections": tag_sections,
|
||||
"recommended_sections": recommended_sections,
|
||||
}
|
||||
context.update(
|
||||
_partner_ui_context(
|
||||
|
|
@ -467,7 +600,8 @@ async def build_step2_submit(
|
|||
)
|
||||
partner_tags = context.pop("partner_theme_tags", None)
|
||||
if partner_tags:
|
||||
context["tags"] = partner_tags
|
||||
partner_tags_sorted, _, _ = _prepare_step2_theme_data(partner_tags, [])
|
||||
context["tags"] = partner_tags_sorted
|
||||
resp = templates.TemplateResponse("build/_step2.html", context)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -508,11 +642,22 @@ async def build_step2_submit(
|
|||
sel_br = int(bracket)
|
||||
except Exception:
|
||||
sel_br = None
|
||||
|
||||
recommended_raw = orch.recommended_tags_for_commander(commander)
|
||||
available_tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(available_tags, recommended_raw)
|
||||
|
||||
# R21: Section themes by pool size if enabled
|
||||
tag_sections = []
|
||||
recommended_sections = []
|
||||
if THEME_POOL_SECTIONS:
|
||||
tag_sections = _section_themes_by_pool_size(available_tags_sorted, pool_size)
|
||||
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"request": request,
|
||||
"commander": {"name": commander},
|
||||
"tags": available_tags,
|
||||
"recommended": orch.recommended_tags_for_commander(commander),
|
||||
"tags": available_tags_sorted,
|
||||
"recommended": recommended_sorted,
|
||||
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
|
||||
"brackets": orch.bracket_options(),
|
||||
"primary_tag": primary_tag or "",
|
||||
|
|
@ -522,6 +667,10 @@ async def build_step2_submit(
|
|||
"tag_mode": (tag_mode or "AND"),
|
||||
"gc_commander": is_gc,
|
||||
"error": None,
|
||||
"pool_size": pool_size,
|
||||
"use_sections": THEME_POOL_SECTIONS,
|
||||
"tag_sections": tag_sections,
|
||||
"recommended_sections": recommended_sections,
|
||||
}
|
||||
context.update(
|
||||
_partner_ui_context(
|
||||
|
|
@ -539,7 +688,8 @@ async def build_step2_submit(
|
|||
)
|
||||
partner_tags = context.pop("partner_theme_tags", None)
|
||||
if partner_tags:
|
||||
context["tags"] = partner_tags
|
||||
partner_tags_sorted, _, _ = _prepare_step2_theme_data(partner_tags, [])
|
||||
context["tags"] = partner_tags_sorted
|
||||
resp = templates.TemplateResponse("build/_step2.html", context)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
|
|||
|
|
@ -4934,6 +4934,21 @@ img.lqip.loaded {
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
/* Pool size badge for chip context (R21 M2) */
|
||||
.badge-pool {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
font-weight: 400;
|
||||
padding: 0;
|
||||
margin-left: .15rem;
|
||||
}
|
||||
|
||||
.chip.active .badge-pool {
|
||||
color: #93c5fd;
|
||||
/* lighter blue for active chips */
|
||||
}
|
||||
|
||||
/* Legacy lifecycle quality badges (draft/reviewed/final) */
|
||||
|
||||
.badge-quality-draft {
|
||||
|
|
|
|||
|
|
@ -2732,6 +2732,20 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
/* Pool size badge for chip context (R21 M2) */
|
||||
.badge-pool {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
font-weight: 400;
|
||||
padding: 0;
|
||||
margin-left: .15rem;
|
||||
}
|
||||
|
||||
.chip.active .badge-pool {
|
||||
color: #93c5fd; /* lighter blue for active chips */
|
||||
}
|
||||
|
||||
/* Legacy lifecycle quality badges (draft/reviewed/final) */
|
||||
.badge-quality-draft {
|
||||
background: #4338ca;
|
||||
|
|
|
|||
|
|
@ -101,18 +101,53 @@
|
|||
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Recommended</div>
|
||||
<div id="modal-tag-reco" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% if recommended and recommended|length %}
|
||||
{# R21: Recommended themes (always flat, never sectioned) #}
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% set pool_count = pool_size.get(r|slugify, 0) if pool_size else 0 %}
|
||||
<button type="button" class="chip chip-reco" data-tag="{{ r }}" data-pool-size="{{ pool_count }}" title="{{ tip }}">★ {{ r }} <span class="badge badge-pool" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ pool_count }}</span></button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button>
|
||||
{% endfor %}
|
||||
{# R21 M3: Visual separator between recommended and general themes #}
|
||||
{% if recommended and recommended|length %}
|
||||
<hr style="margin:.75rem 0; border:none; border-top:1px solid var(--border-color, #333);" />
|
||||
<div style="display:flex; align-items:baseline; gap:.4rem; flex-wrap:wrap; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">All Available Themes</div>
|
||||
<div class="muted" style="font-size:11px;" title="The number on each theme is the approximate number of eligible cards in that theme's pool for your commander's color identity. Sections: Vast (1000+), Large (500–999), Moderate (200–499), Small (50–199), Tiny (<50).">— badge = card pool size</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if use_sections and tag_sections %}
|
||||
{% set section_labels = [] %}
|
||||
{% for section in tag_sections %}{% set _ = section_labels.append({'label': section.label, 'themes': section.themes}) %}{% endfor %}
|
||||
{% endif %}
|
||||
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;"
|
||||
data-use-sections="{% if use_sections and tag_sections %}1{% else %}0{% endif %}"
|
||||
data-pool-size='{{ pool_size | tojson if pool_size else "{}" }}'
|
||||
data-sections='{{ tag_sections | tojson if (use_sections and tag_sections) else "[]" }}'>
|
||||
{% if use_sections and tag_sections %}
|
||||
{# R21: Sectioned general themes #}
|
||||
{% for section in tag_sections %}
|
||||
<div style="width:100%; margin-bottom:.5rem;">
|
||||
{% set section_tip = {'Vast': '1000+ cards', 'Large': '500–999 cards', 'Moderate': '200–499 cards', 'Small': '50–199 cards', 'Tiny': 'fewer than 50 cards'} %}
|
||||
<div class="muted" style="font-size:11px; margin-bottom:.25rem;" title="{{ section.label }} pool: themes with approximately {{ section_tip.get(section.label, '') }} available for your commander.">{{ section.label }} Pool ({{ section.themes|length }})</div>
|
||||
<div style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in section.themes %}
|
||||
{% set pool_count = pool_size.get(t|slugify, 0) if pool_size else 0 %}
|
||||
<button type="button" class="chip" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# R21: Flat sorted general themes #}
|
||||
{% for t in tags %}
|
||||
{% set pool_count = pool_size.get(t|slugify, 0) if pool_size else 0 %}
|
||||
<button type="button" class="chip" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No theme tags available for this commander.</p>
|
||||
|
|
@ -225,74 +260,163 @@
|
|||
}
|
||||
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
|
||||
|
||||
function makeChip(tag, isPartner, poolSizeMap) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip' + (isPartner ? ' partner-added' : '');
|
||||
btn.dataset.tag = tag;
|
||||
var poolCount = (poolSizeMap && poolSizeMap[tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')]) || 0;
|
||||
btn.dataset.poolSize = poolCount;
|
||||
btn.title = isPartner ? 'From combined partner themes' : ('Pool size: approximately ' + poolCount + ' cards available for this theme');
|
||||
btn.innerHTML = tag + (poolCount ? ' <span class="badge badge-pool">' + poolCount + '</span>' : '');
|
||||
return btn;
|
||||
}
|
||||
|
||||
function updatePartnerTags(partnerTags){
|
||||
if (!list || !reco) return;
|
||||
|
||||
// Only rebuild if there are actually partner tags to add
|
||||
var hasPartnerTags = Array.isArray(partnerTags) && partnerTags.length > 0;
|
||||
if (!hasPartnerTags) {
|
||||
// No partner tags, leave DOM structure intact (preserves sections)
|
||||
updateUI();
|
||||
return;
|
||||
}
|
||||
|
||||
var useSections = list.dataset.useSections === '1';
|
||||
var poolSizeMap = {};
|
||||
try { poolSizeMap = JSON.parse(list.dataset.poolSize || '{}'); } catch(_) {}
|
||||
var sections = [];
|
||||
try { sections = JSON.parse(list.dataset.sections || '[]'); } catch(_) {}
|
||||
|
||||
// Remove old partner-added chips from available list
|
||||
Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); });
|
||||
|
||||
// Deduplicate: remove partner tags from recommended section to avoid showing them twice
|
||||
if (partnerTags && partnerTags.length > 0) {
|
||||
var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); });
|
||||
Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){
|
||||
var tag = btn.dataset.tag || '';
|
||||
if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) {
|
||||
btn.remove();
|
||||
var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); });
|
||||
Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){
|
||||
var tag = btn.dataset.tag || '';
|
||||
if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) {
|
||||
btn.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (useSections && sections.length) {
|
||||
// Section thresholds (must match Python _section_themes_by_pool_size)
|
||||
var THRESHOLDS = [
|
||||
{label: 'Vast', min: 1000, tip: '1000+ cards'},
|
||||
{label: 'Large', min: 500, tip: '500\u2013999 cards'},
|
||||
{label: 'Moderate', min: 200, tip: '200\u2013499 cards'},
|
||||
{label: 'Small', min: 50, tip: '50\u2013199 cards'},
|
||||
{label: 'Tiny', min: 0, tip: 'fewer than 50 cards'},
|
||||
];
|
||||
function slugify(str) { return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
|
||||
function getSectionLabel(poolCount) {
|
||||
for (var i = 0; i < THRESHOLDS.length; i++) {
|
||||
if (poolCount >= THRESHOLDS[i].min) return THRESHOLDS[i].label;
|
||||
}
|
||||
return 'Tiny';
|
||||
}
|
||||
|
||||
// Gather all existing original chips (keyed by lowercase tag)
|
||||
var existingChips = {};
|
||||
Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).forEach(function(b){
|
||||
var tag = (b.dataset.tag || '').trim();
|
||||
if (tag) existingChips[tag.toLowerCase()] = b;
|
||||
});
|
||||
|
||||
// Collect all tags: primary (from sections) + partner (new), deduplicated
|
||||
var allTagMap = {}; // lowercase -> {tag, element, isPartner}
|
||||
sections.forEach(function(sec){
|
||||
(sec.themes || []).forEach(function(t){
|
||||
var key = t.toLowerCase();
|
||||
if (!allTagMap[key]) allTagMap[key] = {tag: t, element: existingChips[key] || null, isPartner: false};
|
||||
});
|
||||
});
|
||||
partnerTags.forEach(function(t){
|
||||
var value = String(t || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (!allTagMap[key]) allTagMap[key] = {tag: value, element: null, isPartner: true};
|
||||
});
|
||||
|
||||
// Bucket all tags into sections by pool size
|
||||
var sectionBuckets = {};
|
||||
THRESHOLDS.forEach(function(s){ sectionBuckets[s.label] = []; });
|
||||
Object.keys(allTagMap).forEach(function(key){
|
||||
var item = allTagMap[key];
|
||||
var poolCount = poolSizeMap[slugify(item.tag)] || 0;
|
||||
var label = getSectionLabel(poolCount);
|
||||
sectionBuckets[label].push({tag: item.tag, poolCount: poolCount, element: item.element, isPartner: item.isPartner});
|
||||
});
|
||||
// Sort each bucket by pool size desc, then alphabetically
|
||||
THRESHOLDS.forEach(function(s){
|
||||
sectionBuckets[s.label].sort(function(a, b){
|
||||
if (b.poolCount !== a.poolCount) return b.poolCount - a.poolCount;
|
||||
return a.tag.localeCompare(b.tag);
|
||||
});
|
||||
});
|
||||
|
||||
list.innerHTML = '';
|
||||
THRESHOLDS.forEach(function(s){
|
||||
var bucket = sectionBuckets[s.label];
|
||||
if (!bucket.length) return;
|
||||
var secDiv = document.createElement('div');
|
||||
secDiv.style.cssText = 'width:100%; margin-bottom:.5rem;';
|
||||
var labelDiv = document.createElement('div');
|
||||
labelDiv.className = 'muted';
|
||||
labelDiv.style.cssText = 'font-size:11px; margin-bottom:.25rem;';
|
||||
labelDiv.title = s.label + ' pool: themes with approximately ' + s.tip + ' available for your commander.';
|
||||
labelDiv.textContent = s.label + ' Pool (' + bucket.length + ')';
|
||||
secDiv.appendChild(labelDiv);
|
||||
var innerDiv = document.createElement('div');
|
||||
innerDiv.style.cssText = 'display:flex; gap:.35rem; flex-wrap:wrap;';
|
||||
bucket.forEach(function(item){
|
||||
if (item.element) {
|
||||
innerDiv.appendChild(item.element);
|
||||
} else {
|
||||
innerDiv.appendChild(makeChip(item.tag, item.isPartner, poolSizeMap));
|
||||
}
|
||||
});
|
||||
secDiv.appendChild(innerDiv);
|
||||
list.appendChild(secDiv);
|
||||
});
|
||||
} else {
|
||||
// Flat mode: get existing tags, merge partner tags, sort, re-render
|
||||
var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){
|
||||
return {
|
||||
element: b,
|
||||
tag: (b.dataset.tag || '').trim(),
|
||||
tagLower: (b.dataset.tag || '').trim().toLowerCase()
|
||||
};
|
||||
});
|
||||
|
||||
var combined = [];
|
||||
var seen = new Set();
|
||||
existingTags.forEach(function(item){
|
||||
if (!item.tag || seen.has(item.tagLower)) return;
|
||||
seen.add(item.tagLower);
|
||||
combined.push({ tag: item.tag, element: item.element, isPartner: false });
|
||||
});
|
||||
partnerTags.forEach(function(tag){
|
||||
var value = String(tag || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
combined.push({ tag: value, element: null, isPartner: true });
|
||||
});
|
||||
combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); });
|
||||
list.innerHTML = '';
|
||||
combined.forEach(function(item){
|
||||
if (item.element) {
|
||||
list.appendChild(item.element);
|
||||
} else {
|
||||
list.appendChild(makeChip(item.tag, true, poolSizeMap));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get existing tags from the available list (original server-rendered ones)
|
||||
var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){
|
||||
return {
|
||||
element: b,
|
||||
tag: (b.dataset.tag || '').trim(),
|
||||
tagLower: (b.dataset.tag || '').trim().toLowerCase()
|
||||
};
|
||||
});
|
||||
|
||||
// Build combined list: existing + new partner tags
|
||||
var combined = [];
|
||||
var seen = new Set();
|
||||
|
||||
// Add existing tags first
|
||||
existingTags.forEach(function(item){
|
||||
if (!item.tag || seen.has(item.tagLower)) return;
|
||||
seen.add(item.tagLower);
|
||||
combined.push({ tag: item.tag, element: item.element, isPartner: false });
|
||||
});
|
||||
|
||||
// Add new partner tags
|
||||
(Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){
|
||||
var value = String(tag || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
combined.push({ tag: value, element: null, isPartner: true });
|
||||
});
|
||||
|
||||
// Sort alphabetically
|
||||
combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); });
|
||||
|
||||
// Re-render the list in sorted order
|
||||
list.innerHTML = '';
|
||||
combined.forEach(function(item){
|
||||
if (item.element) {
|
||||
// Re-append existing element
|
||||
list.appendChild(item.element);
|
||||
} else {
|
||||
// Create new partner-added chip
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip partner-added';
|
||||
btn.dataset.tag = item.tag;
|
||||
btn.title = 'From combined partner themes';
|
||||
btn.textContent = item.tag;
|
||||
list.appendChild(btn);
|
||||
}
|
||||
});
|
||||
|
||||
// Update visibility of recommended section
|
||||
var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0;
|
||||
if (recoBlock){
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
|
||||
<div id="tag-reco-block" data-has-reco="{% if recommended and recommended|length %}1{% else %}0{% endif %}" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>
|
||||
<div id="tag-reco-header" style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">Recommended</div>
|
||||
<div class="muted" style="font-size:13px; font-weight:600;">⭐ Recommended</div>
|
||||
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Why?</button>
|
||||
</div>
|
||||
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" data-default-reasons='{{ (recommended_reasons or {}) | tojson }}' style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
|
||||
|
|
@ -120,20 +120,48 @@
|
|||
</div>
|
||||
<div id="tag-reco-list" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% if recommended and recommended|length %}
|
||||
{# R21: Recommended themes (always flat, never sectioned) #}
|
||||
{% for r in recommended %}
|
||||
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% set pool_count = pool_size.get(r|slugify, 0) %}
|
||||
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" data-pool-size="{{ pool_count }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }} <span class="badge badge-pool" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ pool_count }}</span></button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if recommended and recommended|length %}
|
||||
<hr style="border:none; border-top:1px solid var(--border); margin:.75rem 0 .5rem 0;" />
|
||||
<div style="display:flex; align-items:baseline; gap:.4rem; flex-wrap:wrap; margin-bottom:.35rem;">
|
||||
<div class="muted" style="font-size:13px; font-weight:600;">All Available Themes</div>
|
||||
<div class="muted" style="font-size:11px;" title="The number on each theme is the approximate number of eligible cards in that theme's pool for your commander's color identity. Sections: Vast (1000+), Large (500–999), Moderate (200–499), Small (50–199), Tiny (<50).">— badge = card pool size</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
|
||||
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}">{{ t }}</button>
|
||||
{% endfor %}
|
||||
{% if use_sections and tag_sections %}
|
||||
{# R21: Sectioned general themes #}
|
||||
{% for section in tag_sections %}
|
||||
<div style="width:100%; margin-bottom:.5rem;">
|
||||
{% set section_tip = {'Vast': '1000+ cards', 'Large': '500–999 cards', 'Moderate': '200–499 cards', 'Small': '50–199 cards', 'Tiny': 'fewer than 50 cards'} %}
|
||||
<div class="muted" style="font-size:11px; margin-bottom:.25rem;" title="{{ section.label }} pool: themes with approximately {{ section_tip.get(section.label, '') }} available for your commander.">{{ section.label }} Pool ({{ section.themes|length }})</div>
|
||||
<div style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in section.themes %}
|
||||
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
|
||||
{% set pool_count = pool_size.get(t|slugify, 0) %}
|
||||
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# R21: Flat sorted general themes #}
|
||||
{% for t in tags %}
|
||||
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
|
||||
{% set pool_count = pool_size.get(t|slugify, 0) %}
|
||||
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No theme tags available for this commander.</p>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ services:
|
|||
SHOW_THEME_POOL_BADGES: "1" # 1=show pool size badges in theme catalog; 0=hide
|
||||
SHOW_THEME_POPULARITY_BADGES: "1" # 1=show popularity badges in theme catalog; 0=hide
|
||||
SHOW_THEME_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide
|
||||
THEME_POOL_SECTIONS: "1" # 1=group themes by pool size (Vast/Large/Moderate/Small/Tiny); 0=flat sorted list
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ services:
|
|||
SHOW_THEME_POOL_BADGES: "1" # 1=show pool size badges in theme catalog; 0=hide
|
||||
SHOW_THEME_POPULARITY_BADGES: "1" # 1=show popularity badges in theme catalog; 0=hide
|
||||
SHOW_THEME_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide
|
||||
THEME_POOL_SECTIONS: "1" # 1=group themes by pool size (Vast/Large/Moderate/Small/Tiny); 0=flat sorted list
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue