From 10747b8fcd20d9303506ca13b227850c25d55cec Mon Sep 17 00:00:00 2001 From: krisf Date: Sat, 1 Aug 2026 11:57:05 -0400 Subject: [PATCH] Add frontend JavaScript with full interactivity --- public/js/app.js | 376 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 public/js/app.js diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..0a9bf79 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,376 @@ +let currentSort = 'new'; +let currentPosts = []; +let currentPostId = null; +let selectedFile = null; + +document.addEventListener('DOMContentLoaded', () => { + loadPosts(); + setupDragDrop(); +}); + +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(); +} + +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 = '
No posts found
'; + return; + } + gallery.innerHTML = currentPosts.map(post => ` +
+ ${escapeHtml(post.title)} +
+

${escapeHtml(post.title)}

+
+ ▲ ${formatNumber(post.upvotes)} + 👁️ ${formatNumber(post.views)} + 💬 ${post.commentCount || 0} +
+ ${post.tags && post.tags.length ? ` +
+ ${post.tags.slice(0, 3).map(t => `${escapeHtml(t)}`).join('')} +
+ ` : ''} +
+
+ `).join(''); +} + +function setSort(sort) { + currentSort = sort; + document.querySelectorAll('.filter-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.sort === sort); + }); + loadPosts(); +} + +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 = '
No posts match your search
'; + return; + } + gallery.innerHTML = filtered.map(post => ` +
+ ${escapeHtml(post.title)} +
+

${escapeHtml(post.title)}

+
+ ▲ ${formatNumber(post.upvotes)} + 👁️ ${formatNumber(post.views)} + 💬 ${post.commentCount || 0} +
+ ${post.tags && post.tags.length ? ` +
+ ${post.tags.slice(0, 3).map(t => `${escapeHtml(t)}`).join('')} +
+ ` : ''} +
+
+ `).join(''); +} + +function openUploadModal() { + document.getElementById('uploadModal').classList.add('active'); + selectedFile = null; + document.getElementById('uploadPreview').style.display = 'none'; + document.querySelector('.upload-prompt').style.display = ''; + 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', () => input.click()); + zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); }); + zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); + zone.addEventListener('drop', (e) => { + e.preventDefault(); + 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) { + 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(); + const reader = new FileReader(); + reader.onload = (e) => { + document.getElementById('previewImage').src = e.target.result; + document.getElementById('uploadPreview').style.display = 'block'; + document.querySelector('.upload-prompt').style.display = 'none'; + }; + reader.readAsDataURL(file); +} + +function removePreview() { + selectedFile = null; + document.getElementById('uploadPreview').style.display = 'none'; + document.querySelector('.upload-prompt').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...'; + try { + if (selectedFile) { + const formData = new FormData(); + formData.append('image', selectedFile); + formData.append('title', title || 'Untitled'); + formData.append('tags', tags); + formData.append('author', author); + await apiPost('/api/upload', formData); + showToast('Post uploaded successfully! 🎉', 'success'); + } + closeUploadModal(); + await loadPosts(); + } catch (err) { + showError(err.message); + showToast(err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Upload Post'; + } +} + +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 ${escapeHtml(post.author)}`; + document.getElementById('postModalDate').textContent = timeAgo(post.createdAt); + const tagsEl = document.getElementById('postModalTags'); + tagsEl.innerHTML = post.tags && post.tags.length + ? post.tags.map(t => `${escapeHtml(t)}`).join('') + : ''; + 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; +} + +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); + await loadPosts(); + } catch (err) { + showToast('Failed to vote', 'error'); + } +} + +async function loadComments(postId) { + try { + const postComments = await apiGet(`/api/comments/${postId}`); + const list = document.getElementById('commentsList'); + if (postComments.length === 0) { + list.innerHTML = '

No comments yet. Be the first!

'; + 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 ` +
+
${escapeHtml(c.author)}
+
${escapeHtml(c.text)}
+
${timeAgo(c.timestamp)}
+ ${childReplies.map(r => ` +
+
${escapeHtml(r.author)}
+
${escapeHtml(r.text)}
+
${timeAgo(r.timestamp)}
+
+ `).join('')} +
+ `; + }).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'); + } +} + +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'); +} + +function navigateHome() { + document.getElementById('searchInput').value = ''; + loadPosts(); +} + +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.remove(), 3000); +} + +function showError(msg) { + const el = document.getElementById('uploadError'); + el.textContent = msg; + el.style.display = 'block'; +} + +function hideError() { + document.getElementById('uploadError').style.display = 'none'; +} + +document.getElementById('uploadModal').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closeUploadModal(); +}); +document.getElementById('postModal').addEventListener('click', (e) => { + if (e.target === e.currentTarget) closePostModal(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeFullscreen(); + closePostModal(); + closeUploadModal(); + } +}); \ No newline at end of file