mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
refactor(web): consolidate inline JavaScript to TypeScript modules
Migrated app.js and components.js to TypeScript. Extracted inline scripts from base.html to cardHover.ts and cardImages.ts modules for better maintainability and code reuse.
This commit is contained in:
parent
ed381dfdce
commit
9379732eec
8 changed files with 1050 additions and 634 deletions
19
CHANGELOG.md
19
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_
|
||||
|
|
|
|||
|
|
@ -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_
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any>): void;
|
||||
readHash(): URLSearchParams;
|
||||
}
|
||||
|
||||
interface ToastOptions {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface TelemetryManager {
|
||||
send(eventName: string, data?: Record<string, any>): 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)
|
||||
|
|
|
|||
798
code/web/static/ts/cardHover.ts
Normal file
798
code/web/static/ts/cardHover.ts
Normal file
|
|
@ -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 = '' +
|
||||
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">' +
|
||||
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> </div>' +
|
||||
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>' +
|
||||
'<button type="button" class="hcp-close" aria-label="Close card details"><span aria-hidden="true">✕</span></button>' +
|
||||
'</div>' +
|
||||
'<div class="hcp-body">' +
|
||||
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">' +
|
||||
'<img class="hcp-img" alt="Card image" style="max-width:320px;width:100%;height:auto;border-radius:10px;border:1px solid #475569;background:#0b0d12;opacity:1;" />' +
|
||||
'</div>' +
|
||||
'<div class="hcp-right" style="display:flex;flex-direction:column;min-width:0;">' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin:0 0 4px;flex-wrap:wrap;">' +
|
||||
'<div class="hcp-role" style="display:inline-block;padding:3px 8px;font-size:11px;letter-spacing:.65px;border:1px solid #475569;border-radius:12px;background:#243044;text-transform:uppercase;"> </div>' +
|
||||
'<div class="hcp-overlaps" style="flex:1;min-height:14px;"></div>' +
|
||||
'</div>' +
|
||||
'<ul class="hcp-taglist" aria-label="Themes"></ul>' +
|
||||
'<div class="hcp-meta" style="font-size:12px;opacity:.85;margin:2px 0 6px;"></div>' +
|
||||
'<ul class="hcp-reasons" style="list-style:disc;margin:4px 0 8px 18px;padding:0;font-size:11px;line-height:1.35;"></ul>' +
|
||||
'<div class="hcp-tags" style="font-size:11px;opacity:.55;word-break:break-word;"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
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 <img> 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 `<span class="hcp-ov-chip" title="Overlapping synergy">${label}</span>`;
|
||||
}).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 = '<span class="icon" aria-hidden="true" style="font-size:18px;">⥮</span>';
|
||||
|
||||
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();
|
||||
}
|
||||
153
code/web/static/ts/cardImages.ts
Normal file
153
code/web/static/ts/cardImages.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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(); }
|
||||
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 <img data-card-name>
|
||||
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);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
|
@ -477,6 +411,8 @@
|
|||
</script>
|
||||
<script src="/static/js/components.js?v=20251028-1"></script>
|
||||
<script src="/static/js/app.js?v=20250826-4"></script>
|
||||
<script src="/static/js/cardImages.js?v=20251029-1"></script>
|
||||
<script src="/static/js/cardHover.js?v=20251028-1"></script>
|
||||
{% if enable_themes %}
|
||||
<script>
|
||||
(function(){
|
||||
|
|
@ -610,534 +546,7 @@
|
|||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Global delegated hover card panel initializer (ensures functionality after HTMX swaps)
|
||||
(function(){
|
||||
// Global delegated curated-only & reasons controls (works after HTMX swaps and inline render)
|
||||
function findPreviewRoot(el){ return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content'); }
|
||||
function applyCuratedFor(root){
|
||||
var checkbox = root.querySelector('#curated-only-toggle');
|
||||
var status = root.querySelector('#preview-status');
|
||||
if(!checkbox) return;
|
||||
// persist
|
||||
try{ localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1':'0'); }catch(_){ }
|
||||
var curatedOnly = checkbox.checked;
|
||||
var hidden=0;
|
||||
root.querySelectorAll('.card-sample').forEach(function(card){
|
||||
var role = card.getAttribute('data-role');
|
||||
var isCurated = role==='example'|| role==='curated_synergy' || role==='synthetic';
|
||||
if(curatedOnly && !isCurated){ card.style.display='none'; hidden++; } else { card.style.display=''; }
|
||||
});
|
||||
if(status) status.textContent = curatedOnly ? ('Hid '+hidden+' sampled cards') : '';
|
||||
}
|
||||
function applyReasonsFor(root){
|
||||
var cb = root.querySelector('#reasons-toggle'); if(!cb) return;
|
||||
try{ localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1':'0'); }catch(_){ }
|
||||
var show = cb.checked;
|
||||
root.querySelectorAll('[data-reasons-block]').forEach(function(el){ el.style.display = show ? '' : 'none'; });
|
||||
}
|
||||
document.addEventListener('change', function(e){
|
||||
if(e.target && e.target.id === 'curated-only-toggle'){
|
||||
var root = findPreviewRoot(e.target); if(root) applyCuratedFor(root);
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', function(e){
|
||||
if(e.target && e.target.id === 'reasons-toggle'){
|
||||
var root = findPreviewRoot(e.target); if(root) applyReasonsFor(root);
|
||||
}
|
||||
});
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
var 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', function(){
|
||||
document.querySelectorAll('.preview-modal-content').forEach(function(root){
|
||||
// restore persisted states before applying
|
||||
try {
|
||||
var cVal = localStorage.getItem('mtg:preview.curatedOnly');
|
||||
if(cVal !== null){ var cb = root.querySelector('#curated-only-toggle'); if(cb){ cb.checked = cVal === '1'; } }
|
||||
var rVal = localStorage.getItem('mtg:preview.showReasons');
|
||||
if(rVal !== null){ var rb = root.querySelector('#reasons-toggle'); if(rb){ rb.checked = rVal === '1'; } }
|
||||
}catch(_){ }
|
||||
if(root.querySelector('#curated-only-toggle')) applyCuratedFor(root);
|
||||
if(root.querySelector('#reasons-toggle')) applyReasonsFor(root);
|
||||
});
|
||||
});
|
||||
function createPanel(){
|
||||
var 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 = ''+
|
||||
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">'+
|
||||
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> </div>'+
|
||||
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>'+
|
||||
'<button type="button" class="hcp-close" aria-label="Close card details"><span aria-hidden="true">✕</span></button>'+
|
||||
'</div>'+
|
||||
'<div class="hcp-body">'+
|
||||
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">'+
|
||||
'<img class="hcp-img" alt="Card image" style="max-width:320px;width:100%;height:auto;border-radius:10px;border:1px solid #475569;background:#0b0d12;opacity:1;" />'+
|
||||
'</div>'+
|
||||
'<div class="hcp-right" style="display:flex;flex-direction:column;min-width:0;">'+
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin:0 0 4px;flex-wrap:wrap;">'+
|
||||
'<div class="hcp-role" style="display:inline-block;padding:3px 8px;font-size:11px;letter-spacing:.65px;border:1px solid #475569;border-radius:12px;background:#243044;text-transform:uppercase;"> </div>'+
|
||||
'<div class="hcp-overlaps" style="flex:1;min-height:14px;"></div>'+
|
||||
'</div>'+
|
||||
'<ul class="hcp-taglist" aria-label="Themes"></ul>'+
|
||||
'<div class="hcp-meta" style="font-size:12px;opacity:.85;margin:2px 0 6px;"></div>'+
|
||||
'<ul class="hcp-reasons" style="list-style:disc;margin:4px 0 8px 18px;padding:0;font-size:11px;line-height:1.35;"></ul>'+
|
||||
'<div class="hcp-tags" style="font-size:11px;opacity:.55;word-break:break-word;"></div>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
function ensurePanel(){
|
||||
var 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(){
|
||||
var panel = ensurePanel();
|
||||
if(!panel || panel.__hoverInit) return;
|
||||
panel.__hoverInit = true;
|
||||
var imgEl = panel.querySelector('.hcp-img');
|
||||
var nameEl = panel.querySelector('.hcp-name');
|
||||
var rarityEl = panel.querySelector('.hcp-rarity');
|
||||
var metaEl = panel.querySelector('.hcp-meta');
|
||||
var reasonsList = panel.querySelector('.hcp-reasons');
|
||||
var tagsEl = panel.querySelector('.hcp-tags');
|
||||
var bodyEl = panel.querySelector('.hcp-body');
|
||||
var rightCol = panel.querySelector('.hcp-right');
|
||||
var coarseQuery = window.matchMedia('(pointer: coarse)');
|
||||
function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; }
|
||||
function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } }
|
||||
if(coarseQuery){
|
||||
var handler = function(){ refreshPosition(); };
|
||||
if(coarseQuery.addEventListener){ coarseQuery.addEventListener('change', handler); }
|
||||
else if(coarseQuery.addListener){ coarseQuery.addListener(handler); }
|
||||
}
|
||||
window.addEventListener('resize', refreshPosition);
|
||||
var closeBtn = panel.querySelector('.hcp-close');
|
||||
if(closeBtn && !closeBtn.__bound){
|
||||
closeBtn.__bound = true;
|
||||
closeBtn.addEventListener('click', function(ev){ ev.preventDefault(); hide(); });
|
||||
}
|
||||
function positionPanel(evt){
|
||||
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';
|
||||
var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad;
|
||||
var vw=window.innerWidth, vh=window.innerHeight; var 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){
|
||||
if(panel.style.display==='none') return;
|
||||
if(!evt){ evt = window.__lastPointerEvent; }
|
||||
if(!evt && lastCard){
|
||||
var 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) (P2 UI Hover image prefetch)
|
||||
var _imgLRU=[];
|
||||
function prefetch(src){ if(!src) return; if(_imgLRU.indexOf(src)===-1){ _imgLRU.push(src); if(_imgLRU.length>12) _imgLRU.shift(); var im=new Image(); im.src=src; } }
|
||||
var activationDelay=120; // ms (P2 optional activation delay)
|
||||
var hoverTimer=null;
|
||||
function schedule(card, evt){ clearTimeout(hoverTimer); hoverTimer=setTimeout(function(){ show(card, evt); }, activationDelay); }
|
||||
function cancelSchedule(){ clearTimeout(hoverTimer); }
|
||||
var lastCard = null;
|
||||
function show(card, evt){
|
||||
if(!card) return;
|
||||
// Prefer attributes on container, fallback to child (image) if missing
|
||||
function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; }
|
||||
var simpleSource = null;
|
||||
if(card.closest){
|
||||
simpleSource = card.closest('[data-hover-simple]');
|
||||
}
|
||||
var forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource;
|
||||
var nm = attr('data-card-name') || attr('data-original-name') || 'Card';
|
||||
var rarity = (attr('data-rarity')||'').trim();
|
||||
var mana = (attr('data-mana')||'').trim();
|
||||
var role = (attr('data-role')||'').trim();
|
||||
var reasonsRaw = attr('data-reasons')||'';
|
||||
var tagsRaw = attr('data-tags')||'';
|
||||
var metadataTagsRaw = attr('data-metadata-tags')||''; // M5: Extract metadata tags
|
||||
var reasonsRaw = attr('data-reasons')||'';
|
||||
var roleEl = panel.querySelector('.hcp-role');
|
||||
var hasFlip = !!card.querySelector('.dfc-toggle');
|
||||
var tagListEl = panel.querySelector('.hcp-taglist');
|
||||
var overlapsEl = panel.querySelector('.hcp-overlaps');
|
||||
var overlapsAttr = attr('data-overlaps') || '';
|
||||
function displayLabel(text){
|
||||
if(!text) return '';
|
||||
var label = String(text);
|
||||
label = label.replace(/[\u2022\-_]+/g, ' ');
|
||||
label = label.replace(/\s+/g, ' ').trim();
|
||||
return label;
|
||||
}
|
||||
function parseTagList(raw){
|
||||
if(!raw) return [];
|
||||
var trimmed = String(raw).trim();
|
||||
if(!trimmed) return [];
|
||||
var result = [];
|
||||
var candidate = trimmed;
|
||||
if(trimmed[0] === '[' && trimmed[trimmed.length-1] === ']'){
|
||||
candidate = trimmed.slice(1, -1);
|
||||
}
|
||||
// Try JSON parsing after normalizing quotes
|
||||
try {
|
||||
var normalized = trimmed;
|
||||
if(trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1){
|
||||
normalized = trimmed.replace(/'/g, '"');
|
||||
}
|
||||
var 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(function(t){ return String(t || '').trim(); }).filter(Boolean);
|
||||
}
|
||||
function deriveTagsFromReasons(reasons){
|
||||
if(!reasons) return [];
|
||||
// Reasons often include "because it overlaps X, Y" or "by <theme>"
|
||||
var out = [];
|
||||
// Grab bracketed or quoted lists first
|
||||
var m = reasons.match(/\[(.*?)\]/);
|
||||
if(m && m[1]){ out = out.concat(m[1].split(/\s*,\s*/)); }
|
||||
// Common phrasing: "overlap(s) with A, B" or "by A, B"
|
||||
var rx = /(overlap(?:s)?(?:\s+with)?|by)\s+([^.;]+)/ig;
|
||||
var r;
|
||||
while((r = rx.exec(reasons))){ out = out.concat((r[2]||'').split(/\s*,\s*/)); }
|
||||
var tagRx = /tag:\s*([^.;]+)/ig;
|
||||
var tMatch;
|
||||
while((tMatch = tagRx.exec(reasons))){ out = out.concat((tMatch[1]||'').split(/\s*,\s*/)); }
|
||||
return out.map(function(s){ return s.trim(); }).filter(Boolean);
|
||||
}
|
||||
var overlapArr = [];
|
||||
if(overlapsAttr){
|
||||
var parsedOverlaps = parseTagList(overlapsAttr);
|
||||
if(parsedOverlaps.length){ overlapArr = parsedOverlaps; }
|
||||
else { overlapArr = [String(overlapsAttr).trim()]; }
|
||||
}
|
||||
var derivedFromReasons = deriveTagsFromReasons(reasonsRaw);
|
||||
var 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){
|
||||
var normalizedAll = (allTags||[]).map(function(t){ return { raw: t, key: t.toLowerCase() }; });
|
||||
var derivedKeys = new Set(derivedFromReasons.map(function(t){ return t.toLowerCase(); }));
|
||||
var intersect = normalizedAll.filter(function(entry){ return derivedKeys.has(entry.key); }).map(function(entry){ return entry.raw; });
|
||||
if(!intersect.length){
|
||||
intersect = derivedFromReasons.slice();
|
||||
}
|
||||
overlapArr = Array.from(new Set(intersect));
|
||||
}
|
||||
overlapArr = (overlapArr||[]).map(function(t){ return String(t||'').trim(); }).filter(Boolean);
|
||||
allTags = (allTags||[]).map(function(t){ return String(t||'').trim(); }).filter(Boolean);
|
||||
nameEl.textContent = nm;
|
||||
rarityEl.textContent = rarity;
|
||||
var roleLabel = displayLabel(role);
|
||||
var roleKey = (roleLabel || role || '').toLowerCase();
|
||||
var isCommanderRole = roleKey === 'commander';
|
||||
metaEl.textContent = [roleLabel?('Role: '+roleLabel):'', mana?('Mana: '+mana):''].filter(Boolean).join(' • ');
|
||||
reasonsList.innerHTML='';
|
||||
reasonsRaw.split(';').map(function(r){return r.trim();}).filter(Boolean).forEach(function(r){ var 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(function(o){ var label = displayLabel(o); return '<span class="hcp-ov-chip" title="Overlapping synergy">'+label+'</span>'; }).join('');
|
||||
} else {
|
||||
overlapsEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
if(tagsEl){
|
||||
if(isCommanderRole){
|
||||
tagsEl.textContent = '';
|
||||
tagsEl.style.display = 'none';
|
||||
} else {
|
||||
var tagText = allTags.map(displayLabel).join(', ');
|
||||
// M5: Temporarily append metadata tags for debugging
|
||||
if(metadataTagsRaw && metadataTagsRaw.trim()){
|
||||
var metaTags = metadataTagsRaw.split(',').map(function(t){return t.trim();}).filter(Boolean);
|
||||
if(metaTags.length){
|
||||
var 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);
|
||||
var 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 = '';
|
||||
}
|
||||
}
|
||||
var fuzzy = encodeURIComponent(nm);
|
||||
var rawName = nm || '';
|
||||
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
|
||||
if(hasBack) hasFlip = true;
|
||||
var storageKey = 'mtg:face:' + rawName.toLowerCase();
|
||||
var storedFace = (function(){ try { return localStorage.getItem(storageKey); } catch(_){ return null; } })();
|
||||
if(storedFace === 'front' || storedFace === 'back') card.setAttribute('data-current-face', storedFace);
|
||||
var chosenFace = card.getAttribute('data-current-face') || 'front';
|
||||
lastCard = card;
|
||||
function renderHoverFace(face){
|
||||
var desiredVersion='normal'; // Use 'normal' since we only cache small/normal
|
||||
var currentKey = nm+':'+face+':'+desiredVersion;
|
||||
var prevFace = imgEl.getAttribute('data-face');
|
||||
var faceChanged = prevFace && prevFace !== face;
|
||||
if(imgEl.getAttribute('data-current')!== currentKey){
|
||||
// For DFC cards, extract the specific face name for cache lookup
|
||||
// but also send face parameter for Scryfall fallback
|
||||
var faceName = nm;
|
||||
var isDFC = nm.indexOf('//')>-1;
|
||||
if(isDFC){
|
||||
var faces = nm.split('//');
|
||||
faceName = (face==='back') ? faces[1].trim() : faces[0].trim();
|
||||
}
|
||||
// Use cache-aware API endpoint with the specific face name
|
||||
// Add face parameter for DFC back faces to help Scryfall fallback
|
||||
var 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(function(){ imgEl.style.opacity = 1; }); });
|
||||
}
|
||||
if(!imgEl.__errBound){
|
||||
imgEl.__errBound = true;
|
||||
imgEl.addEventListener('error', function(){
|
||||
var 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
|
||||
var checkFlip = window.__dfcHasTwoFaces || function(){ return false; };
|
||||
if(hasFlip && imgEl && checkFlip(card) && isMobileMode()){
|
||||
var imgWrap = imgEl.parentElement; // .hcp-img-wrap
|
||||
if(imgWrap && !imgWrap.querySelector('.dfc-toggle')){
|
||||
// Create a custom flip button that flips the ORIGINAL card (lastCard)
|
||||
// This ensures the popup refreshes with updated tags/themes
|
||||
var flipBtn = document.createElement('button');
|
||||
flipBtn.type = 'button';
|
||||
flipBtn.className = 'dfc-toggle'; // No responsive classes needed - only created on mobile
|
||||
flipBtn.setAttribute('aria-pressed', 'false');
|
||||
flipBtn.setAttribute('tabindex', '0');
|
||||
flipBtn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;">⥮</span>';
|
||||
|
||||
// Flip the ORIGINAL card element, not the popup wrapper
|
||||
flipBtn.addEventListener('click', function(ev){
|
||||
ev.stopPropagation();
|
||||
if(window.__dfcFlipCard && lastCard){
|
||||
window.__dfcFlipCard(lastCard); // This will trigger popup refresh
|
||||
}
|
||||
});
|
||||
flipBtn.addEventListener('keydown', function(ev){
|
||||
if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){
|
||||
ev.preventDefault();
|
||||
if(window.__dfcFlipCard && lastCard){
|
||||
window.__dfcFlipCard(lastCard);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
imgWrap.classList.add('dfc-host');
|
||||
imgWrap.appendChild(flipBtn);
|
||||
}
|
||||
}
|
||||
|
||||
window.__dfcNotifyHover = hasFlip ? function(cardRef, face){ if(cardRef === lastCard){ renderHoverFace(face); } } : null;
|
||||
if(evt){ window.__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(){
|
||||
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.__dfcNotifyHover = null;
|
||||
}
|
||||
document.addEventListener('mousemove', move);
|
||||
function getCardFromEl(el){
|
||||
if(!el) return null;
|
||||
if(el.closest){
|
||||
var altBtn = el.closest('.alts button[data-card-name]');
|
||||
if(altBtn){ return altBtn; }
|
||||
}
|
||||
// If inside flip button
|
||||
var 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')){
|
||||
var 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 (add .stack-card for finished/random deck thumbnails)
|
||||
var 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'))){
|
||||
var up = el.closest && el.closest('.stack-card');
|
||||
return up || el; // fall back to the image itself
|
||||
}
|
||||
// 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', function(e){ window.__lastPointerEvent = e; });
|
||||
document.addEventListener('pointerover', function(e){
|
||||
if(isMobileMode()) return;
|
||||
var card = getCardFromEl(e.target);
|
||||
if(!card) return;
|
||||
// If hovering flip button, refresh immediately (no activation delay)
|
||||
if(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', function(e){
|
||||
if(isMobileMode()) return;
|
||||
var relCard = getCardFromEl(e.relatedTarget);
|
||||
if(relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button)
|
||||
if(!panel.contains(e.relatedTarget)){
|
||||
cancelSchedule();
|
||||
if(!relCard) hide();
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', function(e){
|
||||
if(!isMobileMode()) return;
|
||||
if(panel.contains(e.target)) return;
|
||||
if(e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return;
|
||||
if(e.target.closest && e.target.closest('button, input, select, textarea, a')) return;
|
||||
var card = getCardFromEl(e.target);
|
||||
if(card){
|
||||
cancelSchedule();
|
||||
var rect = card.getBoundingClientRect();
|
||||
var 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.__hoverShowCard = function(card){
|
||||
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };
|
||||
show(card, ev);
|
||||
};
|
||||
window.hoverShowByName = function(name){
|
||||
try {
|
||||
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if(el){ window.__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 (P2 UI Hover keyboard accessibility)
|
||||
document.addEventListener('focusin', function(e){ var card=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', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb'); if(!next) hide(); });
|
||||
document.addEventListener('keydown', function(e){ if(e.key==='Escape') hide(); });
|
||||
// Compact mode event listener
|
||||
document.addEventListener('mtg:hoverCompactToggle', function(){ panel.classList.toggle('compact-img', !!window.__hoverCompactMode); });
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', setup);
|
||||
document.addEventListener('DOMContentLoaded', setup);
|
||||
setup();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Global compact mode toggle function (UI Hover compact mode toggle)
|
||||
(function(){
|
||||
window.toggleHoverCompactMode = function(state){
|
||||
if(typeof state==='boolean') window.__hoverCompactMode = state; else window.__hoverCompactMode = !window.__hoverCompactMode;
|
||||
document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle'));
|
||||
};
|
||||
})();
|
||||
<!-- Hover card panel system moved to TypeScript: code/web/static/ts/cardHover.ts -->
|
||||
</script>
|
||||
</body>
|
||||
</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()"
|
||||
|
|
|
|||
|
|
@ -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==" %}
|
||||
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
||||
<div class="commander-thumb">
|
||||
<div class="commander-thumb"
|
||||
data-card-name="{{ record.name }}"
|
||||
data-original-name="{{ record.name }}"
|
||||
{% if record.layout %}data-layout="{{ record.layout }}"{% endif %}>
|
||||
{% set small = record.display_name|card_image('small') %}
|
||||
{% set normal = record.display_name|card_image('normal') %}
|
||||
<img
|
||||
|
|
@ -16,8 +19,9 @@
|
|||
decoding="async"
|
||||
width="160"
|
||||
height="223"
|
||||
data-card-name="{{ record.display_name }}"
|
||||
data-card-name="{{ record.name }}"
|
||||
data-original-name="{{ record.name }}"
|
||||
{% if record.layout %}data-layout="{{ record.layout }}"{% endif %}
|
||||
data-hover-simple="true"
|
||||
class="commander-thumb-img"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue