602 lines
18 KiB
JavaScript
602 lines
18 KiB
JavaScript
// ===== State =====
|
|
let currentSort = 'new';
|
|
let currentPosts = [];
|
|
let currentPostId = null;
|
|
let selectedFile = null;
|
|
|
|
// ===== Initialize =====
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadPosts();
|
|
setupDragDrop();
|
|
setupPasteUpload();
|
|
});
|
|
|
|
// ===== API Helpers =====
|
|
async function apiGet(endpoint) {
|
|
const res = await fetch(endpoint);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function apiPost(endpoint, formData = null, body = null) {
|
|
let res;
|
|
if (formData) {
|
|
res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
} else {
|
|
res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
throw new Error(err.error || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// ===== Posts =====
|
|
async function loadPosts() {
|
|
const loading = document.getElementById('loading');
|
|
loading.style.display = 'block';
|
|
|
|
try {
|
|
currentPosts = await apiGet(`/api/posts?sort=${currentSort}`);
|
|
renderGallery();
|
|
} catch (err) {
|
|
showToast('Failed to load posts', 'error');
|
|
} finally {
|
|
loading.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function renderGallery() {
|
|
const gallery = document.getElementById('gallery');
|
|
const count = document.getElementById('postCount');
|
|
|
|
count.textContent = `${currentPosts.length} posts`;
|
|
|
|
if (currentPosts.length === 0) {
|
|
gallery.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:48px;color:var(--text-muted);">No posts found</div>';
|
|
return;
|
|
}
|
|
|
|
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>
|
|
<div class="post-card-stats">
|
|
<span class="upvotes">▲ ${formatNumber(post.upvotes)}</span>
|
|
<span>👁️ ${formatNumber(post.views)}</span>
|
|
<span>💬 ${post.commentCount || 0}</span>
|
|
</div>
|
|
${post.tags && post.tags.length ? `
|
|
<div class="post-card-tags">
|
|
${post.tags.slice(0, 3).map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</article>
|
|
`).join('');
|
|
}
|
|
|
|
// ===== Sorting =====
|
|
function setSort(sort) {
|
|
currentSort = sort;
|
|
|
|
document.querySelectorAll('.filter-tab').forEach(tab => {
|
|
tab.classList.toggle('active', tab.dataset.sort === sort);
|
|
});
|
|
|
|
loadPosts();
|
|
}
|
|
|
|
// ===== Search =====
|
|
function filterPosts() {
|
|
const query = document.getElementById('searchInput').value.toLowerCase();
|
|
if (!query) {
|
|
loadPosts();
|
|
return;
|
|
}
|
|
|
|
const filtered = currentPosts.filter(p =>
|
|
p.title.toLowerCase().includes(query) ||
|
|
(p.tags && p.tags.some(t => t.toLowerCase().includes(query)))
|
|
);
|
|
|
|
const gallery = document.getElementById('gallery');
|
|
const count = document.getElementById('postCount');
|
|
count.textContent = `${filtered.length} posts matching "${query}"`;
|
|
|
|
if (filtered.length === 0) {
|
|
gallery.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:48px;color:var(--text-muted);">No posts match your search</div>';
|
|
return;
|
|
}
|
|
|
|
gallery.innerHTML = filtered.map(post => `
|
|
<article class="post-card" onclick="openPostModal('${post.id}')">
|
|
<img class="post-card-image" src="${post.url}" alt="${escapeHtml(post.title)}" loading="lazy">
|
|
<div class="post-card-body">
|
|
<h3 class="post-card-title">${escapeHtml(post.title)}</h3>
|
|
<div class="post-card-stats">
|
|
<span class="upvotes">▲ ${formatNumber(post.upvotes)}</span>
|
|
<span>👁️ ${formatNumber(post.views)}</span>
|
|
<span>💬 ${post.commentCount || 0}</span>
|
|
</div>
|
|
${post.tags && post.tags.length ? `
|
|
<div class="post-card-tags">
|
|
${post.tags.slice(0, 3).map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</article>
|
|
`).join('');
|
|
}
|
|
|
|
// ===== Upload Modal =====
|
|
function openUploadModal() {
|
|
document.getElementById('uploadModal').classList.add('active');
|
|
selectedFile = null;
|
|
document.getElementById('uploadPreview').style.display = 'none';
|
|
document.getElementById('uploadPrompt').style.display = '';
|
|
document.getElementById('uploadProcessing').style.display = 'none';
|
|
document.getElementById('postTitle').value = '';
|
|
document.getElementById('postTags').value = '';
|
|
document.getElementById('postAuthor').value = '';
|
|
hideError();
|
|
}
|
|
|
|
function closeUploadModal() {
|
|
document.getElementById('uploadModal').classList.remove('active');
|
|
}
|
|
|
|
function setupDragDrop() {
|
|
const zone = document.getElementById('uploadZone');
|
|
const input = document.getElementById('fileInput');
|
|
|
|
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', (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);
|
|
});
|
|
|
|
input.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) handleFile(file);
|
|
});
|
|
}
|
|
|
|
function handleFile(file) {
|
|
// Validate
|
|
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
if (!allowed.includes(file.type)) {
|
|
showError(`Unsupported file type: ${file.type}. Please upload JPEG, PNG, GIF, or WebP.`);
|
|
return;
|
|
}
|
|
|
|
if (file.size > 20 * 1024 * 1024) {
|
|
showError('File too large. Maximum size is 20MB.');
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function removePreview() {
|
|
selectedFile = null;
|
|
document.getElementById('uploadPreview').style.display = 'none';
|
|
document.getElementById('uploadPrompt').style.display = '';
|
|
document.getElementById('fileInput').value = '';
|
|
}
|
|
|
|
async function submitPost() {
|
|
const btn = document.getElementById('submitBtn');
|
|
const title = document.getElementById('postTitle').value.trim();
|
|
const tags = document.getElementById('postTags').value.trim();
|
|
const author = document.getElementById('postAuthor').value.trim() || 'Anonymous';
|
|
|
|
if (!selectedFile && !title) {
|
|
showError('Please upload an image or enter a title.');
|
|
return;
|
|
}
|
|
|
|
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();
|
|
formData.append('image', selectedFile);
|
|
formData.append('title', title || 'Untitled');
|
|
formData.append('tags', tags);
|
|
formData.append('author', author);
|
|
|
|
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 =====
|
|
async function openPostModal(postId) {
|
|
currentPostId = postId;
|
|
const modal = document.getElementById('postModal');
|
|
|
|
try {
|
|
const post = await apiGet(`/api/posts/${postId}`);
|
|
|
|
document.getElementById('postModalTitle').textContent = post.title;
|
|
document.getElementById('postModalImage').src = post.url;
|
|
document.getElementById('postModalImage').alt = post.title;
|
|
document.getElementById('postModalVotes').textContent = formatNumber(post.upvotes);
|
|
document.getElementById('postModalViews').textContent = formatNumber(post.views);
|
|
document.getElementById('postModalComments').textContent = post.commentCount || 0;
|
|
document.getElementById('postModalAuthor').innerHTML = `By <strong>${escapeHtml(post.author)}</strong>`;
|
|
document.getElementById('postModalDate').textContent = timeAgo(post.createdAt);
|
|
|
|
// Tags
|
|
const tagsEl = document.getElementById('postModalTags');
|
|
tagsEl.innerHTML = post.tags && post.tags.length
|
|
? post.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')
|
|
: '';
|
|
|
|
// Load comments
|
|
await loadComments(postId);
|
|
|
|
modal.classList.add('active');
|
|
} catch (err) {
|
|
showToast('Failed to load post details', 'error');
|
|
}
|
|
}
|
|
|
|
function closePostModal() {
|
|
document.getElementById('postModal').classList.remove('active');
|
|
currentPostId = null;
|
|
}
|
|
|
|
// ===== Voting =====
|
|
async function votePost(direction) {
|
|
if (!currentPostId) return;
|
|
|
|
try {
|
|
const result = await apiPost('/api/posts/' + currentPostId + '/vote', null, {
|
|
direction,
|
|
userId: 'session-' + Math.random().toString(36).slice(2, 8)
|
|
});
|
|
|
|
document.getElementById('postModalVotes').textContent = formatNumber(result.upvotes);
|
|
document.getElementById('postModalViews').textContent = formatNumber(result.views);
|
|
|
|
// Update the gallery view too
|
|
await loadPosts();
|
|
} catch (err) {
|
|
showToast('Failed to vote', 'error');
|
|
}
|
|
}
|
|
|
|
// ===== Comments =====
|
|
async function loadComments(postId) {
|
|
try {
|
|
const postComments = await apiGet(`/api/comments/${postId}`);
|
|
const list = document.getElementById('commentsList');
|
|
|
|
if (postComments.length === 0) {
|
|
list.innerHTML = '<p style="color:var(--text-muted);text-align:center;padding:24px;">No comments yet. Be the first!</p>';
|
|
return;
|
|
}
|
|
|
|
const topLevel = postComments.filter(c => !c.parentId);
|
|
const replies = postComments.filter(c => c.parentId);
|
|
|
|
list.innerHTML = topLevel.map(c => {
|
|
const childReplies = replies.filter(r => r.parentId === c.id);
|
|
return `
|
|
<div class="comment-item">
|
|
<div class="comment-author">${escapeHtml(c.author)}</div>
|
|
<div class="comment-text">${escapeHtml(c.text)}</div>
|
|
<div class="comment-time">${timeAgo(c.timestamp)}</div>
|
|
${childReplies.map(r => `
|
|
<div class="comment-item reply">
|
|
<div class="comment-author">${escapeHtml(r.author)}</div>
|
|
<div class="comment-text">${escapeHtml(r.text)}</div>
|
|
<div class="comment-time">${timeAgo(r.timestamp)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
document.getElementById('postModalComments').textContent = postComments.length;
|
|
} catch (err) {
|
|
showToast('Failed to load comments', 'error');
|
|
}
|
|
}
|
|
|
|
async function postComment() {
|
|
const input = document.getElementById('commentInput');
|
|
const text = input.value.trim();
|
|
|
|
if (!text || !currentPostId) return;
|
|
|
|
try {
|
|
await apiPost('/api/comments', null, {
|
|
postId: currentPostId,
|
|
text,
|
|
author: 'Anonymous'
|
|
});
|
|
|
|
input.value = '';
|
|
await loadComments(currentPostId);
|
|
showToast('Comment posted! 💬', 'success');
|
|
} catch (err) {
|
|
showToast('Failed to post comment', 'error');
|
|
}
|
|
}
|
|
|
|
// ===== Fullscreen =====
|
|
function openFullscreen() {
|
|
const img = document.getElementById('postModalImage').src;
|
|
document.getElementById('fullscreenImage').src = img;
|
|
document.getElementById('fullscreenOverlay').classList.add('active');
|
|
}
|
|
|
|
function closeFullscreen() {
|
|
document.getElementById('fullscreenOverlay').classList.remove('active');
|
|
}
|
|
|
|
// ===== Navigation =====
|
|
function navigateHome() {
|
|
document.getElementById('searchInput').value = '';
|
|
loadPosts();
|
|
}
|
|
|
|
// ===== Utilities =====
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatNumber(n) {
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
return n.toString();
|
|
}
|
|
|
|
function timeAgo(dateStr) {
|
|
const now = new Date();
|
|
const date = new Date(dateStr);
|
|
const diff = Math.floor((now - date) / 1000);
|
|
|
|
if (diff < 60) return 'just now';
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
const container = document.getElementById('toastContainer');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
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) {
|
|
const el = document.getElementById('uploadError');
|
|
el.textContent = msg;
|
|
el.style.display = 'block';
|
|
}
|
|
|
|
function hideError() {
|
|
document.getElementById('uploadError').style.display = 'none';
|
|
}
|
|
|
|
// Close modals on overlay click
|
|
document.getElementById('uploadModal').addEventListener('click', (e) => {
|
|
if (e.target === e.currentTarget) closeUploadModal();
|
|
});
|
|
|
|
document.getElementById('postModal').addEventListener('click', (e) => {
|
|
if (e.target === e.currentTarget) closePostModal();
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeFullscreen();
|
|
closePostModal();
|
|
closeUploadModal();
|
|
}
|
|
});
|