| | |
| | | this.queue = window.jvbQueue; |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | |
| | | // RECOVERY STORES - Cleared after successful upload |
| | | this.fieldStore = window.jvbStore.register( |
| | | 'upload_fields', |
| | | { |
| | | storeName: 'fieldStates', |
| | | keyPath: 'id', |
| | | version: 2, |
| | | indexes: [ |
| | | { name: 'fieldId', keyPath: 'fieldId' }, |
| | | { name: 'timestamp', keyPath: 'timestamp' }, |
| | | { name: 'content', keyPath: 'content' }, |
| | | { name: 'itemId', keyPath: 'itemId' }, |
| | | { name: 'status', keyPath: 'status' } |
| | | ], |
| | | TTL: 86400000 * 7, // 1 week |
| | | delayFetch: true |
| | | }); |
| | | |
| | | this.uploadStore = window.jvbStore.register( |
| | | this.fieldStoreReady = false; |
| | | this.uploadStoreReady = false; |
| | | this.hasCheckedForUploads = false; |
| | | const {fields, uploads} = window.jvbStore.register( |
| | | 'uploads', |
| | | { |
| | | name: 'uploads', |
| | | storeName: 'uploads', |
| | | keyPath: 'id', |
| | | storeBlobs: true, |
| | | indexes: [ |
| | | { name: 'fieldId', keyPath: 'fieldId' }, |
| | | { name: 'status', keyPath: 'status' }, |
| | | { name: 'groupId', keyPath: 'groupId' }, |
| | | { name: 'attachmentId', keyPath: 'attachmentId' } |
| | | ], |
| | | delayFetch: true |
| | | }); |
| | | [ |
| | | { |
| | | storeName: 'fields', |
| | | keyPath: 'id', |
| | | indexes: [ |
| | | { name: 'fieldId', keyPath: 'fieldId' }, |
| | | { name: 'timestamp', keyPath: 'timestamp' }, |
| | | { name: 'content', keyPath: 'content' }, |
| | | { name: 'itemId', keyPath: 'itemId' }, |
| | | { name: 'status', keyPath: 'status' } |
| | | ], |
| | | TTL: 7 * 24 * 60 * 60 * 1000, // 1 week |
| | | delayFetch: true |
| | | }, |
| | | { |
| | | storeName: 'uploads', |
| | | keyPath: 'id', |
| | | storeBlobs: true, |
| | | indexes: [ |
| | | { name: 'fieldId', keyPath: 'fieldId' }, |
| | | { name: 'status', keyPath: 'status' }, |
| | | { name: 'groupId', keyPath: 'groupId' }, |
| | | { name: 'attachmentId', keyPath: 'attachmentId' } |
| | | ], |
| | | delayFetch: true |
| | | } |
| | | ] |
| | | ); |
| | | this.fieldStore = fields; |
| | | this.uploadStore = uploads; |
| | | |
| | | window.jvbUploadBlobs = this.uploadStore; |
| | | |
| | |
| | | // Notification subscribers |
| | | this.subscribers = new Set(); |
| | | |
| | | // Controllers |
| | | this.dragController = null; |
| | | |
| | | // Selectors |
| | | this.selectors = { |
| | | field: { |
| | |
| | | 'failed_permanent': 'Upload failed permanently' |
| | | }; |
| | | |
| | | // Sortable configuration |
| | | this.sortableConfig = { |
| | | animation: 150, |
| | | draggable: '.item', |
| | | handle: '.select-item, img', |
| | | ghostClass: 'sortable-ghost', |
| | | chosenClass: 'sortable-chosen', |
| | | dragClass: 'sortable-drag', |
| | | onEnd: (evt) => this.handleReorder(evt) |
| | | }; |
| | | |
| | | this.init(); |
| | | } |
| | | |
| | |
| | | /******************************************************************************* |
| | | * FIELD MANAGEMENT |
| | | *******************************************************************************/ |
| | | |
| | | initializeFields() { |
| | | const fields = document.querySelectorAll(this.selectors.field.field); |
| | | fields.forEach(uploader => this.registerUploader(uploader)); |
| | |
| | | uploader.dataset.uploader = fieldId; |
| | | this.addFieldSelectionHandler(fieldId); |
| | | |
| | | if (config.destination === 'post_group' && !this.dragController) { |
| | | this.initGroupFeatures(); |
| | | } |
| | | |
| | | if (config.type !== 'single') { |
| | | this.initSortable(fieldId); |
| | | } |
| | |
| | | /******************************************************************************* |
| | | * SORTABLE INITIALIZATION |
| | | *******************************************************************************/ |
| | | |
| | | initSortable(fieldId) { |
| | | if (!window.Sortable) return; |
| | | |
| | | // Mount MultiDrag plugin once |
| | | if (!Sortable._multiDragMounted && Sortable.MultiDrag) { |
| | | Sortable.mount(new Sortable.MultiDrag()); |
| | | Sortable._multiDragMounted = true; |
| | | } |
| | | |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | | if (!fieldEl) return; |
| | | |
| | | // Find all sortable grids (preview + group grids, but not restore grids) |
| | | // Initialize sortable on all existing grids |
| | | const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group'); |
| | | grids.forEach(grid => { |
| | | const groupId = grid.classList.contains('group') |
| | | ? grid.closest('.upload-group')?.dataset.groupId |
| | | : null; |
| | | this.createSortableForGrid(grid, fieldId, groupId); |
| | | }); |
| | | |
| | | grids.forEach((grid) => { |
| | | // Skip if already initialized |
| | | if (grid.sortableInstance) return; |
| | | // Special handler for empty-group |
| | | const emptyGroup = fieldEl.element.querySelector('.empty-group'); |
| | | if (emptyGroup && !emptyGroup.sortableInstance) { |
| | | emptyGroup.sortableInstance = new Sortable(emptyGroup, { |
| | | animation: 150, |
| | | draggable: '.item', |
| | | multiDrag: true, |
| | | selectedClass: 'selected-for-drag', |
| | | avoidImplicitDeselect: true, |
| | | group: { name: fieldId, pull: false, put: true }, |
| | | ghostClass: 'sortable-ghost', |
| | | chosenClass: 'sortable-chosen', |
| | | dragClass: 'sortable-drag', |
| | | onEnd: (evt) => this.handleDrop(evt, fieldId) |
| | | }); |
| | | } |
| | | } |
| | | |
| | | const isGroupGrid = grid.classList.contains('group'); |
| | | const gridId = isGroupGrid |
| | | ? `${fieldId}-group-${grid.closest('.upload-group')?.dataset.groupId}` |
| | | : `${fieldId}-preview`; |
| | | syncSortableSelection(fieldId, selectedItems) { |
| | | // Update Sortable's selection state to match checkboxes |
| | | this.sortableInstances.forEach((instance, key) => { |
| | | if (key.startsWith(fieldId)) { |
| | | const grid = instance.el; |
| | | const items = grid.querySelectorAll('.item'); |
| | | |
| | | const sortableInstance = new Sortable(grid, { |
| | | ...this.sortableConfig, |
| | | group: { |
| | | name: fieldId, |
| | | pull: true, |
| | | put: true |
| | | }, |
| | | items.forEach(item => { |
| | | const uploadId = item.dataset.uploadId; |
| | | const shouldBeSelected = selectedItems.has(uploadId); |
| | | |
| | | onAdd: (evt) => { |
| | | // Re-enable when items added |
| | | this.updateSortableState(evt.to); |
| | | }, |
| | | onRemove: (evt) => { |
| | | // Disable source if now empty |
| | | this.updateSortableState(evt.from); |
| | | } |
| | | if (shouldBeSelected) { |
| | | Sortable.utils.select(item); |
| | | } else { |
| | | Sortable.utils.deselect(item); |
| | | } |
| | | }); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | handleDrop(evt, fieldId) { |
| | | const dropTarget = evt.to; |
| | | const sourceTarget = evt.from; |
| | | const items = evt.items?.length > 0 ? evt.items : [evt.item]; |
| | | const uploadIds = items.map(item => item.dataset.uploadId); |
| | | |
| | | // Determine drop target type |
| | | const targetType = this.getDropTargetType(dropTarget); |
| | | |
| | | switch (targetType) { |
| | | case 'empty-group': |
| | | this.handleDropToEmptyGroup(items, uploadIds, fieldId); |
| | | break; |
| | | |
| | | case 'preview': |
| | | this.handleDropToPreview(items, uploadIds, fieldId); |
| | | break; |
| | | |
| | | case 'group': |
| | | this.handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId); |
| | | break; |
| | | default: |
| | | // Fallback: return to preview |
| | | this.handleDropToPreview(items, uploadIds, fieldId); |
| | | break; |
| | | } |
| | | |
| | | // Update UI |
| | | this.updateSortableState(dropTarget); |
| | | if (sourceTarget !== dropTarget) { |
| | | this.updateSortableState(sourceTarget); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Determine what type of drop target this is |
| | | */ |
| | | getDropTargetType(target) { |
| | | if (target.classList.contains('empty-group')) { |
| | | return 'empty-group'; |
| | | } |
| | | |
| | | if (target.classList.contains('preview')) { |
| | | return 'preview'; |
| | | } |
| | | |
| | | if (target.classList.contains('group')) { |
| | | return 'group'; |
| | | } |
| | | |
| | | return 'unknown'; |
| | | } |
| | | |
| | | /** |
| | | * Handle drop to group: add to existing group |
| | | */ |
| | | handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId) { |
| | | try { |
| | | // If same container, it's just a reorder |
| | | if (dropTarget === sourceTarget) { |
| | | this.handleReorder({ to: dropTarget, items: items }); |
| | | return; |
| | | } |
| | | |
| | | // Moving to different group |
| | | uploadIds.forEach(uploadId => { |
| | | this.addToGroup(uploadId, dropTarget, false); |
| | | }); |
| | | |
| | | // Store reference on element for easy access |
| | | grid.sortableInstance = sortableInstance; |
| | | this.sortableInstances.set(gridId, sortableInstance); |
| | | }); |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = items.length > 1 |
| | | ? `Moved ${items.length} items to group` |
| | | : 'Moved item to group'; |
| | | this.a11y.announce(message); |
| | | |
| | | // Clear selection |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } catch (error) { |
| | | this.handleDropError(items, fieldId, error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle drop to preview: remove from groups |
| | | */ |
| | | handleDropToPreview(items, uploadIds, fieldId) { |
| | | try { |
| | | uploadIds.forEach(uploadId => { |
| | | this.removeFromGroup(uploadId); |
| | | }); |
| | | |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = items.length > 1 |
| | | ? `Moved ${items.length} items to preview` |
| | | : 'Moved item to preview'; |
| | | this.a11y.announce(message); |
| | | |
| | | // Clear selection |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } catch (error) { |
| | | this.handleDropError(items, fieldId, error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle drop errors consistently |
| | | */ |
| | | handleDropError(items, fieldId, error, message = 'An error occurred') { |
| | | console.error('Drop error:', error); |
| | | |
| | | // Return items to preview as fallback |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | | if (fieldEl?.ui?.preview) { |
| | | items.forEach(item => fieldEl.ui.preview.appendChild(item)); |
| | | } |
| | | |
| | | this.a11y.announce(`${message}. Items returned to preview.`); |
| | | } |
| | | |
| | | /** |
| | | * Handle drop to group: add to existing group |
| | | */ |
| | | handleDropToEmptyGroup(items, uploadIds, fieldId) { |
| | | try { |
| | | const group = this.createGroup(fieldId); |
| | | if (!group) { |
| | | this.handleDropError(items, fieldId, new Error('Group creation failed'), 'Failed to create group'); |
| | | return; |
| | | } |
| | | |
| | | // Move items to new group |
| | | items.forEach((item, index) => { |
| | | group.grid.appendChild(item); |
| | | this.addToGroup(uploadIds[index], group.grid, false); |
| | | }); |
| | | |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = items.length > 1 |
| | | ? `Created group with ${items.length} items` |
| | | : 'Created group with item'; |
| | | this.a11y.announce(message); |
| | | |
| | | // Clear selection after move |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } catch (error) { |
| | | this.handleDropError(items, fieldId, error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | const sortable = grid?.sortableInstance; |
| | | if (!sortable) return; |
| | | |
| | | const hasItems = grid.querySelectorAll('.item').length > 0; |
| | | sortable.option('disabled', !hasItems); |
| | | // const hasItems = grid.querySelectorAll('.item').length > 0; |
| | | sortable.option('disabled', false); |
| | | } |
| | | |
| | | /** |
| | |
| | | const fieldWrapper = grid.closest('.field, .upload'); |
| | | if (!fieldWrapper) return; |
| | | |
| | | let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]'); |
| | | let items = Array.from(fieldWrapper.querySelectorAll('.item')).map(upload => upload.dataset.id); |
| | | console.log(items); |
| | | hiddenInput.value = items.join(','); |
| | | const movedItems = evt.items && evt.items.length > 0 ? evt.items : [evt.item]; |
| | | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Item reordered'); |
| | | // Get current order from DOM |
| | | let items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)')) |
| | | .map(upload => upload.dataset.uploadId) |
| | | .filter(id => id); |
| | | |
| | | console.log('Reordered items:', items); |
| | | |
| | | // Update hidden input (for form submission) |
| | | let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]'); |
| | | if (hiddenInput && items.length > 0) { |
| | | hiddenInput.value = items.join(','); |
| | | } |
| | | |
| | | // ✅ Update fieldState with new order |
| | | const fieldId = this.getFieldIdFromElement(grid); |
| | | if (fieldId) { |
| | | const fieldData = this.getFieldData(fieldId); |
| | | |
| | | // If reordering within a group, update that group's uploads array |
| | | if (grid.classList.contains('group')) { |
| | | const groupId = grid.dataset.groupId; |
| | | const group = fieldData?.groups?.find(g => g.id === groupId); |
| | | if (group) { |
| | | group.uploads = items; // Update order |
| | | } |
| | | } |
| | | // If reordering in preview, the order is implicit by DOM position |
| | | // (we don't store preview order separately) |
| | | |
| | | this.schedulePersistance(fieldId); // ✅ Persist changes |
| | | } |
| | | |
| | | this.a11y.announce('Item reordered'); |
| | | |
| | | fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', { |
| | | detail: { from: evt.from, to: evt.to, oldIndex: evt.oldIndex, newIndex: evt.newIndex }, |
| | | detail: { |
| | | from: evt.from, |
| | | to: evt.to, |
| | | oldIndex: evt.oldIndex, |
| | | newIndex: evt.newIndex, |
| | | items: items |
| | | }, |
| | | bubbles: true |
| | | })); |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * DRAG & DROP INITIALIZATION |
| | | *******************************************************************************/ |
| | | |
| | | initGroupFeatures() { |
| | | this.dragController = new window.jvbDragHandler({ |
| | | draggableSelector: this.selectors.items.item, |
| | | dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`, |
| | | ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a', |
| | | previewElement: 'img, video, .icon', |
| | | getItemId: (element) => element.dataset.uploadId, |
| | | getSelectedItems: (element) => { |
| | | const fieldId = this.getFieldIdFromElement(element); |
| | | const uploadId = element.dataset.uploadId; |
| | | const selected = this.getCurrentSelection(fieldId); |
| | | return (selected && selected.includes(uploadId)) ? selected : [uploadId]; |
| | | }, |
| | | validateDrop: (itemIds, targetElement) => { |
| | | const targetFieldId = this.getFieldIdFromElement(targetElement); |
| | | const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`); |
| | | const itemFieldId = this.getFieldIdFromElement(itemElement); |
| | | return targetFieldId === itemFieldId; |
| | | }, |
| | | onDrop: (itemIds, targetElement) => { |
| | | this.handleItemDrop(itemIds, targetElement); |
| | | targetElement.scrollIntoView({behavior:'smooth', block:'center'}); |
| | | }, |
| | | onDragEnd: (itemIds, success) => { |
| | | if (success) { |
| | | const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`); |
| | | const fieldId = this.getFieldIdFromElement(itemElement); |
| | | const handler = this.selectionHandlers.get(fieldId); |
| | | handler?.clearSelection(); |
| | | } |
| | | }, |
| | | previewOptions: { |
| | | multiOffset: { x: -60, y: -80 }, |
| | | singleOffset: { x: -50, y: -60 }, |
| | | showCount: true |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * FILE DROP HANDLERS |
| | | *******************************************************************************/ |
| | | |
| | |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * ITEM DROP HANDLER (for rearranging) |
| | | *******************************************************************************/ |
| | | |
| | | handleItemDrop(itemIds, targetElement) { |
| | | const isPreviewDrop = targetElement.classList.contains('preview'); |
| | | let actualTarget = targetElement; |
| | | |
| | | // Handle drop on empty group placeholder |
| | | if (targetElement.classList.contains('empty-group')) { |
| | | const fieldId = this.getFieldIdFromElement(targetElement); |
| | | const group = this.createGroup(fieldId); |
| | | if (!group) return; |
| | | actualTarget = group.grid; |
| | | } |
| | | |
| | | // Move each item |
| | | itemIds.forEach(uploadId => { |
| | | if (isPreviewDrop) { |
| | | this.removeFromGroup(uploadId); |
| | | } else { |
| | | this.addToGroup(uploadId, actualTarget); |
| | | } |
| | | }); |
| | | |
| | | const fieldId = this.getFieldIdFromElement(targetElement); |
| | | this.schedulePersistance(fieldId); |
| | | |
| | | const message = itemIds.length > 1 ? `Moved ${itemIds.length} items` : 'Moved item'; |
| | | this.a11y.announce(message); |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * CLICK & CHANGE HANDLERS |
| | | *******************************************************************************/ |
| | | |
| | |
| | | try { |
| | | const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| | | |
| | | // Create upload data (without blob initially) |
| | | // Create initial upload data |
| | | const uploadData = { |
| | | id: uploadId, |
| | | attachmentId: null, |
| | |
| | | } |
| | | }; |
| | | |
| | | // Save initial data |
| | | await this.uploadStore.save(uploadData); |
| | | |
| | | // Process file |
| | | const preview = this.createPreviewUrl(file); |
| | | const processedFile = file.type.startsWith('image/') |
| | | ? await this.processImage(file, fieldData.config.subtype) |
| | | : file; |
| | | |
| | | // Store blob data as ArrayBuffer |
| | | // Show progress |
| | | this.showUploadProgress(uploadId, true); |
| | | this.updateUploadItemProgress(uploadId, 50, 'local_processing'); |
| | | |
| | | // Store blob data (this updates the existing uploadData) |
| | | await this.saveBlobData(uploadId, processedFile || file); |
| | | |
| | | // Create DOM element |
| | |
| | | subtype: subtype |
| | | }, fieldData.config.destination === 'post_group'); |
| | | |
| | | // Show progress |
| | | this.showUploadProgress(uploadId, true); |
| | | this.updateUploadItemProgress(uploadId, 50, 'local_processing'); |
| | | |
| | | // Add to preview grid |
| | | if (fieldEl.ui.preview) { |
| | | fieldEl.ui.preview.appendChild(element); |
| | |
| | | }); |
| | | } |
| | | |
| | | // Store persistent data |
| | | uploadData.status = 'processed'; |
| | | await this.uploadStore.save(uploadData); |
| | | // Update status (gets existing data with blobData intact) |
| | | const storedUpload = this.uploadStore.get(uploadId); |
| | | if (storedUpload) { |
| | | storedUpload.status = 'processed'; |
| | | await this.uploadStore.save(storedUpload); |
| | | } |
| | | |
| | | // Add to field |
| | | fieldData.uploads.add(uploadId); |
| | |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * IMAGE PROCESSING (simplified - keeping existing logic) |
| | | * IMAGE PROCESSING |
| | | *******************************************************************************/ |
| | | |
| | | async processImage(file, uploadId) { |
| | |
| | | * Handle operation cancellation |
| | | */ |
| | | async handleOperationCancelled(fieldId) { |
| | | const fieldData = this.getFieldData(fieldId); // ✅ |
| | | const fieldData = this.getFieldData(fieldId); |
| | | if (!fieldData) return; |
| | | |
| | | const uploadsArray = fieldData.uploads instanceof Set |
| | |
| | | } |
| | | |
| | | for (const groupData of groups) { |
| | | // Create group using existing method which handles all initialization |
| | | const group = this.createGroup(fieldId, groupData.id); |
| | | |
| | | if (!group) { |
| | | console.warn('Failed to create group:', groupData.id); |
| | | continue; |
| | | } |
| | | |
| | | // Find the group in fieldData (createGroup already added it) |
| | | const storedGroup = fieldData.groups?.find(g => g.id === groupData.id); |
| | | |
| | | if (storedGroup) { |
| | | // Restore metadata |
| | | if (groupData.changes) { |
| | | storedGroup.changes = { ...groupData.changes }; |
| | | } |
| | | |
| | | // ✅ Preserve upload order |
| | | if (groupData.uploads) { |
| | | storedGroup.uploads = [...groupData.uploads]; |
| | | } |
| | | |
| | | // Restore form field values if they exist |
| | | // Restore form field values |
| | | if (groupData.changes) { |
| | | const titleInput = group.element.querySelector('[name*="post_title"]'); |
| | | const excerptInput = group.element.querySelector('[name*="post_excerpt"]'); |
| | |
| | | if (titleInput && groupData.changes.post_title) { |
| | | titleInput.value = groupData.changes.post_title; |
| | | } |
| | | |
| | | if (excerptInput && groupData.changes.post_excerpt) { |
| | | excerptInput.value = groupData.changes.post_excerpt; |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | // Save updated field data |
| | | await this.saveFieldData(fieldData); |
| | | } |
| | | |
| | |
| | | const fieldData = this.getFieldData(fieldId); |
| | | if (!fieldEl?.ui?.dropZone || !fieldData) return; |
| | | |
| | | if (fieldData.config.destination === 'post_group') return; |
| | | |
| | | const uploadCount = fieldData.uploads?.size || 0; |
| | | const maxFiles = fieldData.config?.maxFiles || 999; |
| | | |
| | | // For groupable uploads, set max to 20 |
| | | const maxFiles = fieldData.config.destination === 'post_group' |
| | | ? 20 |
| | | : (fieldData.config?.maxFiles || 999); |
| | | |
| | | fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles; |
| | | fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles); |
| | | |
| | | // Show helpful message for groupable uploads |
| | | if (fieldData.config.destination === 'post_group' && uploadCount >= maxFiles) { |
| | | this.a11y.announce('Maximum of 20 uploads reached. Please submit current uploads before adding more.'); |
| | | } |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * GROUP MANAGEMENT |
| | | *******************************************************************************/ |
| | | /** |
| | | * Create sortable instance for a grid |
| | | */ |
| | | createSortableForGrid(grid, fieldId, groupId = null) { |
| | | if (!grid || grid.sortableInstance) return; |
| | | |
| | | const sortableInstance = new Sortable(grid, { |
| | | animation: 150, |
| | | draggable: '.item', |
| | | multiDrag: true, |
| | | selectedClass: 'selected-for-drag', |
| | | avoidImplicitDeselect: true, |
| | | group: { name: fieldId, pull: true, put: true }, |
| | | ghostClass: 'sortable-ghost', |
| | | chosenClass: 'sortable-chosen', |
| | | dragClass: 'sortable-drag', |
| | | |
| | | // Centralized drop handler |
| | | onEnd: (evt) => this.handleDrop(evt, fieldId), |
| | | |
| | | // Selection sync |
| | | onSelect: (evt) => { |
| | | const checkbox = evt.item.querySelector('[name*="select-item"]'); |
| | | if (checkbox && !checkbox.checked) { |
| | | checkbox.checked = true; |
| | | checkbox.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | }, |
| | | |
| | | onDeselect: (evt) => { |
| | | const checkbox = evt.item.querySelector('[name*="select-item"]'); |
| | | if (checkbox && checkbox.checked) { |
| | | checkbox.checked = false; |
| | | checkbox.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | }, |
| | | |
| | | onAdd: (evt) => this.updateSortableState(evt.to), |
| | | onRemove: (evt) => this.updateSortableState(evt.from) |
| | | }); |
| | | |
| | | grid.sortableInstance = sortableInstance; |
| | | |
| | | const gridId = groupId |
| | | ? `${fieldId}-group-${groupId}` |
| | | : `${fieldId}-preview`; |
| | | |
| | | this.sortableInstances.set(gridId, sortableInstance); |
| | | |
| | | return sortableInstance; |
| | | } |
| | | createGroup(fieldId, groupId = null) { |
| | | const fieldData = this.getFieldData(fieldId); |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | |
| | | |
| | | // Add to field groups |
| | | if (!fieldData.groups) fieldData.groups = []; |
| | | fieldData.groups.push({ |
| | | id: groupId, |
| | | uploads: [], |
| | | changes: {} |
| | | }); |
| | | this.saveFieldData(fieldData); |
| | | const existingGroup = fieldData.groups.find(g => g.id === groupId); |
| | | if (!existingGroup) { |
| | | fieldData.groups.push({ |
| | | id: groupId, |
| | | uploads: [], |
| | | changes: {} |
| | | }); |
| | | this.saveFieldData(fieldData); |
| | | } |
| | | |
| | | // Initialize selection handler and sortable |
| | | // Initialize selection handler |
| | | this.addGroupSelectionHandler(fieldId, groupId); |
| | | |
| | | // Initialize sortable for this new group grid |
| | | if (grid) { |
| | | const sortableInstance = new Sortable(grid, { |
| | | ...this.sortableConfig, |
| | | group: { name: fieldId, pull: true, put: true }, |
| | | disabled: true, // Empty initially |
| | | onAdd: (evt) => this.updateSortableState(evt.to), |
| | | onRemove: (evt) => this.updateSortableState(evt.from) |
| | | }); |
| | | grid.sortableInstance = sortableInstance; |
| | | this.sortableInstances.set(`${fieldId}-group-${groupId}`, sortableInstance); |
| | | this.createSortableForGrid(grid, fieldId, groupId); |
| | | } |
| | | |
| | | return { id: groupId, element: groupElement, grid: grid }; |
| | | } |
| | | |
| | | createGroupElement(groupId, fieldId) { |
| | | let groupElement = window.getTemplate('imageGroup'); |
| | | if (!groupElement) return; |
| | |
| | | case 'restore': |
| | | this.handleRestoreUploads().then(() => {}); |
| | | break; |
| | | case 'restore-all': |
| | | this.handleRestoreAll().then(() => {}); |
| | | break; |
| | | case 'clear-cache': |
| | | if (!confirm('Save these uploads for later?')) { |
| | | this.cleanupStoredUploads(); |
| | |
| | | handler.subscribe((event, data) => { |
| | | switch(event) { |
| | | case 'item-selected': |
| | | // Sync with Sortable |
| | | this.syncSortableSelection(fieldId, data.selectedItems); |
| | | this.selected.set(fieldId, data.selectedItems); |
| | | break; |
| | | case 'item-deselected': |
| | | this.syncSortableSelection(fieldId, data.selectedItems); |
| | | this.selected.set(fieldId, data.selectedItems); |
| | | break; |
| | | case 'range-selected': |
| | | this.syncSortableSelection(fieldId, data.selectedItems); |
| | | this.selected.set(fieldId, data.selectedItems); |
| | | break; |
| | | case 'select-all': |
| | |
| | | * Save field data to store, converting Sets to Arrays |
| | | */ |
| | | async saveFieldData(fieldData) { |
| | | console.log('💾 Saving:', fieldData.id, { |
| | | uploads: fieldData.uploads?.size, |
| | | groups: fieldData.groups?.length |
| | | }); |
| | | |
| | | await this.fieldStore.save({ |
| | | ...fieldData, |
| | | timestamp: Date.now() |
| | |
| | | window.debouncer.schedule( |
| | | key, |
| | | () => this.persistFieldState(fieldId), |
| | | 1000 |
| | | 250 |
| | | ); |
| | | } |
| | | |
| | |
| | | handleFieldStoreEvent(event, data) { |
| | | switch(event) { |
| | | case 'data-loaded': |
| | | // Check for pending uploads to restore |
| | | this.checkForStoredUploads(); |
| | | this.fieldStoreReady = true; |
| | | this.checkIfBothStoresReady(); |
| | | break; |
| | | } |
| | | } |
| | |
| | | handleUploadStoreEvent(event, data) { |
| | | switch(event) { |
| | | case 'data-loaded': |
| | | this.uploadStoreReady = true; |
| | | this.checkIfBothStoresReady(); |
| | | break; |
| | | case 'item-saved': |
| | | this.showSaveIndicator(data.key); |
| | |
| | | } |
| | | } |
| | | |
| | | checkIfBothStoresReady() { |
| | | if (this.fieldStoreReady && this.uploadStoreReady && !this.hasCheckedForUploads) { |
| | | this.hasCheckedForUploads = true; |
| | | this.checkForStoredUploads(); |
| | | } |
| | | } |
| | | |
| | | async checkForStoredUploads() { |
| | | const allFieldStates = this.fieldStore.getAll(); |
| | | |
| | |
| | | fieldStates: allFieldStates.length, |
| | | uploadStoreSize: this.uploadStore.data.size |
| | | }); |
| | | console.log(this.uploadStore.getAll()); |
| | | console.log(this.fieldStore.getAll()); |
| | | const pendingFields = allFieldStates.filter(field => { |
| | | if (!field.uploads) return false; |
| | | |
| | |
| | | const itemGrid = fieldTemplate.querySelector('.item-grid.restore'); |
| | | |
| | | // Process each upload |
| | | for (const upload of field.uploads) { |
| | | |
| | | for (let uploadId of field.uploads) { |
| | | const upload = this.uploadStore.get(uploadId); |
| | | let uploadItem = window.getTemplate('uploadItem'); |
| | | if (!uploadItem) continue; |
| | | // |
| | |
| | | this.cleanupRestore(); |
| | | } |
| | | |
| | | async handleRestoreAll() { |
| | | let notification = document.querySelector('dialog.restore-uploads'); |
| | | if (!notification) { |
| | | return; |
| | | } |
| | | // Gets ALL uploads from notification without checking selection |
| | | const allUploads = []; |
| | | notification.querySelectorAll('.item.upload').forEach(item => { |
| | | let uploadId = item.dataset.uploadId; |
| | | let fieldId = item.dataset.fieldId; |
| | | allUploads.push({ uploadId, fieldId }); |
| | | }); |
| | | |
| | | await this.restoreSelectedUploads(allUploads); |
| | | this.cleanupRestore(); |
| | | } |
| | | |
| | | showSaveIndicator(key) { |
| | | // Optional: show user that state is being saved |
| | | } |