- Server renders initial file-upload state (src, alt, label text, has-current class) so there is no flash of broken-image icon while JS boots. JS no longer duplicates that work — it only reacts to user input (select, clear, drag-and-drop) and uses data-current-src solely to restore the server file after a blob preview. - Clicking the current preview image now opens the file dialog, mirrors the Replace button, with hover affordance (cursor + primary border). - Drop the transparency checker pattern in the preview — the admin UI does not need Figma-style transparency semantics. Preview is now a flat theme-aware surface (--app-preview-bg) that keeps white or black logos visible against a neutral gray. - Move tenant favicon out of the aside into the Master-data tab as its own details block next to the Tenant-logos section. Uses the same barrier-form pattern (HTML5 form attribute + app-file-upload.phtml) for a consistent instant-upload UX. - Tenant-logo slot label is a native <label> element — picks up the global form-label typography and spacing, no custom muted-color class needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
8.1 KiB
JavaScript
267 lines
8.1 KiB
JavaScript
/**
|
|
* File upload — card-based dropzone with drag-and-drop, current file preview, and pending file preview.
|
|
*
|
|
* Markup contract:
|
|
* <div data-app-component="file-upload" class="app-file-upload"
|
|
* data-current-src="…" data-current-label="…"> <!-- optional: existing server file -->
|
|
* <div class="app-file-upload-current"> <!-- shown when has existing file -->
|
|
* <img class="app-file-upload-current-image" src="" alt="">
|
|
* <div class="app-file-upload-current-meta">
|
|
* <span class="app-file-upload-current-label"></span>
|
|
* <span class="app-file-upload-current-actions">
|
|
* <button type="button" class="app-file-upload-replace-button">Replace</button>
|
|
* <button type="submit" class="app-file-upload-delete-button"
|
|
* formaction="…" formmethod="post"
|
|
* data-confirm-message="…">Delete</button>
|
|
* </span>
|
|
* </div>
|
|
* </div>
|
|
* <label class="app-file-upload-dropzone"> <!-- shown when no file -->
|
|
* <input type="file" name="…" accept="…">
|
|
* <span class="app-file-upload-dropzone-icon">…</span>
|
|
* <span class="app-file-upload-dropzone-label">…</span>
|
|
* <span class="app-file-upload-dropzone-hint">…</span>
|
|
* </label>
|
|
* <div class="app-file-upload-preview"> <!-- shown when new file selected -->
|
|
* <img class="app-file-upload-thumbnail" hidden>
|
|
* <span class="app-file-upload-file-icon" hidden>…</span>
|
|
* <span class="app-file-upload-meta">
|
|
* <span class="app-file-upload-filename"></span>
|
|
* <span class="app-file-upload-filesize"></span>
|
|
* </span>
|
|
* <button type="button" class="app-file-upload-clear">…</button>
|
|
* </div>
|
|
* </div>
|
|
*/
|
|
|
|
const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
|
|
const isImageFile = (file) => file.type.startsWith('image/');
|
|
|
|
const formatFileSize = (bytes) => {
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
if (bytes < 1024 * 1024) {
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
export function initFileUpload(root) {
|
|
if (!(root instanceof HTMLElement)) {
|
|
return { destroy: () => {} };
|
|
}
|
|
|
|
if (root.dataset.fileUploadBound === '1') {
|
|
return { destroy: () => {} };
|
|
}
|
|
root.dataset.fileUploadBound = '1';
|
|
|
|
const input = root.querySelector('input[type="file"]');
|
|
if (!input) {
|
|
return { destroy: () => {} };
|
|
}
|
|
|
|
const thumbnail = root.querySelector('.app-file-upload-thumbnail');
|
|
const fileIcon = root.querySelector('.app-file-upload-file-icon');
|
|
const filenameEl = root.querySelector('.app-file-upload-filename');
|
|
const filesizeEl = root.querySelector('.app-file-upload-filesize');
|
|
const clearButton = root.querySelector('.app-file-upload-clear');
|
|
const replaceButton = root.querySelector('.app-file-upload-replace-button');
|
|
const currentImage = root.querySelector('.app-file-upload-current-image');
|
|
|
|
const cleanupFns = [];
|
|
let dragCounter = 0;
|
|
|
|
// Server rendered the initial state (img src + label + has-current class).
|
|
// `data-current-src` is kept so clearFile() can restore the server file
|
|
// after a user previewed a different selection.
|
|
const currentSrc = (root.dataset.currentSrc || '').trim();
|
|
|
|
// ── Show pending file (new selection) ──
|
|
const showFile = (file) => {
|
|
root.classList.add('has-file');
|
|
root.classList.remove('has-current');
|
|
|
|
if (filenameEl) {
|
|
filenameEl.textContent = file.name;
|
|
}
|
|
if (filesizeEl) {
|
|
filesizeEl.textContent = formatFileSize(file.size);
|
|
}
|
|
|
|
if (thumbnail) {
|
|
thumbnail.hidden = true;
|
|
}
|
|
if (fileIcon) {
|
|
fileIcon.hidden = true;
|
|
}
|
|
|
|
if (isImageFile(file) && file.size <= MAX_PREVIEW_SIZE && thumbnail) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
thumbnail.src = /** @type {string} */ (e.target?.result ?? '');
|
|
thumbnail.hidden = false;
|
|
if (fileIcon) {
|
|
fileIcon.hidden = true;
|
|
}
|
|
};
|
|
reader.onerror = () => {
|
|
if (fileIcon) {
|
|
fileIcon.hidden = false;
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
} else if (fileIcon) {
|
|
fileIcon.hidden = false;
|
|
}
|
|
};
|
|
|
|
// ── Clear pending file → go back to current (if exists) or dropzone ──
|
|
const clearFile = () => {
|
|
root.classList.remove('has-file');
|
|
input.value = '';
|
|
if (thumbnail) {
|
|
thumbnail.src = '';
|
|
thumbnail.hidden = true;
|
|
}
|
|
if (fileIcon) {
|
|
fileIcon.hidden = true;
|
|
}
|
|
if (filenameEl) {
|
|
filenameEl.textContent = '';
|
|
}
|
|
if (filesizeEl) {
|
|
filesizeEl.textContent = '';
|
|
}
|
|
// Restore current state if a server file exists
|
|
if (currentSrc) {
|
|
root.classList.add('has-current');
|
|
}
|
|
};
|
|
|
|
// ── Input change ──
|
|
const onInputChange = () => {
|
|
const file = input.files?.[0];
|
|
if (file) {
|
|
showFile(file);
|
|
} else {
|
|
clearFile();
|
|
}
|
|
};
|
|
input.addEventListener('change', onInputChange);
|
|
cleanupFns.push(() => input.removeEventListener('change', onInputChange));
|
|
|
|
// ── Clear button ──
|
|
if (clearButton) {
|
|
const onClear = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
clearFile();
|
|
};
|
|
clearButton.addEventListener('click', onClear);
|
|
cleanupFns.push(() => clearButton.removeEventListener('click', onClear));
|
|
}
|
|
|
|
// ── Replace button + click on current preview → open file dialog ──
|
|
const openPicker = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
input.click();
|
|
};
|
|
if (replaceButton) {
|
|
replaceButton.addEventListener('click', openPicker);
|
|
cleanupFns.push(() => replaceButton.removeEventListener('click', openPicker));
|
|
}
|
|
if (currentImage) {
|
|
currentImage.addEventListener('click', openPicker);
|
|
cleanupFns.push(() => currentImage.removeEventListener('click', openPicker));
|
|
}
|
|
|
|
// ── Drag and drop ──
|
|
const onDragEnter = (e) => {
|
|
e.preventDefault();
|
|
dragCounter++;
|
|
root.classList.add('is-dragover');
|
|
};
|
|
|
|
const onDragOver = (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
};
|
|
|
|
const onDragLeave = (e) => {
|
|
e.preventDefault();
|
|
dragCounter--;
|
|
if (dragCounter <= 0) {
|
|
dragCounter = 0;
|
|
root.classList.remove('is-dragover');
|
|
}
|
|
};
|
|
|
|
const onDrop = (e) => {
|
|
e.preventDefault();
|
|
dragCounter = 0;
|
|
root.classList.remove('is-dragover');
|
|
|
|
const file = e.dataTransfer?.files?.[0];
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
// Validate against accept attribute
|
|
const accept = input.accept;
|
|
if (accept && !fileMatchesAccept(file, accept)) {
|
|
root.classList.add('is-rejected');
|
|
setTimeout(() => root.classList.remove('is-rejected'), 600);
|
|
return;
|
|
}
|
|
|
|
// Transfer file to the native input via DataTransfer
|
|
const dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
input.files = dt.files;
|
|
showFile(file);
|
|
};
|
|
|
|
root.addEventListener('dragenter', onDragEnter);
|
|
root.addEventListener('dragover', onDragOver);
|
|
root.addEventListener('dragleave', onDragLeave);
|
|
root.addEventListener('drop', onDrop);
|
|
cleanupFns.push(() => {
|
|
root.removeEventListener('dragenter', onDragEnter);
|
|
root.removeEventListener('dragover', onDragOver);
|
|
root.removeEventListener('dragleave', onDragLeave);
|
|
root.removeEventListener('drop', onDrop);
|
|
});
|
|
|
|
const destroy = () => {
|
|
cleanupFns.forEach((fn) => fn());
|
|
cleanupFns.length = 0;
|
|
delete root.dataset.fileUploadBound;
|
|
};
|
|
|
|
return { destroy };
|
|
}
|
|
|
|
/**
|
|
* Check if a File matches an accept attribute value.
|
|
* Supports MIME types (image/*), extensions (.csv), and exact types (image/png).
|
|
*/
|
|
function fileMatchesAccept(file, accept) {
|
|
const tokens = accept.split(',').map((t) => t.trim().toLowerCase());
|
|
const fileName = file.name.toLowerCase();
|
|
const fileType = file.type.toLowerCase();
|
|
|
|
return tokens.some((token) => {
|
|
if (token.startsWith('.')) {
|
|
return fileName.endsWith(token);
|
|
}
|
|
if (token.endsWith('/*')) {
|
|
return fileType.startsWith(token.slice(0, -1));
|
|
}
|
|
return fileType === token;
|
|
});
|
|
}
|