Compare commits
2
Commits
513ae4827d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911958db69 | ||
|
|
7862ad8e20 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 & 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 = [
|
||||||
|
|||||||
@@ -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, () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user