mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
overhaul: migrated basic JavaScript to TypeScript, began consolidation efforts
This commit is contained in:
parent
b994978f60
commit
6a94b982cb
15 changed files with 2012 additions and 74 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -19,6 +19,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- Interactive examples of all buttons, modals, forms, cards, and panels
|
- Interactive examples of all buttons, modals, forms, cards, and panels
|
||||||
- Jinja2 macros for consistent component usage
|
- Jinja2 macros for consistent component usage
|
||||||
- Component partial templates for reuse across pages
|
- Component partial templates for reuse across pages
|
||||||
|
- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety
|
||||||
|
- Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript
|
||||||
|
- Created shared type definitions for state management, telemetry, HTMX, and UI components
|
||||||
|
- Integrated TypeScript compilation into build process (`npm run build:ts`)
|
||||||
|
- Compiled JavaScript output in `code/web/static/js/` directory
|
||||||
|
- Docker build automatically compiles TypeScript during image creation
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
|
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
|
||||||
|
|
@ -26,6 +32,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- PostCSS build pipeline with autoprefixer
|
- PostCSS build pipeline with autoprefixer
|
||||||
- Reduced inline styles in templates (moved to shared CSS classes)
|
- Reduced inline styles in templates (moved to shared CSS classes)
|
||||||
- Organized CSS into functional sections with clear documentation
|
- Organized CSS into functional sections with clear documentation
|
||||||
|
- **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast
|
||||||
|
- **JavaScript Modernization**: Updated to modern JavaScript patterns
|
||||||
|
- Converted `var` declarations to `const`/`let`
|
||||||
|
- Added TypeScript type annotations for better IDE support and error catching
|
||||||
|
- Consolidated event handlers and utility functions
|
||||||
- **Docker Build Optimization**: Improved developer experience
|
- **Docker Build Optimization**: Improved developer experience
|
||||||
- Hot reload enabled for templates and static files
|
- Hot reload enabled for templates and static files
|
||||||
- Volume mounts for rapid iteration without rebuilds
|
- Volume mounts for rapid iteration without rebuilds
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
Web UI improvements with Tailwind CSS migration, component library, and optional card image caching for faster performance.
|
Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, and optional card image caching for faster performance and better maintainability.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Card Image Caching**: Optional local image cache for faster card display
|
- **Card Image Caching**: Optional local image cache for faster card display
|
||||||
|
|
@ -15,14 +15,24 @@ Web UI improvements with Tailwind CSS migration, component library, and optional
|
||||||
- Interactive examples of all UI components
|
- Interactive examples of all UI components
|
||||||
- Reusable Jinja2 macros for consistent design
|
- Reusable Jinja2 macros for consistent design
|
||||||
- Component partial templates for reuse across pages
|
- Component partial templates for reuse across pages
|
||||||
|
- **TypeScript Support**: Migrated JavaScript to TypeScript for better code quality
|
||||||
|
- Type definitions for state management, telemetry, and UI components
|
||||||
|
- Improved IDE support with autocomplete and type checking
|
||||||
|
- Integrated into build process (compiles during Docker build)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
|
- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture
|
||||||
- Tailwind CSS v3 with custom MTG color palette
|
- Tailwind CSS v3 with custom MTG color palette
|
||||||
- PostCSS build pipeline with autoprefixer
|
- PostCSS build pipeline with autoprefixer
|
||||||
- Minimized inline styles in favor of shared CSS classes
|
- Minimized inline styles in favor of shared CSS classes
|
||||||
|
- **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast
|
||||||
|
- **JavaScript Modernization**: Updated to modern JavaScript patterns
|
||||||
|
- Converted to TypeScript for better type safety
|
||||||
|
- Replaced `var` with `const`/`let` throughout
|
||||||
|
- Improved error handling and code organization
|
||||||
- **Docker Build Optimization**: Improved developer experience
|
- **Docker Build Optimization**: Improved developer experience
|
||||||
- Hot reload for templates and CSS (no rebuild needed)
|
- Hot reload for templates and CSS (no rebuild needed)
|
||||||
|
- TypeScript compilation integrated into build process
|
||||||
- **Template Modernization**: Migrated templates to use component system
|
- **Template Modernization**: Migrated templates to use component system
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
@ -35,11 +45,19 @@ _None_
|
||||||
- Hot reload for CSS/template changes (no Docker rebuild needed)
|
- Hot reload for CSS/template changes (no Docker rebuild needed)
|
||||||
- Optional image caching reduces Scryfall API calls
|
- Optional image caching reduces Scryfall API calls
|
||||||
- Faster page loads with optimized CSS
|
- Faster page loads with optimized CSS
|
||||||
|
- TypeScript compilation produces optimized JavaScript
|
||||||
|
|
||||||
### For Users
|
### For Users
|
||||||
- Faster card image loading with optional caching
|
- Faster card image loading with optional caching
|
||||||
- Cleaner, more consistent web UI design
|
- Cleaner, more consistent web UI design
|
||||||
- Improved page load performance
|
- 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
|
### Deprecated
|
||||||
_None_
|
_None_
|
||||||
|
|
|
||||||
|
|
@ -1108,6 +1108,8 @@ async def build_index(request: Request) -> HTMLResponse:
|
||||||
if q_commander:
|
if q_commander:
|
||||||
# Persist a human-friendly commander name into session for the wizard
|
# Persist a human-friendly commander name into session for the wizard
|
||||||
sess["commander"] = str(q_commander)
|
sess["commander"] = str(q_commander)
|
||||||
|
# Set flag to indicate this is a quick-build scenario
|
||||||
|
sess["quick_build"] = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return_url = None
|
return_url = None
|
||||||
|
|
@ -1147,12 +1149,17 @@ async def build_index(request: Request) -> HTMLResponse:
|
||||||
last_step = 2
|
last_step = 2
|
||||||
else:
|
else:
|
||||||
last_step = 1
|
last_step = 1
|
||||||
|
# Only pass commander to template if coming from commander browser (?commander= query param)
|
||||||
|
# This prevents stale commander from being pre-filled on subsequent builds
|
||||||
|
# The query param only exists on initial navigation from commander browser
|
||||||
|
should_auto_fill = q_commander is not None
|
||||||
|
|
||||||
resp = templates.TemplateResponse(
|
resp = templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"build/index.html",
|
"build/index.html",
|
||||||
{
|
{
|
||||||
"sid": sid,
|
"sid": sid,
|
||||||
"commander": sess.get("commander"),
|
"commander": sess.get("commander") if should_auto_fill else None,
|
||||||
"tags": sess.get("tags", []),
|
"tags": sess.get("tags", []),
|
||||||
"name": sess.get("custom_export_base"),
|
"name": sess.get("custom_export_base"),
|
||||||
"last_step": last_step,
|
"last_step": last_step,
|
||||||
|
|
@ -1350,13 +1357,18 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
||||||
for key in skip_keys:
|
for key in skip_keys:
|
||||||
sess.pop(key, None)
|
sess.pop(key, None)
|
||||||
|
|
||||||
# M2: Clear commander and form selections for fresh start
|
# M2: Check if this is a quick-build scenario (from commander browser)
|
||||||
commander_keys = [
|
# Use the quick_build flag set by /build route when ?commander= param present
|
||||||
"commander", "partner", "background", "commander_mode",
|
is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag
|
||||||
"themes", "bracket"
|
|
||||||
]
|
# M2: Clear commander and form selections for fresh start (unless quick build)
|
||||||
for key in commander_keys:
|
if not is_quick_build:
|
||||||
sess.pop(key, None)
|
commander_keys = [
|
||||||
|
"commander", "partner", "background", "commander_mode",
|
||||||
|
"themes", "bracket"
|
||||||
|
]
|
||||||
|
for key in commander_keys:
|
||||||
|
sess.pop(key, None)
|
||||||
|
|
||||||
theme_context = _custom_theme_context(request, sess)
|
theme_context = _custom_theme_context(request, sess)
|
||||||
ctx = {
|
ctx = {
|
||||||
|
|
@ -1370,6 +1382,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
||||||
"enable_batch_build": ENABLE_BATCH_BUILD,
|
"enable_batch_build": ENABLE_BATCH_BUILD,
|
||||||
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
|
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
|
||||||
"form": {
|
"form": {
|
||||||
|
"commander": sess.get("commander", ""), # Pre-fill for quick-build
|
||||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||||
"combo_count": sess.get("combo_target_count"),
|
"combo_count": sess.get("combo_target_count"),
|
||||||
"combo_balance": sess.get("combo_balance"),
|
"combo_balance": sess.get("combo_balance"),
|
||||||
|
|
|
||||||
|
|
@ -1113,19 +1113,31 @@ video {
|
||||||
|
|
||||||
[data-theme="light-blend"]{
|
[data-theme="light-blend"]{
|
||||||
--bg: #e8e2d0;
|
--bg: #e8e2d0;
|
||||||
/* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
|
/* warm beige background (keep existing) */
|
||||||
--panel: #ffffff;
|
--panel: #ebe5d8;
|
||||||
/* crisp panels for readability */
|
/* lighter warm cream - more contrast with bg, subtle panels */
|
||||||
--text: #0b0d12;
|
--text: #1a1410;
|
||||||
|
/* dark brown for readability */
|
||||||
--muted: #6b655d;
|
--muted: #6b655d;
|
||||||
/* slightly warm muted */
|
/* warm muted brown (keep existing) */
|
||||||
--border: #d6d1c7;
|
--border: #bfb5a3;
|
||||||
/* neutral warm-gray border */
|
/* darker warm-gray border for better definition */
|
||||||
/* Slightly darker banner/sidebar for separation */
|
/* Navbar/banner: darker warm brown for hierarchy */
|
||||||
--surface-banner: #1a1b1e;
|
--surface-banner: #9b8f7a;
|
||||||
--surface-sidebar: #1a1b1e;
|
/* warm medium brown - darker than panels, lighter than dark theme */
|
||||||
--surface-banner-text: #e8e8e8;
|
--surface-sidebar: #9b8f7a;
|
||||||
--surface-sidebar-text: #e8e8e8;
|
/* match banner for consistency */
|
||||||
|
--surface-banner-text: #1a1410;
|
||||||
|
/* dark brown text on medium brown bg */
|
||||||
|
--surface-sidebar-text: #1a1410;
|
||||||
|
/* dark brown text on medium brown bg */
|
||||||
|
/* Button colors: use taupe for buttons so they stand out from light panels */
|
||||||
|
--btn-bg: #d4cbb8;
|
||||||
|
/* medium warm taupe - stands out against light panels */
|
||||||
|
--btn-text: #1a1410;
|
||||||
|
/* dark brown text */
|
||||||
|
--btn-hover-bg: #c4b9a5;
|
||||||
|
/* darker taupe on hover */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"]{
|
[data-theme="dark"]{
|
||||||
|
|
@ -1880,23 +1892,25 @@ small, .muted{
|
||||||
/* Home page darker buttons */
|
/* Home page darker buttons */
|
||||||
|
|
||||||
.home-button.btn-secondary {
|
.home-button.btn-secondary {
|
||||||
background: #1a1d24;
|
background: var(--btn-bg, #1a1d24);
|
||||||
border-color: #2a2d35;
|
color: var(--btn-text, #e8e8e8);
|
||||||
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-button.btn-secondary:hover {
|
.home-button.btn-secondary:hover {
|
||||||
background: #22252d;
|
background: var(--btn-hover-bg, #22252d);
|
||||||
border-color: #3a3d45;
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-button.btn-primary {
|
.home-button.btn-primary {
|
||||||
background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15));
|
background: var(--blue-main);
|
||||||
border-color: #2a5580;
|
color: white;
|
||||||
|
border-color: var(--blue-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-button.btn-primary:hover {
|
.home-button.btn-primary:hover {
|
||||||
background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25));
|
background: #0c5aa6;
|
||||||
border-color: #3a6590;
|
border-color: #0c5aa6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card grid for added cards (responsive, compact tiles) */
|
/* Card grid for added cards (responsive, compact tiles) */
|
||||||
|
|
|
||||||
|
|
@ -39,16 +39,20 @@
|
||||||
|
|
||||||
/* Light blend between Slate and Parchment (leans gray) */
|
/* Light blend between Slate and Parchment (leans gray) */
|
||||||
[data-theme="light-blend"]{
|
[data-theme="light-blend"]{
|
||||||
--bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
|
--bg: #e8e2d0; /* warm beige background (keep existing) */
|
||||||
--panel: #ffffff; /* crisp panels for readability */
|
--panel: #ebe5d8; /* lighter warm cream - more contrast with bg, subtle panels */
|
||||||
--text: #0b0d12;
|
--text: #1a1410; /* dark brown for readability */
|
||||||
--muted: #6b655d; /* slightly warm muted */
|
--muted: #6b655d; /* warm muted brown (keep existing) */
|
||||||
--border: #d6d1c7; /* neutral warm-gray border */
|
--border: #bfb5a3; /* darker warm-gray border for better definition */
|
||||||
/* Slightly darker banner/sidebar for separation */
|
/* Navbar/banner: darker warm brown for hierarchy */
|
||||||
--surface-banner: #1a1b1e;
|
--surface-banner: #9b8f7a; /* warm medium brown - darker than panels, lighter than dark theme */
|
||||||
--surface-sidebar: #1a1b1e;
|
--surface-sidebar: #9b8f7a; /* match banner for consistency */
|
||||||
--surface-banner-text: #e8e8e8;
|
--surface-banner-text: #1a1410; /* dark brown text on medium brown bg */
|
||||||
--surface-sidebar-text: #e8e8e8;
|
--surface-sidebar-text: #1a1410; /* dark brown text on medium brown bg */
|
||||||
|
/* Button colors: use taupe for buttons so they stand out from light panels */
|
||||||
|
--btn-bg: #d4cbb8; /* medium warm taupe - stands out against light panels */
|
||||||
|
--btn-text: #1a1410; /* dark brown text */
|
||||||
|
--btn-hover-bg: #c4b9a5; /* darker taupe on hover */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"]{
|
[data-theme="dark"]{
|
||||||
|
|
@ -282,20 +286,22 @@ small, .muted{ color: var(--muted); }
|
||||||
|
|
||||||
/* Home page darker buttons */
|
/* Home page darker buttons */
|
||||||
.home-button.btn-secondary {
|
.home-button.btn-secondary {
|
||||||
background: #1a1d24;
|
background: var(--btn-bg, #1a1d24);
|
||||||
border-color: #2a2d35;
|
color: var(--btn-text, #e8e8e8);
|
||||||
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
.home-button.btn-secondary:hover {
|
.home-button.btn-secondary:hover {
|
||||||
background: #22252d;
|
background: var(--btn-hover-bg, #22252d);
|
||||||
border-color: #3a3d45;
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
.home-button.btn-primary {
|
.home-button.btn-primary {
|
||||||
background: linear-gradient(180deg, rgba(14,104,171,.35), rgba(14,104,171,.15));
|
background: var(--blue-main);
|
||||||
border-color: #2a5580;
|
color: white;
|
||||||
|
border-color: var(--blue-main);
|
||||||
}
|
}
|
||||||
.home-button.btn-primary:hover {
|
.home-button.btn-primary:hover {
|
||||||
background: linear-gradient(180deg, rgba(14,104,171,.45), rgba(14,104,171,.25));
|
background: #0c5aa6;
|
||||||
border-color: #3a6590;
|
border-color: #0c5aa6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card grid for added cards (responsive, compact tiles) */
|
/* Card grid for added cards (responsive, compact tiles) */
|
||||||
|
|
|
||||||
1393
code/web/static/ts/app.ts
Normal file
1393
code/web/static/ts/app.ts
Normal file
File diff suppressed because it is too large
Load diff
382
code/web/static/ts/components.ts
Normal file
382
code/web/static/ts/components.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
/**
|
||||||
|
* M3 Component Library - TypeScript Utilities
|
||||||
|
*
|
||||||
|
* Core functions for interactive components:
|
||||||
|
* - Card flip button (dual-faced cards)
|
||||||
|
* - Collapsible panels
|
||||||
|
* - Card popups
|
||||||
|
* - Modal management
|
||||||
|
*
|
||||||
|
* Migrated from components.js with TypeScript types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPE DEFINITIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface CardPopupOptions {
|
||||||
|
tags?: string[];
|
||||||
|
highlightTags?: string[];
|
||||||
|
role?: string;
|
||||||
|
layout?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CARD FLIP FUNCTIONALITY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip a dual-faced card image between front and back faces
|
||||||
|
* @param button - The flip button element
|
||||||
|
*/
|
||||||
|
function flipCard(button: HTMLElement): void {
|
||||||
|
const container = button.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const img = container.querySelector('img') as HTMLImageElement | null;
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
const cardName = img.dataset.cardName;
|
||||||
|
if (!cardName) return;
|
||||||
|
|
||||||
|
const faces = cardName.split(' // ');
|
||||||
|
if (faces.length < 2) return;
|
||||||
|
|
||||||
|
// Determine current face (default to 0 = front)
|
||||||
|
const currentFace = parseInt(img.dataset.currentFace || '0', 10);
|
||||||
|
const nextFace = currentFace === 0 ? 1 : 0;
|
||||||
|
const faceName = faces[nextFace];
|
||||||
|
|
||||||
|
// Determine image version based on container
|
||||||
|
const isLarge = container.classList.contains('card-thumb-large') ||
|
||||||
|
container.classList.contains('card-popup-image');
|
||||||
|
const version = isLarge ? 'normal' : 'small';
|
||||||
|
|
||||||
|
// Update image source
|
||||||
|
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`;
|
||||||
|
img.alt = `${faceName} image`;
|
||||||
|
img.dataset.currentFace = nextFace.toString();
|
||||||
|
|
||||||
|
// Update button aria-label
|
||||||
|
const otherFace = faces[currentFace];
|
||||||
|
button.setAttribute('aria-label', `Flip to ${otherFace}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all card images to show front face
|
||||||
|
* Useful when navigating between pages or clearing selections
|
||||||
|
*/
|
||||||
|
function resetCardFaces(): void {
|
||||||
|
document.querySelectorAll<HTMLImageElement>('img[data-card-name][data-current-face]').forEach(img => {
|
||||||
|
const cardName = img.dataset.cardName;
|
||||||
|
if (!cardName) return;
|
||||||
|
|
||||||
|
const faces = cardName.split(' // ');
|
||||||
|
if (faces.length > 1) {
|
||||||
|
const frontFace = faces[0];
|
||||||
|
const container = img.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null;
|
||||||
|
const isLarge = container && (container.classList.contains('card-thumb-large') ||
|
||||||
|
container.classList.contains('card-popup-image'));
|
||||||
|
const version = isLarge ? 'normal' : 'small';
|
||||||
|
|
||||||
|
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`;
|
||||||
|
img.alt = `${frontFace} image`;
|
||||||
|
img.dataset.currentFace = '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COLLAPSIBLE PANEL FUNCTIONALITY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a collapsible panel's expanded/collapsed state
|
||||||
|
* @param panelId - The ID of the panel element
|
||||||
|
*/
|
||||||
|
function togglePanel(panelId: string): void {
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
|
||||||
|
const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
|
||||||
|
if (!button || !content) return;
|
||||||
|
|
||||||
|
const isExpanded = button.getAttribute('aria-expanded') === 'true';
|
||||||
|
|
||||||
|
// Toggle state
|
||||||
|
button.setAttribute('aria-expanded', (!isExpanded).toString());
|
||||||
|
content.style.display = isExpanded ? 'none' : 'block';
|
||||||
|
|
||||||
|
// Toggle classes
|
||||||
|
panel.classList.toggle('panel-expanded', !isExpanded);
|
||||||
|
panel.classList.toggle('panel-collapsed', isExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a collapsible panel
|
||||||
|
* @param panelId - The ID of the panel element
|
||||||
|
*/
|
||||||
|
function expandPanel(panelId: string): void {
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
|
||||||
|
const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
|
||||||
|
if (!button || !content) return;
|
||||||
|
|
||||||
|
button.setAttribute('aria-expanded', 'true');
|
||||||
|
content.style.display = 'block';
|
||||||
|
panel.classList.add('panel-expanded');
|
||||||
|
panel.classList.remove('panel-collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse a collapsible panel
|
||||||
|
* @param panelId - The ID of the panel element
|
||||||
|
*/
|
||||||
|
function collapsePanel(panelId: string): void {
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const button = panel.querySelector('.panel-toggle') as HTMLElement | null;
|
||||||
|
const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null;
|
||||||
|
if (!button || !content) return;
|
||||||
|
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
content.style.display = 'none';
|
||||||
|
panel.classList.add('panel-collapsed');
|
||||||
|
panel.classList.remove('panel-expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MODAL MANAGEMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a modal by ID
|
||||||
|
* @param modalId - The ID of the modal element
|
||||||
|
*/
|
||||||
|
function openModal(modalId: string): void {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
(modal as HTMLElement).style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Focus first focusable element in modal
|
||||||
|
const focusable = modal.querySelector<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||||
|
if (focusable) {
|
||||||
|
setTimeout(() => focusable.focus(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a modal by ID or element
|
||||||
|
* @param modalOrId - Modal element or ID
|
||||||
|
*/
|
||||||
|
function closeModal(modalOrId: string | HTMLElement): void {
|
||||||
|
const modal = typeof modalOrId === 'string'
|
||||||
|
? document.getElementById(modalOrId)
|
||||||
|
: modalOrId;
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
modal.remove();
|
||||||
|
|
||||||
|
// Restore body scroll if no other modals are open
|
||||||
|
if (!document.querySelector('.modal')) {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close all open modals
|
||||||
|
*/
|
||||||
|
function closeAllModals(): void {
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => modal.remove());
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CARD POPUP FUNCTIONALITY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show card details popup on hover or tap
|
||||||
|
* @param cardName - The card name
|
||||||
|
* @param options - Popup options
|
||||||
|
*/
|
||||||
|
function showCardPopup(cardName: string, options: CardPopupOptions = {}): void {
|
||||||
|
// Remove any existing popup
|
||||||
|
closeCardPopup();
|
||||||
|
|
||||||
|
const {
|
||||||
|
tags = [],
|
||||||
|
highlightTags = [],
|
||||||
|
role = '',
|
||||||
|
layout = 'normal'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout);
|
||||||
|
const baseName = cardName.split(' // ')[0];
|
||||||
|
|
||||||
|
// Create popup HTML
|
||||||
|
const popup = document.createElement('div');
|
||||||
|
popup.className = 'card-popup';
|
||||||
|
popup.setAttribute('role', 'dialog');
|
||||||
|
popup.setAttribute('aria-label', `${cardName} details`);
|
||||||
|
|
||||||
|
let tagsHTML = '';
|
||||||
|
if (tags.length > 0) {
|
||||||
|
tagsHTML = '<div class="card-popup-tags">';
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const isHighlight = highlightTags.includes(tag);
|
||||||
|
tagsHTML += `<span class="card-popup-tag${isHighlight ? ' card-popup-tag-highlight' : ''}">${tag}</span>`;
|
||||||
|
});
|
||||||
|
tagsHTML += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let roleHTML = '';
|
||||||
|
if (role) {
|
||||||
|
roleHTML = `<div class="card-popup-role">Role: <span>${role}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let flipButtonHTML = '';
|
||||||
|
if (isDFC) {
|
||||||
|
flipButtonHTML = `
|
||||||
|
<button type="button" class="card-flip-btn" onclick="flipCard(this)" aria-label="Flip card">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 3.293l2.646 2.647.708-.708L8 2.879 4.646 5.232l.708.708L8 3.293zM8 12.707L5.354 10.06l-.708.708L8 13.121l3.354-2.353-.708-.708L8 12.707z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
popup.innerHTML = `
|
||||||
|
<div class="card-popup-backdrop" onclick="closeCardPopup()"></div>
|
||||||
|
<div class="card-popup-content">
|
||||||
|
<div class="card-popup-image">
|
||||||
|
<img src="https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(baseName)}&format=image&version=normal"
|
||||||
|
alt="${cardName} image"
|
||||||
|
data-card-name="${cardName}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async" />
|
||||||
|
${flipButtonHTML}
|
||||||
|
</div>
|
||||||
|
<div class="card-popup-info">
|
||||||
|
<h3 class="card-popup-name">${cardName}</h3>
|
||||||
|
${roleHTML}
|
||||||
|
${tagsHTML}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="card-popup-close" onclick="closeCardPopup()" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(popup);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Focus close button
|
||||||
|
const closeBtn = popup.querySelector<HTMLElement>('.card-popup-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
setTimeout(() => closeBtn.focus(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close card popup
|
||||||
|
* @param element - Element to search from (optional)
|
||||||
|
*/
|
||||||
|
function closeCardPopup(element?: HTMLElement): void {
|
||||||
|
const popup = element
|
||||||
|
? element.closest('.card-popup')
|
||||||
|
: document.querySelector('.card-popup');
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
popup.remove();
|
||||||
|
|
||||||
|
// Restore body scroll if no modals are open
|
||||||
|
if (!document.querySelector('.modal')) {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup card thumbnail hover/tap events
|
||||||
|
* Call this after dynamically adding card thumbnails to the DOM
|
||||||
|
*/
|
||||||
|
function setupCardPopups(): void {
|
||||||
|
document.querySelectorAll<HTMLElement>('.card-thumb-container[data-card-name]').forEach(container => {
|
||||||
|
const img = container.querySelector<HTMLElement>('.card-thumb');
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
const cardName = container.dataset.cardName || img.dataset.cardName;
|
||||||
|
if (!cardName) return;
|
||||||
|
|
||||||
|
// Desktop: hover
|
||||||
|
container.addEventListener('mouseenter', function(e: MouseEvent) {
|
||||||
|
if (window.innerWidth > 768) {
|
||||||
|
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const role = img.dataset.role || '';
|
||||||
|
const layout = img.dataset.layout || 'normal';
|
||||||
|
|
||||||
|
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile: tap
|
||||||
|
container.addEventListener('click', function(e: MouseEvent) {
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const role = img.dataset.role || '';
|
||||||
|
const layout = img.dataset.layout || 'normal';
|
||||||
|
|
||||||
|
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INITIALIZATION
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Setup event listeners when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Setup card popups on initial load
|
||||||
|
setupCardPopups();
|
||||||
|
|
||||||
|
// Close modals/popups on Escape key
|
||||||
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeCardPopup();
|
||||||
|
|
||||||
|
// Close topmost modal only
|
||||||
|
const modals = document.querySelectorAll('.modal');
|
||||||
|
if (modals.length > 0) {
|
||||||
|
closeModal(modals[modals.length - 1] as HTMLElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// DOM already loaded
|
||||||
|
setupCardPopups();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally available for inline onclick handlers
|
||||||
|
(window as any).flipCard = flipCard;
|
||||||
|
(window as any).resetCardFaces = resetCardFaces;
|
||||||
|
(window as any).togglePanel = togglePanel;
|
||||||
|
(window as any).expandPanel = expandPanel;
|
||||||
|
(window as any).collapsePanel = collapsePanel;
|
||||||
|
(window as any).openModal = openModal;
|
||||||
|
(window as any).closeModal = closeModal;
|
||||||
|
(window as any).closeAllModals = closeAllModals;
|
||||||
|
(window as any).showCardPopup = showCardPopup;
|
||||||
|
(window as any).closeCardPopup = closeCardPopup;
|
||||||
|
(window as any).setupCardPopups = setupCardPopups;
|
||||||
105
code/web/static/ts/types.ts
Normal file
105
code/web/static/ts/types.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/* Shared TypeScript type definitions for MTG Deckbuilder web app */
|
||||||
|
|
||||||
|
// Toast system types
|
||||||
|
export interface ToastOptions {
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State management types
|
||||||
|
export interface StateManager {
|
||||||
|
get(key: string, def?: any): any;
|
||||||
|
set(key: string, val: any): void;
|
||||||
|
inHash(obj: Record<string, any>): void;
|
||||||
|
readHash(): URLSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telemetry types
|
||||||
|
export interface TelemetryManager {
|
||||||
|
send(eventName: string, data?: Record<string, any>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skeleton system types
|
||||||
|
export interface SkeletonManager {
|
||||||
|
show(context?: HTMLElement | Document): void;
|
||||||
|
hide(context?: HTMLElement | Document): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card popup types (from components.ts)
|
||||||
|
export interface CardPopupOptions {
|
||||||
|
tags?: string[];
|
||||||
|
highlightTags?: string[];
|
||||||
|
role?: string;
|
||||||
|
layout?: string;
|
||||||
|
showActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX event detail types
|
||||||
|
export interface HtmxResponseErrorDetail {
|
||||||
|
xhr?: XMLHttpRequest;
|
||||||
|
path?: string;
|
||||||
|
target?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmxEventDetail {
|
||||||
|
target?: HTMLElement;
|
||||||
|
elt?: HTMLElement;
|
||||||
|
path?: string;
|
||||||
|
xhr?: XMLHttpRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX cache interface
|
||||||
|
export interface HtmxCache {
|
||||||
|
get(key: string): any;
|
||||||
|
set(key: string, html: string, ttl?: number, meta?: any): void;
|
||||||
|
apply(elt: any, detail: any, entry: any): void;
|
||||||
|
buildKey(detail: any, elt: any): string;
|
||||||
|
ttlFor(elt: any): number;
|
||||||
|
prefetch(url: string, opts?: any): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global window extensions
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__mtgState: StateManager;
|
||||||
|
toast: (msg: string | HTMLElement, type?: string, opts?: ToastOptions) => HTMLElement;
|
||||||
|
toastHTML: (html: string, type?: string, opts?: ToastOptions) => HTMLElement;
|
||||||
|
appTelemetry: TelemetryManager;
|
||||||
|
skeletons: SkeletonManager;
|
||||||
|
__telemetryEndpoint?: string;
|
||||||
|
showCardPopup?: (cardName: string, options?: CardPopupOptions) => void;
|
||||||
|
dismissCardPopup?: () => void;
|
||||||
|
flipCard?: (button: HTMLElement) => void;
|
||||||
|
htmxCache?: HtmxCache;
|
||||||
|
htmx?: any; // HTMX library - use any for external library
|
||||||
|
initHtmxDebounce?: () => void;
|
||||||
|
scrollCardIntoView?: (card: HTMLElement) => void;
|
||||||
|
__virtGlobal?: any;
|
||||||
|
__virtHotkeyBound?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomEvent<T = any> {
|
||||||
|
readonly detail: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX custom events
|
||||||
|
interface DocumentEventMap {
|
||||||
|
'htmx:responseError': CustomEvent<HtmxResponseErrorDetail>;
|
||||||
|
'htmx:sendError': CustomEvent<any>;
|
||||||
|
'htmx:afterSwap': CustomEvent<HtmxEventDetail>;
|
||||||
|
'htmx:beforeRequest': CustomEvent<HtmxEventDetail>;
|
||||||
|
'htmx:afterSettle': CustomEvent<HtmxEventDetail>;
|
||||||
|
'htmx:afterRequest': CustomEvent<HtmxEventDetail>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HTMLElement {
|
||||||
|
__hxCacheKey?: string;
|
||||||
|
__hxCacheTTL?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Element {
|
||||||
|
__hxPrefetched?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty export to make this a module file
|
||||||
|
export {};
|
||||||
|
|
@ -628,8 +628,8 @@
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/components.js?v=20250121-1"></script>
|
<script src="/static/js/components.js?v=20251028-1"></script>
|
||||||
<script src="/static/app.js?v=20250826-4"></script>
|
<script src="/static/js/app.js?v=20250826-4"></script>
|
||||||
{% if enable_themes %}
|
{% if enable_themes %}
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
{% for cand in candidates %}
|
{% for cand in candidates %}
|
||||||
<li>
|
<li>
|
||||||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
|
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
|
||||||
hx-get="/build/new/inspect?name={{ cand.display|urlencode }}"
|
onclick="(function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)"
|
||||||
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
|
hx-get="/build/new/inspect?name={{ cand.value|urlencode }}"
|
||||||
hx-on="htmx:afterOnLoad: (function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} try{ ci.dispatchEvent(new Event('input', { bubbles: true })); }catch(_){ } } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)">
|
hx-target="#newdeck-tags-slot" hx-swap="innerHTML">
|
||||||
{{ cand.display }}
|
{{ cand.display }}
|
||||||
{% if cand.warning %}
|
{% if cand.warning %}
|
||||||
<span aria-hidden="true" style="margin-left:.35rem; font-size:11px; color:#facc15;">⚠</span>
|
<span aria-hidden="true" style="margin-left:.35rem; font-size:11px; color:#facc15;">⚠</span>
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,7 @@
|
||||||
<span>Commander</span>
|
<span>Commander</span>
|
||||||
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
|
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||||
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
|
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
|
||||||
hx-get="/build/new/candidates" hx-trigger="debouncedinput change" hx-target="#newdeck-candidates" hx-sync="this:replace"
|
hx-get="/build/new/candidates" hx-trigger="input changed delay:220ms, change" hx-target="#newdeck-candidates" hx-sync="this:replace" />
|
||||||
data-hx-debounce="220" data-hx-debounce-events="input"
|
|
||||||
data-hx-debounce-flush="blur" />
|
|
||||||
</label>
|
</label>
|
||||||
<small class="muted block mt-1">Start typing to see matches, then select one to load themes.</small>
|
<small class="muted block mt-1">Start typing to see matches, then select one to load themes.</small>
|
||||||
<div id="newdeck-candidates" class="muted text-xs min-h-[1.1em]"></div>
|
<div id="newdeck-candidates" class="muted text-xs min-h-[1.1em]"></div>
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,26 @@
|
||||||
if(!(target.tagName && target.tagName.toLowerCase() === 'body')) return;
|
if(!(target.tagName && target.tagName.toLowerCase() === 'body')) return;
|
||||||
var init = document.getElementById('builder-init');
|
var init = document.getElementById('builder-init');
|
||||||
var preset = init && init.dataset ? (init.dataset.commander || '') : '';
|
var preset = init && init.dataset ? (init.dataset.commander || '') : '';
|
||||||
if(!preset) return;
|
console.log('[Quick-build] DEBUG: preset=', preset);
|
||||||
|
if(!preset) {
|
||||||
|
console.log('[Quick-build] DEBUG: No preset, exiting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
var input = document.querySelector('input[name="commander"]');
|
var input = document.querySelector('input[name="commander"]');
|
||||||
|
console.log('[Quick-build] DEBUG: Found input?', !!input);
|
||||||
if(input){
|
if(input){
|
||||||
if(!input.value){ input.value = preset; }
|
console.log('[Quick-build] DEBUG: Input current value:', input.value);
|
||||||
|
if(!input.value){
|
||||||
|
input.value = preset;
|
||||||
|
console.log('[Quick-build] DEBUG: Set input to:', preset);
|
||||||
|
}
|
||||||
try { input.dispatchEvent(new Event('input', {bubbles:true})); } catch(_){ }
|
try { input.dispatchEvent(new Event('input', {bubbles:true})); } catch(_){ }
|
||||||
try { input.focus(); } catch(_){ }
|
try { input.focus(); } catch(_){ }
|
||||||
}
|
}
|
||||||
// If htmx is available, auto-load the inspect view for an exact preset name.
|
// If htmx is available, auto-load the inspect view for an exact preset name.
|
||||||
try {
|
try {
|
||||||
if (window.htmx && preset && typeof window.htmx.ajax === 'function'){
|
if (window.htmx && preset && typeof window.htmx.ajax === 'function'){
|
||||||
|
console.log('[Quick-build] DEBUG: Calling inspect with:', preset);
|
||||||
window.htmx.ajax('GET', '/build/new/inspect?name=' + encodeURIComponent(preset), { target: '#newdeck-tags-slot', swap: 'innerHTML' });
|
window.htmx.ajax('GET', '/build/new/inspect?name=' + encodeURIComponent(preset), { target: '#newdeck-tags-slot', swap: 'innerHTML' });
|
||||||
// Also try to load multi-copy suggestions based on current radio defaults
|
// Also try to load multi-copy suggestions based on current radio defaults
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
|
|
|
||||||
|
|
@ -5,34 +5,22 @@
|
||||||
{% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
|
{% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
|
||||||
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
||||||
<div class="commander-thumb">
|
<div class="commander-thumb">
|
||||||
{% set small = record.image_small_url or record.image_normal_url %}
|
{% set small = record.display_name|card_image('small') %}
|
||||||
{% set normal = record.image_normal_url or small %}
|
{% set normal = record.display_name|card_image('normal') %}
|
||||||
<img
|
<img
|
||||||
src="{{ placeholder_pixel }}"
|
src="{{ small }}"
|
||||||
|
srcset="{{ small }} 160w, {{ normal }} 488w"
|
||||||
|
sizes="(max-width: 900px) 60vw, 160px"
|
||||||
alt="{{ record.display_name }} card art"
|
alt="{{ record.display_name }} card art"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
width="160"
|
width="160"
|
||||||
height="223"
|
height="223"
|
||||||
data-lazy-image="commander"
|
|
||||||
data-lazy-src="{{ small }}"
|
|
||||||
data-lazy-srcset="{{ small }} 160w, {{ normal }} 488w"
|
|
||||||
data-lazy-sizes="(max-width: 900px) 60vw, 160px"
|
|
||||||
data-card-name="{{ record.display_name }}"
|
data-card-name="{{ record.display_name }}"
|
||||||
data-original-name="{{ record.name }}"
|
data-original-name="{{ record.name }}"
|
||||||
data-hover-simple="true"
|
data-hover-simple="true"
|
||||||
class="commander-thumb-img"
|
class="commander-thumb-img"
|
||||||
/>
|
/>
|
||||||
<noscript>
|
|
||||||
<img
|
|
||||||
src="{{ small }}"
|
|
||||||
srcset="{{ small }} 160w, {{ normal }} 488w"
|
|
||||||
sizes="160px"
|
|
||||||
alt="{{ record.display_name }} card art"
|
|
||||||
width="160"
|
|
||||||
height="223"
|
|
||||||
/>
|
|
||||||
</noscript>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="commander-main">
|
<div class="commander-main">
|
||||||
<div class="commander-header">
|
<div class="commander-header">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue