assets_js_controllers_file-size-validator-controller.js

import { Controller } from "@hotwired/stimulus"

/**
 * File Size Validator Controller
 * 
 * Validates file sizes against PHP upload limits before submission.
 * Provides immediate feedback to users when files exceed server limits,
 * preventing failed uploads and improving user experience.
 * 
 * Features:
 * - Pre-upload file size validation
 * - Multiple file support
 * - Customizable warning messages
 * - Integration with existing upload controls
 * - Real-time feedback on file selection
 * 
 * Values:
 * - maxSize: Maximum file size in bytes (from PHP upload_max_filesize/post_max_size)
 * - maxSizeFormatted: Human-readable max size (e.g., '25MB')
 * - totalMaxSize: Maximum total size for multiple files (defaults to maxSize)
 * - showWarning: Whether to show warning messages (default: true)
 * 
 * Targets:
 * - fileInput: File input element(s) to monitor
 * - warning: Container for warning messages (optional)
 * - submitButton: Submit button to disable when files are invalid (optional)
 * 
 * Events Dispatched:
 * - file-size-validator:valid - All files are valid
 * - file-size-validator:invalid - One or more files exceed limits
 * - file-size-validator:warning - Warning displayed to user
 * 
 * Usage:
 * ```html
 * <div data-controller="file-size-validator"
 *      data-file-size-validator-max-size-value="26214400"
 *      data-file-size-validator-max-size-formatted-value="25MB">
 *   
 *   <input type="file" 
 *          data-file-size-validator-target="fileInput"
 *          data-action="change->file-size-validator#validateFiles">
 *   
 *   <div data-file-size-validator-target="warning" 
 *        class="alert alert-warning d-none"></div>
 *   
 *   <button type="submit" 
 *           data-file-size-validator-target="submitButton">
 *     Upload
 *   </button>
 * </div>
 * ```
 * 
 * @example Multiple Files
 * ```html
 * <input type="file" 
 *        multiple
 *        data-file-size-validator-target="fileInput"
 *        data-action="change->file-size-validator#validateFiles">
 * ```
 * 
 * @example Custom Total Limit
 * ```html
 * <div data-controller="file-size-validator"
 *      data-file-size-validator-max-size-value="26214400"
 *      data-file-size-validator-total-max-size-value="52428800">
 * ```
 */
class FileSizeValidatorController extends Controller {
    static targets = ["fileInput", "warning", "submitButton"]
    
    static values = {
        maxSize: Number,              // Maximum single file size in bytes
        maxSizeFormatted: String,     // Human-readable format (e.g., '25MB')
        totalMaxSize: Number,         // Maximum total size for multiple files
        showWarning: { type: Boolean, default: true },
        warningClass: { type: String, default: 'alert alert-warning' },
        errorClass: { type: String, default: 'alert alert-danger' }
    }

    /**
     * Initialize controller
     */
    connect() {
        console.log('FileSizeValidatorController connected', {
            maxSize: this.maxSizeValue,
            maxSizeFormatted: this.maxSizeFormattedValue,
            totalMaxSize: this.totalMaxSizeValue
        })
        
        // Default total max to single max if not specified
        if (!this.hasTotalMaxSizeValue) {
            this.totalMaxSizeValue = this.maxSizeValue
        }
        
        // Validate any pre-selected files
        if (this.hasFileInputTarget) {
            this.fileInputTargets.forEach(input => {
                if (input.files && input.files.length > 0) {
                    this.validateFiles({ target: input })
                }
            })
        }
    }

    /**
     * Validate selected files
     * 
     * @param {Event} event - File input change event
     */
    validateFiles(event) {
        const input = event.target
        
        // Collect all files from all file inputs in this controller's scope
        let allFiles = []
        
        if (this.hasFileInputTarget) {
            this.fileInputTargets.forEach(inputEl => {
                if (inputEl.files && inputEl.files.length > 0) {
                    allFiles = allFiles.concat(Array.from(inputEl.files))
                }
            })
        }
        
        if (allFiles.length === 0) {
            this.clearWarning()
            this.enableSubmit()
            return
        }

        const validation = this.checkFileSizes(allFiles)
        
        if (!validation.valid) {
            this.showInvalidFilesWarning(validation)
            this.disableSubmit()
            
            // Dispatch invalid event
            this.dispatch('invalid', { 
                detail: { 
                    files: validation.invalidFiles,
                    message: validation.message 
                } 
            })
        } else if (validation.warning) {
            this.showTotalSizeWarning(validation)
            
            // Still allow submission but warn user
            this.enableSubmit()
            
            // Dispatch warning event
            this.dispatch('warning', { 
                detail: { 
                    totalSize: validation.totalSize,
                    message: validation.message 
                } 
            })
        } else {
            this.clearWarning()
            this.enableSubmit()
            
            // Dispatch valid event
            this.dispatch('valid', { 
                detail: { 
                    files: allFiles.map(f => ({ name: f.name, size: f.size })),
                    totalSize: validation.totalSize 
                } 
            })
        }
    }

    /**
     * Check file sizes and return validation result
     * 
     * @param {File[]} files - Array of File objects
     * @returns {Object} Validation result
     */
    checkFileSizes(files) {
        const invalidFiles = []
        let totalSize = 0
        
        files.forEach(file => {
            totalSize += file.size
            
            if (file.size > this.maxSizeValue) {
                invalidFiles.push({
                    name: file.name,
                    size: file.size,
                    formattedSize: this.formatBytes(file.size),
                    exceededBy: file.size - this.maxSizeValue
                })
            }
        })
        
        // Check if any individual files exceed limit
        if (invalidFiles.length > 0) {
            return {
                valid: false,
                invalidFiles,
                totalSize,
                totalFileCount: files.length,
                message: this.buildInvalidFilesMessage(invalidFiles)
            }
        }
        
        // Check if total size exceeds limit (for multiple files or accumulated uploads)
        // Show warning when total size exceeds the post_max_size limit
        if (totalSize > this.totalMaxSizeValue) {
            return {
                valid: true,
                warning: true,
                totalSize,
                totalFileCount: files.length,
                formattedTotal: this.formatBytes(totalSize),
                message: this.buildTotalSizeWarningMessage(totalSize, files.length)
            }
        }
        
        return {
            valid: true,
            warning: false,
            totalSize,
            totalFileCount: files.length,
            formattedTotal: this.formatBytes(totalSize)
        }
    }

    /**
     * Escape HTML special characters to prevent XSS
     * 
     * @param {string} str - String to escape
     * @returns {string} Escaped string
     */
    escapeHtml(str) {
        const div = document.createElement('div')
        div.textContent = str
        return div.innerHTML
    }

    /**
     * Build error message for invalid files
     * 
     * @param {Array} invalidFiles - Array of invalid file objects
     * @returns {string} Error message with HTML-escaped file names
     */
    buildInvalidFilesMessage(invalidFiles) {
        const maxSize = this.maxSizeFormattedValue || this.formatBytes(this.maxSizeValue)
        
        if (invalidFiles.length === 1) {
            const file = invalidFiles[0]
            const escapedName = this.escapeHtml(file.name)
            return `The file "${escapedName}" (${file.formattedSize}) exceeds the maximum upload size of ${maxSize}.`
        }
        
        const fileList = invalidFiles.map(f => 
            `• ${this.escapeHtml(f.name)} (${f.formattedSize})`
        ).join('\n')
        
        return `${invalidFiles.length} file(s) exceed the maximum upload size of ${maxSize}:\n\n${fileList}\n\nPlease remove or replace these files before uploading.`
    }

