From 6a94b982cb9402ba1b1715bcf936e0690ec5802e Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 28 Oct 2025 16:17:55 -0700 Subject: [PATCH] overhaul: migrated basic JavaScript to TypeScript, began consolidation efforts --- CHANGELOG.md | 11 + RELEASE_NOTES_TEMPLATE.md | 20 +- code/web/routes/build.py | 29 +- .../{ => js_backup_pre_typescript}/app.js | 0 .../components.js | 0 code/web/static/styles.css | 54 +- code/web/static/tailwind.css | 42 +- code/web/static/ts/app.ts | 1393 +++++++++++++++++ code/web/static/ts/components.ts | 382 +++++ code/web/static/ts/types.ts | 105 ++ code/web/templates/base.html | 4 +- .../templates/build/_new_deck_candidates.html | 6 +- code/web/templates/build/_new_deck_modal.html | 4 +- code/web/templates/build/index.html | 14 +- .../templates/commanders/row_wireframe.html | 22 +- 15 files changed, 2012 insertions(+), 74 deletions(-) rename code/web/static/{ => js_backup_pre_typescript}/app.js (100%) rename code/web/static/{ => js_backup_pre_typescript}/components.js (100%) create mode 100644 code/web/static/ts/app.ts create mode 100644 code/web/static/ts/components.ts create mode 100644 code/web/static/ts/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fbd36b..d414395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Interactive examples of all buttons, modals, forms, cards, and panels - Jinja2 macros for consistent component usage - Component partial templates for reuse across pages +- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety + - Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript + - Created shared type definitions for state management, telemetry, HTMX, and UI components + - Integrated TypeScript compilation into build process (`npm run build:ts`) + - Compiled JavaScript output in `code/web/static/js/` directory + - Docker build automatically compiles TypeScript during image creation ### Changed - **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture @@ -26,6 +32,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - PostCSS build pipeline with autoprefixer - Reduced inline styles in templates (moved to shared CSS classes) - Organized CSS into functional sections with clear documentation + - **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast +- **JavaScript Modernization**: Updated to modern JavaScript patterns + - Converted `var` declarations to `const`/`let` + - Added TypeScript type annotations for better IDE support and error catching + - Consolidated event handlers and utility functions - **Docker Build Optimization**: Improved developer experience - Hot reload enabled for templates and static files - Volume mounts for rapid iteration without rebuilds diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index ce0fdbf..6fc8334 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Summary -Web UI improvements with Tailwind CSS migration, component library, and optional card image caching for faster performance. +Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, and optional card image caching for faster performance and better maintainability. ### Added - **Card Image Caching**: Optional local image cache for faster card display @@ -15,14 +15,24 @@ Web UI improvements with Tailwind CSS migration, component library, and optional - Interactive examples of all UI components - Reusable Jinja2 macros for consistent design - Component partial templates for reuse across pages +- **TypeScript Support**: Migrated JavaScript to TypeScript for better code quality + - Type definitions for state management, telemetry, and UI components + - Improved IDE support with autocomplete and type checking + - Integrated into build process (compiles during Docker build) ### Changed - **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture - Tailwind CSS v3 with custom MTG color palette - PostCSS build pipeline with autoprefixer - Minimized inline styles in favor of shared CSS classes + - **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast +- **JavaScript Modernization**: Updated to modern JavaScript patterns + - Converted to TypeScript for better type safety + - Replaced `var` with `const`/`let` throughout + - Improved error handling and code organization - **Docker Build Optimization**: Improved developer experience - Hot reload for templates and CSS (no rebuild needed) + - TypeScript compilation integrated into build process - **Template Modernization**: Migrated templates to use component system ### Removed @@ -35,11 +45,19 @@ _None_ - Hot reload for CSS/template changes (no Docker rebuild needed) - Optional image caching reduces Scryfall API calls - Faster page loads with optimized CSS +- TypeScript compilation produces optimized JavaScript ### For Users - Faster card image loading with optional caching - Cleaner, more consistent web UI design - Improved page load performance +- More reliable JavaScript behavior + +### For Developers +- TypeScript provides better IDE support and error detection +- Clear type definitions for all JavaScript utilities +- Easier onboarding with typed interfaces +- Automated build process handles TypeScript compilation ### Deprecated _None_ diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 18e01c3..8cab2c0 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -1108,6 +1108,8 @@ async def build_index(request: Request) -> HTMLResponse: if q_commander: # Persist a human-friendly commander name into session for the wizard sess["commander"] = str(q_commander) + # Set flag to indicate this is a quick-build scenario + sess["quick_build"] = True except Exception: pass return_url = None @@ -1147,12 +1149,17 @@ async def build_index(request: Request) -> HTMLResponse: last_step = 2 else: last_step = 1 + # Only pass commander to template if coming from commander browser (?commander= query param) + # This prevents stale commander from being pre-filled on subsequent builds + # The query param only exists on initial navigation from commander browser + should_auto_fill = q_commander is not None + resp = templates.TemplateResponse( request, "build/index.html", { "sid": sid, - "commander": sess.get("commander"), + "commander": sess.get("commander") if should_auto_fill else None, "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, @@ -1350,13 +1357,18 @@ async def build_new_modal(request: Request) -> HTMLResponse: for key in skip_keys: sess.pop(key, None) - # M2: Clear commander and form selections for fresh start - commander_keys = [ - "commander", "partner", "background", "commander_mode", - "themes", "bracket" - ] - for key in commander_keys: - sess.pop(key, None) + # M2: Check if this is a quick-build scenario (from commander browser) + # Use the quick_build flag set by /build route when ?commander= param present + is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag + + # M2: Clear commander and form selections for fresh start (unless quick build) + if not is_quick_build: + commander_keys = [ + "commander", "partner", "background", "commander_mode", + "themes", "bracket" + ] + for key in commander_keys: + sess.pop(key, None) theme_context = _custom_theme_context(request, sess) ctx = { @@ -1370,6 +1382,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "enable_batch_build": ENABLE_BATCH_BUILD, "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' "form": { + "commander": sess.get("commander", ""), # Pre-fill for quick-build "prefer_combos": bool(sess.get("prefer_combos")), "combo_count": sess.get("combo_target_count"), "combo_balance": sess.get("combo_balance"), diff --git a/code/web/static/app.js b/code/web/static/js_backup_pre_typescript/app.js similarity index 100% rename from code/web/static/app.js rename to code/web/static/js_backup_pre_typescript/app.js diff --git a/code/web/static/components.js b/code/web/static/js_backup_pre_typescript/components.js similarity index 100% rename from code/web/static/components.js rename to code/web/static/js_backup_pre_typescript/components.js diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 3cbc2f8..0dbbdd6 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -1113,19 +1113,31 @@ video { [data-theme="light-blend"]{ --bg: #e8e2d0; - /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ - --panel: #ffffff; - /* crisp panels for readability */ - --text: #0b0d12; + /* warm beige background (keep existing) */ + --panel: #ebe5d8; + /* lighter warm cream - more contrast with bg, subtle panels */ + --text: #1a1410; + /* dark brown for readability */ --muted: #6b655d; - /* slightly warm muted */ - --border: #d6d1c7; - /* neutral warm-gray border */ - /* Slightly darker banner/sidebar for separation */ - --surface-banner: #1a1b1e; - --surface-sidebar: #1a1b1e; - --surface-banner-text: #e8e8e8; - --surface-sidebar-text: #e8e8e8; + /* warm muted brown (keep existing) */ + --border: #bfb5a3; + /* darker warm-gray border for better definition */ + /* Navbar/banner: darker warm brown for hierarchy */ + --surface-banner: #9b8f7a; + /* warm medium brown - darker than panels, lighter than dark theme */ + --surface-sidebar: #9b8f7a; + /* match banner for consistency */ + --surface-banner-text: #1a1410; + /* dark brown text on medium brown bg */ + --surface-sidebar-text: #1a1410; + /* dark brown text on medium brown bg */ + /* Button colors: use taupe for buttons so they stand out from light panels */ + --btn-bg: #d4cbb8; + /* medium warm taupe - stands out against light panels */ + --btn-text: #1a1410; + /* dark brown text */ + --btn-hover-bg: #c4b9a5; + /* darker taupe on hover */ } [data-theme="dark"]{ @@ -1880,23 +1892,25 @@ small, .muted{ /* Home page darker buttons */ .home-button.btn-secondary { - background: #1a1d24; - border-color: #2a2d35; + background: var(--btn-bg, #1a1d24); + color: var(--btn-text, #e8e8e8); + border-color: var(--border); } .home-button.btn-secondary:hover { - background: #22252d; - border-color: #3a3d45; + background: var(--btn-hover-bg, #22252d); + border-color: var(--border); } .home-button.btn-primary { - background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15)); - border-color: #2a5580; + background: var(--blue-main); + color: white; + border-color: var(--blue-main); } .home-button.btn-primary:hover { - background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25)); - border-color: #3a6590; + background: #0c5aa6; + border-color: #0c5aa6; } /* Card grid for added cards (responsive, compact tiles) */ diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css index 94c3b68..1e1abae 100644 --- a/code/web/static/tailwind.css +++ b/code/web/static/tailwind.css @@ -39,16 +39,20 @@ /* Light blend between Slate and Parchment (leans gray) */ [data-theme="light-blend"]{ - --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ - --panel: #ffffff; /* crisp panels for readability */ - --text: #0b0d12; - --muted: #6b655d; /* slightly warm muted */ - --border: #d6d1c7; /* neutral warm-gray border */ - /* Slightly darker banner/sidebar for separation */ - --surface-banner: #1a1b1e; - --surface-sidebar: #1a1b1e; - --surface-banner-text: #e8e8e8; - --surface-sidebar-text: #e8e8e8; + --bg: #e8e2d0; /* warm beige background (keep existing) */ + --panel: #ebe5d8; /* lighter warm cream - more contrast with bg, subtle panels */ + --text: #1a1410; /* dark brown for readability */ + --muted: #6b655d; /* warm muted brown (keep existing) */ + --border: #bfb5a3; /* darker warm-gray border for better definition */ + /* Navbar/banner: darker warm brown for hierarchy */ + --surface-banner: #9b8f7a; /* warm medium brown - darker than panels, lighter than dark theme */ + --surface-sidebar: #9b8f7a; /* match banner for consistency */ + --surface-banner-text: #1a1410; /* dark brown text on medium brown bg */ + --surface-sidebar-text: #1a1410; /* dark brown text on medium brown bg */ + /* Button colors: use taupe for buttons so they stand out from light panels */ + --btn-bg: #d4cbb8; /* medium warm taupe - stands out against light panels */ + --btn-text: #1a1410; /* dark brown text */ + --btn-hover-bg: #c4b9a5; /* darker taupe on hover */ } [data-theme="dark"]{ @@ -282,20 +286,22 @@ small, .muted{ color: var(--muted); } /* Home page darker buttons */ .home-button.btn-secondary { - background: #1a1d24; - border-color: #2a2d35; + background: var(--btn-bg, #1a1d24); + color: var(--btn-text, #e8e8e8); + border-color: var(--border); } .home-button.btn-secondary:hover { - background: #22252d; - border-color: #3a3d45; + background: var(--btn-hover-bg, #22252d); + border-color: var(--border); } .home-button.btn-primary { - background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15)); - border-color: #2a5580; + background: var(--blue-main); + color: white; + border-color: var(--blue-main); } .home-button.btn-primary:hover { - background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25)); - border-color: #3a6590; + background: #0c5aa6; + border-color: #0c5aa6; } /* Card grid for added cards (responsive, compact tiles) */ diff --git a/code/web/static/ts/app.ts b/code/web/static/ts/app.ts new file mode 100644 index 0000000..db02622 --- /dev/null +++ b/code/web/static/ts/app.ts @@ -0,0 +1,1393 @@ +/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */ +import type { StateManager, TelemetryManager, ToastOptions, SkeletonManager } from './types.js'; + +(function(){ + // Design tokens fallback (in case CSS variables missing in older browsers) + // No-op here since styles.css defines variables; kept for future JS reads. + + // State persistence helpers (localStorage + URL hash) + const state: StateManager = { + get: function(key: string, def?: any): any { + try { const v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; } + }, + set: function(key: string, val: any): void { + try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){} + }, + inHash: function(obj: Record): void { + // Merge obj into location.hash as query-like params + try { + const params = new URLSearchParams((location.hash||'').replace(/^#/, '')); + Object.keys(obj||{}).forEach(function(k: string){ params.set(k, obj[k]); }); + location.hash = params.toString(); + } catch(e){} + }, + readHash: function(): URLSearchParams { + try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); } + } + }; + window.__mtgState = state; + + // Toast system + let toastHost: HTMLElement | null = null; + function ensureToastHost(): HTMLElement { + if (!toastHost){ + toastHost = document.createElement('div'); + toastHost.className = 'toast-host'; + document.body.appendChild(toastHost); + } + return toastHost; + } + function toast(msg: string | HTMLElement, type?: string, opts?: ToastOptions): HTMLElement { + ensureToastHost(); + const t = document.createElement('div'); + t.className = 'toast' + (type ? ' '+type : ''); + t.setAttribute('role','status'); + t.setAttribute('aria-live','polite'); + t.textContent = ''; + if (typeof msg === 'string') { t.textContent = msg; } + else if (msg && msg.nodeType === 1) { t.appendChild(msg); } + toastHost!.appendChild(t); + const delay = (opts && opts.duration) || 2600; + setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay); + return t; + } + window.toast = toast; + function toastHTML(html: string, type?: string, opts?: ToastOptions): HTMLElement { + const container = document.createElement('div'); + container.innerHTML = html; + return toast(container, type, opts); + } + window.toastHTML = toastHTML; + + const telemetryEndpoint: string = (function(): string { + if (typeof window.__telemetryEndpoint === 'string' && window.__telemetryEndpoint.trim()){ + return window.__telemetryEndpoint.trim(); + } + return '/telemetry/events'; + })(); + const telemetry: TelemetryManager = { + send: function(eventName: string, data?: Record): void { + if (!telemetryEndpoint || !eventName) return; + let payload: string; + try { + payload = JSON.stringify({ event: eventName, data: data || {}, ts: Date.now() }); + } catch(_){ return; } + try { + if (navigator.sendBeacon){ + const blob = new Blob([payload], { type: 'application/json' }); + navigator.sendBeacon(telemetryEndpoint, blob); + } else if (window.fetch){ + fetch(telemetryEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + }).catch(function(){ /* noop */ }); + } + } catch(_){ } + } + }; + window.appTelemetry = telemetry; + + // Global HTMX error handling => toast + document.addEventListener('htmx:responseError', function(e){ + const detail = e.detail || {} as any; + const xhr = detail.xhr || {} as any; + const rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || ''; + const payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })() as any; + const status = payload.status || xhr.status || ''; + const msg = payload.detail || payload.message || 'Action failed'; + const path = payload.path || (e && e.detail && e.detail.path) || ''; + const html = ''+ + '
'+ + ''+String(msg)+''+ (status? ' ('+status+')' : '')+ + (rid ? '' : '')+ + '
'+ + (rid ? '
Request-ID: '+rid+'
' : ''); + const t = toastHTML(html, 'error', { duration: 7000 }); + // Wire Copy + const btn = t.querySelector('[data-copy-error]') as HTMLButtonElement; + if (btn){ + btn.addEventListener('click', function(){ + const lines = [ + 'Error: '+String(msg), + 'Status: '+String(status), + 'Path: '+String(path || (xhr.responseURL||'')), + 'Request-ID: '+String(rid) + ]; + try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ } + }); + } + // Optional inline banner if a surface is available + try { + const target = e && e.target as HTMLElement; + const surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]'); + if (surface){ + const banner = document.createElement('div'); + banner.className = 'inline-error-banner'; + banner.innerHTML = ''+String(msg)+'' + (rid? ' (Request-ID: '+rid+')' : ''); + surface.prepend(banner); + setTimeout(function(){ banner.remove(); }, 8000); + } + } catch(_){ } + }); + document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); }); + + // Keyboard shortcuts + const keymap: Record void> = { + ' ': function(){ const el = document.querySelector('[data-action="continue"], .btn-continue') as HTMLElement; if (el) el.click(); }, + 'r': function(){ const el = document.querySelector('[data-action="rerun"], .btn-rerun') as HTMLElement; if (el) el.click(); }, + 'b': function(){ const el = document.querySelector('[data-action="back"], .btn-back') as HTMLElement; if (el) el.click(); }, + 'l': function(){ const el = document.querySelector('[data-action="toggle-logs"], .btn-logs') as HTMLElement; if (el) el.click(); }, + }; + document.addEventListener('keydown', function(e){ + const target = e.target as HTMLElement; + if (target && (/input|textarea|select/i).test(target.tagName)) return; // don't hijack inputs + const k = e.key.toLowerCase(); + // If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock) + try { + const active = document.activeElement as HTMLElement; + if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) { + return; + } + } catch(_) { /* noop */ } + if (keymap[k]){ e.preventDefault(); keymap[k](); } + }); + + // Focus ring visibility for keyboard nav + function addFocusVisible(){ + let hadKeyboardEvent = false; + function onKeyDown(){ hadKeyboardEvent = true; } + function onPointer(){ hadKeyboardEvent = false; } + function onFocus(e: FocusEvent){ if (hadKeyboardEvent) (e.target as HTMLElement).classList.add('focus-visible'); } + function onBlur(e: FocusEvent){ (e.target as HTMLElement).classList.remove('focus-visible'); } + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('mousedown', onPointer, true); + window.addEventListener('pointerdown', onPointer, true); + window.addEventListener('touchstart', onPointer, true); + document.addEventListener('focusin', onFocus); + document.addEventListener('focusout', onBlur); + } + addFocusVisible(); + + // Skeleton utility: defer placeholders until the request lasts long enough to be noticeable + let SKELETON_DELAY_DEFAULT = 400; + let skeletonTimers = new WeakMap(); + function gatherSkeletons(root){ + if (!root){ return []; } + let list = []; + let scope = (root.nodeType === 9) ? root.documentElement : root; + if (scope && scope.matches && scope.hasAttribute('data-skeleton')){ + list.push(scope); + } + if (scope && scope.querySelectorAll){ + scope.querySelectorAll('[data-skeleton]').forEach(function(el){ + if (list.indexOf(el) === -1){ list.push(el); } + }); + } + return list; + } + function scheduleSkeleton(el){ + let delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10); + let delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr); + clearSkeleton(el, false); + const timer = setTimeout(function(){ + el.classList.add('is-loading'); + el.setAttribute('aria-busy', 'true'); + skeletonTimers.set(el, null); + }, delay); + skeletonTimers.set(el, timer); + } + function clearSkeleton(el: HTMLElement, removeBusy?: boolean): void { + let timer = skeletonTimers.get(el); + if (typeof timer === 'number'){ + clearTimeout(timer); + } + skeletonTimers.delete(el); + el.classList.remove('is-loading'); + if (removeBusy !== false){ el.removeAttribute('aria-busy'); } + } + function showSkeletons(context?: HTMLElement | Document): void { + gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); }); + } + function hideSkeletons(context?: HTMLElement | Document): void { + gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el, true); }); + } + window.skeletons = { show: showSkeletons, hide: hideSkeletons }; + + document.addEventListener('htmx:beforeRequest', function(e){ + const detail = e.detail as any; + const target = detail.target || detail.elt || e.target; + showSkeletons(target); + }); + document.addEventListener('htmx:afterSwap', function(e){ + const detail = e.detail as any; + const target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + document.addEventListener('htmx:afterRequest', function(e){ + const detail = e.detail as any; + const target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + + // Commander catalog image lazy loader + (function(){ + let PLACEHOLDER_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; + let observer = null; + let supportsIO = 'IntersectionObserver' in window; + + function ensureObserver(){ + if (observer || !supportsIO) return observer; + observer = new IntersectionObserver(function(entries){ + entries.forEach(function(entry){ + if (entry.isIntersecting || entry.intersectionRatio > 0){ + let img = entry.target; + load(img); + if (observer) observer.unobserve(img); + } + }); + }, { rootMargin: '160px 0px', threshold: 0.05 }); + return observer; + } + + function load(img){ + if (!img || img.__lazyLoaded) return; + let src = img.getAttribute('data-lazy-src'); + if (src){ img.setAttribute('src', src); } + let srcset = img.getAttribute('data-lazy-srcset'); + if (srcset){ img.setAttribute('srcset', srcset); } + let sizes = img.getAttribute('data-lazy-sizes'); + if (sizes){ img.setAttribute('sizes', sizes); } + img.classList.remove('is-placeholder'); + img.removeAttribute('data-lazy-image'); + img.removeAttribute('data-lazy-src'); + img.removeAttribute('data-lazy-srcset'); + img.removeAttribute('data-lazy-sizes'); + img.__lazyLoaded = true; + } + + function prime(img){ + if (!img || img.__lazyPrimed) return; + let desired = img.getAttribute('data-lazy-src'); + if (!desired) return; + img.__lazyPrimed = true; + let placeholder = img.getAttribute('data-lazy-placeholder') || PLACEHOLDER_PIXEL; + img.setAttribute('loading', 'lazy'); + img.setAttribute('decoding', 'async'); + img.classList.add('is-placeholder'); + img.removeAttribute('srcset'); + img.removeAttribute('sizes'); + img.setAttribute('src', placeholder); + if (supportsIO){ + ensureObserver().observe(img); + } else { + const loader = window.requestIdleCallback || window.requestAnimationFrame || function(cb){ return setTimeout(cb, 0); }; + loader(function(){ load(img); }); + } + } + + function collect(scope){ + if (!scope) scope = document; + if (scope === document){ + return Array.prototype.slice.call(document.querySelectorAll('[data-lazy-image]')); + } + if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-lazy-image')){ + return [scope]; + } + if (scope.querySelectorAll){ + return Array.prototype.slice.call(scope.querySelectorAll('[data-lazy-image]')); + } + return []; + } + + function process(scope){ + collect(scope).forEach(function(img){ + if (img.__lazyLoaded) return; + prime(img); + }); + } + + if (document.readyState === 'loading'){ + document.addEventListener('DOMContentLoaded', function(){ process(document); }); + } else { + process(document); + } + + document.addEventListener('htmx:afterSwap', function(evt){ + let target = evt && evt.detail ? evt.detail.target : null; + process(target || document); + }); + })(); + + const htmxCache = (function(){ + let store = new Map(); + function ttlFor(elt){ + let raw = parseInt((elt && elt.getAttribute && elt.getAttribute('data-hx-cache-ttl')) || '', 10); + if (isNaN(raw) || raw <= 0){ return 30000; } + return Math.max(1000, raw); + } + function buildKey(detail, elt){ + if (!detail) detail = {}; + if (elt && elt.getAttribute){ + let explicit = elt.getAttribute('data-hx-cache-key'); + if (explicit && explicit.trim()){ return explicit.trim(); } + } + let verb = (detail.verb || 'GET').toUpperCase(); + let path = detail.path || ''; + let params = detail.parameters && Object.keys(detail.parameters).length ? JSON.stringify(detail.parameters) : ''; + return verb + ' ' + path + ' ' + params; + } + function set(key, html, ttl, meta){ + if (!key || typeof html !== 'string') return; + store.set(key, { + key: key, + html: html, + expires: Date.now() + (ttl || 30000), + meta: meta || {}, + }); + } + function get(key){ + if (!key) return null; + let entry = store.get(key); + if (!entry) return null; + if (entry.expires && entry.expires <= Date.now()){ + store.delete(key); + return null; + } + return entry; + } + function applyCached(elt, detail, entry){ + if (!entry) return; + let target = detail && detail.target ? detail.target : elt; + if (!target) return; + dispatchHtmx(target, 'htmx:beforeSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + let swapSpec = ''; + try { swapSpec = (elt && elt.getAttribute && elt.getAttribute('hx-swap')) || ''; } catch(_){ } + swapSpec = (swapSpec || 'innerHTML').toLowerCase(); + if (swapSpec.indexOf('outer') === 0){ + if (target.outerHTML !== undefined){ + target.outerHTML = entry.html; + } + } else if (target.innerHTML !== undefined){ + target.innerHTML = entry.html; + } + if (window.htmx && typeof window.htmx.process === 'function'){ + window.htmx.process(target); + } + dispatchHtmx(target, 'htmx:afterSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + dispatchHtmx(target, 'htmx:afterRequest', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + } + function prefetch(url, opts){ + if (!url) return; + opts = opts || {}; + let key = opts.key || ('GET ' + url); + if (get(key)) return; + try { + fetch(url, { + headers: { 'HX-Request': 'true', 'Accept': 'text/html' }, + cache: 'no-store', + }).then(function(resp){ + if (!resp.ok) throw new Error('prefetch failed'); + return resp.text(); + }).then(function(html){ + set(key, html, opts.ttl || opts.cacheTtl || 30000, { url: url, prefetch: true }); + telemetry.send('htmx.cache.prefetch', { key: key, url: url }); + }).catch(function(){ /* noop */ }); + } catch(_){ } + } + return { + set: set, + get: get, + apply: applyCached, + buildKey: buildKey, + ttlFor: ttlFor, + prefetch: prefetch, + }; + })(); + window.htmxCache = htmxCache; + + document.addEventListener('htmx:configRequest', function(e: any){ + const detail = e && e.detail ? e.detail : {} as any; + const elt = detail.elt as HTMLElement; + if (!elt || !elt.getAttribute || !elt.hasAttribute('data-hx-cache')) return; + const verb = (detail.verb || 'GET').toUpperCase(); + if (verb !== 'GET') return; + const key = htmxCache.buildKey(detail, elt); + elt.__hxCacheKey = key; + elt.__hxCacheTTL = htmxCache.ttlFor(elt); + detail.headers = detail.headers || {}; + try { detail.headers['X-HTMX-Cache-Key'] = key; } catch(_){ } + }); + + document.addEventListener('htmx:beforeRequest', function(e: any){ + const detail = e && e.detail ? e.detail : {} as any; + const elt = detail.elt as HTMLElement; + if (!elt || !elt.__hxCacheKey) return; + const entry = htmxCache.get(elt.__hxCacheKey); + if (entry){ + telemetry.send('htmx.cache.hit', { key: elt.__hxCacheKey, path: detail.path || '' }); + e.preventDefault(); + htmxCache.apply(elt, detail, entry); + } else { + telemetry.send('htmx.cache.miss', { key: elt.__hxCacheKey, path: detail.path || '' }); + } + }); + + document.addEventListener('htmx:afterSwap', function(e: any){ + const detail = e && e.detail ? e.detail : {} as any; + const elt = detail.elt as HTMLElement; + if (!elt || !elt.__hxCacheKey) return; + try { + const xhr = detail.xhr; + const status = xhr && xhr.status ? xhr.status : 0; + if (status >= 200 && status < 300 && xhr && typeof xhr.responseText === 'string'){ + const ttl = elt.__hxCacheTTL || 30000; + htmxCache.set(elt.__hxCacheKey, xhr.responseText, ttl, { path: detail.path || '' }); + telemetry.send('htmx.cache.store', { key: elt.__hxCacheKey, path: detail.path || '', ttl: ttl }); + } + } catch(_){ } + elt.__hxCacheKey = undefined; + elt.__hxCacheTTL = undefined; + }); + + (function(){ + function handlePrefetch(evt: Event){ + try { + const el = (evt.target as HTMLElement)?.closest ? (evt.target as HTMLElement).closest('[data-hx-prefetch]') : null; + if (!el || el.__hxPrefetched) return; + let url = el.getAttribute('data-hx-prefetch'); + if (!url) return; + el.__hxPrefetched = true; + let key = el.getAttribute('data-hx-cache-key') || el.getAttribute('data-hx-prefetch-key') || ('GET ' + url); + let ttlAttr = parseInt((el.getAttribute('data-hx-cache-ttl') || el.getAttribute('data-hx-prefetch-ttl') || ''), 10); + let ttl = isNaN(ttlAttr) ? 30000 : Math.max(1000, ttlAttr); + htmxCache.prefetch(url, { key: key, ttl: ttl }); + } catch(_){ } + } + document.addEventListener('pointerenter', handlePrefetch, true); + document.addEventListener('focusin', handlePrefetch, true); + })(); + + // Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce) + let hxDebounceGroups = new Map(); + function dispatchHtmx(el, evtName, detail){ + if (!el) return; + if (window.htmx && typeof window.htmx.trigger === 'function'){ + window.htmx.trigger(el, evtName, detail); + } else { + try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ } + } + } + function bindHtmxDebounce(el){ + if (!el || el.__hxDebounceBound) return; + el.__hxDebounceBound = true; + let delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10); + let delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw); + let eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input'; + let events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean); + if (!events.length){ events = ['input']; } + let trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput'; + let group = el.getAttribute('data-hx-debounce-group') || ''; + let flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase(); + let flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true'); + function clearTimer(){ + if (el.__hxDebounceTimer){ + clearTimeout(el.__hxDebounceTimer); + el.__hxDebounceTimer = null; + } + } + function schedule(){ + clearTimer(); + if (group){ + let prev = hxDebounceGroups.get(group); + if (prev && prev !== el && prev.__hxDebounceTimer){ + clearTimeout(prev.__hxDebounceTimer); + prev.__hxDebounceTimer = null; + } + hxDebounceGroups.set(group, el); + } + el.__hxDebounceTimer = setTimeout(function(){ + el.__hxDebounceTimer = null; + dispatchHtmx(el, trigger, {}); + }, delay); + } + events.forEach(function(evt){ + el.addEventListener(evt, schedule, { passive: true }); + }); + if (flushOnBlur){ + el.addEventListener('blur', function(){ + if (el.__hxDebounceTimer){ + clearTimer(); + dispatchHtmx(el, trigger, {}); + } + }); + } + el.addEventListener('htmx:beforeRequest', clearTimer); + } + function initHtmxDebounce(root){ + let scope = root || document; + if (scope === document){ scope = document.body || document; } + if (!scope) return; + let seen = new Set(); + function collect(candidate){ + if (!candidate || seen.has(candidate)) return; + seen.add(candidate); + bindHtmxDebounce(candidate); + } + if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){ + collect(scope); + } + if (scope.querySelectorAll){ + scope.querySelectorAll('[data-hx-debounce]').forEach(collect); + } + } + window.initHtmxDebounce = () => initHtmxDebounce(document.body); + + // Example: persist "show skipped" toggle if present + document.addEventListener('change', function(e){ + const el = e.target as HTMLInputElement; + if (el && el.matches('[data-pref]')){ + let key = el.getAttribute('data-pref'); + let val = (el.type === 'checkbox') ? !!el.checked : el.value; + state.set(key, val); + state.inHash((function(o){ o[key] = val; return o; })({})); + } + }); + // On load, initialize any data-pref elements + document.addEventListener('DOMContentLoaded', function(){ + document.querySelectorAll('[data-pref]').forEach(function(el){ + let key = el.getAttribute('data-pref'); + let saved = state.get(key, undefined); + if (typeof saved !== 'undefined'){ + if ((el as HTMLInputElement).type === 'checkbox') (el as HTMLInputElement).checked = !!saved; else (el as HTMLInputElement).value = saved; + } + }); + hydrateProgress(document); + syncShowSkipped(document); + initCardFilters(document); + initVirtualization(document); + initHtmxDebounce(document); + initMustHaveControls(document); + }); + + // Hydrate progress bars with width based on data-pct + function hydrateProgress(root){ + (root || document).querySelectorAll('.progress[data-pct]') + .forEach(function(p){ + let pct = parseInt(p.getAttribute('data-pct') || '0', 10); + if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100; + let bar = p.querySelector('.bar'); if (!bar) return; + // Animate width for a bit of delight + requestAnimationFrame(function(){ bar.style.width = pct + '%'; }); + }); + } + // Keep hidden inputs for show_skipped in sync with the sticky checkbox + function syncShowSkipped(root){ + let cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]'); + if (!cb) return; + let val = cb.checked ? '1' : '0'; + (root || document).querySelectorAll('section form').forEach(function(f){ + let h = f.querySelector('input[name="show_skipped"]'); + if (h) h.value = val; + }); + } + document.addEventListener('htmx:afterSwap', function(e){ + hydrateProgress(e.target as HTMLElement); + syncShowSkipped(e.target as HTMLElement); + initCardFilters(e.target as HTMLElement); + initVirtualization(e.target as HTMLElement); + initHtmxDebounce(e.target as HTMLElement); + initMustHaveControls(e.target as HTMLElement); + }); + + // Scroll a card-tile into view (cooperates with virtualization by re-rendering first) + function scrollCardIntoView(name){ + if (!name) return; + try{ + let section = document.querySelector('section'); + let grid = section && section.querySelector('.card-grid'); + if (!grid) return; + // If virtualized, force a render around the approximate match by searching stored children + let target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); + if (!target) { + // Trigger a render update and try again + grid.dispatchEvent(new Event('scroll')); // noop but can refresh + target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); + } + if (target) { + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + (target as HTMLElement).focus && (target as HTMLElement).focus(); + } + }catch(_){} + } + window.scrollCardIntoView = scrollCardIntoView; + + // --- Card grid filters, reasons, and collapsible groups --- + function initCardFilters(root){ + let section = (root || document).querySelector('section'); + if (!section) return; + let toolbar = section.querySelector('.cards-toolbar'); + if (!toolbar) return; // nothing to do + let q = toolbar.querySelector('input[name="filter_query"]'); + let ownedSel = toolbar.querySelector('select[name="filter_owned"]'); + let showReasons = toolbar.querySelector('input[name="show_reasons"]'); + let collapseGroups = toolbar.querySelector('input[name="collapse_groups"]'); + let resultsEl = toolbar.querySelector('[data-results]'); + let emptyEl = section.querySelector('[data-empty]'); + let sortSel = toolbar.querySelector('select[name="filter_sort"]'); + let chipOwned = toolbar.querySelector('[data-chip-owned="owned"]'); + let chipNot = toolbar.querySelector('[data-chip-owned="not"]'); + let chipAll = toolbar.querySelector('[data-chip-owned="all"]'); + let chipClear = toolbar.querySelector('[data-chip-clear]'); + + function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; } + // Read URL hash on first init to hydrate controls + try { + let params = window.__mtgState.readHash(); + if (params){ + let hv = params.get('q'); if (q && hv !== null) q.value = hv; + hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv; + hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1'); + hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1'); + hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv; + } + } catch(_){} + function apply(){ + let query = (getVal(q)+ '').toLowerCase().trim(); + let ownedMode = (getVal(ownedSel) || 'all'); + let showR = !!getVal(showReasons); + let collapse = !!getVal(collapseGroups); + let sortMode = (getVal(sortSel) || 'az'); + // Toggle reasons visibility via section class + section.classList.toggle('hide-reasons', !showR); + // Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state + section.querySelectorAll('.group').forEach(function(wrapper){ + let grid = wrapper.querySelector('.group-grid'); if (!grid) return; + let key = wrapper.getAttribute('data-group-key'); + if (collapse){ + grid.setAttribute('data-collapsed','1'); + } else { + // restore stored + if (key){ + let stored = state.get('cards:group:'+key, null); + if (stored === true){ grid.setAttribute('data-collapsed','1'); } + else { grid.removeAttribute('data-collapsed'); } + } else { + grid.removeAttribute('data-collapsed'); + } + } + }); + // Filter tiles + let tiles = section.querySelectorAll('.card-grid .card-tile'); + let visible = 0; + tiles.forEach(function(tile){ + let name = (tile.getAttribute('data-card-name')||'').toLowerCase(); + let role = (tile.getAttribute('data-role')||'').toLowerCase(); + let tags = (tile.getAttribute('data-tags')||'').toLowerCase(); + let tagsSlug = (tile.getAttribute('data-tags-slug')||'').toLowerCase(); + let owned = tile.getAttribute('data-owned') === '1'; + let text = name + ' ' + role + ' ' + tags + ' ' + tagsSlug; + let qOk = !query || text.indexOf(query) !== -1; + let oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned); + let show = qOk && oOk; + tile.style.display = show ? '' : 'none'; + if (show) visible++; + }); + // Sort within each grid + function keyFor(tile){ + let name = (tile.getAttribute('data-card-name')||''); + let owned = tile.getAttribute('data-owned') === '1' ? 1 : 0; + let gc = tile.classList.contains('game-changer') ? 1 : 0; + return { name: name.toLowerCase(), owned: owned, gc: gc }; + } + section.querySelectorAll('.card-grid').forEach(function(grid){ + const arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile')); + arr.sort(function(a,b){ + let ka = keyFor(a), kb = keyFor(b); + if (sortMode === 'owned'){ + if (kb.owned !== ka.owned) return kb.owned - ka.owned; + if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next + return ka.name.localeCompare(kb.name); + } else if (sortMode === 'gc'){ + if (kb.gc !== ka.gc) return kb.gc - ka.gc; + if (kb.owned !== ka.owned) return kb.owned - ka.owned; + return ka.name.localeCompare(kb.name); + } + // default A–Z + return ka.name.localeCompare(kb.name); + }); + arr.forEach(function(el){ grid.appendChild(el); }); + }); + // Update group counts based on visible tiles within each group + section.querySelectorAll('.group').forEach(function(wrapper){ + let grid = wrapper.querySelector('.group-grid'); + let count = 0; + if (grid){ + grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; }); + } + let cEl = wrapper.querySelector('[data-count]'); + if (cEl) cEl.textContent = count; + }); + if (resultsEl) resultsEl.textContent = String(visible); + if (emptyEl) emptyEl.hidden = (visible !== 0); + // Persist prefs + if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value); + if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value); + if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked); + if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked); + if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value); + // Update URL hash for shareability + try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ } + } + // Wire events + if (q) q.addEventListener('input', apply); + if (ownedSel) ownedSel.addEventListener('change', apply); + if (showReasons) showReasons.addEventListener('change', apply); + if (collapseGroups) collapseGroups.addEventListener('change', apply); + if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); }); + if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); }); + if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); }); + if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); }); + // Individual group toggles + section.querySelectorAll('.group-header .toggle').forEach(function(btn){ + btn.addEventListener('click', function(){ + let wrapper = btn.closest('.group'); + let grid = wrapper && wrapper.querySelector('.group-grid'); + if (!grid) return; + let key = wrapper.getAttribute('data-group-key'); + let willCollapse = !grid.getAttribute('data-collapsed'); + if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed'); + if (key){ state.set('cards:group:'+key, !!willCollapse); } + // ARIA + btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true'); + }); + }); + // Per-card reason toggle: delegate clicks on .btn-why + section.addEventListener('click', function(e){ + let t = e.target; + if (!t || !t.classList || !t.classList.contains('btn-why')) return; + e.preventDefault(); + let tile = t.closest('.card-tile'); + if (!tile) return; + let globalHidden = section.classList.contains('hide-reasons'); + if (globalHidden){ + // Force-show overrides global hidden + let on = tile.classList.toggle('force-show'); + if (on) tile.classList.remove('force-hide'); + t.textContent = on ? 'Hide why' : 'Why?'; + } else { + // Hide this tile only + let off = tile.classList.toggle('force-hide'); + if (off) tile.classList.remove('force-show'); + t.textContent = off ? 'Show why' : 'Hide why'; + } + }); + // Initial apply on hydrate + apply(); + + // Keyboard helpers: '/' focuses query, Esc clears + function onKey(e){ + // avoid when typing in inputs + if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return; + if (e.key === '/'){ + if (q){ e.preventDefault(); q.focus(); q.select && q.select(); } + } else if (e.key === 'Escape'){ + if (q && q.value){ q.value=''; apply(); } + } + } + document.addEventListener('keydown', onKey); + } + + // --- Lightweight virtualization (feature-flagged via data-virtualize) --- + function initVirtualization(root){ + try{ + let body = document.body || document.documentElement; + const DIAG = !!(body && body.getAttribute('data-diag') === '1'); + const GLOBAL = (function(){ + if (!DIAG) return null; + if (window.__virtGlobal) return window.__virtGlobal; + let store = { grids: [], summaryEl: null }; + function ensure(){ + if (!store.summaryEl){ + let el = document.createElement('div'); + el.id = 'virt-global-diag'; + el.style.position = 'fixed'; + el.style.right = '8px'; + el.style.bottom = '8px'; + el.style.background = 'rgba(17,24,39,.85)'; + el.style.border = '1px solid var(--border)'; + el.style.padding = '.25rem .5rem'; + el.style.borderRadius = '6px'; + el.style.fontSize = '12px'; + el.style.color = '#cbd5e1'; + el.style.zIndex = '50'; + el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)'; + el.style.cursor = 'default'; + el.style.display = 'none'; + document.body.appendChild(el); + store.summaryEl = el; + } + return store.summaryEl; + } + function update(){ + let el = ensure(); if (!el) return; + let g = store.grids; + let total = 0, visible = 0, lastMs = 0; + for (let i=0;i -1 ? 110 : 240); + let minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow; + let rowH = minRowH; + let explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null; + let perRow = explicitCols || 1; + + let diagBox = null; let lastRenderAt = 0; let lastRenderMs = 0; + let renderCount = 0; let measureCount = 0; let swapCount = 0; + let gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6); + let globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null; + + function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } } + function ensureDiag(){ + if (!DIAG) return null; + if (diagBox) return diagBox; + diagBox = document.createElement('div'); + diagBox.className = 'virt-diag'; + diagBox.style.position = 'sticky'; + diagBox.style.top = '0'; + diagBox.style.zIndex = '5'; + diagBox.style.background = 'rgba(17,24,39,.85)'; + diagBox.style.border = '1px solid var(--border)'; + diagBox.style.padding = '.25rem .5rem'; + diagBox.style.borderRadius = '6px'; + diagBox.style.fontSize = '12px'; + diagBox.style.margin = '0 0 .35rem 0'; + diagBox.style.color = '#cbd5e1'; + diagBox.style.display = 'none'; + let controls = document.createElement('div'); + controls.style.display = 'flex'; + controls.style.gap = '.35rem'; + controls.style.alignItems = 'center'; + controls.style.marginBottom = '.25rem'; + let title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af'; + let btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small'; + btnCopy.addEventListener('click', function(){ + try{ + let payload = { + id: gridId, + rowH: rowH, + perRow: perRow, + start: start, + end: end, + total: total, + renderCount: renderCount, + measureCount: measureCount, + swapCount: swapCount, + lastRenderMs: lastRenderMs, + lastRenderAt: lastRenderAt, + }; + navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + btnCopy.textContent = 'Copied'; + setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); + }catch(_){ } + }); + let btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small'; + btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; }); + controls.appendChild(title); + controls.appendChild(btnCopy); + controls.appendChild(btnHide); + diagBox.appendChild(controls); + let text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text); + let host = (container.id === 'owned-box') ? container : container.parentElement || container; + host.insertBefore(diagBox, host.firstChild); + return diagBox; + } + + function measure(){ + try { + measureCount++; + let probe = store.firstElementChild || all[0]; + if (probe){ + let fake = probe.cloneNode(true); + fake.style.position = 'absolute'; + fake.style.visibility = 'hidden'; + fake.style.pointerEvents = 'none'; + (ownedGrid || container).appendChild(fake); + let rect = fake.getBoundingClientRect(); + rowH = Math.max(minRowH, Math.ceil(rect.height) + 16); + (ownedGrid || container).removeChild(fake); + } + let style = window.getComputedStyle(ownedGrid || container); + let cols = style.getPropertyValue('grid-template-columns'); + try { + let displayMode = style.getPropertyValue('display'); + if (displayMode && displayMode.trim()){ + wrapper.style.display = displayMode; + } else if (!wrapper.style.display){ + wrapper.style.display = 'grid'; + } + if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols; + let gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap'); + if (gap && gap.trim()) wrapper.style.gap = gap; + let ji = style.getPropertyValue('justify-items'); + if (ji && ji.trim()) wrapper.style.justifyItems = ji; + let ai = style.getPropertyValue('align-items'); + if (ai && ai.trim()) wrapper.style.alignItems = ai; + } catch(_){ } + const derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){ + return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1); + }).length : 0); + if (explicitCols){ + perRow = explicitCols; + } else if (derivedCols){ + perRow = Math.max(1, derivedCols); + } else { + perRow = Math.max(1, perRow); + } + } catch(_){ } + } + + measure(); + let total = all.length; + let start = 0, end = 0; + + function render(){ + let t0 = DIAG ? performance.now() : 0; + let scroller = container; + let vh, scrollTop, top; + + if (useWindowScroll) { + // Window-scroll mode: measure relative to viewport + vh = window.innerHeight; + let rect = container.getBoundingClientRect(); + top = Math.max(0, -rect.top); + scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; + } else { + // Container-scroll mode: measure relative to container + vh = scroller.clientHeight || window.innerHeight; + scrollTop = scroller.scrollTop; + top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0); + } + + let rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2; + let rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1); + let rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow))); + let newStart = rowStart * Math.max(1, perRow); + let newEnd = Math.min(total, rowEnd * Math.max(1, perRow)); + if (newStart === start && newEnd === end) return; + start = newStart; + end = newEnd; + let beforeRows = Math.floor(start / Math.max(1, perRow)); + let afterRows = Math.ceil((total - end) / Math.max(1, perRow)); + padTop.style.height = (beforeRows * rowH) + 'px'; + padBottom.style.height = (afterRows * rowH) + 'px'; + wrapper.innerHTML = ''; + for (let i = start; i < end; i++){ + let node = all[i]; + if (node) wrapper.appendChild(node); + } + if (DIAG){ + let box = ensureDiag(); + if (box){ + let dt = performance.now() - t0; + lastRenderMs = dt; + renderCount++; + lastRenderAt = Date.now(); + let vis = end - start; + let rowsTotal = Math.ceil(total / Math.max(1, perRow)); + let textEl = box.querySelector('.virt-diag-text'); + let msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount; + textEl.textContent = msg; + let bad = (dt > 33) || (vis > 300); + let warn = (!bad) && ((dt > 16) || (vis > 200)); + box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)'); + box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none'); + if (globalReg && globalReg.set){ + globalReg.set({ total: total, start: start, end: end, lastMs: dt }); + } + } + } + } + + function onScroll(){ render(); } + function onResize(){ measure(); render(); } + + // Support both container-scroll (default) and window-scroll modes + let scrollMode = overflowAttr || container.style.overflow || 'auto'; + let useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window'); + + if (useWindowScroll) { + // Window-scroll mode: listen to window scroll events + window.addEventListener('scroll', onScroll, { passive: true }); + } else { + // Container-scroll mode: listen to container scroll events + container.addEventListener('scroll', onScroll, { passive: true }); + } + window.addEventListener('resize', onResize); + + render(); + + // Track cleanup for disconnected containers + grid.__virtCleanup = function(){ + try { + if (useWindowScroll) { + window.removeEventListener('scroll', onScroll); + } else { + container.removeEventListener('scroll', onScroll); + } + window.removeEventListener('resize', onResize); + } catch(_){} + }; + + document.addEventListener('htmx:afterSwap', function(ev){ + if (!container.isConnected) return; + if (!container.contains(ev.target)) return; + swapCount++; + let merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); + const known = new Map(); + all.forEach(function(node, idx){ + let index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx; + known.set(node, index); + }); + let nextIndex = known.size; + merged.forEach(function(node){ + if (!known.has(node)){ + node.__virtIndex = nextIndex; + known.set(node, nextIndex); + nextIndex++; + } + }); + merged.sort(function(a, b){ + let ia = known.get(a); + const ib = known.get(b); + return (ia - ib); + }); + merged.forEach(function(node, idx){ node.__virtIndex = idx; }); + all = merged; + total = all.length; + measure(); + render(); + }); + + if (DIAG && !window.__virtHotkeyBound){ + window.__virtHotkeyBound = true; + document.addEventListener('keydown', function(e){ + try{ + if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return; + if (e.key && e.key.toLowerCase() === 'v'){ + e.preventDefault(); + let shown = null; + document.querySelectorAll('.virt-diag').forEach(function(b){ + if (shown === null) shown = ((b as HTMLElement).style.display === 'none'); + (b as HTMLElement).style.display = shown ? '' : 'none'; + }); + if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle(); + } + }catch(_){ } + }); + } + }); + }catch(_){ } + } + + function setTileState(tile, type, active){ + if (!tile) return; + let attr = 'data-must-' + type; + tile.setAttribute(attr, active ? '1' : '0'); + tile.classList.toggle('must-' + type, !!active); + let selector = '.must-have-btn.' + (type === 'include' ? 'include' : 'exclude'); + try { + let btn = tile.querySelector(selector); + if (btn){ + btn.setAttribute('data-active', active ? '1' : '0'); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + btn.classList.toggle('is-active', !!active); + } + } catch(_){ } + } + + function restoreMustHaveState(tile, state){ + if (!tile || !state) return; + setTileState(tile, 'include', state.include ? 1 : 0); + setTileState(tile, 'exclude', state.exclude ? 1 : 0); + } + + function applyLocalMustHave(tile, type, enabled){ + if (!tile) return; + if (type === 'include'){ + setTileState(tile, 'include', enabled ? 1 : 0); + if (enabled){ setTileState(tile, 'exclude', 0); } + } else if (type === 'exclude'){ + setTileState(tile, 'exclude', enabled ? 1 : 0); + if (enabled){ setTileState(tile, 'include', 0); } + } + } + + function sendMustHaveRequest(tile, type, enabled, cardName, prevState){ + if (!window.htmx){ + restoreMustHaveState(tile, prevState); + tile.setAttribute('data-must-pending', '0'); + toast('Offline: cannot update preference', 'error', { duration: 4000 }); + return; + } + let summaryTarget = document.getElementById('include-exclude-summary'); + let ajaxOptions = { + source: tile, + target: summaryTarget || tile, + swap: summaryTarget ? 'outerHTML' : 'none', + values: { + card_name: cardName, + list_type: type, + enabled: enabled ? '1' : '0', + }, + }; + let xhr; + try { + xhr = window.htmx.ajax('POST', '/build/must-haves/toggle', ajaxOptions); + } catch(_){ + restoreMustHaveState(tile, prevState); + tile.setAttribute('data-must-pending', '0'); + toast('Unable to submit preference update', 'error', { duration: 4500 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'exception' }); + return; + } + if (!xhr || !xhr.addEventListener){ + tile.setAttribute('data-must-pending', '0'); + return; + } + xhr.addEventListener('load', function(evt){ + tile.setAttribute('data-must-pending', '0'); + let request = evt && evt.currentTarget ? evt.currentTarget : xhr; + let status = request.status || 0; + if (status >= 400){ + restoreMustHaveState(tile, prevState); + let msg = 'Failed to update preference'; + try { + let data = JSON.parse(request.responseText || '{}'); + if (data && data.error) msg = data.error; + } catch(_){ } + toast(msg, 'error', { duration: 5000 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: status }); + return; + } + let message; + if (enabled){ + message = (type === 'include') ? 'Pinned as must include' : 'Pinned as must exclude'; + } else { + message = (type === 'include') ? 'Removed must include' : 'Removed must exclude'; + } + toast(message + ': ' + cardName, 'success', { duration: 2400 }); + telemetry.send('must_have.toggle', { + card: cardName, + list: type, + enabled: enabled, + requestId: request.getResponseHeader ? request.getResponseHeader('X-Request-ID') : null, + }); + }); + xhr.addEventListener('error', function(){ + tile.setAttribute('data-must-pending', '0'); + restoreMustHaveState(tile, prevState); + toast('Network error updating preference', 'error', { duration: 5000 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'network' }); + }); + } + + function initMustHaveControls(root){ + let scope = root && root.querySelectorAll ? root : document; + if (scope === document && document.body) scope = document.body; + if (!scope || !scope.querySelectorAll) return; + scope.querySelectorAll('.must-have-btn').forEach(function(btn){ + if (!btn || btn.__mustHaveBound) return; + btn.__mustHaveBound = true; + let active = btn.getAttribute('data-active') === '1'; + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + btn.addEventListener('click', function(ev){ + ev.preventDefault(); + let tile = btn.closest('.card-tile'); + if (!tile) return; + if (tile.getAttribute('data-must-pending') === '1') return; + let type = btn.getAttribute('data-toggle'); + if (!type) return; + let prevState = { + include: tile.getAttribute('data-must-include') === '1', + exclude: tile.getAttribute('data-must-exclude') === '1', + }; + let nextEnabled = !(type === 'include' ? prevState.include : prevState.exclude); + let label = btn.getAttribute('data-card-label') || btn.getAttribute('data-card-name') || tile.getAttribute('data-card-name') || ''; + tile.setAttribute('data-must-pending', '1'); + applyLocalMustHave(tile, type, nextEnabled); + sendMustHaveRequest(tile, type, nextEnabled, label, prevState); + }); + }); + } + + // LQIP blur/fade-in for thumbnails marked with data-lqip + document.addEventListener('DOMContentLoaded', function(){ + try{ + document.querySelectorAll('img[data-lqip]') + .forEach(function(img){ + img.classList.add('lqip'); + img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true }); + }); + }catch(_){ } + }); + + // --- Lazy-loading analytics accordions --- + function initLazyAccordions(root){ + try { + let scope = root || document; + if (!scope || !scope.querySelectorAll) return; + + scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){ + if (!details || details.__lazyBound) return; + details.__lazyBound = true; + + let loaded = false; + + details.addEventListener('toggle', function(){ + if (!details.open || loaded) return; + loaded = true; + + // Mark as loaded to prevent re-initialization + let content = details.querySelector('.analytics-content'); + if (!content) return; + + // Remove placeholder if present + let placeholder = content.querySelector('.analytics-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + // Content is already rendered in the template, just need to initialize any scripts + // Re-run virtualization if needed + try { + initVirtualization(content); + } catch(_){} + + // Re-attach chart interactivity if this is mana overview + let type = details.getAttribute('data-analytics-type'); + if (type === 'mana') { + try { + // Tooltip and highlight logic is already in the template scripts + // Just trigger a synthetic event to re-attach if needed + let event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } }); + details.dispatchEvent(event); + } catch(_){} + } + + // Send telemetry + telemetry.send('analytics.accordion_expand', { + type: type || 'unknown', + accordion: details.id || 'unnamed', + }); + }); + }); + } catch(_){} + } + + // Initialize on load and after HTMX swaps + document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(document.body); }); + document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); }); +})(); diff --git a/code/web/static/ts/components.ts b/code/web/static/ts/components.ts new file mode 100644 index 0000000..b9493b2 --- /dev/null +++ b/code/web/static/ts/components.ts @@ -0,0 +1,382 @@ +/** + * M3 Component Library - TypeScript Utilities + * + * Core functions for interactive components: + * - Card flip button (dual-faced cards) + * - Collapsible panels + * - Card popups + * - Modal management + * + * Migrated from components.js with TypeScript types + */ + +// ============================================ +// TYPE DEFINITIONS +// ============================================ + +interface CardPopupOptions { + tags?: string[]; + highlightTags?: string[]; + role?: string; + layout?: string; +} + +// ============================================ +// CARD FLIP FUNCTIONALITY +// ============================================ + +/** + * Flip a dual-faced card image between front and back faces + * @param button - The flip button element + */ +function flipCard(button: HTMLElement): void { + const container = button.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null; + if (!container) return; + + const img = container.querySelector('img') as HTMLImageElement | null; + if (!img) return; + + const cardName = img.dataset.cardName; + if (!cardName) return; + + const faces = cardName.split(' // '); + if (faces.length < 2) return; + + // Determine current face (default to 0 = front) + const currentFace = parseInt(img.dataset.currentFace || '0', 10); + const nextFace = currentFace === 0 ? 1 : 0; + const faceName = faces[nextFace]; + + // Determine image version based on container + const isLarge = container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image'); + const version = isLarge ? 'normal' : 'small'; + + // Update image source + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`; + img.alt = `${faceName} image`; + img.dataset.currentFace = nextFace.toString(); + + // Update button aria-label + const otherFace = faces[currentFace]; + button.setAttribute('aria-label', `Flip to ${otherFace}`); +} + +/** + * Reset all card images to show front face + * Useful when navigating between pages or clearing selections + */ +function resetCardFaces(): void { + document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => { + const cardName = img.dataset.cardName; + if (!cardName) return; + + const faces = cardName.split(' // '); + if (faces.length > 1) { + const frontFace = faces[0]; + const container = img.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null; + const isLarge = container && (container.classList.contains('card-thumb-large') || + container.classList.contains('card-popup-image')); + const version = isLarge ? 'normal' : 'small'; + + img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`; + img.alt = `${frontFace} image`; + img.dataset.currentFace = '0'; + } + }); +} + +// ============================================ +// COLLAPSIBLE PANEL FUNCTIONALITY +// ============================================ + +/** + * Toggle a collapsible panel's expanded/collapsed state + * @param panelId - The ID of the panel element + */ +function togglePanel(panelId: string): void { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle') as HTMLElement | null; + const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; + if (!button || !content) return; + + const isExpanded = button.getAttribute('aria-expanded') === 'true'; + + // Toggle state + button.setAttribute('aria-expanded', (!isExpanded).toString()); + content.style.display = isExpanded ? 'none' : 'block'; + + // Toggle classes + panel.classList.toggle('panel-expanded', !isExpanded); + panel.classList.toggle('panel-collapsed', isExpanded); +} + +/** + * Expand a collapsible panel + * @param panelId - The ID of the panel element + */ +function expandPanel(panelId: string): void { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle') as HTMLElement | null; + const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'true'); + content.style.display = 'block'; + panel.classList.add('panel-expanded'); + panel.classList.remove('panel-collapsed'); +} + +/** + * Collapse a collapsible panel + * @param panelId - The ID of the panel element + */ +function collapsePanel(panelId: string): void { + const panel = document.getElementById(panelId); + if (!panel) return; + + const button = panel.querySelector('.panel-toggle') as HTMLElement | null; + const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; + if (!button || !content) return; + + button.setAttribute('aria-expanded', 'false'); + content.style.display = 'none'; + panel.classList.add('panel-collapsed'); + panel.classList.remove('panel-expanded'); +} + +// ============================================ +// MODAL MANAGEMENT +// ============================================ + +/** + * Open a modal by ID + * @param modalId - The ID of the modal element + */ +function openModal(modalId: string): void { + const modal = document.getElementById(modalId); + if (!modal) return; + + (modal as HTMLElement).style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // Focus first focusable element in modal + const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusable) { + setTimeout(() => focusable.focus(), 100); + } +} + +/** + * Close a modal by ID or element + * @param modalOrId - Modal element or ID + */ +function closeModal(modalOrId: string | HTMLElement): void { + const modal = typeof modalOrId === 'string' + ? document.getElementById(modalOrId) + : modalOrId; + + if (!modal) return; + + modal.remove(); + + // Restore body scroll if no other modals are open + if (!document.querySelector('.modal')) { + document.body.style.overflow = ''; + } +} + +/** + * Close all open modals + */ +function closeAllModals(): void { + document.querySelectorAll('.modal').forEach(modal => modal.remove()); + document.body.style.overflow = ''; +} + +// ============================================ +// CARD POPUP FUNCTIONALITY +// ============================================ + +/** + * Show card details popup on hover or tap + * @param cardName - The card name + * @param options - Popup options + */ +function showCardPopup(cardName: string, options: CardPopupOptions = {}): void { + // Remove any existing popup + closeCardPopup(); + + const { + tags = [], + highlightTags = [], + role = '', + layout = 'normal' + } = options; + + const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout); + const baseName = cardName.split(' // ')[0]; + + // Create popup HTML + const popup = document.createElement('div'); + popup.className = 'card-popup'; + popup.setAttribute('role', 'dialog'); + popup.setAttribute('aria-label', `${cardName} details`); + + let tagsHTML = ''; + if (tags.length > 0) { + tagsHTML = '
'; + tags.forEach(tag => { + const isHighlight = highlightTags.includes(tag); + tagsHTML += `${tag}`; + }); + tagsHTML += '
'; + } + + let roleHTML = ''; + if (role) { + roleHTML = `
Role: ${role}
`; + } + + let flipButtonHTML = ''; + if (isDFC) { + flipButtonHTML = ` + + `; + } + + popup.innerHTML = ` +
+
+
+ ${cardName} image + ${flipButtonHTML} +
+
+

