diff --git a/CHANGELOG.md b/CHANGELOG.md index d414395..c774beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Docker build automatically compiles TypeScript during image creation ### Changed +- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code) +- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules + - Created `cardHover.ts` for unified hover panel functionality + - Created `cardImages.ts` for card image loading with automatic retry fallbacks + - Reduced inline script size in base template for better maintainability - **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture - Tailwind CSS v3 with custom MTG color palette - PostCSS build pipeline with autoprefixer @@ -62,9 +67,6 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Visual highlighting for selected theme chips in deck builder ### Changed -- Optimized Docker build process: Reduced build time from ~134s to ~6s - - Removed redundant card_files copy (already mounted as volume) - - Added volume mounts for templates and static files (hot reload support) - Migrated 5 templates to new component system (home, 404, 500, setup, commanders) ### Removed @@ -74,7 +76,16 @@ _None_ _None_ ### Performance -- Docker hot reload now works for CSS and template changes (no rebuild required) +- 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 ### Deprecated _None_ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 6fc8334..14f356d 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -7,33 +7,61 @@ Web UI improvements with Tailwind CSS migration, TypeScript conversion, componen ### Added - **Card Image Caching**: Optional local image cache for faster card display - - Downloads card images from Scryfall bulk data + - Downloads card images from Scryfall bulk data (respects API guidelines) - Graceful fallback to Scryfall API for uncached images - - Enable with `CACHE_CARD_IMAGES=1` environment variable - - Intelligent statistics caching (weekly refresh, matching card data staleness) -- **Component Library**: Living documentation at `/docs/components` - - Interactive examples of all UI components - - Reusable Jinja2 macros for consistent design + - Enabled via `CACHE_CARD_IMAGES=1` environment variable + - Integrated with setup/tagging process + - Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness) +- **Component Library**: Living documentation of reusable UI components at `/docs/components` + - Interactive examples of all buttons, modals, forms, cards, and panels + - Jinja2 macros for consistent component usage - 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) +- **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 +- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code) +- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules + - Created `cardHover.ts` for unified hover panel functionality + - Created `cardImages.ts` for card image loading with automatic retry fallbacks + - Reduced inline script size in base template for better maintainability - **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 + - 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 to TypeScript for better type safety - - Replaced `var` with `const`/`let` throughout - - Improved error handling and code organization + - 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 for templates and CSS (no rebuild needed) - - TypeScript compilation integrated into build process + - Hot reload enabled for templates and static files + - Volume mounts for rapid iteration without rebuilds - **Template Modernization**: Migrated templates to use component system +- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck + - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) + - 10% bonus for cards appearing in 80%+ of builds + - Color-coded synergy scores in preview (green=high, red=low) + - Partner commander support with combined color identity + - Multi-copy card tracking (e.g., 8 Mountains, 7 Islands) + - Export synergy deck with full metadata (CSV, TXT, JSON files) +- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) +- Detailed progress logging for multi-build orchestration +- User guide: `docs/user_guides/batch_build_compare.md` +- **Web UI Component Library**: Standardized UI components for consistent design across all pages + - 5 component partial template files (buttons, modals, forms, cards, panels) + - ~900 lines of component CSS styles + - Interactive JavaScript utilities (components.js) + - Living component library page at `/docs/components` + - 1600+ lines developer documentation (component_catalog.md) +- **Custom UI Enhancements**: + - Darker gray styling for home page buttons + - Visual highlighting for selected theme chips in deck builder ### Removed _None_ @@ -53,12 +81,6 @@ _None_ - 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/static/ts/app.ts b/code/web/static/ts/app.ts index db02622..91a8138 100644 --- a/code/web/static/ts/app.ts +++ b/code/web/static/ts/app.ts @@ -1,5 +1,24 @@ /* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */ -import type { StateManager, TelemetryManager, ToastOptions, SkeletonManager } from './types.js'; +// Type definitions moved inline to avoid module system +interface StateManager { + get(key: string, def?: any): any; + set(key: string, val: any): void; + inHash(obj: Record): void; + readHash(): URLSearchParams; +} + +interface ToastOptions { + duration?: number; +} + +interface TelemetryManager { + send(eventName: string, data?: Record): void; +} + +interface SkeletonManager { + show(context?: HTMLElement | Document): void; + hide(context?: HTMLElement | Document): void; +} (function(){ // Design tokens fallback (in case CSS variables missing in older browsers) diff --git a/code/web/static/ts/cardHover.ts b/code/web/static/ts/cardHover.ts new file mode 100644 index 0000000..260e783 --- /dev/null +++ b/code/web/static/ts/cardHover.ts @@ -0,0 +1,798 @@ +/** + * Card Hover Panel System + * + * Unified hover/tap card preview panel with mobile support. + * Displays card images with metadata (role, tags, themes, overlaps). + * + * Features: + * - Desktop: Hover to show, follows mouse pointer + * - Mobile: Tap to show, centered modal with close button + * - Keyboard accessible with focus/escape handling + * - Image prefetch LRU cache for performance + * - DFC (double-faced card) flip support + * - Tag overlap highlighting + * - Curated-only and reasons toggles for preview modals + * + * NOTE: This module exposes functions globally on window for browser compatibility + */ + +interface PointerEventLike { + clientX: number; + clientY: number; +} + +// Expose globally for browser usage (CommonJS exports don't work in browser without bundler) +(window as any).__initHoverCardPanel = function initHoverCardPanel(): void { + // Global delegated curated-only & reasons controls (works after HTMX swaps and inline render) + function findPreviewRoot(el: Element): Element | null { + return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content'); + } + + function applyCuratedFor(root: Element): void { + const checkbox = root.querySelector('#curated-only-toggle') as HTMLInputElement | null; + const status = root.querySelector('#preview-status') as HTMLElement | null; + if (!checkbox) return; + + // Persist + try { + localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1' : '0'); + } catch (_) { } + + const curatedOnly = checkbox.checked; + let hidden = 0; + root.querySelectorAll('.card-sample').forEach((card) => { + const role = card.getAttribute('data-role'); + const isCurated = role === 'example' || role === 'curated_synergy' || role === 'synthetic'; + if (curatedOnly && !isCurated) { + (card as HTMLElement).style.display = 'none'; + hidden++; + } else { + (card as HTMLElement).style.display = ''; + } + }); + + if (status) status.textContent = curatedOnly ? (`Hid ${hidden} sampled cards`) : ''; + } + + function applyReasonsFor(root: Element): void { + const cb = root.querySelector('#reasons-toggle') as HTMLInputElement | null; + if (!cb) return; + + try { + localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1' : '0'); + } catch (_) { } + + const show = cb.checked; + root.querySelectorAll('[data-reasons-block]').forEach((el) => { + (el as HTMLElement).style.display = show ? '' : 'none'; + }); + } + + document.addEventListener('change', (e) => { + if (e.target && (e.target as HTMLElement).id === 'curated-only-toggle') { + const root = findPreviewRoot(e.target as HTMLElement); + if (root) applyCuratedFor(root); + } + }); + + document.addEventListener('change', (e) => { + if (e.target && (e.target as HTMLElement).id === 'reasons-toggle') { + const root = findPreviewRoot(e.target as HTMLElement); + if (root) applyReasonsFor(root); + } + }); + + document.addEventListener('htmx:afterSwap', (ev: any) => { + const frag = ev.target; + if (frag && frag.querySelector) { + if (frag.querySelector('#curated-only-toggle')) applyCuratedFor(frag); + if (frag.querySelector('#reasons-toggle')) applyReasonsFor(frag); + } + }); + + document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.preview-modal-content').forEach((root) => { + // Restore persisted states before applying + try { + const cVal = localStorage.getItem('mtg:preview.curatedOnly'); + if (cVal !== null) { + const cb = root.querySelector('#curated-only-toggle') as HTMLInputElement | null; + if (cb) cb.checked = cVal === '1'; + } + const rVal = localStorage.getItem('mtg:preview.showReasons'); + if (rVal !== null) { + const rb = root.querySelector('#reasons-toggle') as HTMLInputElement | null; + if (rb) rb.checked = rVal === '1'; + } + } catch (_) { } + + if (root.querySelector('#curated-only-toggle')) applyCuratedFor(root); + if (root.querySelector('#reasons-toggle')) applyReasonsFor(root); + }); + }); + + function createPanel(): HTMLElement { + const panel = document.createElement('div'); + panel.id = 'hover-card-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-label', 'Card detail hover panel'); + panel.setAttribute('aria-hidden', 'true'); + panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:#1f2937;border:1px solid #374151;border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:#f3f4f6;font-size:14px;line-height:1.45;pointer-events:none;'; + panel.innerHTML = '' + + '
' + + '
 
