Files
Myxeliums_Battlemap_Importe…/scripts/lib/import-panel-view.js

852 lines
33 KiB
JavaScript

/**
* Import Panel View
*
* Manages the drag-and-drop UI panel for the battlemap import workflow.
* Handles panel creation, visibility, status updates, and user interactions.
* This is a pure view component - it only manages DOM and emits events.
*
* @module ImportPanelView
*/
/**
* @typedef {Object} PanelCallbacks
* @property {Function} onCreateSceneRequested - Called when user clicks "Create Scene"
* @property {Function} onResetRequested - Called when user clicks "Reset"
* @property {Function} onCloseRequested - Called when user clicks close button
* @property {Function} onFilesDropped - Called when files are dropped on the panel
* @property {Function} onNoGridPreferenceChanged - Called when no-grid checkbox changes
*/
/** CSS selectors for frequently accessed elements */
const PANEL_SELECTORS = {
PANEL_ROOT: '#myxeliums-battlemap-drop-area',
CREATE_BUTTON: '.create-scene-button',
RESET_BUTTON: '.reset-button',
CLOSE_BUTTON: '.header-button.close',
NO_GRID_CHECKBOX: 'input.ebm-no-grid',
BACKGROUND_STATUS: '.background-status .status-value',
WALL_DATA_STATUS: '.wall-data-status .status-value',
PROGRESS_GROUP: '.progress-group',
PROGRESS_TEXT: '.ebm-progress-text',
FLOOR_LIST: '.qbi-floor-list',
FLOOR_ITEM: '.qbi-floor-item',
FILE_MATCH_DIALOG: '.qbi-file-match-dialog',
UNMATCHED_FILES: '.qbi-unmatched-files'
};
/** LocalStorage key for persisting no-grid preference */
const NO_GRID_STORAGE_KEY = 'myxeliums-battlemap:no-grid';
/**
* View class that manages the import panel DOM and user interactions.
* Follows a callback pattern for communicating events to the controller.
*/
export class ImportPanelView {
constructor() {
/** @type {boolean} Whether the panel DOM has been created */
this.isPanelCreated = false;
/** @type {boolean} Whether the panel is currently visible */
this.isCurrentlyVisible = false;
/** @type {boolean} Whether a background operation is in progress */
this.isShowingBusyState = false;
// Event callbacks - set by the controller
/** @type {Function|null} */
this.onCreateSceneRequested = null;
/** @type {Function|null} */
this.onResetRequested = null;
/** @type {Function|null} */
this.onCloseRequested = null;
/** @type {Function|null} */
this.onFilesDropped = null;
/** @type {Function|null} */
this.onNoGridPreferenceChanged = null;
/** @type {Function|null} */
this.onFloorOrderChanged = null;
/** @type {Function|null} */
this.onFloorRemoved = null;
/** @type {Function|null} */
this.onFileMatchConfirmed = null;
/** @type {Function|null} */
this.onFileMatchRequested = null;
}
/**
* Ensure the panel DOM structure exists.
* Creates the panel if it doesn't exist, removes duplicates if found.
*/
ensureCreated() {
if (this.isPanelCreated) {
return;
}
// Remove any existing panel to prevent duplicates
const existingPanel = document.getElementById('myxeliums-battlemap-drop-area');
if (existingPanel) {
existingPanel.remove();
}
// Create and insert the panel HTML
const panelHtml = this.buildPanelHtml();
document.body.insertAdjacentHTML('beforeend', panelHtml);
// Set up event listeners
this.attachButtonEventListeners();
this.attachNoGridCheckboxListener();
this.attachDragHandlers();
this.attachDropZoneHandlers();
this.isPanelCreated = true;
}
/**
* Build the complete HTML structure for the import panel.
* Uses custom styling for a clean, modern look.
*
* @returns {string} The complete panel HTML
*/
buildPanelHtml() {
const i18n = (key) => game.i18n.localize(key);
const isLevelsActive = game.modules.get('levels')?.active ?? false;
// Build floor section HTML only if Levels module is active
const floorSectionHtml = isLevelsActive ? `
<div class="qbi-floor-section">
<div class="qbi-floor-header">
<span class="qbi-floor-title">
<i class="fas fa-layer-group"></i>
${i18n('QUICKBATTLEMAP.FloorsTitle')}
</span>
<span class="qbi-floor-count">0 ${i18n('QUICKBATTLEMAP.FloorsCount')}</span>
</div>
<div class="qbi-floor-list" id="floorList">
<div class="qbi-floor-empty">
<i class="fas fa-info-circle"></i>
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
</div>
</div>
<div class="qbi-unmatched-files" id="unmatchedFiles" style="display: none;">
<div class="qbi-unmatched-header">
<i class="fas fa-exclamation-triangle"></i>
<span>${i18n('QUICKBATTLEMAP.UnmatchedFiles')}</span>
</div>
<div class="qbi-unmatched-list"></div>
</div>
</div>
` : '';
return `
<div id="myxeliums-battlemap-drop-area" class="qbi-panel">
<header class="qbi-header">
<div class="qbi-header-title">
<i class="fas fa-map qbi-header-icon"></i>
<h4>${i18n('QUICKBATTLEMAP.DropAreaTitle')}</h4>
</div>
<button class="qbi-close-btn header-button close" type="button" aria-label="Close">
<i class="fas fa-times"></i>
</button>
</header>
<section class="qbi-content">
<form autocomplete="off">
<p class="qbi-instructions">${i18n('QUICKBATTLEMAP.DropInstructions')}</p>
<div class="qbi-dropzone" id="dropZone">
<div class="qbi-dropzone-inner">
<i class="fas fa-cloud-upload-alt qbi-dropzone-icon"></i>
<span class="qbi-dropzone-text">Drop files here</span>
<span class="qbi-dropzone-hint">Images & JSON supported</span>
</div>
</div>
<p class="qbi-instructions qbi-instructions-secondary">${i18n('QUICKBATTLEMAP.DropInstructionsMore')}</p>
${floorSectionHtml}
<div class="qbi-status-grid">
<div class="qbi-status-item">
<div class="qbi-status-indicator background-status">
<span class="status-value qbi-status-icon" data-status="pending">❌</span>
</div>
<span class="qbi-status-label">${i18n('QUICKBATTLEMAP.BackgroundStatus')}</span>
</div>
<div class="qbi-status-item">
<div class="qbi-status-indicator wall-data-status">
<span class="status-value qbi-status-icon" data-status="pending">❌</span>
</div>
<span class="qbi-status-label">${i18n('QUICKBATTLEMAP.WallDataStatus')}</span>
</div>
</div>
<div class="qbi-options">
<label class="qbi-checkbox">
<input type="checkbox" class="ebm-no-grid qbi-checkbox-input" />
<span class="qbi-checkbox-mark"></span>
<span class="qbi-checkbox-label">${i18n('QUICKBATTLEMAP.NoGridLabel')}</span>
</label>
</div>
<div class="qbi-progress progress-group">
<div class="qbi-progress-content">
<div class="qbi-spinner">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
<div class="qbi-progress-info">
<span class="ebm-progress-text qbi-progress-text">${i18n('QUICKBATTLEMAP.ProgressIdle')}</span>
<span class="qbi-progress-note">${i18n('QUICKBATTLEMAP.ProgressNote')}</span>
</div>
</div>
</div>
<footer class="qbi-footer">
<button type="button" class="qbi-btn qbi-btn-secondary reset-button">
<i class="fas fa-undo"></i>
<span>${i18n('QUICKBATTLEMAP.Reset')}</span>
</button>
<button type="button" class="qbi-btn qbi-btn-primary create-scene-button" disabled>
<i class="fas fa-plus-circle"></i>
<span>${i18n('QUICKBATTLEMAP.CreateScene')}</span>
</button>
</footer>
</form>
</section>
</div>`;
}
/**
* Attach click event listeners to the panel's action buttons.
*/
attachButtonEventListeners() {
const panel = this.getPanelElement();
if (!panel) return;
const createButton = panel.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
const resetButton = panel.querySelector(PANEL_SELECTORS.RESET_BUTTON);
const closeButton = panel.querySelector(PANEL_SELECTORS.CLOSE_BUTTON);
createButton?.addEventListener('click', () => {
this.onCreateSceneRequested?.();
});
resetButton?.addEventListener('click', () => {
this.onResetRequested?.();
});
closeButton?.addEventListener('click', () => {
this.onCloseRequested?.();
});
}
/**
* Attach change listener to the no-grid checkbox.
* Loads and applies the persisted preference.
*/
attachNoGridCheckboxListener() {
const panel = this.getPanelElement();
if (!panel) return;
// Load persisted preference
const persistedValue = this.loadNoGridPreference();
const checkbox = panel.querySelector(PANEL_SELECTORS.NO_GRID_CHECKBOX);
if (checkbox) {
checkbox.checked = persistedValue;
checkbox.addEventListener('change', (event) => {
const isChecked = !!event.currentTarget.checked;
this.saveNoGridPreference(isChecked);
this.onNoGridPreferenceChanged?.(isChecked);
});
}
}
/**
* Attach drag event handlers to make the panel header draggable.
*/
attachDragHandlers() {
const panel = this.getPanelElement();
const header = panel?.querySelector('header');
if (!panel || !header) return;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panelStartLeft = 0;
let panelStartTop = 0;
const handleMouseMove = (event) => {
if (!isDragging) return;
const deltaX = event.clientX - dragStartX;
const deltaY = event.clientY - dragStartY;
// Keep panel within viewport bounds
const newLeft = Math.max(0, panelStartLeft + deltaX);
const newTop = Math.max(0, panelStartTop + deltaY);
panel.style.left = `${newLeft}px`;
panel.style.top = `${newTop}px`;
};
const handleMouseUp = () => {
isDragging = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
header.addEventListener('mousedown', (event) => {
// Only respond to left mouse button
if (event.button !== 0) return;
isDragging = true;
const panelRect = panel.getBoundingClientRect();
dragStartX = event.clientX;
dragStartY = event.clientY;
panelStartLeft = panelRect.left + window.scrollX;
panelStartTop = panelRect.top + window.scrollY;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
});
}
/**
* Attach drag-and-drop event handlers to the drop zone.
*/
attachDropZoneHandlers() {
const dropArea = this.getPanelElement();
if (!dropArea) return;
// Prevent default browser behavior for all drag events
const preventDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
};
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false);
});
// Add visual highlight during drag
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.add('highlight');
}, false);
});
// Remove highlight when drag ends
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.remove('highlight');
}, false);
});
// Forward drop events to callback
dropArea.addEventListener('drop', (event) => {
this.onFilesDropped?.(event);
}, false);
}
/**
* Load the no-grid preference from localStorage.
* @returns {boolean} Whether no-grid mode is enabled
*/
loadNoGridPreference() {
try {
return localStorage.getItem(NO_GRID_STORAGE_KEY) === 'true';
} catch (_error) {
return false;
}
}
/**
* Save the no-grid preference to localStorage.
* @param {boolean} isEnabled - Whether no-grid mode is enabled
*/
saveNoGridPreference(isEnabled) {
try {
localStorage.setItem(NO_GRID_STORAGE_KEY, String(isEnabled));
} catch (_error) {
// localStorage may not be available
}
}
/**
* Get the panel's root DOM element.
* @returns {HTMLElement|null} The panel element
*/
getPanelElement() {
return document.getElementById('myxeliums-battlemap-drop-area');
}
/**
* Show the import panel.
*/
show() {
this.ensureCreated();
const panel = this.getPanelElement();
if (panel) {
panel.style.display = 'block';
this.isCurrentlyVisible = true;
}
}
/**
* Hide the import panel.
*/
hide() {
const panel = this.getPanelElement();
if (panel) {
panel.style.display = 'none';
this.isCurrentlyVisible = false;
}
}
/**
* Enable or disable the Create Scene button.
* @param {boolean} isEnabled - Whether the button should be enabled
*/
setCreateButtonEnabled(isEnabled) {
const button = document.querySelector(PANEL_SELECTORS.CREATE_BUTTON);
if (button) {
button.disabled = !isEnabled;
}
}
/**
* Update the background media status indicator.
* @param {boolean} isLoaded - Whether background media is loaded
* @param {string} tooltipText - Tooltip text (usually the filename)
*/
updateBackgroundMediaStatus(isLoaded, tooltipText) {
const statusElement = document.querySelector(PANEL_SELECTORS.BACKGROUND_STATUS);
if (statusElement) {
statusElement.textContent = isLoaded ? '✅' : '❌';
statusElement.title = tooltipText || '';
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
}
}
/**
* Update the wall/grid data status indicator.
* @param {boolean} isLoaded - Whether wall data is loaded
* @param {string} tooltipText - Tooltip text (usually the filename or "Auto-detected")
*/
updateWallDataStatus(isLoaded, tooltipText) {
const statusElement = document.querySelector(PANEL_SELECTORS.WALL_DATA_STATUS);
if (statusElement) {
statusElement.textContent = isLoaded ? '✅' : '❌';
statusElement.title = tooltipText || '';
statusElement.dataset.status = isLoaded ? 'complete' : 'pending';
}
}
/**
* Show the progress/busy indicator with a message.
* @param {string} statusMessage - Message to display
*/
showBusyState(statusMessage) {
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
if (progressGroup) {
progressGroup.style.display = '';
}
if (progressText && statusMessage) {
progressText.textContent = statusMessage;
}
this.isShowingBusyState = true;
}
/**
* Hide the progress/busy indicator.
*/
clearBusyState() {
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
if (progressGroup) {
progressGroup.style.display = 'none';
}
if (progressText) {
progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
}
this.isShowingBusyState = false;
}
/**
* Reset all status indicators to their default (empty) state.
* @param {boolean} persistedNoGridValue - Whether to check the no-grid checkbox
*/
resetAllStatuses(persistedNoGridValue) {
this.updateBackgroundMediaStatus(false, '');
this.updateWallDataStatus(false, '');
this.setCreateButtonEnabled(false);
// Hide and reset progress indicator
const progressGroup = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_GROUP}`);
const progressText = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.PROGRESS_TEXT}`);
if (progressGroup) {
progressGroup.style.display = 'none';
}
if (progressText) {
progressText.textContent = game.i18n.localize('QUICKBATTLEMAP.ProgressIdle');
}
// Restore no-grid checkbox to persisted value
const noGridCheckbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
if (noGridCheckbox) {
noGridCheckbox.checked = !!persistedNoGridValue;
}
}
/**
* Get the current state of the no-grid checkbox.
* @returns {boolean} Whether no-grid mode is selected
*/
getNoGridCheckboxState() {
const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
return !!checkbox?.checked;
}
/**
* Set the state of the no-grid checkbox.
* @param {boolean} isChecked - Whether the checkbox should be checked
*/
setNoGridCheckboxState(isChecked) {
const checkbox = document.querySelector(`${PANEL_SELECTORS.PANEL_ROOT} ${PANEL_SELECTORS.NO_GRID_CHECKBOX}`);
if (checkbox) {
checkbox.checked = !!isChecked;
}
}
/**
* Render the floor list from the provided floors data.
* @param {Array} floors - Array of floor objects with id, name, mediaFile, jsonFile
*/
renderFloorList(floors) {
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
const floorCount = document.querySelector('.qbi-floor-count');
const i18n = (key) => game.i18n.localize(key);
if (!floorList) return;
// Update floor count
if (floorCount) {
floorCount.textContent = `${floors.length} ${i18n('QUICKBATTLEMAP.FloorsCount')}`;
}
// Show empty state if no floors
if (floors.length === 0) {
floorList.innerHTML = `
<div class="qbi-floor-empty">
<i class="fas fa-info-circle"></i>
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
</div>`;
return;
}
// Render floor items
floorList.innerHTML = floors.map((floor, index) => `
<div class="qbi-floor-item" data-floor-id="${floor.id}" draggable="true">
<div class="qbi-floor-drag-handle">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="qbi-floor-info">
<div class="qbi-floor-name">
<span class="qbi-floor-level">${i18n('QUICKBATTLEMAP.FloorLevel')} ${index + 1}</span>
<span class="qbi-floor-filename" title="${floor.mediaFile?.name || ''}">${this.truncateFilename(floor.mediaFile?.name || i18n('QUICKBATTLEMAP.NoMedia'))}</span>
</div>
<div class="qbi-floor-json ${floor.jsonFile ? 'has-json' : 'no-json'}">
<i class="fas ${floor.jsonFile ? 'fa-check-circle' : 'fa-times-circle'}"></i>
<span>${floor.jsonFile ? this.truncateFilename(floor.jsonFile.name) : i18n('QUICKBATTLEMAP.NoJson')}</span>
</div>
</div>
<div class="qbi-floor-actions">
<button type="button" class="qbi-floor-btn qbi-floor-move-up" title="${i18n('QUICKBATTLEMAP.MoveUp')}" ${index === 0 ? 'disabled' : ''}>
<i class="fas fa-chevron-up"></i>
</button>
<button type="button" class="qbi-floor-btn qbi-floor-move-down" title="${i18n('QUICKBATTLEMAP.MoveDown')}" ${index === floors.length - 1 ? 'disabled' : ''}>
<i class="fas fa-chevron-down"></i>
</button>
<button type="button" class="qbi-floor-btn qbi-floor-remove" title="${i18n('QUICKBATTLEMAP.RemoveFloor')}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
`).join('');
// Attach event listeners to floor items
this.attachFloorItemEventListeners();
}
/**
* Truncate a filename for display.
* @param {string} filename - The filename to truncate
* @param {number} maxLength - Maximum length before truncation
* @returns {string} Truncated filename
*/
truncateFilename(filename, maxLength = 25) {
if (!filename || filename.length <= maxLength) return filename;
const extension = filename.split('.').pop();
const nameWithoutExt = filename.slice(0, filename.lastIndexOf('.'));
const truncatedName = nameWithoutExt.slice(0, maxLength - extension.length - 4) + '...';
return `${truncatedName}.${extension}`;
}
/**
* Attach event listeners to floor item buttons.
*/
attachFloorItemEventListeners() {
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
if (!floorList) return;
// Move up buttons
floorList.querySelectorAll('.qbi-floor-move-up').forEach(btn => {
btn.addEventListener('click', (e) => {
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
const floorId = floorItem?.dataset.floorId;
if (floorId) this.onFloorOrderChanged?.('up', floorId);
});
});
// Move down buttons
floorList.querySelectorAll('.qbi-floor-move-down').forEach(btn => {
btn.addEventListener('click', (e) => {
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
const floorId = floorItem?.dataset.floorId;
if (floorId) this.onFloorOrderChanged?.('down', floorId);
});
});
// Remove buttons
floorList.querySelectorAll('.qbi-floor-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
const floorItem = e.currentTarget.closest(PANEL_SELECTORS.FLOOR_ITEM);
const floorId = floorItem?.dataset.floorId;
if (floorId) this.onFloorRemoved?.(floorId);
});
});
// Setup drag and drop for reordering
this.setupFloorDragAndDrop();
}
/**
* Setup drag and drop functionality for floor reordering.
*/
setupFloorDragAndDrop() {
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
if (!floorList) return;
let draggedItem = null;
floorList.querySelectorAll(PANEL_SELECTORS.FLOOR_ITEM).forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedItem = item;
item.classList.add('qbi-floor-dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => {
if (draggedItem) {
draggedItem.classList.remove('qbi-floor-dragging');
draggedItem = null;
}
floorList.querySelectorAll(PANEL_SELECTORS.FLOOR_ITEM).forEach(i => {
i.classList.remove('qbi-floor-drag-over');
});
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (item !== draggedItem) {
item.classList.add('qbi-floor-drag-over');
}
});
item.addEventListener('dragleave', () => {
item.classList.remove('qbi-floor-drag-over');
});
item.addEventListener('drop', (e) => {
e.preventDefault();
item.classList.remove('qbi-floor-drag-over');
if (draggedItem && item !== draggedItem) {
const fromId = draggedItem.dataset.floorId;
const toId = item.dataset.floorId;
this.onFloorOrderChanged?.('reorder', fromId, toId);
}
});
});
}
/**
* Show unmatched files that need user attention.
* @param {Array} unmatchedMedia - Array of unmatched media files
* @param {Array} unmatchedJson - Array of unmatched JSON files
*/
showUnmatchedFiles(unmatchedMedia, unmatchedJson) {
const container = document.querySelector(PANEL_SELECTORS.UNMATCHED_FILES);
const listElement = container?.querySelector('.qbi-unmatched-list');
const i18n = (key) => game.i18n.localize(key);
if (!container || !listElement) return;
const hasUnmatched = unmatchedMedia.length > 0 || unmatchedJson.length > 0;
container.style.display = hasUnmatched ? '' : 'none';
if (!hasUnmatched) return;
let html = '';
if (unmatchedMedia.length > 0) {
html += `
<div class="qbi-unmatched-group">
<span class="qbi-unmatched-label"><i class="fas fa-image"></i> ${i18n('QUICKBATTLEMAP.UnmatchedMedia')}</span>
${unmatchedMedia.map(file => `
<div class="qbi-unmatched-item" data-file-name="${file.name}" data-file-type="media">
<span class="qbi-unmatched-name" title="${file.name}">${this.truncateFilename(file.name)}</span>
<button type="button" class="qbi-unmatched-assign" title="${i18n('QUICKBATTLEMAP.AssignToFloor')}">
<i class="fas fa-link"></i>
</button>
</div>
`).join('')}
</div>`;
}
if (unmatchedJson.length > 0) {
html += `
<div class="qbi-unmatched-group">
<span class="qbi-unmatched-label"><i class="fas fa-file-code"></i> ${i18n('QUICKBATTLEMAP.UnmatchedJson')}</span>
${unmatchedJson.map(file => `
<div class="qbi-unmatched-item" data-file-name="${file.name}" data-file-type="json">
<span class="qbi-unmatched-name" title="${file.name}">${this.truncateFilename(file.name)}</span>
<button type="button" class="qbi-unmatched-assign" title="${i18n('QUICKBATTLEMAP.AssignToFloor')}">
<i class="fas fa-link"></i>
</button>
</div>
`).join('')}
</div>`;
}
listElement.innerHTML = html;
// Attach click handlers for assign buttons
listElement.querySelectorAll('.qbi-unmatched-assign').forEach(btn => {
btn.addEventListener('click', (e) => {
const item = e.currentTarget.closest('.qbi-unmatched-item');
const fileName = item?.dataset.fileName;
const fileType = item?.dataset.fileType;
if (fileName && fileType) {
this.showFileMatchDialog(fileName, fileType);
}
});
});
}
/**
* Hide unmatched files section.
*/
hideUnmatchedFiles() {
const container = document.querySelector(PANEL_SELECTORS.UNMATCHED_FILES);
if (container) {
container.style.display = 'none';
}
}
/**
* Show a dialog to match a file to a floor.
* @param {string} fileName - The file to match
* @param {string} fileType - 'media' or 'json'
*/
showFileMatchDialog(fileName, fileType) {
// This will be handled by the controller opening a proper dialog
this.onFileMatchRequested?.(fileName, fileType);
}
/**
* Show a dialog for the user to match files to floors.
* @param {Array} floors - Current floors list
* @param {string} fileName - File to match
* @param {string} fileType - 'media' or 'json'
* @returns {Promise<string|null>} Selected floor ID or null if cancelled
*/
async promptFloorSelection(floors, fileName, fileType) {
const i18n = (key) => game.i18n.localize(key);
return new Promise((resolve) => {
const dialogContent = `
<div class="qbi-match-dialog-content">
<p>${i18n('QUICKBATTLEMAP.SelectFloorFor')} <strong>${fileName}</strong></p>
<select class="qbi-floor-select">
<option value="">${i18n('QUICKBATTLEMAP.SelectFloor')}</option>
${floors.map((floor, index) => `
<option value="${floor.id}">${i18n('QUICKBATTLEMAP.FloorLevel')} ${index + 1} - ${floor.mediaFile?.name || i18n('QUICKBATTLEMAP.NoMedia')}</option>
`).join('')}
${fileType === 'media' ? `<option value="__new__">${i18n('QUICKBATTLEMAP.CreateNewFloor')}</option>` : ''}
</select>
</div>
`;
new Dialog({
title: i18n('QUICKBATTLEMAP.MatchFileTitle'),
content: dialogContent,
buttons: {
confirm: {
icon: '<i class="fas fa-check"></i>',
label: i18n('QUICKBATTLEMAP.Confirm'),
callback: (html) => {
const select = html.find('.qbi-floor-select')[0];
resolve(select?.value || null);
}
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: i18n('QUICKBATTLEMAP.Cancel'),
callback: () => resolve(null)
}
},
default: 'confirm'
}).render(true);
});
}
/**
* Clear the floor list display.
*/
clearFloorList() {
const i18n = (key) => game.i18n.localize(key);
const floorList = document.querySelector(PANEL_SELECTORS.FLOOR_LIST);
const floorCount = document.querySelector('.qbi-floor-count');
if (floorList) {
floorList.innerHTML = `
<div class="qbi-floor-empty">
<i class="fas fa-info-circle"></i>
<span>${i18n('QUICKBATTLEMAP.FloorsEmpty')}</span>
</div>`;
}
if (floorCount) {
floorCount.textContent = `0 ${i18n('QUICKBATTLEMAP.FloorsCount')}`;
}
this.hideUnmatchedFiles();
}
}