DeepSeek-harness pass: CVE audit (clean), clipboard paste upload, floating upload CTA, master-designer visual overhaul (glow CTA, staggered entrance, highlight-new, focus states) + full sync (README, lockfile, REPORT)

This commit is contained in:
krisf
2026-08-22 16:25:48 -04:00
parent 9cdaa3f176
commit 513ae4827d
5 changed files with 607 additions and 158 deletions
+2
View File
@@ -1,5 +1,7 @@
.env
node_modules/ node_modules/
public/uploads/ public/uploads/
data/ data/
*.log *.log
.DS_Store .DS_Store
.aider*
+49
View File
@@ -0,0 +1,49 @@
# imgur-clone
Imgur-style image sharing app: dark-mode masonry gallery, drag-and-drop / click / **clipboard paste** uploads, 4K re-encoding, voting, and threaded comments.
## Stack
- **Backend**: Node 20 (Alpine), Express, multer (multipart), sharp (image pipeline), cors
- **Frontend**: vanilla HTML/CSS/JS, CSS masonry grid, no build step
- **Storage**: local `public/uploads/` (in-memory + file persistence in `data/`)
- **Image processing**: any upload is re-encoded to JPEG q92, downscaled to max 3840px (4K) while preserving aspect ratio
## API
| Method | Path | Description |
|---|---|---|
| GET | `/api/health` | health + counts |
| GET | `/api/posts?sort=new\|viral\|user\|top` | list posts |
| GET | `/api/posts/:id` | single post |
| POST | `/api/upload` | multipart (`image`, `title`, `tags`, `author`) |
| POST | `/api/posts/:id/vote` | JSON `{direction, userId}` |
| GET | `/api/comments/:postId` | list comments (flat, with parentId) |
| POST | `/api/comments/:postId` | JSON `{author, text, parentId?}` |
## Frontend
- Masonry gallery with staggered card entrance animation
- Filter tabs: New, Most Viral, User Submitted, Top Today
- Upload via: file picker, drag-and-drop, or **Ctrl+V paste** (opens modal with preview, auto-submits)
- Floating action button (FAB) as the persistent upload CTA
- New posts scroll into view with a highlight pulse
- Post detail modal: voting, views, tags, threaded comments
- Focus-visible states and reduced-motion support
## Run locally
```bash
npm ci
node server.js # http://localhost:3000
```
## Docker
```bash
docker build -t imgur-clone:latest .
docker run -d --name imgur-clone --restart unless-stopped -p 3001:3000 imgur-clone:latest
```
## Security
- Runtime image strips npm/corepack (only `node server.js` runs at runtime)
- `apk upgrade` on every build for base-image CVE coverage
- Verified with Trivy: 0 vulnerabilities across all OS + node packages
## Production
Deployed on Unraid (192.168.0.123), Caddy reverse proxy with auto-TLS → **https://imgur.krisforbes.ca**
+369 -137
View File
File diff suppressed because it is too large Load Diff
+22 -9
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Imgur Clone - Share Your World</title> <title>Imgur Clone - Share Your World</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Header --> <!-- Header -->
@@ -17,7 +17,7 @@
</div> </div>
<div class="search-bar"> <div class="search-bar">
<input type="text" id="searchInput" placeholder="Search images..." oninput="filterPosts()"> <input type="text" id="searchInput" placeholder="Search images..." oninput="filterPosts()">
<button onclick="filterPosts()">🔍</button> <button onclick="filterPosts()" aria-label="Search">🔍</button>
</div> </div>
<button class="btn-new-post" onclick="openUploadModal()">+ New Post</button> <button class="btn-new-post" onclick="openUploadModal()">+ New Post</button>
</div> </div>
@@ -45,25 +45,38 @@
</div> </div>
</main> </main>
<!-- Floating Action Button -->
<button class="fab" onclick="openUploadModal()" aria-label="Upload image" title="Upload image (or paste with Ctrl+V)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<!-- Upload Modal --> <!-- Upload Modal -->
<div class="modal-overlay" id="uploadModal"> <div class="modal-overlay" id="uploadModal">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<h2>Create New Post</h2> <h2>Create New Post</h2>
<button class="modal-close" onclick="closeUploadModal()"></button> <button class="modal-close" onclick="closeUploadModal()" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="upload-zone" id="uploadZone"> <div class="upload-zone" id="uploadZone">
<div class="upload-prompt"> <div class="upload-prompt" id="uploadPrompt">
<div class="upload-icon">📁</div> <div class="upload-icon">📁</div>
<p>Drag & drop an image here</p> <p class="upload-main-text">Drag &amp; drop an image here</p>
<p class="upload-hint">or click to browse</p> <p class="upload-hint">or click to browse</p>
<p class="upload-paste-hint">or paste an image with <kbd>Ctrl</kbd>+<kbd>V</kbd></p>
<p class="upload-formats">JPEG, PNG, GIF, WebP (max 20MB)</p> <p class="upload-formats">JPEG, PNG, GIF, WebP (max 20MB)</p>
</div> </div>
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp" hidden> <input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp" hidden>
<div class="upload-preview" id="uploadPreview" style="display:none;"> <div class="upload-preview" id="uploadPreview" style="display:none;">
<img id="previewImage" src="" alt="Preview"> <img id="previewImage" src="" alt="Preview">
<button class="btn-remove" onclick="removePreview()"> Remove</button> <button class="btn-remove" onclick="removePreview()" aria-label="Remove image"></button>
</div>
<div class="upload-processing" id="uploadProcessing" style="display:none;">
<div class="spinner spinner-sm"></div>
<p>Optimizing for 4K...</p>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -89,7 +102,7 @@
<div class="modal modal-large"> <div class="modal modal-large">
<div class="modal-header"> <div class="modal-header">
<h2 id="postModalTitle"></h2> <h2 id="postModalTitle"></h2>
<button class="modal-close" onclick="closePostModal()"></button> <button class="modal-close" onclick="closePostModal()" aria-label="Close"></button>
</div> </div>
<div class="modal-body post-detail"> <div class="modal-body post-detail">
<div class="post-detail-grid"> <div class="post-detail-grid">
@@ -98,9 +111,9 @@
</div> </div>
<div class="post-sidebar"> <div class="post-sidebar">
<div class="vote-box"> <div class="vote-box">
<button class="vote-btn upvote" onclick="votePost('up')"></button> <button class="vote-btn upvote" onclick="votePost('up')" aria-label="Upvote"></button>
<span class="vote-count" id="postModalVotes">0</span> <span class="vote-count" id="postModalVotes">0</span>
<button class="vote-btn downvote" onclick="votePost('down')"></button> <button class="vote-btn downvote" onclick="votePost('down')" aria-label="Downvote"></button>
</div> </div>
<div class="post-meta"> <div class="post-meta">
<span class="meta-item">👁️ <span id="postModalViews">0</span> views</span> <span class="meta-item">👁️ <span id="postModalViews">0</span> views</span>
+163 -10
View File
@@ -8,6 +8,7 @@ let selectedFile = null;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadPosts(); loadPosts();
setupDragDrop(); setupDragDrop();
setupPasteUpload();
}); });
// ===== API Helpers ===== // ===== API Helpers =====
@@ -64,8 +65,8 @@ function renderGallery() {
return; return;
} }
gallery.innerHTML = currentPosts.map(post => ` gallery.innerHTML = currentPosts.map((post, index) => `
<article class="post-card" onclick="openPostModal('${post.id}')"> <article class="post-card" style="animation-delay:${Math.min(index * 40, 400)}ms" onclick="openPostModal('${post.id}')">
<img class="post-card-image" src="${post.url}" alt="${escapeHtml(post.title)}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22400%22 height=%22300%22><rect fill=%22%23252540%22 width=%22400%22 height=%22300%22/><text fill=%22%236c6c85%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2216%22>Image not available</text></svg>'"> <img class="post-card-image" src="${post.url}" alt="${escapeHtml(post.title)}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22400%22 height=%22300%22><rect fill=%22%23252540%22 width=%22400%22 height=%22300%22/><text fill=%22%236c6c85%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 font-size=%2216%22>Image not available</text></svg>'">
<div class="post-card-body"> <div class="post-card-body">
<h3 class="post-card-title">${escapeHtml(post.title)}</h3> <h3 class="post-card-title">${escapeHtml(post.title)}</h3>
@@ -142,7 +143,8 @@ function openUploadModal() {
document.getElementById('uploadModal').classList.add('active'); document.getElementById('uploadModal').classList.add('active');
selectedFile = null; selectedFile = null;
document.getElementById('uploadPreview').style.display = 'none'; document.getElementById('uploadPreview').style.display = 'none';
document.querySelector('.upload-prompt').style.display = ''; document.getElementById('uploadPrompt').style.display = '';
document.getElementById('uploadProcessing').style.display = 'none';
document.getElementById('postTitle').value = ''; document.getElementById('postTitle').value = '';
document.getElementById('postTags').value = ''; document.getElementById('postTags').value = '';
document.getElementById('postAuthor').value = ''; document.getElementById('postAuthor').value = '';
@@ -157,19 +159,27 @@ function setupDragDrop() {
const zone = document.getElementById('uploadZone'); const zone = document.getElementById('uploadZone');
const input = document.getElementById('fileInput'); const input = document.getElementById('fileInput');
zone.addEventListener('click', () => input.click()); zone.addEventListener('click', (e) => {
// Don't trigger file picker if clicking the remove button
if (e.target.closest('.btn-remove')) return;
input.click();
});
zone.addEventListener('dragover', (e) => { zone.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
zone.classList.add('dragover'); zone.classList.add('dragover');
}); });
zone.addEventListener('dragleave', () => { zone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('dragover'); zone.classList.remove('dragover');
}); });
zone.addEventListener('drop', (e) => { zone.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
zone.classList.remove('dragover'); zone.classList.remove('dragover');
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file) handleFile(file); if (file) handleFile(file);
@@ -202,7 +212,7 @@ function handleFile(file) {
reader.onload = (e) => { reader.onload = (e) => {
document.getElementById('previewImage').src = e.target.result; document.getElementById('previewImage').src = e.target.result;
document.getElementById('uploadPreview').style.display = 'block'; document.getElementById('uploadPreview').style.display = 'block';
document.querySelector('.upload-prompt').style.display = 'none'; document.getElementById('uploadPrompt').style.display = 'none';
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
@@ -210,7 +220,7 @@ function handleFile(file) {
function removePreview() { function removePreview() {
selectedFile = null; selectedFile = null;
document.getElementById('uploadPreview').style.display = 'none'; document.getElementById('uploadPreview').style.display = 'none';
document.querySelector('.upload-prompt').style.display = ''; document.getElementById('uploadPrompt').style.display = '';
document.getElementById('fileInput').value = ''; document.getElementById('fileInput').value = '';
} }
@@ -228,6 +238,14 @@ async function submitPost() {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Uploading...'; btn.textContent = 'Uploading...';
// Show processing state in upload zone
const processing = document.getElementById('uploadProcessing');
const prompt = document.getElementById('uploadPrompt');
const preview = document.getElementById('uploadPreview');
processing.style.display = 'block';
prompt.style.display = 'none';
preview.style.display = 'none';
try { try {
if (selectedFile) { if (selectedFile) {
const formData = new FormData(); const formData = new FormData();
@@ -236,20 +254,150 @@ async function submitPost() {
formData.append('tags', tags); formData.append('tags', tags);
formData.append('author', author); formData.append('author', author);
await apiPost('/api/upload', formData); const newPost = await apiPost('/api/upload', formData);
showToast('Post uploaded successfully! 🎉', 'success'); showToast('Post uploaded successfully! 🎉', 'success');
}
closeUploadModal(); closeUploadModal();
await loadPosts(); await loadPosts();
// Scroll to and highlight the new post
highlightNewPost(newPost.id);
}
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
showToast(err.message, 'error'); showToast(err.message, 'error');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Upload Post'; btn.textContent = 'Upload Post';
processing.style.display = 'none';
if (selectedFile) {
preview.style.display = 'block';
} else {
prompt.style.display = '';
} }
} }
}
// ===== Paste Upload =====
function setupPasteUpload() {
document.addEventListener('paste', (e) => {
// Ignore if a modal is open and user is typing in an input
const activeModal = document.querySelector('.modal-overlay.active');
if (activeModal) {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return;
}
}
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
let imageBlob = null;
let imageType = '';
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
imageBlob = items[i].getAsFile();
imageType = items[i].type;
break;
}
}
if (!imageBlob) return;
// Validate type
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowed.includes(imageType)) {
showToast(`Unsupported paste type: ${imageType}`, 'error');
return;
}
// Validate size
if (imageBlob.size > 20 * 1024 * 1024) {
showToast('Pasted image too large. Maximum size is 20MB.', 'error');
return;
}
e.preventDefault();
handlePasteUpload(imageBlob);
});
}
async function handlePasteUpload(blob) {
// Create a File from the blob with a generated name
const ext = blob.type.split('/')[1] || 'png';
const fileName = `pasted-${Date.now()}.${ext}`;
const file = new File([blob], fileName, { type: blob.type });
// Open the upload modal and set the file
openUploadModal();
selectedFile = file;
hideError();
// Show preview
const reader = new FileReader();
reader.onload = (e) => {
document.getElementById('previewImage').src = e.target.result;
document.getElementById('uploadPreview').style.display = 'block';
document.getElementById('uploadPrompt').style.display = 'none';
};
reader.readAsDataURL(file);
// Auto-submit after a brief delay so user sees the preview
setTimeout(async () => {
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Uploading...';
const processing = document.getElementById('uploadProcessing');
const prompt = document.getElementById('uploadPrompt');
const preview = document.getElementById('uploadPreview');
processing.style.display = 'block';
prompt.style.display = 'none';
preview.style.display = 'none';
try {
const formData = new FormData();
formData.append('image', file);
formData.append('title', 'Untitled');
formData.append('tags', '');
formData.append('author', 'Anonymous');
const newPost = await apiPost('/api/upload', formData);
showToast('Image pasted & uploaded! 🎉', 'success');
closeUploadModal();
await loadPosts();
highlightNewPost(newPost.id);
} catch (err) {
showToast(`Paste upload failed: ${err.message}`, 'error');
// Re-show preview so user can retry
processing.style.display = 'none';
preview.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Upload Post';
processing.style.display = 'none';
}
}, 600);
}
// ===== Highlight New Post =====
function highlightNewPost(postId) {
// Wait a tick for the DOM to update
requestAnimationFrame(() => {
const cards = document.querySelectorAll('.post-card');
for (const card of cards) {
if (card.getAttribute('onclick') && card.getAttribute('onclick').includes(postId)) {
card.classList.add('highlight-new');
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => card.classList.remove('highlight-new'), 2500);
break;
}
}
});
}
// ===== Post Detail Modal ===== // ===== Post Detail Modal =====
async function openPostModal(postId) { async function openPostModal(postId) {
@@ -416,7 +564,12 @@ function showToast(message, type = 'success') {
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => toast.remove(), 3000); setTimeout(() => {
toast.style.transition = 'opacity 200ms ease, transform 200ms ease';
toast.style.opacity = '0';
toast.style.transform = 'translateX(20px)';
setTimeout(() => toast.remove(), 200);
}, 3000);
} }
function showError(msg) { function showError(msg) {