mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
Merge pull request #9 from mwisnowski/maintenance/mobile-ui
This commit is contained in:
commit
42c8fc9f9e
14 changed files with 408 additions and 60 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
@ -13,6 +13,16 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [2.2.4] - 2025-09-02
|
||||
|
||||
### Added
|
||||
- Mobile: Collapsible left sidebar with persisted state; sticky build controls adjusted for mobile header.
|
||||
- New Deck modal integrates Multi-Copy suggestions (opt-in) and commander/theme preview.
|
||||
- Web: Setup/Refresh prompt modal shown on Create when environment is missing or stale; routes to `/setup/running` (force on stale) and transitions into the progress view. Template: `web/templates/build/_setup_prompt_modal.html`.
|
||||
- Orchestrator helpers: `is_setup_ready()` and `is_setup_stale()` for non-invasive readiness/staleness checks from the UI.
|
||||
- Env flags for setup behavior: `WEB_AUTO_SETUP` (default 1) to enable/disable auto setup, and `WEB_AUTO_REFRESH_DAYS` (default 7) to tune staleness.
|
||||
|
@ -28,6 +38,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- `build_utils.step5_error_ctx()` shape and flags
|
||||
|
||||
### Changed
|
||||
- Mobile UI scaling and layout fixed across steps; overlap in DevTools emulation resolved with CSS variable offsets for sticky elements.
|
||||
- Multi-Copy is now explicitly opt-in from the New Deck modal; suggestions are filtered to only show archetypes whose matched tags intersect the user-selected themes (e.g., Rabbit Kindred shows only Hare Apparent).
|
||||
- Web cleanup: centralized combos/synergies detection and model/version loading in `web/services/combo_utils.py` and refactored routes to use it:
|
||||
- `routes/build.py` (Combos panel), `routes/configs.py` (run results), `routes/decks.py` (finished/compare), and diagnostics endpoint in `app.py`.
|
||||
- Create (New Deck) flow: no longer auto-runs setup on submit; instead presents a modal prompt to run setup/refresh when needed.
|
||||
|
@ -50,6 +62,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- Build: Extended Step 5 error handling to Continue, Rerun, and Rewind using `step5_error_ctx()`.
|
||||
|
||||
### Fixed
|
||||
- Continue button responsiveness on mobile fixed (eliminated sticky overlap); Multi-Copy application preserved across New Deck submit; emulator misclicks resolved.
|
||||
- Banner subtitle now stays inline inside the header when the menu is collapsed (no overhang/wrap to a new row).
|
||||
- Docker: normalized line endings for `entrypoint.sh` during image build to avoid `env: 'sh\r': No such file or directory` on Windows checkouts.
|
||||
|
||||
### Removed
|
||||
|
|
|
@ -1,40 +1,31 @@
|
|||
# MTG Python Deckbuilder ${VERSION}
|
||||
|
||||
## Highlights
|
||||
- Combos & Synergies: detect curated two-card combos and synergies, surface them in a unified chip-style panel on Step 5 and Finished Decks, and preview both cards on hover.
|
||||
- Auto-Complete Combos: optional mode that adds missing partners up to a target before theme fill/monolithic spells so added pairs persist.
|
||||
- Mobile UI polish: collapsible left sidebar with persisted state, sticky controls that respect the header, and banner subtitle that stays inline when the menu is collapsed.
|
||||
- Multi-Copy is now opt-in from the New Deck modal, and suggestions are filtered to match selected themes (e.g., Rabbit Kindred → Hare Apparent).
|
||||
- New Deck modal improvements: integrated commander preview, theme selection, and optional Multi-Copy in one flow.
|
||||
|
||||
## What’s new
|
||||
- Detection: exact two-card combos and curated synergies with list version badges (combos.json/synergies.json).
|
||||
- UI polish:
|
||||
- Chip-style rows with compact badges (cheap/early, setup) in both the end-of-build panel and finished deck summary.
|
||||
- Dual-card hover: moving your mouse over a combo row previews both cards side-by-side; hovering a single name shows that card alone.
|
||||
- Ordering: when enabled, Auto-Complete Combos runs earlier (before theme fill and monolithic spells) to retain partners.
|
||||
- Enforcement:
|
||||
- Color identity respected via the filtered pool; off-color or unavailable partners are skipped gracefully.
|
||||
- Honors Locks, Owned-only, and Replace toggles.
|
||||
- Persistence & Headless parity:
|
||||
- Interactive runs export these JSON fields and Web headless runs accept them:
|
||||
- prefer_combos (bool)
|
||||
- combo_target_count (int)
|
||||
- combo_balance ("early" | "late" | "mix")
|
||||
|
||||
## JSON (Web Configs) — example
|
||||
```json
|
||||
{
|
||||
"prefer_combos": true,
|
||||
"combo_target_count": 3,
|
||||
"combo_balance": "mix"
|
||||
}
|
||||
```
|
||||
- Mobile & layout
|
||||
- Sidebar toggle button (persisted in localStorage), smooth hide/show.
|
||||
- Sticky build controls offset via CSS variables to avoid overlap in emulators and mobile.
|
||||
- Banner subtitle stays within the header and remains inline with the title when the sidebar is collapsed.
|
||||
- Multi-Copy
|
||||
- Moved to Commander selection now instead of happening during building.
|
||||
- Opt-in checkbox in the New Deck modal; disabled by default.
|
||||
- Suggestions only appear when at least one theme is selected and are limited to archetypes whose matched tags intersect the themes.
|
||||
- Multi-Copy runs first when selected, with an applied marker to avoid redundant rebuilds.
|
||||
- New Deck & Setup
|
||||
- Setup/Refresh prompt modal if the environment is missing or stale, with a clear path to run/refresh setup before building.
|
||||
- Centralized staged context creation and error/render helpers for a more robust Step 5 flow.
|
||||
|
||||
## Notes
|
||||
- Curated list versions are displayed in the UI for transparency.
|
||||
- Existing completed pairs are counted toward the target; only missing partners are added.
|
||||
- No changes to CLI inputs for this feature in this release.
|
||||
- Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON.
|
||||
- Logic for removal tagging causing self-targetting cards (e.g. Conjurer's Closet) to be tagged as removal (2.2.3)
|
||||
- Multi-Copy selection is part of the interactive New Deck modal (not a JSON field); it remains off unless explicitly enabled.
|
||||
- Setup helpers: `is_setup_ready()` and `is_setup_stale()` inform the modal prompt and can be tuned with `WEB_AUTO_SETUP` and `WEB_AUTO_REFRESH_DAYS`.
|
||||
- Headless parity: `tag_mode` (AND/OR) remains supported in JSON/env and exported in interactive run-config JSON.
|
||||
|
||||
## Fixes
|
||||
- Fixed an issue with the Docker Hub image not having the config files for combos/synergies/default deck json example
|
||||
- Bug causing basic lands to no longer be added due to combined dataframe not including basics (2.2.3)
|
||||
- Continue responsiveness and click reliability on mobile/emulators; sticky overlap eliminated.
|
||||
- Multi-Copy application preserved across New Deck submit; duplicate re-application avoided with an applied marker.
|
||||
- Banner subtitle alignment fixed in collapsed-menu mode (no overhang, no line-wrap into a new row).
|
||||
- Docker: normalized line endings for entrypoint to avoid Windows checkout issues.
|
|
@ -332,6 +332,73 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
return templates.TemplateResponse("build/_new_deck_tags.html", ctx)
|
||||
|
||||
|
||||
@router.get("/new/multicopy", response_class=HTMLResponse)
|
||||
async def build_new_multicopy(
|
||||
request: Request,
|
||||
commander: str = Query(""),
|
||||
primary_tag: str | None = Query(None),
|
||||
secondary_tag: str | None = Query(None),
|
||||
tertiary_tag: str | None = Query(None),
|
||||
tag_mode: str | None = Query("AND"),
|
||||
) -> HTMLResponse:
|
||||
"""Return multi-copy suggestions for the New Deck modal based on commander + selected tags.
|
||||
|
||||
This does not mutate the session; it simply renders a form snippet that posts with the main modal.
|
||||
"""
|
||||
name = (commander or "").strip()
|
||||
if not name:
|
||||
return HTMLResponse("")
|
||||
try:
|
||||
tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True)
|
||||
df = tmp.load_commander_data()
|
||||
row = df[df["name"].astype(str) == name]
|
||||
if row.empty:
|
||||
return HTMLResponse("")
|
||||
tmp._apply_commander_selection(row.iloc[0])
|
||||
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
||||
tmp.selected_tags = list(tags or [])
|
||||
try:
|
||||
tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None
|
||||
tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None
|
||||
tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
tmp.determine_color_identity()
|
||||
except Exception:
|
||||
pass
|
||||
results = bu.detect_viable_multi_copy_archetypes(tmp) or []
|
||||
# For the New Deck modal, only show suggestions where the matched tags intersect
|
||||
# the explicitly selected tags (ignore commander-default themes).
|
||||
sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()}
|
||||
def _matched_reason_tags(item: dict) -> set[str]:
|
||||
out = set()
|
||||
try:
|
||||
for r in item.get('reasons', []) or []:
|
||||
if not isinstance(r, str):
|
||||
continue
|
||||
rl = r.strip().lower()
|
||||
if rl.startswith('tags:'):
|
||||
body = rl.split('tags:', 1)[1].strip()
|
||||
parts = [p.strip() for p in body.split(',') if p.strip()]
|
||||
out.update(parts)
|
||||
except Exception:
|
||||
return set()
|
||||
return out
|
||||
if sel_tags:
|
||||
results = [it for it in results if (_matched_reason_tags(it) & sel_tags)]
|
||||
else:
|
||||
# If no selected tags, do not show any multi-copy suggestions in the modal
|
||||
results = []
|
||||
if not results:
|
||||
return HTMLResponse("")
|
||||
items = results[:5]
|
||||
ctx = {"request": request, "items": items}
|
||||
return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx)
|
||||
except Exception:
|
||||
return HTMLResponse("")
|
||||
|
||||
|
||||
@router.post("/new", response_class=HTMLResponse)
|
||||
async def build_new_submit(
|
||||
request: Request,
|
||||
|
@ -353,6 +420,11 @@ async def build_new_submit(
|
|||
prefer_combos: bool = Form(False),
|
||||
combo_count: int | None = Form(None),
|
||||
combo_balance: str | None = Form(None),
|
||||
enable_multicopy: bool = Form(False),
|
||||
# Integrated Multi-Copy (optional)
|
||||
multi_choice_id: str | None = Form(None),
|
||||
multi_count: int | None = Form(None),
|
||||
multi_thrumming: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
@ -440,6 +512,39 @@ async def build_new_submit(
|
|||
sess["combo_balance"] = bval
|
||||
except Exception:
|
||||
pass
|
||||
# Multi-Copy selection from modal (opt-in)
|
||||
try:
|
||||
# Clear any prior selection first; this flow should define it explicitly when present
|
||||
if "multi_copy" in sess:
|
||||
del sess["multi_copy"]
|
||||
if enable_multicopy and multi_choice_id and str(multi_choice_id).strip():
|
||||
meta = bc.MULTI_COPY_ARCHETYPES.get(str(multi_choice_id), {})
|
||||
printed_cap = meta.get("printed_cap")
|
||||
cnt: int
|
||||
if multi_count is None:
|
||||
cnt = int(meta.get("default_count", 25))
|
||||
else:
|
||||
try:
|
||||
cnt = int(multi_count)
|
||||
except Exception:
|
||||
cnt = int(meta.get("default_count", 25))
|
||||
if isinstance(printed_cap, int) and printed_cap > 0:
|
||||
cnt = max(1, min(printed_cap, cnt))
|
||||
sess["multi_copy"] = {
|
||||
"id": str(multi_choice_id),
|
||||
"name": meta.get("name") or str(multi_choice_id),
|
||||
"count": int(cnt),
|
||||
"thrumming": True if (multi_thrumming and str(multi_thrumming).strip() in ("1","true","on","yes")) else False,
|
||||
}
|
||||
else:
|
||||
# Ensure disabled when not opted-in
|
||||
if "multi_copy" in sess:
|
||||
del sess["multi_copy"]
|
||||
# Reset the applied marker so the run can account for the new selection
|
||||
if "mc_applied_key" in sess:
|
||||
del sess["mc_applied_key"]
|
||||
except Exception:
|
||||
pass
|
||||
# Clear any old staged build context
|
||||
for k in ["build_ctx", "locks", "replace_mode"]:
|
||||
if k in sess:
|
||||
|
@ -447,11 +552,10 @@ async def build_new_submit(
|
|||
del sess[k]
|
||||
except Exception:
|
||||
pass
|
||||
# Reset multi-copy suggestion debounce and selection for a fresh run
|
||||
for k in ["mc_seen_keys", "multi_copy"]:
|
||||
if k in sess:
|
||||
# Reset multi-copy suggestion debounce for a fresh run (keep selected choice)
|
||||
if "mc_seen_keys" in sess:
|
||||
try:
|
||||
del sess[k]
|
||||
del sess["mc_seen_keys"]
|
||||
except Exception:
|
||||
pass
|
||||
# Persist optional custom export base name
|
||||
|
@ -496,6 +600,13 @@ async def build_new_submit(
|
|||
# Centralized staged context creation
|
||||
sess["build_ctx"] = start_ctx_from_session(sess)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
|
||||
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
|
||||
try:
|
||||
if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
|
||||
mc = sess.get("multi_copy")
|
||||
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
|
||||
except Exception:
|
||||
pass
|
||||
status = "Build complete" if res.get("done") else "Stage complete"
|
||||
sess["last_step"] = 5
|
||||
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False)
|
||||
|
|
|
@ -84,7 +84,7 @@ body {
|
|||
.top-banner{ min-height: var(--banner-h); }
|
||||
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
|
||||
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
|
||||
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; }
|
||||
.banner-status.busy{ color:#fbbf24; }
|
||||
.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
|
||||
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
|
||||
|
@ -107,6 +107,27 @@ body {
|
|||
}
|
||||
.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
|
||||
|
||||
/* Collapsible sidebar behavior */
|
||||
body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); }
|
||||
body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; }
|
||||
body.nav-collapsed .content{ grid-column: 2; }
|
||||
body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; }
|
||||
body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; }
|
||||
/* Smooth hide/show on mobile while keeping fixed positioning */
|
||||
.sidebar{ transition: transform .2s ease-out, visibility .2s linear; }
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 900px){
|
||||
:root{ --sidebar-w: 240px; }
|
||||
.top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem .5rem; }
|
||||
.banner-status{ padding-left: .5rem; }
|
||||
.layout{ grid-template-columns: 0 1fr; }
|
||||
.sidebar{ transform: translateX(-100%); visibility: hidden; }
|
||||
body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
|
||||
body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
|
||||
.content{ padding: .9rem .8rem; }
|
||||
}
|
||||
|
||||
.brand h1{ display:none; }
|
||||
.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
|
||||
.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
|
||||
|
@ -128,6 +149,13 @@ body {
|
|||
|
||||
/* Left-rail variant puts the image first */
|
||||
.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
|
||||
/* Ensure left-rail variant also collapses to 1 column on small screens */
|
||||
@media (max-width: 900px){
|
||||
.two-col.two-col-left-rail{ grid-template-columns: 1fr; }
|
||||
/* So the commander image doesn't dominate on mobile */
|
||||
.two-col .card-preview{ max-width: 360px; margin: 0 auto; }
|
||||
.two-col .card-preview img{ width: 100%; height: auto; }
|
||||
}
|
||||
.card-preview.card-sm{ max-width:200px; }
|
||||
|
||||
/* Buttons, inputs */
|
||||
|
@ -184,6 +212,11 @@ small, .muted{ color: var(--muted); }
|
|||
margin-top:.5rem;
|
||||
justify-content: start; /* pack as many as possible per row */
|
||||
}
|
||||
@media (max-width: 420px){
|
||||
.card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.card-tile{ width: 100%; }
|
||||
.card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; }
|
||||
}
|
||||
.card-tile{
|
||||
width:170px;
|
||||
position: relative;
|
||||
|
@ -256,9 +289,14 @@ small, .muted{ color: var(--muted); }
|
|||
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
|
||||
.stage-nav .name { font-size:12px; }
|
||||
|
||||
/* Build controls sticky box tweaks for small screens */
|
||||
/* Build controls sticky box tweaks */
|
||||
.build-controls { top: calc(var(--banner-offset, 48px) + 6px); }
|
||||
@media (max-width: 720px){
|
||||
.build-controls { position: sticky; top: 0; border-radius: 0; margin-left: -1.5rem; margin-right: -1.5rem; }
|
||||
:root { --banner-offset: 56px; }
|
||||
.build-controls { position: sticky; border-radius: 8px; margin-left: 0; margin-right: 0; }
|
||||
}
|
||||
@media (min-width: 721px){
|
||||
:root { --banner-offset: 48px; }
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250828-14" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250902-3" />
|
||||
<!-- Performance hints -->
|
||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||
|
@ -45,7 +45,12 @@
|
|||
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
||||
<header class="top-banner">
|
||||
<div class="top-inner">
|
||||
<h1>MTG Deckbuilder</h1>
|
||||
<div style="display:flex; align-items:center; gap:.5rem; padding-left: 1rem;">
|
||||
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
|
||||
☰ Menu
|
||||
</button>
|
||||
<h1 style="margin:0;">MTG Deckbuilder</h1>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:.5rem">
|
||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||
|
@ -70,7 +75,7 @@
|
|||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<aside id="sidebar" class="sidebar" aria-label="Primary navigation">
|
||||
<div class="brand">
|
||||
<div class="mana-dots" aria-hidden="true">
|
||||
<span class="dot green"></span>
|
||||
|
@ -117,9 +122,49 @@
|
|||
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
|
||||
.site-footer a { color: #cbd5e1; text-decoration: underline; }
|
||||
footer.site-footer { flex-shrink: 0; }
|
||||
/* Hide hover preview on narrow screens to avoid covering content */
|
||||
@media (max-width: 900px){
|
||||
.card-hover{ display: none !important; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
// Sidebar toggle and persistence
|
||||
try{
|
||||
var BODY = document.body;
|
||||
var SIDEBAR = document.getElementById('sidebar');
|
||||
var TOGGLE = document.getElementById('nav-toggle');
|
||||
var KEY = 'mtg:navCollapsed';
|
||||
function apply(collapsed){
|
||||
if (collapsed){
|
||||
BODY.classList.add('nav-collapsed');
|
||||
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'false');
|
||||
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
BODY.classList.remove('nav-collapsed');
|
||||
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'true');
|
||||
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
}
|
||||
// Initial state: respect saved pref, else collapse on small screens
|
||||
var saved = localStorage.getItem(KEY);
|
||||
var initialCollapsed = (saved === '1') || (saved === null && (window.innerWidth || 0) < 900);
|
||||
apply(initialCollapsed);
|
||||
if (TOGGLE){
|
||||
TOGGLE.addEventListener('click', function(){
|
||||
var isCollapsed = BODY.classList.contains('nav-collapsed');
|
||||
apply(!isCollapsed);
|
||||
try{ localStorage.setItem(KEY, (!isCollapsed) ? '1' : '0'); }catch(_){ }
|
||||
});
|
||||
}
|
||||
// Keep ARIA in sync on resize for first-load default when no pref yet
|
||||
window.addEventListener('resize', function(){
|
||||
// Do not override if user has an explicit preference saved
|
||||
if (localStorage.getItem(KEY) !== null) return;
|
||||
apply((window.innerWidth || 0) < 900);
|
||||
});
|
||||
}catch(_){ }
|
||||
|
||||
// Setup/Tagging status poller
|
||||
var statusEl;
|
||||
function ensureStatusEl(){
|
||||
|
|
|
@ -1 +1 @@
|
|||
<div id="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>
|
||||
<div id="banner-status" class="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
|
||||
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
|
||||
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:flex-start; justify-content:center; padding:1rem; overflow:auto;">
|
||||
<div class="modal-backdrop" style="position:fixed; inset:0; background:rgba(0,0,0,.6);"></div>
|
||||
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem; max-height:min(92vh, 100%); overflow:auto; -webkit-overflow-scrolling:touch;">
|
||||
<div class="modal-header">
|
||||
<h3 id="newDeckTitle">Build a New Deck</h3>
|
||||
</div>
|
||||
|
@ -39,6 +39,7 @@
|
|||
<input type="hidden" name="tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
</div>
|
||||
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
|
||||
<div style="margin-top:.5rem;">
|
||||
<label>Bracket
|
||||
<select name="bracket">
|
||||
|
@ -54,6 +55,10 @@
|
|||
<label title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" /> Prioritize combos (auto-complete partners)
|
||||
</label>
|
||||
<div style="margin-top:.35rem;"></div>
|
||||
<label title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" /> Enable Multi-Copy package
|
||||
</label>
|
||||
<div id="pref-combos-config" style="margin-top:.5rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; display:none;">
|
||||
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
|
||||
<label>
|
||||
|
@ -95,14 +100,42 @@
|
|||
|
||||
<script>
|
||||
(function(){
|
||||
// Backdrop click to close
|
||||
try{
|
||||
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
|
||||
var backdrop = modal ? modal.querySelector('.modal-backdrop') : null;
|
||||
if (backdrop){ backdrop.addEventListener('click', function(){ try{ modal.remove(); }catch(_){} }); }
|
||||
}catch(_){ }
|
||||
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
|
||||
// Prevent Enter in text inputs from submitting the form
|
||||
try {
|
||||
var form = modal ? modal.querySelector('form') : document.querySelector('.modal form');
|
||||
if (form){
|
||||
// Prevent Enter in name field from submitting
|
||||
// Form-level Enter: if suggestions exist, pick the first by default
|
||||
form.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (e.key !== 'Enter' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var first = list && list.querySelector('button.candidate-btn');
|
||||
if (first){
|
||||
e.preventDefault(); e.stopPropagation(); first.click();
|
||||
} else {
|
||||
// No suggestions: allow normal form submit
|
||||
}
|
||||
}catch(_){ }
|
||||
}, true);
|
||||
// Name field: only block Enter if suggestions exist; otherwise allow submit
|
||||
var nameEl = form.querySelector('input[name="name"]');
|
||||
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
|
||||
if (nameEl){
|
||||
nameEl.addEventListener('keydown', function(e){
|
||||
if (e.key !== 'Enter') return;
|
||||
try{
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
|
||||
if (hasBtns){ e.preventDefault(); e.stopPropagation(); }
|
||||
}catch(_){ }
|
||||
});
|
||||
}
|
||||
// In commander field, Enter picks the first candidate (if any) without closing the modal
|
||||
var cmdEl = form.querySelector('input[name=\"commander\"]');
|
||||
if (cmdEl){
|
||||
|
@ -186,4 +219,62 @@
|
|||
sync();
|
||||
} catch(_){}
|
||||
})();
|
||||
|
||||
// Integrated Multi-Copy: fetch suggestions once commander + tags are present
|
||||
(function(){
|
||||
function fetchMulti(){
|
||||
try{
|
||||
var slot = document.getElementById('newdeck-multicopy-slot');
|
||||
var form = document.querySelector('.modal form');
|
||||
if (!slot || !form) return;
|
||||
var enable = form.querySelector('#pref-mc-chk');
|
||||
if (!enable || !enable.checked){ slot.innerHTML = ''; return; }
|
||||
var cmd = form.querySelector('input[name="commander"]').value.trim();
|
||||
var p = form.querySelector('input[name="primary_tag"]').value.trim();
|
||||
var s = form.querySelector('input[name="secondary_tag"]').value.trim();
|
||||
var t = form.querySelector('input[name="tertiary_tag"]').value.trim();
|
||||
var mode = form.querySelector('input[name="tag_mode"]').value.trim() || 'AND';
|
||||
if (!cmd) { slot.innerHTML = ''; return; }
|
||||
// Only fetch if at least one tag is present (to avoid noise)
|
||||
if (!(p || s || t)) { slot.innerHTML = ''; return; }
|
||||
var params = new URLSearchParams({ commander: cmd, tag_mode: mode });
|
||||
if (p) params.append('primary_tag', p); if (s) params.append('secondary_tag', s); if (t) params.append('tertiary_tag', t);
|
||||
fetch('/build/new/multicopy?' + params.toString(), { headers: { 'HX-Request': 'true' } })
|
||||
.then(function(r){ return r.text(); })
|
||||
.then(function(html){ slot.innerHTML = html; })
|
||||
.catch(function(){ slot.innerHTML = ''; });
|
||||
}catch(_){ }
|
||||
}
|
||||
// Listen for OOB updates to the tags slot to trigger fetch
|
||||
document.body.addEventListener('htmx:afterSwap', function(ev){
|
||||
try{
|
||||
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
|
||||
if (tgt && tgt.id === 'newdeck-tags-slot'){ fetchMulti(); }
|
||||
}catch(_){ }
|
||||
});
|
||||
// Respond to explicit tag-change signal from the tags partial
|
||||
try{ document.addEventListener('newdeck:tagsChanged', fetchMulti); }catch(_){ }
|
||||
// Also debounce on commander input changes
|
||||
try{
|
||||
var cmdEl = document.querySelector('.modal form input[name="commander"]');
|
||||
var timer;
|
||||
if (cmdEl){ cmdEl.addEventListener('input', function(){ clearTimeout(timer); timer = setTimeout(fetchMulti, 300); }); }
|
||||
}catch(_){ }
|
||||
// React to preference toggle
|
||||
try{
|
||||
var mcChk = document.querySelector('.modal form #pref-mc-chk');
|
||||
if (mcChk){ mcChk.addEventListener('change', fetchMulti); }
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Modal responsive tweaks (scoped) */
|
||||
@media (max-width: 720px){
|
||||
.modal .basics-grid{ grid-template-columns: 1fr !important; }
|
||||
#newdeck-commander-slot{ max-width: 100% !important; }
|
||||
#newdeck-commander-slot aside.card-preview{ max-width: 100% !important; }
|
||||
#newdeck-commander-slot img{ width: 100% !important; max-width: 260px; height: auto; margin: 0 auto; display: block; }
|
||||
.modal .modal-content{ width: min(95vw, 560px) !important; }
|
||||
}
|
||||
</style>
|
||||
|
|
59
code/web/templates/build/_new_deck_multicopy.html
Normal file
59
code/web/templates/build/_new_deck_multicopy.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
{% if items and items|length %}
|
||||
<fieldset id="mc-integrated" style="margin-top:.75rem;">
|
||||
<legend>Optional: Multi-Copy package</legend>
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.</div>
|
||||
<div style="display:grid; gap:.5rem;">
|
||||
{% for it in items %}
|
||||
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
|
||||
<input type="radio" name="multi_choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
|
||||
<div>
|
||||
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
|
||||
{% if it.reasons %}
|
||||
<div class="muted" style="font-size:12px;">Signals: {{ ', '.join(it.reasons) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% set first = items[0] %}
|
||||
{% set cap = first.printed_cap %}
|
||||
{% set rec = first.rec_window if first.rec_window else (20,30) %}
|
||||
<div id="mc-count-row" class="mc-count" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-top:.5rem;">
|
||||
<label>Copies <input type="number" min="1" name="multi_count" value="{{ first.default_count or 25 }}" style="width:6rem; margin-left:.35rem;"></label>
|
||||
{% if cap %}
|
||||
<small class="muted">Max {{ cap }}</small>
|
||||
{% else %}
|
||||
<small class="muted">Suggested {{ rec[0] }}–{{ rec[1] }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="mc-thrum-row" style="margin-top:.35rem;">
|
||||
<label title="Adds 1 copy of Thrumming Stone if applicable.">
|
||||
<input type="checkbox" name="multi_thrumming" value="1" {% if first.thrumming_stone_synergy %}checked{% endif %} /> Include Thrumming Stone
|
||||
</label>
|
||||
</div>
|
||||
<div class="muted" style="font-size:12px; margin-top:.35rem;">You can leave this unselected to skip multi-copy for this build.</div>
|
||||
</fieldset>
|
||||
<script>
|
||||
(function(){
|
||||
var root = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling : document;
|
||||
var container = root.querySelector ? root : document;
|
||||
var fieldset = container.querySelector('#mc-integrated');
|
||||
if (!fieldset) return;
|
||||
function updateForChoice(){
|
||||
try{
|
||||
var checked = fieldset.querySelector('input[name="multi_choice_id"]:checked');
|
||||
var count = fieldset.querySelector('input[name="multi_count"]');
|
||||
if (!checked || !count) return;
|
||||
// Use label text to parse Cap when present
|
||||
var label = checked.closest('label.mc-option');
|
||||
var capEl = label && label.querySelector('.muted');
|
||||
var m = capEl && capEl.textContent && capEl.textContent.match(/Cap:\s*(\d+)/);
|
||||
if (m){ var cap = parseInt(m[1],10); count.max = String(cap); if (parseInt(count.value||'0',10) > cap) count.value = String(cap); }
|
||||
else { count.removeAttribute('max'); }
|
||||
}catch(_){}
|
||||
}
|
||||
fieldset.querySelectorAll('input[name="multi_choice_id"]').forEach(function(r){ r.addEventListener('change', updateForChoice); });
|
||||
updateForChoice();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
|
@ -94,6 +94,8 @@
|
|||
}catch(_){ }
|
||||
function apply(container){ if(!container) return; var chips = container.querySelectorAll('button.chip'); chips.forEach(function(btn){ var tag=btn.dataset.tag||''; var active=getSel().indexOf(tag)>=0; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active?'true':'false'); }); }
|
||||
apply(list); apply(reco);
|
||||
// Notify parent modal so it can refresh multi-copy suggestions
|
||||
try{ document.dispatchEvent(new CustomEvent('newdeck:tagsChanged')); }catch(_){ }
|
||||
}
|
||||
if (resetBtn) resetBtn.addEventListener('click', function(){ setSel([]); });
|
||||
list.querySelectorAll('button.chip').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); });
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
|
||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
{% if locks_restored and locks_restored > 0 %}
|
||||
<div class="muted" style="margin:.35rem 0;">
|
||||
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
|
@ -137,7 +137,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Sticky build controls on mobile -->
|
||||
<div class="build-controls" style="position:sticky; top:0; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||
<div class="build-controls" style="position:sticky; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||
|
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "mtg-deckbuilder"
|
||||
version = "2.2.3"
|
||||
version = "2.2.4"
|
||||
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue