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:
matt 2025-10-29 10:44:29 -07:00
parent ed381dfdce
commit 9379732eec
8 changed files with 1050 additions and 634 deletions

View 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();
}