    /**
     * Build warning message for total size
     * 
     * @param {number} totalSize - Total size in bytes
     * @param {number} fileCount - Number of files
     * @returns {string} Warning message
     */
    buildTotalSizeWarningMessage(totalSize, fileCount) {
        const totalFormatted = this.formatBytes(totalSize)
        const maxFormatted = this.formatBytes(this.totalMaxSizeValue)
        
        if (fileCount === 1) {
            return `Warning: The file size (${totalFormatted}) exceeds the recommended upload limit of ${maxFormatted}. The upload may fail depending on server configuration.`
        }
        
        return `Warning: You have selected ${fileCount} file(s) with a combined size of ${totalFormatted}, which exceeds the recommended limit of ${maxFormatted}. The upload may fail depending on server configuration.`
    }

    /**
     * Show warning for invalid files
     * 
     * @param {Object} validation - Validation result
     */
    showInvalidFilesWarning(validation) {
        if (!this.showWarningValue || !this.hasWarningTarget) {
            // Still show browser alert if no warning target
            alert(validation.message)
            return
        }
        
        this.warningTarget.innerHTML = this.formatWarningMessage(validation.message, 'error')
        this.warningTarget.className = this.errorClassValue
        this.warningTarget.classList.remove('d-none')
    }

    /**
     * Show warning for total size
     * 
     * @param {Object} validation - Validation result
     */
    showTotalSizeWarning(validation) {
        if (!this.showWarningValue || !this.hasWarningTarget) {
            // Still show browser alert if no warning target
            alert(validation.message)
            return
        }
        
        this.warningTarget.innerHTML = this.formatWarningMessage(validation.message, 'warning')
        this.warningTarget.className = this.warningClassValue
        this.warningTarget.classList.remove('d-none')
    }

    /**
     * Format warning message with icon
     * 
     * @param {string} message - Warning message
     * @param {string} type - Message type ('error' or 'warning')
     * @returns {string} Formatted HTML
     */
    formatWarningMessage(message, type = 'warning') {
        const icon = type === 'error' 
            ? '<i class="bi bi-exclamation-triangle-fill"></i>'
            : '<i class="bi bi-exclamation-circle-fill"></i>'
        
        // Preserve line breaks
        const formattedMessage = message.replace(/\n/g, '<br>')
        
        return `${icon} ${formattedMessage}`
    }

    /**
     * Clear warning message
     */
    clearWarning() {
        if (this.hasWarningTarget) {
            this.warningTarget.classList.add('d-none')
            this.warningTarget.innerHTML = ''
        }
    }

    /**
     * Disable submit button
     */
    disableSubmit() {
        if (this.hasSubmitButtonTarget) {
            this.submitButtonTargets.forEach(button => {
                button.disabled = true
            })
        }
    }

    /**
     * Enable submit button
     */
    enableSubmit() {
        if (this.hasSubmitButtonTarget) {
            this.submitButtonTargets.forEach(button => {
                button.disabled = false
            })
        }
    }

    /**
     * Format bytes to human-readable string
     * 
     * @param {number} bytes - Size in bytes
     * @param {number} decimals - Number of decimal places
     * @returns {string} Formatted size string
     */
    formatBytes(bytes, decimals = 2) {
        if (bytes === 0) return '0 Bytes'
        
        const k = 1024
        const dm = decimals < 0 ? 0 : decimals
        const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
        
        const i = Math.floor(Math.log(bytes) / Math.log(k))
        
        return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i]
    }

    /**
     * Dispatch custom event
     * 
     * @param {string} eventName - Event name (without prefix)
     * @param {Object} options - Event options
     */
    dispatch(eventName, options = {}) {
        const event = new CustomEvent(`file-size-validator:${eventName}`, {
            bubbles: true,
            cancelable: true,
            ...options
        })
        
        this.element.dispatchEvent(event)
    }
}

// Register controller
if (!window.Controllers) {
    window.Controllers = {}
}
window.Controllers["file-size-validator"] = FileSizeValidatorController

export default FileSizeValidatorController