More Forms Snippets
Media Upload Grid — Free HTML CSS JS Snippet
Media Upload Grid · Forms · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Media Upload Grid — Drag and Drop, Thumbnail Previews, and Per-File Status

A media upload grid lets users select multiple image files at once and see thumbnail previews before upload — the standard pattern for photo galleries, product image uploads, social media posts, and profile media. This snippet provides a drag-and-drop zone, click-to-browse via a hidden file input, a responsive thumbnail grid using URL.createObjectURL, per-file remove buttons, a file count footer with upload and clear buttons, and a simulated per-file upload status (uploading/done) demonstration.
The drag-and-drop zone
onDragOver(e) calls e.preventDefault() — this is required to allow the drop event to fire (browsers block drop by default). It also adds the .active class for the visual hover state. onDragLeave removes .active. onDrop(e) calls e.preventDefault() to prevent browser default file-open behavior, removes .active, and calls addFiles() with the dropped FileList items.
URL.createObjectURL for instant previews
URL.createObjectURL(file) generates a temporary blob:// URL from a local File object. This URL can be used directly as an img src — no upload is needed for the preview. The object URL is valid for the lifetime of the page. For cleanup, call URL.revokeObjectURL(url) when the card is removed to free browser memory.
File validation
addFiles() filters for image/* MIME type and enforces MAX_MB (5MB) per file and MAX_FILES (12) total. Files that fail the size check show an alert. The remaining count limit prevents adding more than MAX_FILES thumbnails.
The remove pattern
removeFile(idx) sets files[idx] = null (preserving array indices for other cards) and animates the card out with opacity and scale transitions before removing it from the DOM. A null-check in updateFooter() uses files.filter(Boolean).length for the accurate remaining count.
The simulated upload status
fakeUpload() demonstrates the per-card status chip pattern: uploading (blue) → done (green). In production, replace the setTimeout logic with real fetch() or XMLHttpRequest calls that update each card's chip based on the upload response.
Step by step
How to Use
- 1Click the drop zone or drag images onto itClick the dashed drop zone to open the file browser. Select one or more image files (JPG, PNG, GIF, WebP). Or drag image files directly from your desktop onto the zone — the border turns indigo while dragging.
- 2Preview and remove imagesSelected images appear as square thumbnails in a responsive grid. Hover any thumbnail to reveal the filename and an × remove button. Click × to remove that image from the selection.
- 3Clear all or uploadThe footer shows the file count. Click Clear All to remove all selections. Click Upload Files to see the simulated per-card upload status (replace with a real API call in production).
- 4Change limitsEdit var MAX_FILES = 12 and var MAX_MB = 5 at the top of the JS to change the maximum file count and size limit.
- 5Wire to a real upload endpointReplace fakeUpload() with a FormData POST: for each non-null file, create a FormData, append the file, and fetch("/api/upload", { method: "POST", body: formData }). Update the card chip with the response status.
- 6Export for your frameworkClick "JSX" for a React component using useState for files array and useRef for the input. Click "Vue" for a Vue 3 SFC with reactive file list.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Three events: ondragover must call e.preventDefault() to allow dropping (browsers block drop by default). ondragleave resets visual state. ondrop calls e.preventDefault() to stop the browser from navigating to the file, then reads e.dataTransfer.files — a FileList of dropped files. Convert to Array with Array.from() to use .filter() and .forEach(). To accept drops from external sources like a browser tab showing an image, also check e.dataTransfer.items[0].kind === "file" before reading the file. For improved UX, add a dragenter listener on the entire document to show a full-page drop overlay when the user drags a file anywhere over the browser window, not just the upload zone. Remove the overlay on dragleave when e.relatedTarget is null (the drag left the window entirely) or on drop.
Create a FormData object and append each file: const fd = new FormData(); files.filter(Boolean).forEach((f, i) => fd.append("file" + i, f)); Then POST: const res = await fetch("/api/upload", { method: "POST", body: fd }). No Content-Type header needed — the browser sets the correct multipart/form-data boundary automatically.
Use XMLHttpRequest instead of fetch for progress events: const xhr = new XMLHttpRequest(); xhr.upload.onprogress = e => { const pct = Math.round(e.loaded / e.total * 100); updateCardProgress(idx, pct); }; xhr.open("POST", "/api/upload"); xhr.send(formData). Track progress per card by storing the XHR in a map keyed by file index. Show a thin progress bar overlay at the bottom of each thumbnail card — set its width to pct + "%" on each onprogress event. On xhr.onload, switch the overlay to the green "done" chip. On xhr.onerror, show a red "failed" chip with a retry button that re-runs the same XHR with the original file. This pattern handles concurrent multi-file uploads because each card has its own independent XHR object.
In React, store files in const [files, setFiles] = useState([]). Use a ref on the hidden input: const inputRef = useRef(). Handle drops with onDrop on the dropzone div (add onDragOver with e.preventDefault()). Generate previews with URL.createObjectURL(file) inside useMemo or directly on add. In Vue 3, use a ref([]) for the files array and define handleDrop, handlePick, and removeFile as functions. The template uses @drop.prevent and @dragover.prevent on the dropzone div.
Media Upload Grid — Export as HTML, React, Vue, Angular & Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Media Upload Grid</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; }
.wrap { width: 100%; max-width: 640px; }
.uploader { background: #fff; border-radius: 20px; box-shadow: 0 4px 24px rgba(0,0,0,0.07); padding: 28px; }
.heading { font-size: 18px; font-weight: 800; color: #0f172a; margin-bottom: 4px; }
.sub { font-size: 13px; color: #64748b; margin-bottom: 20px; }
.dropzone { border: 2px dashed #e2e8f0; border-radius: 16px; padding: 36px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fafafa; display: flex; flex-direction: column; align-items: center; }
.dropzone:hover, .dropzone.active { border-color: #6366f1; background: rgba(99,102,241,0.03); }
.dz-text { font-size: 14px; color: #475569; margin: 12px 0 6px; }
.dz-link { color: #6366f1; font-weight: 700; text-decoration: underline; }
.dz-hint { font-size: 12px; color: #94a3b8; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin-top: 16px; }
.preview-card { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; }
.preview-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.preview-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.15s; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; }
.preview-card:hover .preview-overlay { background: rgba(0,0,0,0.45); }
.preview-name { font-size: 10px; color: #fff; font-weight: 600; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preview-card:hover .preview-name { opacity: 1; }
.remove-btn { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: rgba(15,23,42,0.7); border: none; border-radius: 50%; color: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; padding: 0; line-height: 1; }
.preview-card:hover .remove-btn { opacity: 1; }
.status-chip { position: absolute; bottom: 6px; right: 6px; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 20px; opacity: 0; transition: opacity 0.15s; }
.status-chip.uploading { background: rgba(14,165,233,0.9); color: #fff; }
.status-chip.done { background: rgba(34,197,94,0.9); color: #fff; }
.status-chip.error { background: rgba(239,68,68,0.9); color: #fff; }
.preview-card.is-uploading .status-chip { opacity: 1; }
.preview-card.is-done .status-chip { opacity: 1; }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9; }
.footer span { font-size: 13px; color: #64748b; }
.footer-btns { display: flex; gap: 8px; }
.btn-clear { background: none; border: 1px solid #e2e8f0; color: #64748b; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.btn-clear:hover { background: #f8fafc; color: #1e293b; }
.btn-upload { background: #6366f1; color: #fff; border: none; padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background 0.15s; }
.btn-upload:hover { background: #4f46e5; }
.btn-upload:disabled { background: #e2e8f0; color: #94a3b8; cursor: not-allowed; }
</style>
</head>
<body>
<div class="wrap">
<div class="uploader">
<h2 class="heading">Upload Photos</h2>
<p class="sub">Add up to 12 images. Drag & drop or click to browse.</p>
<div class="dropzone" id="dropzone" onclick="triggerInput()" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p class="dz-text">Drop images here or <span class="dz-link">click to upload</span></p>
<p class="dz-hint">JPG, PNG, GIF, WebP — max 5MB each</p>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" onchange="onFilePick(event)">
<div class="grid" id="previewGrid"></div>
<div class="footer" id="footer" style="display:none">
<span id="fileCount">0 files selected</span>
<div class="footer-btns">
<button class="btn-clear" onclick="clearAll()">Clear All</button>
<button class="btn-upload" onclick="fakeUpload()">Upload Files</button>
</div>
</div>
</div>
</div>
<script>
var MAX_FILES = 12;
var MAX_MB = 5;
var files = [];
function triggerInput() {
document.getElementById('fileInput').click();
}
function onDragOver(e) {
e.preventDefault();
document.getElementById('dropzone').classList.add('active');
}
function onDragLeave() {
document.getElementById('dropzone').classList.remove('active');
}
function onDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('active');
addFiles(Array.from(e.dataTransfer.files));
}
function onFilePick(e) {
addFiles(Array.from(e.target.files));
e.target.value = '';
}
function addFiles(newFiles) {
var imageFiles = newFiles.filter(function(f) { return f.type.startsWith('image/'); });
var remaining = MAX_FILES - files.length;
imageFiles.slice(0, remaining).forEach(function(file) {
if (file.size > MAX_MB * 1024 * 1024) { alert(file.name + ' exceeds ' + MAX_MB + 'MB limit.'); return; }
files.push(file);
renderCard(file, files.length - 1);
});
updateFooter();
}
function renderCard(file, idx) {
var grid = document.getElementById('previewGrid');
var card = document.createElement('div');
card.className = 'preview-card';
card.id = 'card-' + idx;
var url = URL.createObjectURL(file);
card.innerHTML = '<img class="preview-img" src="' + url + '" alt="' + file.name + '"><div class="preview-overlay"><span class="preview-name">' + file.name + '</span></div><button class="remove-btn" onclick="removeFile(' + idx + ')" title="Remove">×</button><span class="status-chip"></span>';
grid.appendChild(card);
}
function removeFile(idx) {
files[idx] = null;
var card = document.getElementById('card-' + idx);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.2s'; setTimeout(function() { card.remove(); }, 200); }
updateFooter();
}
function updateFooter() {
var count = files.filter(Boolean).length;
var footer = document.getElementById('footer');
footer.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('fileCount').textContent = count + ' file' + (count !== 1 ? 's' : '') + ' selected';
}
function clearAll() {
files = [];
document.getElementById('previewGrid').innerHTML = '';
updateFooter();
}
function fakeUpload() {
var cards = document.querySelectorAll('.preview-card');
var btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Uploading...';
cards.forEach(function(card, i) {
var chip = card.querySelector('.status-chip');
chip.textContent = 'Uploading';
chip.className = 'status-chip uploading';
card.classList.add('is-uploading');
setTimeout(function() {
card.classList.remove('is-uploading');
card.classList.add('is-done');
chip.textContent = 'Done';
chip.className = 'status-chip done';
}, 800 + i * 300);
});
setTimeout(function() {
btn.disabled = false;
btn.textContent = 'Upload Files';
}, 800 + cards.length * 300);
}
</script>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Media Upload Grid</title>
<!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
* {
box-sizing: border-box; margin: 0; padding: 0;
}
body {
font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px;
}
.dropzone {
border: 2px dashed #e2e8f0; border-radius: 16px; padding: 36px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fafafa; display: flex; flex-direction: column; align-items: center;
}
.dropzone:hover, .dropzone.active {
border-color: #6366f1; background: rgba(99,102,241,0.03);
}
.preview-card {
position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9;
}
.preview-img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.preview-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.15s; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px;
}
.preview-card:hover .preview-overlay {
background: rgba(0,0,0,0.45);
}
.preview-name {
font-size: 10px; color: #fff; font-weight: 600; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.preview-card:hover .preview-name {
opacity: 1;
}
.remove-btn {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: rgba(15,23,42,0.7); border: none; border-radius: 50%; color: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; padding: 0; line-height: 1;
}
.preview-card:hover .remove-btn {
opacity: 1;
}
.status-chip {
position: absolute; bottom: 6px; right: 6px; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 20px; opacity: 0; transition: opacity 0.15s;
}
.status-chip.uploading {
background: rgba(14,165,233,0.9); color: #fff;
}
.status-chip.done {
background: rgba(34,197,94,0.9); color: #fff;
}
.status-chip.error {
background: rgba(239,68,68,0.9); color: #fff;
}
.preview-card.is-uploading .status-chip {
opacity: 1;
}
.preview-card.is-done .status-chip {
opacity: 1;
}
.footer {
display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9;
}
.footer span {
font-size: 13px; color: #64748b;
}
.btn-upload:disabled {
background: #e2e8f0; color: #94a3b8; cursor: not-allowed;
}
</style>
</head>
<body>
<div class="w-full max-w-[640px]">
<div class="bg-[#fff] rounded-[20px] shadow-[0_4px_24px_rgba(0,0,0,0.07)] p-7">
<h2 class="text-lg font-extrabold text-[#0f172a] mb-1">Upload Photos</h2>
<p class="text-[13px] text-[#64748b] mb-5">Add up to 12 images. Drag & drop or click to browse.</p>
<div class="dropzone" id="dropzone" onclick="triggerInput()" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p class="text-sm text-[#475569] mt-3 mx-0 mb-1.5">Drop images here or <span class="text-[#6366f1] font-bold underline">click to upload</span></p>
<p class="text-xs text-[#94a3b8]">JPG, PNG, GIF, WebP — max 5MB each</p>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" onchange="onFilePick(event)">
<div class="grid grid-cols-[repeat(auto-fill,_minmax(130px,_1fr))] gap-2.5 mt-4" id="previewGrid"></div>
<div class="footer" id="footer" style="display:none">
<span id="fileCount">0 files selected</span>
<div class="flex gap-2">
<button class="bg-transparent border border-[#e2e8f0] text-[#64748b] py-2 px-4 rounded-lg text-[13px] cursor-pointer [transition:all_0.15s] hover:bg-[#f8fafc] hover:text-[#1e293b]" onclick="clearAll()">Clear All</button>
<button class="btn-upload bg-[#6366f1] text-[#fff] border-0 py-2 px-5 rounded-lg text-[13px] font-bold cursor-pointer [transition:background_0.15s] hover:bg-[#4f46e5]" onclick="fakeUpload()">Upload Files</button>
</div>
</div>
</div>
</div>
<script>
var MAX_FILES = 12;
var MAX_MB = 5;
var files = [];
function triggerInput() {
document.getElementById('fileInput').click();
}
function onDragOver(e) {
e.preventDefault();
document.getElementById('dropzone').classList.add('active');
}
function onDragLeave() {
document.getElementById('dropzone').classList.remove('active');
}
function onDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('active');
addFiles(Array.from(e.dataTransfer.files));
}
function onFilePick(e) {
addFiles(Array.from(e.target.files));
e.target.value = '';
}
function addFiles(newFiles) {
var imageFiles = newFiles.filter(function(f) { return f.type.startsWith('image/'); });
var remaining = MAX_FILES - files.length;
imageFiles.slice(0, remaining).forEach(function(file) {
if (file.size > MAX_MB * 1024 * 1024) { alert(file.name + ' exceeds ' + MAX_MB + 'MB limit.'); return; }
files.push(file);
renderCard(file, files.length - 1);
});
updateFooter();
}
function renderCard(file, idx) {
var grid = document.getElementById('previewGrid');
var card = document.createElement('div');
card.className = 'preview-card';
card.id = 'card-' + idx;
var url = URL.createObjectURL(file);
card.innerHTML = '<img class="preview-img" src="' + url + '" alt="' + file.name + '"><div class="preview-overlay"><span class="preview-name">' + file.name + '</span></div><button class="remove-btn" onclick="removeFile(' + idx + ')" title="Remove">×</button><span class="status-chip"></span>';
grid.appendChild(card);
}
function removeFile(idx) {
files[idx] = null;
var card = document.getElementById('card-' + idx);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.2s'; setTimeout(function() { card.remove(); }, 200); }
updateFooter();
}
function updateFooter() {
var count = files.filter(Boolean).length;
var footer = document.getElementById('footer');
footer.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('fileCount').textContent = count + ' file' + (count !== 1 ? 's' : '') + ' selected';
}
function clearAll() {
files = [];
document.getElementById('previewGrid').innerHTML = '';
updateFooter();
}
function fakeUpload() {
var cards = document.querySelectorAll('.preview-card');
var btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Uploading...';
cards.forEach(function(card, i) {
var chip = card.querySelector('.status-chip');
chip.textContent = 'Uploading';
chip.className = 'status-chip uploading';
card.classList.add('is-uploading');
setTimeout(function() {
card.classList.remove('is-uploading');
card.classList.add('is-done');
chip.textContent = 'Done';
chip.className = 'status-chip done';
}, 800 + i * 300);
});
setTimeout(function() {
btn.disabled = false;
btn.textContent = 'Upload Files';
}, 800 + cards.length * 300);
}
</script>
</body>
</html>import React, { useEffect } from 'react';
// CSS — optionally move to MediaUploadGrid.module.css
const css = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; }
.wrap { width: 100%; max-width: 640px; }
.uploader { background: #fff; border-radius: 20px; box-shadow: 0 4px 24px rgba(0,0,0,0.07); padding: 28px; }
.heading { font-size: 18px; font-weight: 800; color: #0f172a; margin-bottom: 4px; }
.sub { font-size: 13px; color: #64748b; margin-bottom: 20px; }
.dropzone { border: 2px dashed #e2e8f0; border-radius: 16px; padding: 36px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fafafa; display: flex; flex-direction: column; align-items: center; }
.dropzone:hover, .dropzone.active { border-color: #6366f1; background: rgba(99,102,241,0.03); }
.dz-text { font-size: 14px; color: #475569; margin: 12px 0 6px; }
.dz-link { color: #6366f1; font-weight: 700; text-decoration: underline; }
.dz-hint { font-size: 12px; color: #94a3b8; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin-top: 16px; }
.preview-card { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; }
.preview-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.preview-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.15s; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; }
.preview-card:hover .preview-overlay { background: rgba(0,0,0,0.45); }
.preview-name { font-size: 10px; color: #fff; font-weight: 600; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preview-card:hover .preview-name { opacity: 1; }
.remove-btn { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: rgba(15,23,42,0.7); border: none; border-radius: 50%; color: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; padding: 0; line-height: 1; }
.preview-card:hover .remove-btn { opacity: 1; }
.status-chip { position: absolute; bottom: 6px; right: 6px; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 20px; opacity: 0; transition: opacity 0.15s; }
.status-chip.uploading { background: rgba(14,165,233,0.9); color: #fff; }
.status-chip.done { background: rgba(34,197,94,0.9); color: #fff; }
.status-chip.error { background: rgba(239,68,68,0.9); color: #fff; }
.preview-card.is-uploading .status-chip { opacity: 1; }
.preview-card.is-done .status-chip { opacity: 1; }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9; }
.footer span { font-size: 13px; color: #64748b; }
.footer-btns { display: flex; gap: 8px; }
.btn-clear { background: none; border: 1px solid #e2e8f0; color: #64748b; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.btn-clear:hover { background: #f8fafc; color: #1e293b; }
.btn-upload { background: #6366f1; color: #fff; border: none; padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background 0.15s; }
.btn-upload:hover { background: #4f46e5; }
.btn-upload:disabled { background: #e2e8f0; color: #94a3b8; cursor: not-allowed; }
`;
export default function MediaUploadGrid() {
// Auto-generated escape hatch: the original snippet's vanilla JS runs once
// after mount and queries the rendered DOM. For idiomatic React, lift this
// into state + handlers.
useEffect(() => {
const _listeners = [];
const _originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
_listeners.push({ target: this, type, listener, options });
return _originalAddEventListener.call(this, type, listener, options);
};
var MAX_FILES = 12;
var MAX_MB = 5;
var files = [];
function triggerInput() {
document.getElementById('fileInput').click();
}
function onDragOver(e) {
e.preventDefault();
document.getElementById('dropzone').classList.add('active');
}
function onDragLeave() {
document.getElementById('dropzone').classList.remove('active');
}
function onDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('active');
addFiles(Array.from(e.dataTransfer.files));
}
function onFilePick(e) {
addFiles(Array.from(e.target.files));
e.target.value = '';
}
function addFiles(newFiles) {
var imageFiles = newFiles.filter(function(f) { return f.type.startsWith('image/'); });
var remaining = MAX_FILES - files.length;
imageFiles.slice(0, remaining).forEach(function(file) {
if (file.size > MAX_MB * 1024 * 1024) { alert(file.name + ' exceeds ' + MAX_MB + 'MB limit.'); return; }
files.push(file);
renderCard(file, files.length - 1);
});
updateFooter();
}
function renderCard(file, idx) {
var grid = document.getElementById('previewGrid');
var card = document.createElement('div');
card.className = 'preview-card';
card.id = 'card-' + idx;
var url = URL.createObjectURL(file);
card.innerHTML = '<img class="preview-img" src="' + url + '" alt="' + file.name + '"><div class="preview-overlay"><span class="preview-name">' + file.name + '</span></div><button class="remove-btn" onclick="removeFile(' + idx + ')" title="Remove">×</button><span class="status-chip"></span>';
grid.appendChild(card);
}
function removeFile(idx) {
files[idx] = null;
var card = document.getElementById('card-' + idx);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.2s'; setTimeout(function() { card.remove(); }, 200); }
updateFooter();
}
function updateFooter() {
var count = files.filter(Boolean).length;
var footer = document.getElementById('footer');
footer.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('fileCount').textContent = count + ' file' + (count !== 1 ? 's' : '') + ' selected';
}
function clearAll() {
files = [];
document.getElementById('previewGrid').innerHTML = '';
updateFooter();
}
function fakeUpload() {
var cards = document.querySelectorAll('.preview-card');
var btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Uploading...';
cards.forEach(function(card, i) {
var chip = card.querySelector('.status-chip');
chip.textContent = 'Uploading';
chip.className = 'status-chip uploading';
card.classList.add('is-uploading');
setTimeout(function() {
card.classList.remove('is-uploading');
card.classList.add('is-done');
chip.textContent = 'Done';
chip.className = 'status-chip done';
}, 800 + i * 300);
});
setTimeout(function() {
btn.disabled = false;
btn.textContent = 'Upload Files';
}, 800 + cards.length * 300);
}
// Cleanup: restore addEventListener and remove all listeners
return () => {
EventTarget.prototype.addEventListener = _originalAddEventListener;
for (const { target, type, listener, options } of _listeners) {
target.removeEventListener(type, listener, options);
}
};
// Expose handlers used by inline JSX events to the global scope
if (typeof triggerInput === 'function') window.triggerInput = triggerInput;
if (typeof onDragOver === 'function') window.onDragOver = onDragOver;
if (typeof onDragLeave === 'function') window.onDragLeave = onDragLeave;
if (typeof onDrop === 'function') window.onDrop = onDrop;
if (typeof onFilePick === 'function') window.onFilePick = onFilePick;
if (typeof clearAll === 'function') window.clearAll = clearAll;
if (typeof fakeUpload === 'function') window.fakeUpload = fakeUpload;
if (typeof removeFile === 'function') window.removeFile = removeFile;
}, []);
return (
<>
<style>{css}</style>
<div className="wrap">
<div className="uploader">
<h2 className="heading">Upload Photos</h2>
<p className="sub">Add up to 12 images. Drag & drop or click to browse.</p>
<div className="dropzone" id="dropzone" onClick={(event) => { triggerInput() }} onDragOver={(event) => { onDragOver(event) }} onDragLeave={(event) => { onDragLeave(event) }} onDrop={(event) => { onDrop(event) }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p className="dz-text">Drop images here or <span className="dz-link">click to upload</span></p>
<p className="dz-hint">JPG, PNG, GIF, WebP — max 5MB each</p>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style={{ display: 'none' }} onChange={(event) => { onFilePick(event) }} />
<div className="grid" id="previewGrid"></div>
<div className="footer" id="footer" style={{ display: 'none' }}>
<span id="fileCount">0 files selected</span>
<div className="footer-btns">
<button className="btn-clear" onClick={(event) => { clearAll() }}>Clear All</button>
<button className="btn-upload" onClick={(event) => { fakeUpload() }}>Upload Files</button>
</div>
</div>
</div>
</div>
</>
);
}import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+
export default function MediaUploadGrid() {
// Auto-generated escape hatch: the original snippet's vanilla JS runs once
// after mount and queries the rendered DOM. For idiomatic React, lift this
// into state + handlers.
useEffect(() => {
const _listeners = [];
const _originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
_listeners.push({ target: this, type, listener, options });
return _originalAddEventListener.call(this, type, listener, options);
};
var MAX_FILES = 12;
var MAX_MB = 5;
var files = [];
function triggerInput() {
document.getElementById('fileInput').click();
}
function onDragOver(e) {
e.preventDefault();
document.getElementById('dropzone').classList.add('active');
}
function onDragLeave() {
document.getElementById('dropzone').classList.remove('active');
}
function onDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('active');
addFiles(Array.from(e.dataTransfer.files));
}
function onFilePick(e) {
addFiles(Array.from(e.target.files));
e.target.value = '';
}
function addFiles(newFiles) {
var imageFiles = newFiles.filter(function(f) { return f.type.startsWith('image/'); });
var remaining = MAX_FILES - files.length;
imageFiles.slice(0, remaining).forEach(function(file) {
if (file.size > MAX_MB * 1024 * 1024) { alert(file.name + ' exceeds ' + MAX_MB + 'MB limit.'); return; }
files.push(file);
renderCard(file, files.length - 1);
});
updateFooter();
}
function renderCard(file, idx) {
var grid = document.getElementById('previewGrid');
var card = document.createElement('div');
card.className = 'preview-card';
card.id = 'card-' + idx;
var url = URL.createObjectURL(file);
card.innerHTML = '<img class="preview-img" src="' + url + '" alt="' + file.name + '"><div class="preview-overlay"><span class="preview-name">' + file.name + '</span></div><button class="remove-btn" onclick="removeFile(' + idx + ')" title="Remove">×</button><span class="status-chip"></span>';
grid.appendChild(card);
}
function removeFile(idx) {
files[idx] = null;
var card = document.getElementById('card-' + idx);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.2s'; setTimeout(function() { card.remove(); }, 200); }
updateFooter();
}
function updateFooter() {
var count = files.filter(Boolean).length;
var footer = document.getElementById('footer');
footer.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('fileCount').textContent = count + ' file' + (count !== 1 ? 's' : '') + ' selected';
}
function clearAll() {
files = [];
document.getElementById('previewGrid').innerHTML = '';
updateFooter();
}
function fakeUpload() {
var cards = document.querySelectorAll('.preview-card');
var btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Uploading...';
cards.forEach(function(card, i) {
var chip = card.querySelector('.status-chip');
chip.textContent = 'Uploading';
chip.className = 'status-chip uploading';
card.classList.add('is-uploading');
setTimeout(function() {
card.classList.remove('is-uploading');
card.classList.add('is-done');
chip.textContent = 'Done';
chip.className = 'status-chip done';
}, 800 + i * 300);
});
setTimeout(function() {
btn.disabled = false;
btn.textContent = 'Upload Files';
}, 800 + cards.length * 300);
}
// Cleanup: restore addEventListener and remove all listeners
return () => {
EventTarget.prototype.addEventListener = _originalAddEventListener;
for (const { target, type, listener, options } of _listeners) {
target.removeEventListener(type, listener, options);
}
};
// Expose handlers used by inline JSX events to the global scope
if (typeof triggerInput === 'function') window.triggerInput = triggerInput;
if (typeof onDragOver === 'function') window.onDragOver = onDragOver;
if (typeof onDragLeave === 'function') window.onDragLeave = onDragLeave;
if (typeof onDrop === 'function') window.onDrop = onDrop;
if (typeof onFilePick === 'function') window.onFilePick = onFilePick;
if (typeof clearAll === 'function') window.clearAll = clearAll;
if (typeof fakeUpload === 'function') window.fakeUpload = fakeUpload;
if (typeof removeFile === 'function') window.removeFile = removeFile;
}, []);
return (
<>
<style>{`
* {
box-sizing: border-box; margin: 0; padding: 0;
}
body {
font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px;
}
.dropzone {
border: 2px dashed #e2e8f0; border-radius: 16px; padding: 36px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fafafa; display: flex; flex-direction: column; align-items: center;
}
.dropzone:hover, .dropzone.active {
border-color: #6366f1; background: rgba(99,102,241,0.03);
}
.preview-card {
position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9;
}
.preview-img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.preview-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.15s; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px;
}
.preview-card:hover .preview-overlay {
background: rgba(0,0,0,0.45);
}
.preview-name {
font-size: 10px; color: #fff; font-weight: 600; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.preview-card:hover .preview-name {
opacity: 1;
}
.remove-btn {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: rgba(15,23,42,0.7); border: none; border-radius: 50%; color: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; padding: 0; line-height: 1;
}
.preview-card:hover .remove-btn {
opacity: 1;
}
.status-chip {
position: absolute; bottom: 6px; right: 6px; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 20px; opacity: 0; transition: opacity 0.15s;
}
.status-chip.uploading {
background: rgba(14,165,233,0.9); color: #fff;
}
.status-chip.done {
background: rgba(34,197,94,0.9); color: #fff;
}
.status-chip.error {
background: rgba(239,68,68,0.9); color: #fff;
}
.preview-card.is-uploading .status-chip {
opacity: 1;
}
.preview-card.is-done .status-chip {
opacity: 1;
}
.footer {
display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9;
}
.footer span {
font-size: 13px; color: #64748b;
}
.btn-upload:disabled {
background: #e2e8f0; color: #94a3b8; cursor: not-allowed;
}
`}</style>
<div className="w-full max-w-[640px]">
<div className="bg-[#fff] rounded-[20px] shadow-[0_4px_24px_rgba(0,0,0,0.07)] p-7">
<h2 className="text-lg font-extrabold text-[#0f172a] mb-1">Upload Photos</h2>
<p className="text-[13px] text-[#64748b] mb-5">Add up to 12 images. Drag & drop or click to browse.</p>
<div className="dropzone" id="dropzone" onClick={(event) => { triggerInput() }} onDragOver={(event) => { onDragOver(event) }} onDragLeave={(event) => { onDragLeave(event) }} onDrop={(event) => { onDrop(event) }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p className="text-sm text-[#475569] mt-3 mx-0 mb-1.5">Drop images here or <span className="text-[#6366f1] font-bold underline">click to upload</span></p>
<p className="text-xs text-[#94a3b8]">JPG, PNG, GIF, WebP — max 5MB each</p>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style={{ display: 'none' }} onChange={(event) => { onFilePick(event) }} />
<div className="grid grid-cols-[repeat(auto-fill,_minmax(130px,_1fr))] gap-2.5 mt-4" id="previewGrid"></div>
<div className="footer" id="footer" style={{ display: 'none' }}>
<span id="fileCount">0 files selected</span>
<div className="flex gap-2">
<button className="bg-transparent border border-[#e2e8f0] text-[#64748b] py-2 px-4 rounded-lg text-[13px] cursor-pointer [transition:all_0.15s] hover:bg-[#f8fafc] hover:text-[#1e293b]" onClick={(event) => { clearAll() }}>Clear All</button>
<button className="btn-upload bg-[#6366f1] text-[#fff] border-0 py-2 px-5 rounded-lg text-[13px] font-bold cursor-pointer [transition:background_0.15s] hover:bg-[#4f46e5]" onClick={(event) => { fakeUpload() }}>Upload Files</button>
</div>
</div>
</div>
</div>
</>
);
}<template>
<div class="wrap">
<div class="uploader">
<h2 class="heading">Upload Photos</h2>
<p class="sub">Add up to 12 images. Drag & drop or click to browse.</p>
<div class="dropzone" id="dropzone" @click="triggerInput()" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p class="dz-text">Drop images here or <span class="dz-link">click to upload</span></p>
<p class="dz-hint">JPG, PNG, GIF, WebP — max 5MB each</p>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" @change="onFilePick($event)">
<div class="grid" id="previewGrid"></div>
<div class="footer" id="footer" style="display:none">
<span id="fileCount">0 files selected</span>
<div class="footer-btns">
<button class="btn-clear" @click="clearAll()">Clear All</button>
<button class="btn-upload" @click="fakeUpload()">Upload Files</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
var MAX_FILES = 12;
var MAX_MB = 5;
var files = [];
function triggerInput() {
document.getElementById('fileInput').click();
}
function onDragOver(e) {
e.preventDefault();
document.getElementById('dropzone').classList.add('active');
}
function onDragLeave() {
document.getElementById('dropzone').classList.remove('active');
}
function onDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('active');
addFiles(Array.from(e.dataTransfer.files));
}
function onFilePick(e) {
addFiles(Array.from(e.target.files));
e.target.value = '';
}
function addFiles(newFiles) {
var imageFiles = newFiles.filter(function(f) { return f.type.startsWith('image/'); });
var remaining = MAX_FILES - files.length;
imageFiles.slice(0, remaining).forEach(function(file) {
if (file.size > MAX_MB * 1024 * 1024) { alert(file.name + ' exceeds ' + MAX_MB + 'MB limit.'); return; }
files.push(file);
renderCard(file, files.length - 1);
});
updateFooter();
}
function renderCard(file, idx) {
var grid = document.getElementById('previewGrid');
var card = document.createElement('div');
card.className = 'preview-card';
card.id = 'card-' + idx;
var url = URL.createObjectURL(file);
card.innerHTML = '<img class="preview-img" src="' + url + '" alt="' + file.name + '"><div class="preview-overlay"><span class="preview-name">' + file.name + '</span></div><button class="remove-btn" onclick="removeFile(' + idx + ')" title="Remove">×</button><span class="status-chip"></span>';
grid.appendChild(card);
}
function removeFile(idx) {
files[idx] = null;
var card = document.getElementById('card-' + idx);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.2s'; setTimeout(function() { card.remove(); }, 200); }
updateFooter();
}
function updateFooter() {
var count = files.filter(Boolean).length;
var footer = document.getElementById('footer');
footer.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('fileCount').textContent = count + ' file' + (count !== 1 ? 's' : '') + ' selected';
}
function clearAll() {
files = [];
document.getElementById('previewGrid').innerHTML = '';
updateFooter();
}
function fakeUpload() {
var cards = document.querySelectorAll('.preview-card');
var btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Uploading...';
cards.forEach(function(card, i) {
var chip = card.querySelector('.status-chip');
chip.textContent = 'Uploading';
chip.className = 'status-chip uploading';
card.classList.add('is-uploading');
setTimeout(function() {
card.classList.remove('is-uploading');
card.classList.add('is-done');
chip.textContent = 'Done';
chip.className = 'status-chip done';
}, 800 + i * 300);
});
setTimeout(function() {
btn.disabled = false;
btn.textContent = 'Upload Files';
}, 800 + cards.length * 300);
}
onMounted(() => {
if (typeof removeFile === 'function') window.removeFile = removeFile;
});
</script>
<style scoped>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; }
.wrap { width: 100%; max-width: 640px; }
.uploader { background: #fff; border-radius: 20px; box-shadow: 0 4px 24px rgba(0,0,0,0.07); padding: 28px; }
.heading { font-size: 18px; font-weight: 800; color: #0f172a; margin-bottom: 4px; }
.sub { font-size: 13px; color: #64748b; margin-bottom: 20px; }
.dropzone { border: 2px dashed #e2e8f0; border-radius: 16px; padding: 36px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fafafa; display: flex; flex-direction: column; align-items: center; }
.dropzone:hover, .dropzone.active { border-color: #6366f1; background: rgba(99,102,241,0.03); }
.dz-text { font-size: 14px; color: #475569; margin: 12px 0 6px; }
.dz-link { color: #6366f1; font-weight: 700; text-decoration: underline; }
.dz-hint { font-size: 12px; color: #94a3b8; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin-top: 16px; }
.preview-card { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; }
.preview-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.preview-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.15s; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; }
.preview-card:hover .preview-overlay { background: rgba(0,0,0,0.45); }
.preview-name { font-size: 10px; color: #fff; font-weight: 600; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preview-card:hover .preview-name { opacity: 1; }
.remove-btn { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: rgba(15,23,42,0.7); border: none; border-radius: 50%; color: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; padding: 0; line-height: 1; }
.preview-card:hover .remove-btn { opacity: 1; }
.status-chip { position: absolute; bottom: 6px; right: 6px; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 20px; opacity: 0; transition: opacity 0.15s; }
.status-chip.uploading { background: rgba(14,165,233,0.9); color: #fff; }
.status-chip.done { background: rgba(34,197,94,0.9); color: #fff; }
.status-chip.error { background: rgba(239,68,68,0.9); color: #fff; }
.preview-card.is-uploading .status-chip { opacity: 1; }
.preview-card.is-done .status-chip { opacity: 1; }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9; }
.footer span { font-size: 13px; color: #64748b; }
.footer-btns { display: flex; gap: 8px; }
.btn-clear { background: none; border: 1px solid #e2e8f0; color: #64748b; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.btn-clear:hover { background: #f8fafc; color: #1e293b; }
.btn-upload { background: #6366f1; color: #fff; border: none; padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background 0.15s; }
.btn-upload:hover { background: #4f46e5; }
.btn-upload:disabled { background: #e2e8f0; color: #94a3b8; cursor: not-allowed; }
</style>// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-media-upload-grid',
standalone: true,
imports: [CommonModule],
encapsulation: ViewEncapsulation.None,
template: `
<div class="wrap">
<div class="uploader">
<h2 class="heading">Upload Photos</h2>
<p class="sub">Add up to 12 images. Drag & drop or click to browse.</p>
<div class="dropzone" id="dropzone" (click)="triggerInput()" ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p class="dz-text">Drop images here or <span class="dz-link">click to upload</span></p>
<p class="dz-hint">JPG, PNG, GIF, WebP — max 5MB each</p>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" (change)="onFilePick($event)">
<div class="grid" id="previewGrid"></div>
<div class="footer" id="footer" style="display:none">
<span id="fileCount">0 files selected</span>
<div class="footer-btns">
<button class="btn-clear" (click)="clearAll()">Clear All</button>
<button class="btn-upload" (click)="fakeUpload()">Upload Files</button>
</div>
</div>
</div>
</div>
`,
styles: [`
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 32px 20px; }
.wrap { width: 100%; max-width: 640px; }
.uploader { background: #fff; border-radius: 20px; box-shadow: 0 4px 24px rgba(0,0,0,0.07); padding: 28px; }
.heading { font-size: 18px; font-weight: 800; color: #0f172a; margin-bottom: 4px; }
.sub { font-size: 13px; color: #64748b; margin-bottom: 20px; }
.dropzone { border: 2px dashed #e2e8f0; border-radius: 16px; padding: 36px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #fafafa; display: flex; flex-direction: column; align-items: center; }
.dropzone:hover, .dropzone.active { border-color: #6366f1; background: rgba(99,102,241,0.03); }
.dz-text { font-size: 14px; color: #475569; margin: 12px 0 6px; }
.dz-link { color: #6366f1; font-weight: 700; text-decoration: underline; }
.dz-hint { font-size: 12px; color: #94a3b8; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin-top: 16px; }
.preview-card { position: relative; border-radius: 12px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; }
.preview-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.preview-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0); transition: background 0.15s; display: flex; flex-direction: column; justify-content: flex-end; padding: 8px; }
.preview-card:hover .preview-overlay { background: rgba(0,0,0,0.45); }
.preview-name { font-size: 10px; color: #fff; font-weight: 600; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preview-card:hover .preview-name { opacity: 1; }
.remove-btn { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; background: rgba(15,23,42,0.7); border: none; border-radius: 50%; color: #fff; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; padding: 0; line-height: 1; }
.preview-card:hover .remove-btn { opacity: 1; }
.status-chip { position: absolute; bottom: 6px; right: 6px; font-size: 9px; font-weight: 700; padding: 2px 7px; border-radius: 20px; opacity: 0; transition: opacity 0.15s; }
.status-chip.uploading { background: rgba(14,165,233,0.9); color: #fff; }
.status-chip.done { background: rgba(34,197,94,0.9); color: #fff; }
.status-chip.error { background: rgba(239,68,68,0.9); color: #fff; }
.preview-card.is-uploading .status-chip { opacity: 1; }
.preview-card.is-done .status-chip { opacity: 1; }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f1f5f9; }
.footer span { font-size: 13px; color: #64748b; }
.footer-btns { display: flex; gap: 8px; }
.btn-clear { background: none; border: 1px solid #e2e8f0; color: #64748b; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.btn-clear:hover { background: #f8fafc; color: #1e293b; }
.btn-upload { background: #6366f1; color: #fff; border: none; padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background 0.15s; }
.btn-upload:hover { background: #4f46e5; }
.btn-upload:disabled { background: #e2e8f0; color: #94a3b8; cursor: not-allowed; }
`]
})
export class MediaUploadGridComponent implements AfterViewInit {
ngAfterViewInit(): void {
var MAX_FILES = 12;
var MAX_MB = 5;
var files = [];
function triggerInput() {
document.getElementById('fileInput').click();
}
function onDragOver(e) {
e.preventDefault();
document.getElementById('dropzone').classList.add('active');
}
function onDragLeave() {
document.getElementById('dropzone').classList.remove('active');
}
function onDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('active');
addFiles(Array.from(e.dataTransfer.files));
}
function onFilePick(e) {
addFiles(Array.from(e.target.files));
e.target.value = '';
}
function addFiles(newFiles) {
var imageFiles = newFiles.filter(function(f) { return f.type.startsWith('image/'); });
var remaining = MAX_FILES - files.length;
imageFiles.slice(0, remaining).forEach(function(file) {
if (file.size > MAX_MB * 1024 * 1024) { alert(file.name + ' exceeds ' + MAX_MB + 'MB limit.'); return; }
files.push(file);
renderCard(file, files.length - 1);
});
updateFooter();
}
function renderCard(file, idx) {
var grid = document.getElementById('previewGrid');
var card = document.createElement('div');
card.className = 'preview-card';
card.id = 'card-' + idx;
var url = URL.createObjectURL(file);
card.innerHTML = '<img class="preview-img" src="' + url + '" alt="' + file.name + '"><div class="preview-overlay"><span class="preview-name">' + file.name + '</span></div><button class="remove-btn" onclick="removeFile(' + idx + ')" title="Remove">×</button><span class="status-chip"></span>';
grid.appendChild(card);
}
function removeFile(idx) {
files[idx] = null;
var card = document.getElementById('card-' + idx);
if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.8)'; card.style.transition = 'all 0.2s'; setTimeout(function() { card.remove(); }, 200); }
updateFooter();
}
function updateFooter() {
var count = files.filter(Boolean).length;
var footer = document.getElementById('footer');
footer.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('fileCount').textContent = count + ' file' + (count !== 1 ? 's' : '') + ' selected';
}
function clearAll() {
files = [];
document.getElementById('previewGrid').innerHTML = '';
updateFooter();
}
function fakeUpload() {
var cards = document.querySelectorAll('.preview-card');
var btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Uploading...';
cards.forEach(function(card, i) {
var chip = card.querySelector('.status-chip');
chip.textContent = 'Uploading';
chip.className = 'status-chip uploading';
card.classList.add('is-uploading');
setTimeout(function() {
card.classList.remove('is-uploading');
card.classList.add('is-done');
chip.textContent = 'Done';
chip.className = 'status-chip done';
}, 800 + i * 300);
});
setTimeout(function() {
btn.disabled = false;
btn.textContent = 'Upload Files';
}, 800 + cards.length * 300);
}
// Bind inline-handler functions to the component so the template can call them
Object.assign(this, { triggerInput, onDragOver, onDragLeave, onDrop, onFilePick, addFiles, renderCard, removeFile, updateFooter, clearAll, fakeUpload });
// Expose handlers used by runtime-injected inline markup to window
if (typeof removeFile === 'function') window.removeFile = removeFile;
}
}