Compare commits

..
2 Commits
8 changed files with 152 additions and 17 deletions
+4 -2
View File
@@ -1,6 +1,6 @@
# imgur-clone # Picta
Imgur-style image sharing app: dark-mode masonry gallery, drag-and-drop / click / **clipboard paste** uploads, 4K re-encoding, voting, and threaded comments. Image sharing app: dark-mode masonry gallery, drag-and-drop / click / **clipboard paste** uploads, 4K re-encoding, voting, and threaded comments.
## Stack ## Stack
- **Backend**: Node 20 (Alpine), Express, multer (multipart), sharp (image pipeline), cors - **Backend**: Node 20 (Alpine), Express, multer (multipart), sharp (image pipeline), cors
@@ -40,6 +40,8 @@ docker build -t imgur-clone:latest .
docker run -d --name imgur-clone --restart unless-stopped -p 3001:3000 imgur-clone:latest docker run -d --name imgur-clone --restart unless-stopped -p 3001:3000 imgur-clone:latest
``` ```
> Note: The Docker image and container names remain `imgur-clone` for deployment compatibility.
## Security ## Security
- Runtime image strips npm/corepack (only `node server.js` runs at runtime) - Runtime image strips npm/corepack (only `node server.js` runs at runtime)
- `apk upgrade` on every build for base-image CVE coverage - `apk upgrade` on every build for base-image CVE coverage
+3 -3
View File
@@ -1,6 +1,6 @@
# MCP Deployment & Test Summary Report # MCP Deployment & Test Summary Report
## Imgur Clone — Pipeline Validation Project ## Picta — Pipeline Validation Project
--- ---
@@ -13,7 +13,7 @@
| **Container Port** | `3001:3000` (host:container) | | **Container Port** | `3001:3000` (host:container) |
| **Backend Port** | `3000` (internal) | | **Backend Port** | `3000` (internal) |
| **Caddy Reverse Proxy** | `192.168.0.123:2080/2443``192.168.0.123:3001` | | **Caddy Reverse Proxy** | `192.168.0.123:2080/2443``192.168.0.123:3001` |
| **Gitea Repo** | `https://gitea.krisforbes.ca/krisf/imgur-clone` | | **Gitea Repo** | `https://gitea.krisforbes.ca/krisf/imgur-clone` (Picta) |
| **Build Engine** | Docker BuildKit (node:20-alpine) | | **Build Engine** | Docker BuildKit (node:20-alpine) |
| **Build Time** | ~2s (cached layers) | | **Build Time** | ~2s (cached layers) |
| **Container Status** | `running` (healthy) | | **Container Status** | `running` (healthy) |
@@ -146,7 +146,7 @@ User → Cloudflare DNS → dyn.krisforbes.ca → Caddy (2443) → imgur-clone:3
### Files in Gitea Repository ### Files in Gitea Repository
``` ```
imgur-clone/ imgur-clone/ (Picta)
├── .gitignore ├── .gitignore
├── Dockerfile ├── Dockerfile
├── README.md ├── README.md
+73 -2
View File
@@ -62,6 +62,10 @@
--text-3xl: 24px; --text-3xl: 24px;
} }
html, body {
overflow-x: hidden;
}
body { body {
font-family: var(--font-family); font-family: var(--font-family);
background: var(--bg-primary); background: var(--bg-primary);
@@ -73,6 +77,19 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
body.modal-open {
overflow: hidden;
}
/* Hide scrollbar tracks but keep scrollability */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
*::-webkit-scrollbar {
display: none; /* WebKit */
}
/* ===== Focus Visible ===== */ /* ===== Focus Visible ===== */
:focus-visible { :focus-visible {
outline: 2px solid var(--accent-green); outline: 2px solid var(--accent-green);
@@ -116,8 +133,11 @@ body {
opacity: 0.85; opacity: 0.85;
} }
.logo-icon { .logo-img {
font-size: 28px; width: 28px;
height: 28px;
border-radius: 6px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
} }
.logo-text { .logo-text {
@@ -304,6 +324,8 @@ body {
.post-card-image { .post-card-image {
width: 100%; width: 100%;
max-width: 100%;
height: auto;
aspect-ratio: 4/3; aspect-ratio: 4/3;
object-fit: cover; object-fit: cover;
display: block; display: block;
@@ -324,6 +346,8 @@ body {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
letter-spacing: -0.01em; letter-spacing: -0.01em;
overflow-wrap: break-word;
word-break: break-word;
} }
.post-card-stats { .post-card-stats {
@@ -410,9 +434,14 @@ body {
max-width: 560px; max-width: 560px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
animation: modalIn 200ms ease; animation: modalIn 200ms ease;
scrollbar-width: none;
}
.modal::-webkit-scrollbar {
display: none;
} }
@keyframes modalIn { @keyframes modalIn {
@@ -652,6 +681,9 @@ body {
.post-image-container img { .post-image-container img {
width: 100%; width: 100%;
max-width: 100%;
height: auto;
display: block;
border-radius: var(--radius); border-radius: var(--radius);
cursor: zoom-in; cursor: zoom-in;
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -815,6 +847,8 @@ body {
font-size: var(--text-base); font-size: var(--text-base);
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
overflow-wrap: break-word;
word-break: break-word;
} }
.comment-time { .comment-time {
@@ -922,6 +956,43 @@ body {
} }
} }
/* ===== Full-Page Drag Overlay ===== */
.drag-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(20, 20, 28, 0.92);
backdrop-filter: blur(6px);
z-index: 5000;
align-items: center;
justify-content: center;
pointer-events: none;
}
.drag-overlay.active {
display: flex;
}
.drag-overlay-inner {
text-align: center;
padding: var(--space-10) var(--space-9);
border: 3px dashed var(--accent-green);
border-radius: var(--radius-xl);
background: rgba(27, 183, 110, 0.06);
}
.drag-overlay-icon {
font-size: 56px;
margin-bottom: var(--space-4);
}
.drag-overlay-text {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--accent-green);
letter-spacing: -0.02em;
}
/* ===== Responsive ===== */ /* ===== Responsive ===== */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.post-detail-grid { .post-detail-grid {
+15 -8
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<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>Picta — 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;800&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>
@@ -12,8 +12,8 @@
<header class="header"> <header class="header">
<div class="header-inner"> <div class="header-inner">
<div class="logo" onclick="navigateHome()"> <div class="logo" onclick="navigateHome()">
<span class="logo-icon">📷</span> <img src="/logo.svg" class="logo-img" alt="Picta">
<span class="logo-text">Imgur Clone</span> <span class="logo-text">Picta</span>
</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()">
@@ -57,16 +57,15 @@
<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>Share</h2>
<button class="modal-close" onclick="closeUploadModal()" aria-label="Close"></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" id="uploadPrompt"> <div class="upload-prompt" id="uploadPrompt">
<div class="upload-icon">📁</div> <div class="upload-icon">📁</div>
<p class="upload-main-text">Drag &amp; drop an image here</p> <p class="upload-main-text">Drop an image, click to browse, or paste</p>
<p class="upload-hint">or click to browse</p> <p class="upload-paste-hint"><kbd>Ctrl</kbd>+<kbd>V</kbd> works anywhere</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>
@@ -92,7 +91,7 @@
<input type="text" id="postAuthor" placeholder="Anonymous" maxlength="30"> <input type="text" id="postAuthor" placeholder="Anonymous" maxlength="30">
</div> </div>
<div id="uploadError" class="error-message" style="display:none;"></div> <div id="uploadError" class="error-message" style="display:none;"></div>
<button class="btn-submit" id="submitBtn" onclick="submitPost()">Upload Post</button> <button class="btn-submit" id="submitBtn" onclick="submitPost()">Share</button>
</div> </div>
</div> </div>
</div> </div>
@@ -141,6 +140,14 @@
<img id="fullscreenImage" src="" alt=""> <img id="fullscreenImage" src="" alt="">
</div> </div>
<!-- Full-page Drag Overlay -->
<div class="drag-overlay" id="dragOverlay">
<div class="drag-overlay-inner">
<div class="drag-overlay-icon">📥</div>
<p class="drag-overlay-text">Drop to share</p>
</div>
</div>
<!-- Toast Notifications --> <!-- Toast Notifications -->
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
+45
View File
@@ -3,11 +3,13 @@ let currentSort = 'new';
let currentPosts = []; let currentPosts = [];
let currentPostId = null; let currentPostId = null;
let selectedFile = null; let selectedFile = null;
let dragCounter = 0;
// ===== Initialize ===== // ===== Initialize =====
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadPosts(); loadPosts();
setupDragDrop(); setupDragDrop();
setupPageDrag();
setupPasteUpload(); setupPasteUpload();
}); });
@@ -141,6 +143,7 @@ function filterPosts() {
// ===== Upload Modal ===== // ===== Upload Modal =====
function openUploadModal() { function openUploadModal() {
document.getElementById('uploadModal').classList.add('active'); document.getElementById('uploadModal').classList.add('active');
document.body.classList.add('modal-open');
selectedFile = null; selectedFile = null;
document.getElementById('uploadPreview').style.display = 'none'; document.getElementById('uploadPreview').style.display = 'none';
document.getElementById('uploadPrompt').style.display = ''; document.getElementById('uploadPrompt').style.display = '';
@@ -153,6 +156,7 @@ function openUploadModal() {
function closeUploadModal() { function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('active'); document.getElementById('uploadModal').classList.remove('active');
document.body.classList.remove('modal-open');
} }
function setupDragDrop() { function setupDragDrop() {
@@ -191,6 +195,43 @@ function setupDragDrop() {
}); });
} }
// ===== Full-Page Drag Overlay =====
function setupPageDrag() {
const overlay = document.getElementById('dragOverlay');
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) {
overlay.classList.add('active');
}
});
document.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
overlay.classList.remove('active');
}
});
document.addEventListener('dragover', (e) => {
e.preventDefault();
});
document.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
overlay.classList.remove('active');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
openUploadModal();
handleFile(file);
}
});
}
function handleFile(file) { function handleFile(file) {
// Validate // Validate
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
@@ -213,6 +254,8 @@ function handleFile(file) {
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.getElementById('uploadPrompt').style.display = 'none'; document.getElementById('uploadPrompt').style.display = 'none';
// Auto-focus the Share button so user just presses Enter/clicks
document.getElementById('submitBtn').focus();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
@@ -341,6 +384,8 @@ async function handlePasteUpload(blob) {
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.getElementById('uploadPrompt').style.display = 'none'; document.getElementById('uploadPrompt').style.display = 'none';
// Auto-focus the Share button
document.getElementById('submitBtn').focus();
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<defs>
<linearGradient id="pictaGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1bb76e"/>
<stop offset="1" stop-color="#0e8f52"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="44" height="44" rx="12" fill="url(#pictaGrad)"/>
<path d="M12 36 L12 12 L18 12 L26 24 L34 12 L40 12 L40 36 L34 36 L34 22 L26 33 L18 22 L18 36 Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

+1 -1
View File
@@ -1,4 +1,4 @@
// Seed data for Imgur Clone - 12 sample posts with placeholder images // Seed data for Picta — 12 sample posts with placeholder images
// Using picsum.photos for realistic placeholder images // Using picsum.photos for realistic placeholder images
const posts = [ const posts = [
+1 -1
View File
@@ -268,7 +268,7 @@ app.get('/api/health', (req, res) => {
// Initialize and start // Initialize and start
loadSeedData(); loadSeedData();
console.log(`🚀 Imgur Clone running on port ${PORT}`); console.log(`🚀 Picta running on port ${PORT}`);
console.log(`📦 ${posts.length} seed posts loaded`); console.log(`📦 ${posts.length} seed posts loaded`);
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {