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:
@@ -1,5 +1,7 @@
|
||||
.env
|
||||
node_modules/
|
||||
public/uploads/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.aider*
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+22
-9
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Imgur Clone - Share Your World</title>
|
||||
<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>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="Search images..." oninput="filterPosts()">
|
||||
<button onclick="filterPosts()">🔍</button>
|
||||
<button onclick="filterPosts()" aria-label="Search">🔍</button>
|
||||
</div>
|
||||
<button class="btn-new-post" onclick="openUploadModal()">+ New Post</button>
|
||||
</div>
|
||||
@@ -45,25 +45,38 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="modal-overlay" id="uploadModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Create New Post</h2>
|
||||
<button class="modal-close" onclick="closeUploadModal()">✕</button>
|
||||
<button class="modal-close" onclick="closeUploadModal()" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div class="upload-prompt">
|
||||
<div class="upload-prompt" id="uploadPrompt">
|
||||
<div class="upload-icon">📁</div>
|
||||
<p>Drag & drop an image here</p>
|
||||
<p class="upload-main-text">Drag & drop an image here</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>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp" hidden>
|
||||
<div class="upload-preview" id="uploadPreview" style="display:none;">
|
||||
<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 class="form-group">
|
||||
@@ -89,7 +102,7 @@
|
||||
<div class="modal modal-large">
|
||||
<div class="modal-header">
|
||||
<h2 id="postModalTitle"></h2>
|
||||
<button class="modal-close" onclick="closePostModal()">✕</button>
|
||||
<button class="modal-close" onclick="closePostModal()" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body post-detail">
|
||||
<div class="post-detail-grid">
|
||||
@@ -98,9 +111,9 @@
|
||||
</div>
|
||||
<div class="post-sidebar">
|
||||
<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>
|
||||
<button class="vote-btn downvote" onclick="votePost('down')">▼</button>
|
||||
<button class="vote-btn downvote" onclick="votePost('down')" aria-label="Downvote">▼</button>
|
||||
</div>
|
||||
<div class="post-meta">
|
||||
<span class="meta-item">👁️ <span id="postModalViews">0</span> views</span>
|
||||
|
||||
+163
-10
@@ -8,6 +8,7 @@ let selectedFile = null;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadPosts();
|
||||
setupDragDrop();
|
||||
setupPasteUpload();
|
||||
});
|
||||
|
||||
// ===== API Helpers =====
|
||||
@@ -64,8 +65,8 @@ function renderGallery() {
|
||||
return;
|
||||
}
|
||||
|
||||
gallery.innerHTML = currentPosts.map(post => `
|
||||
<article class="post-card" onclick="openPostModal('${post.id}')">
|
||||
gallery.innerHTML = currentPosts.map((post, index) => `
|
||||
<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>'">
|
||||
<div class="post-card-body">
|
||||
<h3 class="post-card-title">${escapeHtml(post.title)}</h3>
|
||||
@@ -142,7 +143,8 @@ function openUploadModal() {
|
||||
document.getElementById('uploadModal').classList.add('active');
|
||||
selectedFile = null;
|
||||
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('postTags').value = '';
|
||||
document.getElementById('postAuthor').value = '';
|
||||
@@ -157,19 +159,27 @@ function setupDragDrop() {
|
||||
const zone = document.getElementById('uploadZone');
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
zone.classList.add('dragover');
|
||||
});
|
||||
|
||||
zone.addEventListener('dragleave', () => {
|
||||
zone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
zone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
zone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
zone.classList.remove('dragover');
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
@@ -202,7 +212,7 @@ function handleFile(file) {
|
||||
reader.onload = (e) => {
|
||||
document.getElementById('previewImage').src = e.target.result;
|
||||
document.getElementById('uploadPreview').style.display = 'block';
|
||||
document.querySelector('.upload-prompt').style.display = 'none';
|
||||
document.getElementById('uploadPrompt').style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
@@ -210,7 +220,7 @@ function handleFile(file) {
|
||||
function removePreview() {
|
||||
selectedFile = null;
|
||||
document.getElementById('uploadPreview').style.display = 'none';
|
||||
document.querySelector('.upload-prompt').style.display = '';
|
||||
document.getElementById('uploadPrompt').style.display = '';
|
||||
document.getElementById('fileInput').value = '';
|
||||
}
|
||||
|
||||
@@ -228,6 +238,14 @@ async function submitPost() {
|
||||
btn.disabled = true;
|
||||
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 {
|
||||
if (selectedFile) {
|
||||
const formData = new FormData();
|
||||
@@ -236,19 +254,149 @@ async function submitPost() {
|
||||
formData.append('tags', tags);
|
||||
formData.append('author', author);
|
||||
|
||||
await apiPost('/api/upload', formData);
|
||||
const newPost = await apiPost('/api/upload', formData);
|
||||
showToast('Post uploaded successfully! 🎉', 'success');
|
||||
}
|
||||
|
||||
closeUploadModal();
|
||||
await loadPosts();
|
||||
|
||||
// Scroll to and highlight the new post
|
||||
highlightNewPost(newPost.id);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
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 =====
|
||||
@@ -416,7 +564,12 @@ function showToast(message, type = 'success') {
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user