${cardName}

+ ${roleHTML} + ${tagsHTML} +
+ +
+ `; + + document.body.appendChild(popup); + document.body.style.overflow = 'hidden'; + + // Focus close button + const closeBtn = popup.querySelector('.card-popup-close'); + if (closeBtn) { + setTimeout(() => closeBtn.focus(), 100); + } +} + +/** + * Close card popup + * @param element - Element to search from (optional) + */ +function closeCardPopup(element?: HTMLElement): void { + const popup = element + ? element.closest('.card-popup') + : document.querySelector('.card-popup'); + + if (popup) { + popup.remove(); + + // Restore body scroll if no modals are open + if (!document.querySelector('.modal')) { + document.body.style.overflow = ''; + } + } +} + +/** + * Setup card thumbnail hover/tap events + * Call this after dynamically adding card thumbnails to the DOM + */ +function setupCardPopups(): void { + document.querySelectorAll('.card-thumb-container[data-card-name]').forEach(container => { + const img = container.querySelector('.card-thumb'); + if (!img) return; + + const cardName = container.dataset.cardName || img.dataset.cardName; + if (!cardName) return; + + // Desktop: hover + container.addEventListener('mouseenter', function(e: MouseEvent) { + if (window.innerWidth > 768) { + const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean); + const role = img.dataset.role || ''; + const layout = img.dataset.layout || 'normal'; + + showCardPopup(cardName, { tags, highlightTags: [], role, layout }); + } + }); + + // Mobile: tap + container.addEventListener('click', function(e: MouseEvent) { + if (window.innerWidth <= 768) { + e.preventDefault(); + + const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean); + const role = img.dataset.role || ''; + const layout = img.dataset.layout || 'normal'; + + showCardPopup(cardName, { tags, highlightTags: [], role, layout }); + } + }); + }); +} + +// ============================================ +// INITIALIZATION +// ============================================ + +// Setup event listeners when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + // Setup card popups on initial load + setupCardPopups(); + + // Close modals/popups on Escape key + document.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') { + closeCardPopup(); + + // Close topmost modal only + const modals = document.querySelectorAll('.modal'); + if (modals.length > 0) { + closeModal(modals[modals.length - 1] as HTMLElement); + } + } + }); + }); +} else { + // DOM already loaded + setupCardPopups(); +} + +// Make functions globally available for inline onclick handlers +(window as any).flipCard = flipCard; +(window as any).resetCardFaces = resetCardFaces; +(window as any).togglePanel = togglePanel; +(window as any).expandPanel = expandPanel; +(window as any).collapsePanel = collapsePanel; +(window as any).openModal = openModal; +(window as any).closeModal = closeModal; +(window as any).closeAllModals = closeAllModals; +(window as any).showCardPopup = showCardPopup; +(window as any).closeCardPopup = closeCardPopup; +(window as any).setupCardPopups = setupCardPopups; diff --git a/code/web/static/ts/types.ts b/code/web/static/ts/types.ts new file mode 100644 index 0000000..bb7fb65 --- /dev/null +++ b/code/web/static/ts/types.ts @@ -0,0 +1,105 @@ +/* Shared TypeScript type definitions for MTG Deckbuilder web app */ + +// Toast system types +export interface ToastOptions { + duration?: number; +} + +// State management types +export interface StateManager { + get(key: string, def?: any): any; + set(key: string, val: any): void; + inHash(obj: Record): void; + readHash(): URLSearchParams; +} + +// Telemetry types +export interface TelemetryManager { + send(eventName: string, data?: Record): void; +} + +// Skeleton system types +export interface SkeletonManager { + show(context?: HTMLElement | Document): void; + hide(context?: HTMLElement | Document): void; +} + +// Card popup types (from components.ts) +export interface CardPopupOptions { + tags?: string[]; + highlightTags?: string[]; + role?: string; + layout?: string; + showActions?: boolean; +} + +// HTMX event detail types +export interface HtmxResponseErrorDetail { + xhr?: XMLHttpRequest; + path?: string; + target?: HTMLElement; +} + +export interface HtmxEventDetail { + target?: HTMLElement; + elt?: HTMLElement; + path?: string; + xhr?: XMLHttpRequest; +} + +// HTMX cache interface +export interface HtmxCache { + get(key: string): any; + set(key: string, html: string, ttl?: number, meta?: any): void; + apply(elt: any, detail: any, entry: any): void; + buildKey(detail: any, elt: any): string; + ttlFor(elt: any): number; + prefetch(url: string, opts?: any): void; +} + +// Global window extensions +declare global { + interface Window { + __mtgState: StateManager; + toast: (msg: string | HTMLElement, type?: string, opts?: ToastOptions) => HTMLElement; + toastHTML: (html: string, type?: string, opts?: ToastOptions) => HTMLElement; + appTelemetry: TelemetryManager; + skeletons: SkeletonManager; + __telemetryEndpoint?: string; + showCardPopup?: (cardName: string, options?: CardPopupOptions) => void; + dismissCardPopup?: () => void; + flipCard?: (button: HTMLElement) => void; + htmxCache?: HtmxCache; + htmx?: any; // HTMX library - use any for external library + initHtmxDebounce?: () => void; + scrollCardIntoView?: (card: HTMLElement) => void; + __virtGlobal?: any; + __virtHotkeyBound?: boolean; + } + + interface CustomEvent { + readonly detail: T; + } + + // HTMX custom events + interface DocumentEventMap { + 'htmx:responseError': CustomEvent; + 'htmx:sendError': CustomEvent; + 'htmx:afterSwap': CustomEvent; + 'htmx:beforeRequest': CustomEvent; + 'htmx:afterSettle': CustomEvent; + 'htmx:afterRequest': CustomEvent; + } + + interface HTMLElement { + __hxCacheKey?: string; + __hxCacheTTL?: number; + } + + interface Element { + __hxPrefetched?: boolean; + } +} + +// Empty export to make this a module file +export {}; diff --git a/code/web/templates/base.html b/code/web/templates/base.html index f79ae00..3f8fe5b 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -628,8 +628,8 @@ } catch(_) {} })(); - - + + {% if enable_themes %}