// — single-image upload with preview, unifying the // byte-near-identical static/wizard-item-basics.js and // wizard-project-appearance.js. Entity-agnostic via data attributes: // // data-presign-url POST endpoint returning { upload_url, s3_key, cache_control } // data-confirm-url POST endpoint returning { image_url } // data-id-field body field naming the entity, e.g. "item_id" / "project_id" // data-id-value the entity id // data-alt alt text for the rendered image (optional) // data-preview-target id of an element to also fill with the image (optional) // // A controller element (carries only the data-* config): it wires the // page's dropzone markup by id, so there's one uploader per wizard step. import { MnwElement, define } from '../base.ts'; import { S3Uploader, initDropzone, presignPutConfirm } from './s3.ts'; class ImageUploader extends MnwElement { protected init(): void { const { presignUrl, confirmUrl, idField, idValue } = this.dataset; if (!presignUrl || !confirmUrl || !idField || idValue === undefined) return; const alt = this.dataset.alt || 'Image'; const previewTargetId = this.dataset.previewTarget; const pick = (id: string): T | null => document.getElementById(id) as T | null; const dropzone = pick('image-dropzone'); const fileInput = pick('image-file-input'); if (!dropzone || !fileInput) return; const hiddenUrl = pick('cover-image-url'); const progressEl = pick('image-upload-progress'); const errorEl = pick('image-upload-error'); const placeholder = pick('image-placeholder'); const chooseBtn = pick('choose-image-btn'); let currentImg = pick('image-current'); const uploader = new S3Uploader({ filenameEl: pick('image-upload-filename'), percentEl: pick('image-upload-percent'), progressBar: pick('image-progress-bar'), }); const showError = (message: string): void => { errorEl?.classList.remove('hidden'); const m = pick('image-error-message'); if (m) m.textContent = message; }; const renderImg = (src: string, altText: string): HTMLImageElement => { const img = document.createElement('img'); img.src = src; img.alt = altText; img.className = 'wizard-cover-img'; return img; }; const showUploadedImage = (url: string): void => { placeholder?.classList.add('hidden'); if (currentImg) { const img = currentImg.querySelector('img'); if (img) img.src = url; currentImg.classList.remove('hidden'); } else { const div = document.createElement('div'); div.className = 'project-image-current'; div.id = 'image-current'; div.appendChild(renderImg(url, alt)); dropzone.insertBefore(div, fileInput); currentImg = div; } if (previewTargetId) { const preview = document.getElementById(previewTargetId); if (preview) { preview.textContent = ''; preview.appendChild(renderImg(url, 'Preview')); } } }; const upload = async (file: File): Promise => { errorEl?.classList.add('hidden'); progressEl?.classList.remove('hidden'); if (chooseBtn) chooseBtn.disabled = true; try { const result = await presignPutConfirm<{ image_url: string }>({ presignUrl, confirmUrl, presignBody: { [idField]: idValue, file_name: file.name, content_type: file.type || 'image/jpeg' }, confirmBody: (s3Key) => ({ [idField]: idValue, s3_key: s3Key }), file, uploader, fallbackContentType: 'image/jpeg', }); if (hiddenUrl) hiddenUrl.value = result.image_url; showUploadedImage(result.image_url); } catch (err) { showError(err instanceof Error ? err.message : 'Upload failed. Please try again.'); } finally { progressEl?.classList.add('hidden'); if (chooseBtn) chooseBtn.disabled = false; } }; initDropzone(dropzone, fileInput, (file) => void upload(file)); } } define('mnw-image-uploader', ImageUploader);