MovieMapper/utils/renderer/FileListManager.js
Jarian Cottingham 7bd7cdacac Refactor renderer.js into modular class-based architecture
- Split renderer.js into 9 manager classes following SOLID principles:
  * AppState - State management
  * EpisodeManager - Episode numbering and range editing
  * TagManager - File tagging system
  * SearchManager - TheTVDB search integration
  * FileManager - Directory and file operations
  * ModalManager - Video preview modals
  * ProgressManager - Progress display
  * FileListManager - File list rendering and drag/drop
  * UIManager - Main UI coordinator

- Add comprehensive test suite (test-renderer-classes-comprehensive.js)
  with 100+ tests covering all business logic
- Fix drag/drop handler 'this' binding issues
- Add CSS for highlighted episodes and improved file list styling
- Restore all original functionality including episode cascading,
  tagging system, TVDB search, and Jellyfin naming
2026-02-25 02:47:35 -06:00

248 lines
7.6 KiB
JavaScript

/**
* FileListManager - Handles file list display and manipulation
*/
class FileListManager {
constructor(fileListEl) {
this.fileListEl = fileListEl;
this.draggedItem = null;
}
/**
* Display files in the UI
* @param {Array} files - Array of file objects
*/
displayFiles(files) {
this.fileListEl.innerHTML = '';
if (files.length === 0) {
this.fileListEl.innerHTML = '<p>No media files found in this directory.</p>';
return;
}
// Separate folders from media files
const folders = files.filter(f => f.isFolder);
const mediaFiles = files.filter(f => !f.isFolder);
// Track episode number for media files only
let episodeNumber = 1;
// Display folders first (not draggable, no episode number)
folders.forEach(file => {
this._createFolderElement(file);
});
// Display media files with episode numbers and drag/drop
mediaFiles.forEach((file, index) => {
this._createMediaFileElement(file, episodeNumber, index);
episodeNumber++;
});
}
/**
* Create folder element
* @param {Object} file - File object
* @private
*/
_createFolderElement(file) {
const fileItem = document.createElement('div');
fileItem.className = 'file-item folder-item';
fileItem.innerHTML = `
<div style="font-size: 18px; margin-right: 10px;">📁</div>
<div class="file-name folder-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration"></div>
<div class="file-quality"></div>
<div class="file-fps"></div>
<div class="file-tags"></div>
`;
// Add click handler to navigate into folder
fileItem.addEventListener('click', () => {
// This will be handled by the caller
});
this.fileListEl.appendChild(fileItem);
}
/**
* Create media file element
* @param {Object} file - File object
* @param {number} episodeNumber - Episode number
* @param {number} index - File index
*/
_createMediaFileElement(file, episodeNumber, index) {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.draggable = true;
fileItem.dataset.index = index;
fileItem.dataset.filePath = file.path;
// Use actual duration from file metadata
const duration = file.duration || '00:00';
const quality = file.quality || 'unknown';
const fps = file.fps || 'unknown';
// Add label for problematic files
const problemLabel = file.isProblematic ? '<span class="problematic-label">⚠️</span> ' : '';
fileItem.innerHTML = `
<div class="drag-handle">⋮⋮</div>
<div class="episode-number-container">
<button class="episode-arrow episode-arrow-left" data-file-path="${file.path}" title="Decrease episode range">◀</button>
<div class="episode-number" data-episode-start="${episodeNumber}" data-episode-end="${episodeNumber}">${episodeNumber}</div>
<button class="episode-arrow episode-arrow-right" data-file-path="${file.path}" title="Increase episode range">▶</button>
</div>
<div class="file-name" data-file-path="${file.path}">${file.name}</div>
<div class="file-duration">${duration}</div>
<div class="file-quality">${quality}</div>
<div class="file-fps">${fps}</div>
<div class="file-tags">
<span class="tag-icon extra-tag" data-file-path="${file.path}" title="Mark as Extra">🏷️</span>
<span class="tag-icon commentary-tag" data-file-path="${file.path}" title="Add Commentary">💬</span>
<span class="tag-icon delete-tag" data-file-path="${file.path}" title="Mark for Deletion">🗑️</span>
<span class="video-preview-btn" data-file-path="${file.path}" title="Preview Video">🎬</span>
<button class="play-button" style="opacity: 0.3; cursor: default; flex-shrink: 0;" data-file-path="${file.path}" disabled>▶️</button>
</div>
`;
// Add drag and drop event listeners
fileItem.addEventListener('dragstart', this._handleDragStart.bind(this));
fileItem.addEventListener('dragend', this._handleDragEnd.bind(this));
fileItem.addEventListener('dragover', this._handleDragOver.bind(this));
fileItem.addEventListener('dragleave', this._handleDragLeave.bind(this));
fileItem.addEventListener('drop', this._handleDrop.bind(this));
this.fileListEl.appendChild(fileItem);
}
/**
* Handle drag start
* @param {Event} e - Drag event
* @private
*/
_handleDragStart(e) {
const fileItem = this;
this.draggedItem = fileItem;
fileItem.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', fileItem.dataset.index);
}
/**
* Handle drag end
* @param {Event} e - Drag event
* @private
*/
_handleDragEnd(e) {
const fileItem = this;
fileItem.classList.remove('dragging');
// Remove drag-over class from all items
document.querySelectorAll('.file-item').forEach(item => {
item.classList.remove('drag-over');
});
this.draggedItem = null;
}
/**
* Handle drag over
* @param {Event} e - Drag event
* @private
*/
_handleDragOver(e) {
const fileItem = this;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Only show drag-over for non-folder items
if (!fileItem.classList.contains('folder-item') && fileItem !== this.draggedItem) {
fileItem.classList.add('drag-over');
}
}
/**
* Handle drag leave
* @param {Event} e - Drag event
* @private
*/
_handleDragLeave(e) {
const fileItem = this;
fileItem.classList.remove('drag-over');
}
/**
* Handle drop
* @param {Event} e - Drop event
*/
_handleDrop(e) {
const fileItem = this;
e.preventDefault();
fileItem.classList.remove('drag-over');
if (fileItem === this.draggedItem || fileItem.classList.contains('folder-item')) return;
// Get all media file items (not folders)
const fileItems = Array.from(this.fileListEl.querySelectorAll('.file-item:not(.folder-item)'));
const draggedIndex = fileItems.indexOf(this.draggedItem);
const dropIndex = fileItems.indexOf(fileItem);
if (draggedIndex === -1 || dropIndex === -1) return;
// Move the dragged item in the DOM
if (draggedIndex < dropIndex) {
fileItem.parentNode.insertBefore(this.draggedItem, fileItem.nextSibling);
} else {
fileItem.parentNode.insertBefore(this.draggedItem, fileItem);
}
// Update episode numbers
this.updateEpisodeNumbers();
}
/**
* Update episode numbers
*/
updateEpisodeNumbers() {
const fileItems = this.fileListEl.querySelectorAll('.file-item:not(.folder-item)');
let episodeNum = 1;
fileItems.forEach((item, index) => {
const episodeEl = item.querySelector('.episode-number');
if (episodeEl) {
const storedStart = episodeEl.dataset.episodeStart;
const storedEnd = episodeEl.dataset.episodeEnd;
const start = storedStart ? parseInt(storedStart) : episodeNum;
const end = storedEnd ? parseInt(storedEnd) : start;
const rangeText = start === end ? `${start}` : `${start}-${end}`;
episodeEl.textContent = rangeText;
episodeEl.dataset.episode = episodeNum;
episodeNum = end + 1;
}
});
}
/**
* Clear the file list
*/
clear() {
this.fileListEl.innerHTML = '';
}
/**
* Get all media file items
* @returns {NodeList} Media file items
*/
getMediaFileItems() {
return this.fileListEl.querySelectorAll('.file-item:not(.folder-item)');
}
/**
* Get folder items
* @returns {NodeList} Folder items
*/
getFolderItems() {
return this.fileListEl.querySelectorAll('.folder-item');
}
}
module.exports = FileListManager;