' + + '
' + + '' + + '
' + + '
' + + '
' + + 'Card image' + + '
' + + '
' + + '
' + + '
 
' + + '
' + + '
' + + '
    ' + + '
    ' + + '
      ' + + '
      ' + + '
      ' + + '
      '; + document.body.appendChild(panel); + return panel; + } + + function ensurePanel(): HTMLElement { + let panel = document.getElementById('hover-card-panel'); + if (panel) return panel; + // Auto-create for direct theme pages where fragment-specific markup not injected + return createPanel(); + } + + function setup(): void { + const panel = ensurePanel(); + if (!panel || (panel as any).__hoverInit) return; + (panel as any).__hoverInit = true; + + const imgEl = panel.querySelector('.hcp-img') as HTMLImageElement; + const nameEl = panel.querySelector('.hcp-name') as HTMLElement; + const rarityEl = panel.querySelector('.hcp-rarity') as HTMLElement; + const metaEl = panel.querySelector('.hcp-meta') as HTMLElement; + const reasonsList = panel.querySelector('.hcp-reasons') as HTMLElement; + const tagsEl = panel.querySelector('.hcp-tags') as HTMLElement; + const bodyEl = panel.querySelector('.hcp-body') as HTMLElement; + const rightCol = panel.querySelector('.hcp-right') as HTMLElement; + + const coarseQuery = window.matchMedia('(pointer: coarse)'); + + function isMobileMode(): boolean { + return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; + } + + function refreshPosition(): void { + if (panel.style.display === 'block') { + move((window as any).__lastPointerEvent); + } + } + + if (coarseQuery) { + const handler = () => { refreshPosition(); }; + if (coarseQuery.addEventListener) { + coarseQuery.addEventListener('change', handler); + } else if ((coarseQuery as any).addListener) { + (coarseQuery as any).addListener(handler); + } + } + + window.addEventListener('resize', refreshPosition); + + const closeBtn = panel.querySelector('.hcp-close') as HTMLButtonElement; + if (closeBtn && !(closeBtn as any).__bound) { + (closeBtn as any).__bound = true; + closeBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + hide(); + }); + } + + function positionPanel(evt: PointerEventLike): void { + if (isMobileMode()) { + panel.classList.add('mobile'); + panel.style.bottom = 'auto'; + panel.style.left = '50%'; + panel.style.top = '50%'; + panel.style.right = 'auto'; + panel.style.transform = 'translate(-50%, -50%)'; + panel.style.pointerEvents = 'auto'; + } else { + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.transform = 'none'; + const pad = 18; + let x = evt.clientX + pad, y = evt.clientY + pad; + const vw = window.innerWidth, vh = window.innerHeight; + const r = panel.getBoundingClientRect(); + if (x + r.width + 8 > vw) x = evt.clientX - r.width - pad; + if (y + r.height + 8 > vh) y = evt.clientY - r.height - pad; + if (x < 8) x = 8; + if (y < 8) y = 8; + panel.style.left = x + 'px'; + panel.style.top = y + 'px'; + panel.style.bottom = 'auto'; + panel.style.right = 'auto'; + } + } + + function move(evt?: PointerEventLike): void { + if (panel.style.display === 'none') return; + if (!evt) evt = (window as any).__lastPointerEvent; + if (!evt && lastCard) { + const rect = lastCard.getBoundingClientRect(); + evt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + } + if (!evt) evt = { clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 }; + positionPanel(evt); + } + + // Lightweight image prefetch LRU cache (size 12) + const imgLRU: string[] = []; + function prefetch(src: string): void { + if (!src) return; + if (imgLRU.indexOf(src) === -1) { + imgLRU.push(src); + if (imgLRU.length > 12) imgLRU.shift(); + const im = new Image(); + im.src = src; + } + } + + const activationDelay = 120; // ms + let hoverTimer: number | null = null; + + function schedule(card: Element, evt: PointerEventLike): void { + if (hoverTimer !== null) clearTimeout(hoverTimer); + hoverTimer = window.setTimeout(() => { show(card, evt); }, activationDelay); + } + + function cancelSchedule(): void { + if (hoverTimer !== null) { + clearTimeout(hoverTimer); + hoverTimer = null; + } + } + + let lastCard: Element | null = null; + + function show(card: Element, evt?: PointerEventLike): void { + if (!card) return; + + // Prefer attributes on container, fallback to child (image) if missing + function attr(name: string): string { + return card.getAttribute(name) || + (card.querySelector(`[data-${name.slice(5)}]`)?.getAttribute(name)) || ''; + } + + let simpleSource: Element | null = null; + if (card.closest) { + simpleSource = card.closest('[data-hover-simple]'); + } + + const forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource; + const nm = attr('data-card-name') || attr('data-original-name') || 'Card'; + const rarity = (attr('data-rarity') || '').trim(); + const mana = (attr('data-mana') || '').trim(); + const role = (attr('data-role') || '').trim(); + let reasonsRaw = attr('data-reasons') || ''; + const tagsRaw = attr('data-tags') || ''; + const metadataTagsRaw = attr('data-metadata-tags') || ''; + const roleEl = panel.querySelector('.hcp-role') as HTMLElement; + // Check for flip button on card or its parent container (for elements in commander browser) + let hasFlip = !!card.querySelector('.dfc-toggle'); + if (!hasFlip && card.parentElement) { + hasFlip = !!card.parentElement.querySelector('.dfc-toggle'); + } + const tagListEl = panel.querySelector('.hcp-taglist') as HTMLElement; + const overlapsEl = panel.querySelector('.hcp-overlaps') as HTMLElement; + const overlapsAttr = attr('data-overlaps') || ''; + + function displayLabel(text: string): string { + if (!text) return ''; + let label = String(text); + label = label.replace(/[\u2022\-_]+/g, ' '); + label = label.replace(/\s+/g, ' ').trim(); + return label; + } + + function parseTagList(raw: string): string[] { + if (!raw) return []; + const trimmed = String(raw).trim(); + if (!trimmed) return []; + let result: string[] = []; + let candidate = trimmed; + + if (trimmed[0] === '[' && trimmed[trimmed.length - 1] === ']') { + candidate = trimmed.slice(1, -1); + } + + // Try JSON parsing after normalizing quotes + try { + let normalized = trimmed; + if (trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1) { + normalized = trimmed.replace(/'/g, '"'); + } + const parsed = JSON.parse(normalized); + if (Array.isArray(parsed)) { + result = parsed; + } + } catch (_) { /* fall back below */ } + + if (!result || !result.length) { + result = candidate.split(/\s*,\s*/); + } + + return result.map((t) => String(t || '').trim()).filter(Boolean); + } + + function deriveTagsFromReasons(reasons: string): string[] { + if (!reasons) return []; + const out: string[] = []; + + // Grab bracketed or quoted lists first + const m = reasons.match(/\[(.*?)\]/); + if (m && m[1]) out.push(...m[1].split(/\s*,\s*/)); + + // Common phrasing: "overlap(s) with A, B" or "by A, B" + const rx = /(overlap(?:s)?(?:\s+with)?|by)\s+([^.;]+)/ig; + let r; + while ((r = rx.exec(reasons))) { + out.push(...(r[2] || '').split(/\s*,\s*/)); + } + + const tagRx = /tag:\s*([^.;]+)/ig; + let tMatch; + while ((tMatch = tagRx.exec(reasons))) { + out.push(...(tMatch[1] || '').split(/\s*,\s*/)); + } + + return out.map((s) => s.trim()).filter(Boolean); + } + + let overlapArr: string[] = []; + if (overlapsAttr) { + const parsedOverlaps = parseTagList(overlapsAttr); + if (parsedOverlaps.length) { + overlapArr = parsedOverlaps; + } else { + overlapArr = [String(overlapsAttr).trim()]; + } + } + + const derivedFromReasons = deriveTagsFromReasons(reasonsRaw); + let allTags = parseTagList(tagsRaw); + + if (!allTags.length && derivedFromReasons.length) { + // Fallback: try to derive tags from reasons text when tags missing + allTags = derivedFromReasons.slice(); + } + + if ((!overlapArr || !overlapArr.length) && derivedFromReasons.length) { + const normalizedAll = (allTags || []).map((t) => ({ raw: t, key: t.toLowerCase() })); + const derivedKeys = new Set(derivedFromReasons.map((t) => t.toLowerCase())); + let intersect = normalizedAll.filter((entry) => derivedKeys.has(entry.key)).map((entry) => entry.raw); + + if (!intersect.length) { + intersect = derivedFromReasons.slice(); + } + + overlapArr = Array.from(new Set(intersect)); + } + + overlapArr = (overlapArr || []).map((t) => String(t || '').trim()).filter(Boolean); + allTags = (allTags || []).map((t) => String(t || '').trim()).filter(Boolean); + + nameEl.textContent = nm; + rarityEl.textContent = rarity; + + const roleLabel = displayLabel(role); + const roleKey = (roleLabel || role || '').toLowerCase(); + const isCommanderRole = roleKey === 'commander'; + + metaEl.textContent = [ + roleLabel ? ('Role: ' + roleLabel) : '', + mana ? ('Mana: ' + mana) : '' + ].filter(Boolean).join(' • '); + + reasonsList.innerHTML = ''; + reasonsRaw.split(';').map((r) => r.trim()).filter(Boolean).forEach((r) => { + const li = document.createElement('li'); + li.style.margin = '2px 0'; + li.textContent = r; + reasonsList.appendChild(li); + }); + + // Build inline tag list with overlap highlighting + if (tagListEl) { + tagListEl.innerHTML = ''; + tagListEl.style.display = 'none'; + tagListEl.setAttribute('aria-hidden', 'true'); + } + + if (overlapsEl) { + if (overlapArr && overlapArr.length) { + overlapsEl.innerHTML = overlapArr.map((o) => { + const label = displayLabel(o); + return `${label}`; + }).join(''); + } else { + overlapsEl.innerHTML = ''; + } + } + + if (tagsEl) { + if (isCommanderRole) { + tagsEl.textContent = ''; + tagsEl.style.display = 'none'; + } else { + let tagText = allTags.map(displayLabel).join(', '); + + // M5: Temporarily append metadata tags for debugging + if (metadataTagsRaw && metadataTagsRaw.trim()) { + const metaTags = metadataTagsRaw.split(',').map((t) => t.trim()).filter(Boolean); + if (metaTags.length) { + const metaText = metaTags.map(displayLabel).join(', '); + tagText = tagText ? (tagText + ' | META: ' + metaText) : ('META: ' + metaText); + } + } + + tagsEl.textContent = tagText; + tagsEl.style.display = tagText ? '' : 'none'; + } + } + + if (roleEl) { + roleEl.textContent = roleLabel || ''; + roleEl.style.display = roleLabel ? 'inline-block' : 'none'; + } + + panel.classList.toggle('is-payoff', role === 'payoff'); + panel.classList.toggle('is-commander', isCommanderRole); + + const hasDetails = !forceSimple && ( + !!roleLabel || !!mana || !!rarity || + (reasonsRaw && reasonsRaw.trim()) || + (overlapArr && overlapArr.length) || + (allTags && allTags.length) + ); + + panel.classList.toggle('hcp-simple', !hasDetails); + + if (rightCol) { + rightCol.style.display = hasDetails ? 'flex' : 'none'; + } + + if (bodyEl) { + if (!hasDetails) { + bodyEl.style.display = 'flex'; + bodyEl.style.flexDirection = 'column'; + bodyEl.style.alignItems = 'center'; + bodyEl.style.gap = '12px'; + } else { + bodyEl.style.display = ''; + bodyEl.style.flexDirection = ''; + bodyEl.style.alignItems = ''; + bodyEl.style.gap = ''; + } + } + + const rawName = nm || ''; + let hasBack = rawName.indexOf('//') > -1 || (attr('data-original-name') || '').indexOf('//') > -1; + if (hasBack) hasFlip = true; + + const storageKey = 'mtg:face:' + rawName.toLowerCase(); + const storedFace = (() => { + try { + return localStorage.getItem(storageKey); + } catch (_) { + return null; + } + })(); + + if (storedFace === 'front' || storedFace === 'back') { + card.setAttribute('data-current-face', storedFace); + } + + const chosenFace = card.getAttribute('data-current-face') || 'front'; + lastCard = card; + + function renderHoverFace(face: string): void { + const desiredVersion = 'normal'; + const currentKey = nm + ':' + face + ':' + desiredVersion; + const prevFace = imgEl.getAttribute('data-face'); + const faceChanged = prevFace && prevFace !== face; + + if (imgEl.getAttribute('data-current') !== currentKey) { + // For DFC cards, extract the specific face name for cache lookup + let faceName = nm; + const isDFC = nm.indexOf('//') > -1; + if (isDFC) { + const faces = nm.split('//'); + faceName = (face === 'back') ? faces[1].trim() : faces[0].trim(); + } + + let src = '/api/images/' + desiredVersion + '/' + encodeURIComponent(faceName); + if (isDFC && face === 'back') { + src += '?face=back'; + } + + if (faceChanged) imgEl.style.opacity = '0'; + prefetch(src); + imgEl.src = src; + imgEl.setAttribute('data-current', currentKey); + imgEl.setAttribute('data-face', face); + + imgEl.addEventListener('load', function onLoad() { + imgEl.removeEventListener('load', onLoad); + requestAnimationFrame(() => { imgEl.style.opacity = '1'; }); + }); + } + + if (!(imgEl as any).__errBound) { + (imgEl as any).__errBound = true; + imgEl.addEventListener('error', () => { + const cur = imgEl.getAttribute('src') || ''; + // Fallback from normal to small if image fails to load + if (cur.indexOf('/api/images/normal/') > -1) { + imgEl.src = cur.replace('/api/images/normal/', '/api/images/small/'); + } + }); + } + } + + renderHoverFace(chosenFace); + + // Add DFC flip button to popup panel ONLY on mobile + const checkFlip = (window as any).__dfcHasTwoFaces || (() => false); + if (hasFlip && imgEl && checkFlip(card) && isMobileMode()) { + const imgWrap = imgEl.parentElement; + if (imgWrap && !imgWrap.querySelector('.dfc-toggle')) { + const flipBtn = document.createElement('button'); + flipBtn.type = 'button'; + flipBtn.className = 'dfc-toggle'; + flipBtn.setAttribute('aria-pressed', 'false'); + flipBtn.setAttribute('tabindex', '0'); + flipBtn.innerHTML = ''; + + flipBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + if ((window as any).__dfcFlipCard && lastCard) { + // For image elements, find the parent container with the flip button + let cardToFlip = lastCard; + if (lastCard.tagName === 'IMG' && lastCard.parentElement) { + const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle'); + if (parentWithButton) { + cardToFlip = lastCard.parentElement; + } + } + (window as any).__dfcFlipCard(cardToFlip); + } + }); + + flipBtn.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'f' || ev.key === 'F') { + ev.preventDefault(); + if ((window as any).__dfcFlipCard && lastCard) { + // For image elements, find the parent container with the flip button + let cardToFlip = lastCard; + if (lastCard.tagName === 'IMG' && lastCard.parentElement) { + const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle'); + if (parentWithButton) { + cardToFlip = lastCard.parentElement; + } + } + (window as any).__dfcFlipCard(cardToFlip); + } + } + }); + + imgWrap.classList.add('dfc-host'); + imgWrap.appendChild(flipBtn); + } + } + + (window as any).__dfcNotifyHover = hasFlip ? (cardRef: Element, face: string) => { + if (cardRef === lastCard) renderHoverFace(face); + } : null; + + if (evt) (window as any).__lastPointerEvent = evt; + + if (isMobileMode()) { + panel.classList.add('mobile'); + panel.style.pointerEvents = 'auto'; + panel.style.maxHeight = '80vh'; + } else { + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.maxHeight = ''; + panel.style.bottom = 'auto'; + } + + panel.style.display = 'block'; + panel.setAttribute('aria-hidden', 'false'); + move(evt); + } + + function hide(): void { + // Blur any focused element inside panel to avoid ARIA focus warning + if (panel.contains(document.activeElement)) { + (document.activeElement as HTMLElement)?.blur(); + } + panel.style.display = 'none'; + panel.setAttribute('aria-hidden', 'true'); + cancelSchedule(); + panel.classList.remove('mobile'); + panel.style.pointerEvents = 'none'; + panel.style.transform = 'none'; + panel.style.bottom = 'auto'; + panel.style.maxHeight = ''; + (window as any).__dfcNotifyHover = null; + } + + document.addEventListener('mousemove', move); + + function getCardFromEl(el: EventTarget | null): Element | null { + if (!el || !(el instanceof Element)) return null; + + if (el.closest) { + const altBtn = el.closest('.alts button[data-card-name]'); + if (altBtn) return altBtn; + } + + // If inside flip button + const btn = el.closest && el.closest('.dfc-toggle'); + if (btn) { + return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card'); + } + + // For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile) + if (el.closest && el.closest('.card-tile')) { + const imgBtn = el.closest('.img-btn'); + if (imgBtn) return imgBtn.closest('.card-tile'); + + // If directly on the image + if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))) { + return el.closest('.card-tile'); + } + + // Don't trigger on other parts of the tile (buttons, text, etc.) + return null; + } + + // Recognized container classes + const container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card'); + if (container) return container; + + // Image-based detection (any card image carrying data-card-name) + if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))) { + const up = el.closest && el.closest('.stack-card'); + return up || el; + } + + // List view spans (deck summary list mode, finished deck list, etc.) + if (el.hasAttribute && el.hasAttribute('data-card-name')) return el; + + return null; + } + + document.addEventListener('pointermove', (e) => { (window as any).__lastPointerEvent = e; }); + + document.addEventListener('pointerover', (e) => { + if (isMobileMode()) return; + const card = getCardFromEl(e.target); + if (!card) return; + + // If hovering flip button, refresh immediately (no activation delay) + if (e.target instanceof Element && e.target.closest && e.target.closest('.dfc-toggle')) { + show(card, e); + return; + } + + if (lastCard === card && panel.style.display === 'block') return; + schedule(card, e); + }); + + document.addEventListener('pointerout', (e) => { + if (isMobileMode()) return; + const relCard = getCardFromEl(e.relatedTarget); + if (relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button) + if (!panel.contains(e.relatedTarget as Node)) { + cancelSchedule(); + if (!relCard) hide(); + } + }); + + document.addEventListener('click', (e) => { + if (!isMobileMode()) return; + if (panel.contains(e.target as Node)) return; + if (e.target instanceof Element && e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return; + if (e.target instanceof Element && e.target.closest && e.target.closest('button, input, select, textarea, a')) return; + + const card = getCardFromEl(e.target); + if (card) { + cancelSchedule(); + const rect = card.getBoundingClientRect(); + const syntheticEvt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + show(card, syntheticEvt); + } else if (panel.style.display === 'block') { + hide(); + } + }); + + // Expose show function for external refresh (flip updates) + (window as any).__hoverShowCard = (card: Element) => { + const ev = (window as any).__lastPointerEvent || { + clientX: card.getBoundingClientRect().left + 12, + clientY: card.getBoundingClientRect().top + 12 + }; + show(card, ev); + }; + + (window as any).hoverShowByName = (name: string) => { + try { + const el = document.querySelector('[data-card-name="' + CSS.escape(name) + '"]'); + if (el) { + (window as any).__hoverShowCard( + el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el + ); + } + } catch (_) { } + }; + + // Keyboard accessibility & focus traversal + document.addEventListener('focusin', (e) => { + const card = e.target instanceof Element && e.target.closest && e.target.closest('.card-sample, .commander-cell, .commander-thumb'); + if (card) { + show(card, { + clientX: card.getBoundingClientRect().left + 10, + clientY: card.getBoundingClientRect().top + 10 + }); + } + }); + + document.addEventListener('focusout', (e) => { + const next = e.relatedTarget instanceof Element && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb'); + if (!next) hide(); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') hide(); + }); + + // Compact mode event listener + document.addEventListener('mtg:hoverCompactToggle', () => { + panel.classList.toggle('compact-img', !!(window as any).__hoverCompactMode); + }); + } + + document.addEventListener('htmx:afterSwap', setup); + document.addEventListener('DOMContentLoaded', setup); + setup(); +}; + +// Global compact mode toggle function +(window as any).__initHoverCompactMode = function initHoverCompactMode(): void { + (window as any).toggleHoverCompactMode = (state?: boolean) => { + if (typeof state === 'boolean') { + (window as any).__hoverCompactMode = state; + } else { + (window as any).__hoverCompactMode = !(window as any).__hoverCompactMode; + } + document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle')); + }; +}; + +// Auto-initialize on load +if (typeof window !== 'undefined') { + (window as any).__initHoverCardPanel(); + (window as any).__initHoverCompactMode(); +} diff --git a/code/web/static/ts/cardImages.ts b/code/web/static/ts/cardImages.ts new file mode 100644 index 0000000..b7f8455 --- /dev/null +++ b/code/web/static/ts/cardImages.ts @@ -0,0 +1,153 @@ +/** + * Card Image URL Builders & Retry Logic + * + * Utilities for constructing card image URLs and handling image load failures + * with automatic fallback to different image sizes. + * + * Features: + * - Build card image URLs with face (front/back) support + * - Build Scryfall image URLs with version control + * - Automatic retry on image load failure (different sizes) + * - Cache-busting support for failed loads + * - HTMX swap integration for dynamic content + * + * NOTE: This module exposes functions globally on window for browser compatibility + */ + +interface ImageRetryState { + vi: number; // Current version index + nocache: number; // Cache-busting flag (0 or 1) + versions: string[]; // Image versions to try ['small', 'normal', 'large'] +} + +const IMG_FLAG = '__cardImgRetry'; + +/** + * Normalize card name by removing synergy suffixes + */ +function normalizeCardName(raw: string): string { + if (!raw) return raw; + const normalize = (window as any).__normalizeCardName || ((name: string) => { + if (!name) return name; + const m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(name); + if (m) return m[1].trim(); + return name; + }); + return normalize(raw); +} + +/** + * Build card image URL with face support (front/back) + * @param name - Card name + * @param version - Image version ('small', 'normal', 'large') + * @param nocache - Add cache-busting timestamp + * @param face - Card face ('front' or 'back') + */ +function buildCardUrl(name: string, version?: string, nocache?: boolean, face?: string): string { + name = normalizeCardName(name); + const q = encodeURIComponent(name || ''); + let url = '/api/images/' + (version || 'normal') + '/' + q; + if (face === 'back') url += '?face=back'; + if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now(); + return url; +} + +/** + * Build Scryfall image URL + * @param name - Card name + * @param version - Image version ('small', 'normal', 'large') + * @param nocache - Add cache-busting timestamp + */ +function buildScryfallImageUrl(name: string, version?: string, nocache?: boolean): string { + name = normalizeCardName(name); + const q = encodeURIComponent(name || ''); + let url = '/api/images/' + (version || 'normal') + '/' + q; + if (nocache) url += '?t=' + Date.now(); + return url; +} + +/** + * Bind error handler to an image element for automatic retry with fallback versions + * @param img - Image element with data-card-name attribute + * @param versions - Array of image versions to try in order + */ +function bindCardImageRetry(img: HTMLImageElement, versions?: string[]): void { + try { + if (!img || (img as any)[IMG_FLAG]) return; + const name = img.getAttribute('data-card-name') || ''; + if (!name) return; + + // Default versions: normal -> large + const versionList = versions && versions.length ? versions.slice() : ['normal', 'large']; + (img as any)[IMG_FLAG] = { + vi: 0, + nocache: 0, + versions: versionList + } as ImageRetryState; + + img.addEventListener('error', function() { + const st = (img as any)[IMG_FLAG] as ImageRetryState; + if (!st) return; + + // Try next version + if (st.vi < st.versions.length - 1) { + st.vi += 1; + img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); + } + // Try cache-busting current version + else if (!st.nocache) { + st.nocache = 1; + img.src = buildScryfallImageUrl(name, st.versions[st.vi], true); + } + }); + + // If initial load already failed before binding, try next immediately + if (img.complete && img.naturalWidth === 0) { + const st = (img as any)[IMG_FLAG] as ImageRetryState; + const current = img.src || ''; + const first = buildScryfallImageUrl(name, st.versions[0], false); + + // Check if current src matches first version + if (current.indexOf(encodeURIComponent(name)) !== -1 && + current.indexOf('version=' + st.versions[0]) !== -1) { + st.vi = Math.min(1, st.versions.length - 1); + img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); + } else { + // Re-trigger current request (may succeed if transient error) + img.src = current; + } + } + } catch (_) { + // Silently fail - image retry is a nice-to-have feature + } +} + +/** + * Bind retry handlers to all card images in the document + */ +function bindAllCardImageRetries(): void { + document.querySelectorAll('img[data-card-name]').forEach((img) => { + // Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks + const versions = (img.classList && img.classList.contains('card-thumb')) + ? ['small', 'normal', 'large'] + : ['normal', 'large']; + bindCardImageRetry(img as HTMLImageElement, versions); + }); +} + +// Expose globally for browser usage +(window as any).__initCardImages = function initCardImages(): void { + // Expose retry binding globally for dynamic content + (window as any).bindAllCardImageRetries = bindAllCardImageRetries; + + // Initial bind + bindAllCardImageRetries(); + + // Re-bind after HTMX swaps + document.addEventListener('htmx:afterSwap', bindAllCardImageRetries); +}; + +// Auto-initialize on load +if (typeof window !== 'undefined') { + (window as any).__initCardImages(); +} diff --git a/code/web/templates/base.html b/code/web/templates/base.html index a9bf533..3e27358 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -240,79 +240,13 @@ setInterval(pollStatus, 10000); pollStatus(); - // Card image URL builder with synergy annotation stripping - var PREVIEW_VERSIONS = ['normal','large']; - function normalizeCardName(raw){ + // Expose normalizeCardName for cardImages module + window.__normalizeCardName = function(raw){ if(!raw) return raw; - // Strip ' - Synergy (...' annotation if present - var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw); - if(m){ return m[1].trim(); } + var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw); + if(m){ return m[1].trim(); } return raw; - } - function buildCardUrl(name, version, nocache, face){ - name = normalizeCardName(name); - var q = encodeURIComponent(name||''); - var url = '/api/images/' + (version||'normal') + '/' + q; - if (face === 'back') url += '?face=back'; - if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now(); - return url; - } - // Generic card image URL builder - function buildScryfallImageUrl(name, version, nocache){ - name = normalizeCardName(name); - var q = encodeURIComponent(name||''); - var url = '/api/images/' + (version||'normal') + '/' + q; - if (nocache) url += '?t=' + Date.now(); - return url; - } - - // Global image retry binding for any - var IMG_FLAG = '__cardImgRetry'; - function bindCardImageRetry(img, versions){ - try { - if (!img || img[IMG_FLAG]) return; - var name = img.getAttribute('data-card-name') || ''; - if (!name) return; - img[IMG_FLAG] = { vi: 0, nocache: 0, versions: versions && versions.length ? versions.slice() : ['normal','large'] }; - img.addEventListener('error', function(){ - var st = img[IMG_FLAG]; - if (!st) return; - if (st.vi < st.versions.length - 1){ - st.vi += 1; - img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); - } else if (!st.nocache){ - st.nocache = 1; - img.src = buildScryfallImageUrl(name, st.versions[st.vi], true); - } - }); - // If the initial load already failed before binding, try the next immediately - if (img.complete && img.naturalWidth === 0){ - // If src corresponds to the first version, move to next; else, just force a reload - var st = img[IMG_FLAG]; - var current = img.src || ''; - var first = buildScryfallImageUrl(name, st.versions[0], false); - if (current.indexOf(encodeURIComponent(name)) !== -1 && current.indexOf('version='+st.versions[0]) !== -1){ - st.vi = Math.min(1, st.versions.length - 1); - img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); - } else { - // Re-trigger current request (may succeed if transient) - img.src = current; - } - } - } catch(_){} - } - function bindAllCardImageRetries(){ - document.querySelectorAll('img[data-card-name]').forEach(function(img){ - // Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks - var versions = (img.classList && img.classList.contains('card-thumb')) ? ['small','normal','large'] : ['normal','large']; - bindCardImageRetry(img, versions); - }); - } - - // Expose image retry binding globally for dynamic content - window.bindAllCardImageRetries = bindAllCardImageRetries; - bindAllCardImageRetries(); - document.addEventListener('htmx:afterSwap', bindAllCardImageRetries); + }; })(); + + {% if enable_themes %} - diff --git a/code/web/templates/browse/cards/index.html b/code/web/templates/browse/cards/index.html index 6c98a11..ccc9e28 100644 --- a/code/web/templates/browse/cards/index.html +++ b/code/web/templates/browse/cards/index.html @@ -242,7 +242,7 @@ id="filter-cmc-min" min="0" max="16" - value="{{ cmc_min if cmc_min is defined else '' }}" + value="{{ cmc_min if cmc_min is not none and cmc_min != '' else '' }}" placeholder="Min" style="width:70px;" onchange="applyFilter()" @@ -253,7 +253,7 @@ id="filter-cmc-max" min="0" max="16" - value="{{ cmc_max if cmc_max is defined else '' }}" + value="{{ cmc_max if cmc_max is not none and cmc_max != '' else '' }}" placeholder="Max" style="width:70px;" onchange="applyFilter()" @@ -268,7 +268,7 @@ id="filter-power-min" min="0" max="99" - value="{{ power_min if power_min is defined else '' }}" + value="{{ power_min if power_min is not none and power_min != '' else '' }}" placeholder="Min" style="width:70px;" onchange="applyFilter()" @@ -279,7 +279,7 @@ id="filter-power-max" min="0" max="99" - value="{{ power_max if power_max is defined else '' }}" + value="{{ power_max if power_max is not none and power_max != '' else '' }}" placeholder="Max" style="width:70px;" onchange="applyFilter()" @@ -294,7 +294,7 @@ id="filter-tough-min" min="0" max="99" - value="{{ tough_min if tough_min is defined else '' }}" + value="{{ tough_min if tough_min is not none and tough_min != '' else '' }}" placeholder="Min" style="width:70px;" onchange="applyFilter()" @@ -305,7 +305,7 @@ id="filter-tough-max" min="0" max="99" - value="{{ tough_max if tough_max is defined else '' }}" + value="{{ tough_max if tough_max is not none and tough_max != '' else '' }}" placeholder="Max" style="width:70px;" onchange="applyFilter()" diff --git a/code/web/templates/commanders/row_wireframe.html b/code/web/templates/commanders/row_wireframe.html index fa6c76c..c90e098 100644 --- a/code/web/templates/commanders/row_wireframe.html +++ b/code/web/templates/commanders/row_wireframe.html @@ -4,7 +4,10 @@ {% set display_label = record.name if '//' in record.name else record.display_name %} {% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
      -
      +
      {% set small = record.display_name|card_image('small') %} {% set normal = record.display_name|card_image('normal') %}