Files
breadcrumb-the-shire/web/js/components/app-file-upload.js
fs e814b5fd11 refactor(file-upload): SSR-first state, click-to-replace, cleaner preview
- 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>
2026-04-24 20:53:58 +02:00

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;
});
}