feat: add reusable app-file-upload component with drag-and-drop and preview
Replace bare <input type="file"> across all upload locations with a card-based dropzone component via shared partial. Three visual states: current server file (thumbnail + Replace/Delete), empty dropzone, and pending file preview (local FileReader thumbnail + metadata). Delete actions use data-confirm-message for confirmation dialog. Centralized as templates/partials/app-file-upload.phtml to prevent markup drift — documented as deliberate exception to plain-HTML inputs convention in CLAUDE.md and developer checklist. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
245
web/css/components/app-file-upload.css
Normal file
245
web/css/components/app-file-upload.css
Normal file
@@ -0,0 +1,245 @@
|
||||
@layer components {
|
||||
/* File upload — card-based dropzone with drag-and-drop, current file preview, and pending file preview. */
|
||||
.app-file-upload {
|
||||
position: relative;
|
||||
border: 1px solid var(--app-muted-border-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
background: var(--app-card-background-color);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
margin-bottom: var(--app-spacing);
|
||||
}
|
||||
|
||||
.app-file-upload.is-dragover {
|
||||
border-color: var(--app-primary);
|
||||
background: color-mix(in srgb, var(--app-primary) 5%, var(--app-card-background-color));
|
||||
}
|
||||
|
||||
/* ── Current file (server-side, shown when data-current-src is set) ── */
|
||||
.app-file-upload-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
padding: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.app-file-upload-current-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: calc(var(--app-border-radius) * 0.5);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--app-muted-border-color);
|
||||
background: var(--app-background-color);
|
||||
}
|
||||
|
||||
.app-file-upload-current-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.app-file-upload-current-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--app-contrast);
|
||||
}
|
||||
|
||||
.app-file-upload-current-actions {
|
||||
display: flex;
|
||||
gap: calc(var(--app-spacing) * 0.25);
|
||||
}
|
||||
|
||||
.app-file-upload-current-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: calc(var(--app-spacing) * 0.15) calc(var(--app-spacing) * 0.35);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--app-border-radius);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
.app-file-upload-replace-button {
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.app-file-upload-replace-button:hover {
|
||||
background: color-mix(in srgb, var(--app-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.app-file-upload-delete-button {
|
||||
color: var(--app-del-color);
|
||||
}
|
||||
|
||||
.app-file-upload-delete-button:hover {
|
||||
background: color-mix(in srgb, var(--app-del-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.app-file-upload.has-current .app-file-upload-dropzone {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-file-upload:not(.has-current) .app-file-upload-current {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Dropzone (visible when no current file and no pending file) ── */
|
||||
.app-file-upload-dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
padding: calc(var(--app-spacing) * 1.5) var(--app-spacing);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
color: var(--app-muted-color);
|
||||
border: 2px dashed var(--app-muted-border-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
margin: calc(var(--app-spacing) * 0.5);
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.app-file-upload.is-dragover .app-file-upload-dropzone {
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.app-file-upload-dropzone:hover {
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.app-file-upload-dropzone-icon {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-none);
|
||||
}
|
||||
|
||||
.app-file-upload-dropzone-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.app-file-upload-dropzone-hint {
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-file-upload.has-file .app-file-upload-dropzone,
|
||||
.app-file-upload.has-file .app-file-upload-current {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-file-upload.is-rejected {
|
||||
animation: file-upload-shake 0.4s ease;
|
||||
}
|
||||
|
||||
.app-file-upload.is-rejected .app-file-upload-dropzone {
|
||||
border-color: var(--app-del-color);
|
||||
color: var(--app-del-color);
|
||||
}
|
||||
|
||||
@keyframes file-upload-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-2px); }
|
||||
80% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
/* ── Pending file preview (visible when new file selected) ── */
|
||||
.app-file-upload-preview {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
padding: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.app-file-upload.has-file .app-file-upload-preview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-file-upload-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: calc(var(--app-border-radius) * 0.5);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--app-muted-border-color);
|
||||
background: var(--app-background-color);
|
||||
}
|
||||
|
||||
.app-file-upload-file-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: calc(var(--app-border-radius) * 0.5);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--app-muted-color);
|
||||
background: var(--app-background-color);
|
||||
border: 1px solid var(--app-muted-border-color);
|
||||
}
|
||||
|
||||
.app-file-upload-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.app-file-upload-filename {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--app-contrast);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-file-upload-filesize {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.app-file-upload-clear {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: calc(var(--app-spacing) * 0.25);
|
||||
cursor: pointer;
|
||||
color: var(--app-muted-color);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-none);
|
||||
border-radius: var(--app-border-radius);
|
||||
transition: color 0.15s ease;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.app-file-upload-clear:hover {
|
||||
color: var(--app-del-color);
|
||||
}
|
||||
|
||||
/* ── Hidden native input ── */
|
||||
.app-file-upload input[type="file"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { initAutoSubmit } from './components/app-auto-submit.js';
|
||||
import { initTabs } from './components/app-tabs.js';
|
||||
import { initSessionWarning } from './components/app-session-warning.js';
|
||||
import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js';
|
||||
import { initFileUpload } from './components/app-file-upload.js';
|
||||
|
||||
const runtime = createComponentRuntime({
|
||||
root: document,
|
||||
@@ -188,6 +189,10 @@ const bootRuntime = async () => {
|
||||
scope: 'global',
|
||||
configPath: 'components.autoSubmit',
|
||||
});
|
||||
runtime.register('file-upload', initFileUpload, {
|
||||
selector: '[data-app-component="file-upload"]',
|
||||
configPath: 'components.fileUpload',
|
||||
});
|
||||
await mountModuleComponentsByPhase('early');
|
||||
|
||||
// Phase B: structure-defining components.
|
||||
|
||||
273
web/js/components/app-file-upload.js
Normal file
273
web/js/components/app-file-upload.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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 currentImage = root.querySelector('.app-file-upload-current-image');
|
||||
const currentLabel = root.querySelector('.app-file-upload-current-label');
|
||||
const replaceButton = root.querySelector('.app-file-upload-replace-button');
|
||||
|
||||
const cleanupFns = [];
|
||||
let dragCounter = 0;
|
||||
|
||||
// ── Initialize current file state from data attributes ──
|
||||
const currentSrc = (root.dataset.currentSrc || '').trim();
|
||||
const currentLabelText = (root.dataset.currentLabel || '').trim();
|
||||
|
||||
if (currentSrc) {
|
||||
root.classList.add('has-current');
|
||||
if (currentImage) {
|
||||
currentImage.src = currentSrc;
|
||||
currentImage.alt = currentLabelText;
|
||||
}
|
||||
if (currentLabel) {
|
||||
currentLabel.textContent = currentLabelText;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 → open file dialog ──
|
||||
if (replaceButton) {
|
||||
const onReplace = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.click();
|
||||
};
|
||||
replaceButton.addEventListener('click', onReplace);
|
||||
cleanupFns.push(() => replaceButton.removeEventListener('click', onReplace));
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user