From 105f257c5ff832d9a45288c1a2e779af28b14d86 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:32:03 -0500 Subject: [PATCH] can add images to forum posts and comments --- backend/middleware/upload.js | 33 +++++++- backend/models/ForumComment.js | 5 ++ backend/models/ForumPost.js | 5 ++ backend/routes/forum.js | 42 ++++++++--- backend/server.js | 8 +- frontend/src/components/CommentForm.tsx | 42 ++++++++++- frontend/src/components/CommentThread.tsx | 34 ++++++++- frontend/src/components/ForumImageUpload.tsx | 72 ++++++++++++++++++ frontend/src/components/ForumPostListItem.tsx | 75 +++++++++++-------- frontend/src/pages/CreateForumPost.tsx | 61 ++++++++++++++- frontend/src/pages/ForumPostDetail.tsx | 63 +++++++++++----- frontend/src/services/api.ts | 19 ++++- frontend/src/types/index.ts | 2 + 13 files changed, 383 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/ForumImageUpload.tsx diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js index cd617cc..e7a7a0d 100644 --- a/backend/middleware/upload.js +++ b/backend/middleware/upload.js @@ -57,7 +57,38 @@ const uploadMessageImage = multer({ } }).single('image'); +// Configure storage for forum images +const forumImageStorage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, path.join(__dirname, '../uploads/forum')); + }, + filename: function (req, file, cb) { + const uniqueId = uuidv4(); + const ext = path.extname(file.originalname); + cb(null, `${uniqueId}${ext}`); + } +}); + +// Factory function to create forum image upload middleware +const createForumImageUpload = (maxFiles) => { + return multer({ + storage: forumImageStorage, + fileFilter: imageFileFilter, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit per file + } + }).array('images', maxFiles); +}; + +// Create multer upload middleware for forum post images (up to 5 images) +const uploadForumPostImages = createForumImageUpload(5); + +// Create multer upload middleware for forum comment images (up to 3 images) +const uploadForumCommentImages = createForumImageUpload(3); + module.exports = { uploadProfileImage, - uploadMessageImage + uploadMessageImage, + uploadForumPostImages, + uploadForumCommentImages }; \ No newline at end of file diff --git a/backend/models/ForumComment.js b/backend/models/ForumComment.js index b5a978a..976de15 100644 --- a/backend/models/ForumComment.js +++ b/backend/models/ForumComment.js @@ -38,6 +38,11 @@ const ForumComment = sequelize.define('ForumComment', { isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false + }, + images: { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: true, + defaultValue: [] } }); diff --git a/backend/models/ForumPost.js b/backend/models/ForumPost.js index cacf061..98cca13 100644 --- a/backend/models/ForumPost.js +++ b/backend/models/ForumPost.js @@ -51,6 +51,11 @@ const ForumPost = sequelize.define('ForumPost', { model: 'ForumComments', key: 'id' } + }, + images: { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: true, + defaultValue: [] } }); diff --git a/backend/routes/forum.js b/backend/routes/forum.js index e1d917c..d212c0a 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -2,6 +2,7 @@ const express = require('express'); const { Op } = require('sequelize'); const { ForumPost, ForumComment, PostTag, User } = require('../models'); const { authenticateToken } = require('../middleware/auth'); +const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload'); const logger = require('../utils/logger'); const router = express.Router(); @@ -83,7 +84,7 @@ router.get('/posts', async (req, res) => { { model: PostTag, as: 'tags', - attributes: ['tagName'] + attributes: ['id', 'tagName'] } ]; @@ -143,7 +144,7 @@ router.get('/posts/:id', async (req, res) => { { model: PostTag, as: 'tags', - attributes: ['tagName'] + attributes: ['id', 'tagName'] }, { model: ForumComment, @@ -195,15 +196,28 @@ router.get('/posts/:id', async (req, res) => { }); // POST /api/forum/posts - Create new post -router.post('/posts', authenticateToken, async (req, res) => { +router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => { try { - const { title, content, category, tags } = req.body; + let { title, content, category, tags } = req.body; + + // Parse tags if they come as JSON string (from FormData) + if (typeof tags === 'string') { + try { + tags = JSON.parse(tags); + } catch (e) { + tags = []; + } + } + + // Extract image filenames if uploaded + const images = req.files ? req.files.map(file => file.filename) : []; const post = await ForumPost.create({ title, content, category, - authorId: req.user.id + authorId: req.user.id, + images }); // Create tags if provided @@ -227,7 +241,7 @@ router.post('/posts', authenticateToken, async (req, res) => { { model: PostTag, as: 'tags', - attributes: ['tagName'] + attributes: ['id', 'tagName'] } ] }); @@ -297,7 +311,7 @@ router.put('/posts/:id', authenticateToken, async (req, res) => { { model: PostTag, as: 'tags', - attributes: ['tagName'] + attributes: ['id', 'tagName'] } ] }); @@ -388,7 +402,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => { { model: PostTag, as: 'tags', - attributes: ['tagName'] + attributes: ['id', 'tagName'] } ] }); @@ -464,7 +478,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) => { model: PostTag, as: 'tags', - attributes: ['tagName'] + attributes: ['id', 'tagName'] } ] }); @@ -490,7 +504,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) => }); // POST /api/forum/posts/:id/comments - Add comment/reply -router.post('/posts/:id/comments', authenticateToken, async (req, res) => { +router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, async (req, res) => { try { const { content, parentCommentId } = req.body; const post = await ForumPost.findByPk(req.params.id); @@ -507,11 +521,15 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res) => { } } + // Extract image filenames if uploaded + const images = req.files ? req.files.map(file => file.filename) : []; + const comment = await ForumComment.create({ postId: req.params.id, authorId: req.user.id, content, - parentCommentId: parentCommentId || null + parentCommentId: parentCommentId || null, + images }); // Increment comment count @@ -653,7 +671,7 @@ router.get('/my-posts', authenticateToken, async (req, res) => { { model: PostTag, as: 'tags', - attributes: ['tagName'] + attributes: ['id', 'tagName'] } ], order: [['createdAt', 'DESC']] diff --git a/backend/server.js b/backend/server.js index 38cd6d9..3bf28ff 100644 --- a/backend/server.js +++ b/backend/server.js @@ -131,8 +131,12 @@ app.use( }) ); -// Serve static files from uploads directory -app.use("/uploads", express.static(path.join(__dirname, "uploads"))); +// Serve static files from uploads directory with CORS headers +app.use( + "/uploads", + helmet.crossOriginResourcePolicy({ policy: "cross-origin" }), + express.static(path.join(__dirname, "uploads")) +); // Public routes (no alpha access required) app.use("/api/alpha", alphaRoutes); diff --git a/frontend/src/components/CommentForm.tsx b/frontend/src/components/CommentForm.tsx index 47531ba..46aaa9b 100644 --- a/frontend/src/components/CommentForm.tsx +++ b/frontend/src/components/CommentForm.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; +import ForumImageUpload from './ForumImageUpload'; interface CommentFormProps { - onSubmit: (content: string) => Promise; + onSubmit: (content: string, images: File[]) => Promise; onCancel?: () => void; placeholder?: string; buttonText?: string; @@ -16,9 +17,34 @@ const CommentForm: React.FC = ({ isReply = false, }) => { const [content, setContent] = useState(''); + const [imageFiles, setImageFiles] = useState([]); + const [imagePreviews, setImagePreviews] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(''); + const handleImageChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const remainingSlots = 3 - imageFiles.length; + const filesToAdd = files.slice(0, remainingSlots); + + setImageFiles((prev) => [...prev, ...filesToAdd]); + + filesToAdd.forEach((file) => { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreviews((prev) => [...prev, reader.result as string]); + }; + reader.readAsDataURL(file); + }); + + e.target.value = ""; + }; + + const handleRemoveImage = (index: number) => { + setImageFiles((prev) => prev.filter((_, i) => i !== index)); + setImagePreviews((prev) => prev.filter((_, i) => i !== index)); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -31,8 +57,10 @@ const CommentForm: React.FC = ({ setError(''); try { - await onSubmit(content); + await onSubmit(content, imageFiles); setContent(''); + setImageFiles([]); + setImagePreviews([]); } catch (err: any) { setError(err.message || 'Failed to post comment'); } finally { @@ -53,6 +81,16 @@ const CommentForm: React.FC = ({ /> {error &&
{error}
} + {!isReply && ( + + )}
) : ( -

- {comment.content} -

+ <> +

+ {comment.content} +

+ {comment.images && comment.images.length > 0 && ( +
+ {comment.images.map((image, index) => ( +
+ {`Comment + window.open(getForumImageUrl(image), "_blank") + } + /> +
+ ))} +
+ )} + )}
diff --git a/frontend/src/components/ForumImageUpload.tsx b/frontend/src/components/ForumImageUpload.tsx new file mode 100644 index 0000000..dc1f9ef --- /dev/null +++ b/frontend/src/components/ForumImageUpload.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +interface ForumImageUploadProps { + imageFiles: File[]; + imagePreviews: string[]; + onImageChange: (e: React.ChangeEvent) => void; + onRemoveImage: (index: number) => void; + maxImages?: number; + compact?: boolean; +} + +const ForumImageUpload: React.FC = ({ + imageFiles, + imagePreviews, + onImageChange, + onRemoveImage, + maxImages = 5, + compact = false +}) => { + return ( +
+ + = maxImages} + /> + {imageFiles.length > 0 && ( + + {imageFiles.length} / {maxImages} images selected + + )} + + {imagePreviews.length > 0 && ( +
+ {imagePreviews.map((preview, index) => ( +
+
+ {`Preview + +
+
+ ))} +
+ )} +
+ ); +}; + +export default ForumImageUpload; diff --git a/frontend/src/components/ForumPostListItem.tsx b/frontend/src/components/ForumPostListItem.tsx index 00d8b7d..80195a7 100644 --- a/frontend/src/components/ForumPostListItem.tsx +++ b/frontend/src/components/ForumPostListItem.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { ForumPost } from '../types'; -import CategoryBadge from './CategoryBadge'; -import PostStatusBadge from './PostStatusBadge'; +import React from "react"; +import { Link } from "react-router-dom"; +import { ForumPost } from "../types"; +import CategoryBadge from "./CategoryBadge"; +import PostStatusBadge from "./PostStatusBadge"; interface ForumPostListItemProps { post: ForumPost; @@ -18,7 +18,7 @@ const ForumPostListItem: React.FC = ({ post }) => { const diffDays = Math.floor(diffHours / 24); if (diffMinutes < 1) { - return 'Just now'; + return "Just now"; } else if (diffMinutes < 60) { return `${diffMinutes}m ago`; } else if (diffHours < 24) { @@ -32,8 +32,10 @@ const ForumPostListItem: React.FC = ({ post }) => { // Strip HTML tags for preview const getTextPreview = (html: string, maxLength: number = 100) => { - const text = html.replace(/<[^>]*>/g, ''); - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; + const text = html.replace(/<[^>]*>/g, ""); + return text.length > maxLength + ? `${text.substring(0, maxLength)}...` + : text; }; return ( @@ -43,32 +45,30 @@ const ForumPostListItem: React.FC = ({ post }) => { {/* Main content - 60% */}
- {post.isPinned && ( - - - - )} - - - {post.tags && post.tags.length > 0 && ( - <> - {post.tags.slice(0, 2).map((tag) => ( +
+ {post.isPinned && ( + + + + )} + + + {post.tags && + post.tags.length > 0 && + post.tags.slice(0, 2).map((tag) => ( #{tag.tagName} ))} - {post.tags.length > 2 && ( - - +{post.tags.length - 2} - - )} - - )} + {post.tags && post.tags.length > 2 && ( + + +{post.tags.length - 2} + + )} +
-
- {post.title} -
+
{post.title}

{getTextPreview(post.content)} @@ -78,13 +78,21 @@ const ForumPostListItem: React.FC = ({ post }) => { {/* Author - 20% */}

-
- {post.author?.firstName?.charAt(0) || '?'} +
+ {post.author?.firstName?.charAt(0) || "?"}
- {post.author?.firstName || 'Unknown'} {post.author?.lastName || ''} + {post.author?.firstName || "Unknown"}{" "} + {post.author?.lastName || ""} {formatDate(post.updatedAt)} @@ -98,7 +106,8 @@ const ForumPostListItem: React.FC = ({ post }) => {
- {post.commentCount || 0} {post.commentCount === 1 ? 'reply' : 'replies'} + {post.commentCount || 0}{" "} + {post.commentCount === 1 ? "reply" : "replies"}
diff --git a/frontend/src/pages/CreateForumPost.tsx b/frontend/src/pages/CreateForumPost.tsx index 984f0f9..a9d2d82 100644 --- a/frontend/src/pages/CreateForumPost.tsx +++ b/frontend/src/pages/CreateForumPost.tsx @@ -3,6 +3,7 @@ import { useNavigate, Link } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { forumAPI } from "../services/api"; import TagInput from "../components/TagInput"; +import ForumImageUpload from "../components/ForumImageUpload"; const CreateForumPost: React.FC = () => { const { user } = useAuth(); @@ -21,6 +22,9 @@ const CreateForumPost: React.FC = () => { tags: [] as string[], }); + const [imageFiles, setImageFiles] = useState([]); + const [imagePreviews, setImagePreviews] = useState([]); + const categories = [ { value: "item_request", @@ -57,6 +61,31 @@ const CreateForumPost: React.FC = () => { setFormData((prev) => ({ ...prev, tags })); }; + const handleImageChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const remainingSlots = 5 - imageFiles.length; + const filesToAdd = files.slice(0, remainingSlots); + + setImageFiles((prev) => [...prev, ...filesToAdd]); + + // Create preview URLs + filesToAdd.forEach((file) => { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreviews((prev) => [...prev, reader.result as string]); + }; + reader.readAsDataURL(file); + }); + + // Reset input + e.target.value = ""; + }; + + const handleRemoveImage = (index: number) => { + setImageFiles((prev) => prev.filter((_, i) => i !== index)); + setImagePreviews((prev) => prev.filter((_, i) => i !== index)); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -84,7 +113,24 @@ const CreateForumPost: React.FC = () => { try { setIsSubmitting(true); - const response = await forumAPI.createPost(formData); + + // Create FormData + const submitData = new FormData(); + submitData.append('title', formData.title); + submitData.append('content', formData.content); + submitData.append('category', formData.category); + + // Add tags as JSON string + if (formData.tags.length > 0) { + submitData.append('tags', JSON.stringify(formData.tags)); + } + + // Add images + imageFiles.forEach((file) => { + submitData.append('images', file); + }); + + const response = await forumAPI.createPost(submitData); navigate(`/forum/${response.data.id}`); } catch (err: any) { setError(err.response?.data?.error || "Failed to create post"); @@ -223,7 +269,7 @@ const CreateForumPost: React.FC = () => {
{/* Tags */} -
+
@@ -237,6 +283,17 @@ const CreateForumPost: React.FC = () => {
+ {/* Images */} +
+ +
+ {/* Category-specific guidelines */} {formData.category === "item_request" && (
diff --git a/frontend/src/pages/ForumPostDetail.tsx b/frontend/src/pages/ForumPostDetail.tsx index b4ff4fe..d3d9d12 100644 --- a/frontend/src/pages/ForumPostDetail.tsx +++ b/frontend/src/pages/ForumPostDetail.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; -import { forumAPI } from '../services/api'; +import { forumAPI, getForumImageUrl } from '../services/api'; import { ForumPost, ForumComment } from '../types'; import CategoryBadge from '../components/CategoryBadge'; import PostStatusBadge from '../components/PostStatusBadge'; @@ -36,14 +36,21 @@ const ForumPostDetail: React.FC = () => { } }; - const handleAddComment = async (content: string) => { + const handleAddComment = async (content: string, images: File[]) => { if (!user) { alert('Please log in to comment'); return; } try { - await forumAPI.createComment(id!, { content }); + const formData = new FormData(); + formData.append('content', content); + + images.forEach((file) => { + formData.append('images', file); + }); + + await forumAPI.createComment(id!, formData); await fetchPost(); // Refresh to get new comment } catch (err: any) { throw new Error(err.response?.data?.error || 'Failed to post comment'); @@ -57,7 +64,11 @@ const ForumPostDetail: React.FC = () => { } try { - await forumAPI.createComment(id!, { content, parentCommentId }); + const formData = new FormData(); + formData.append('content', content); + formData.append('parentCommentId', parentCommentId); + + await forumAPI.createComment(id!, formData); await fetchPost(); // Refresh to get new reply } catch (err: any) { throw new Error(err.response?.data?.error || 'Failed to post reply'); @@ -180,21 +191,19 @@ const ForumPostDetail: React.FC = () => {

{post.title}

- - - {post.tags && post.tags.length > 0 && ( - <> - {post.tags.map((tag) => ( - - #{tag.tagName} - - ))} - - )} +
+ + +
+ {(post.tags || []).map((tag) => ( + + #{tag.tagName} + + ))}
@@ -210,6 +219,22 @@ const ForumPostDetail: React.FC = () => { {post.content}
+ {post.images && post.images.length > 0 && ( +
+ {post.images.map((image, index) => ( +
+ {`Post window.open(getForumImageUrl(image), '_blank')} + /> +
+ ))} +
+ )} + {isAuthor && (
{post.status !== 'closed' && ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a8170fc..3a46018 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -257,7 +257,12 @@ export const messageAPI = { export const forumAPI = { getPosts: (params?: any) => api.get("/forum/posts", { params }), getPost: (id: string) => api.get(`/forum/posts/${id}`), - createPost: (data: any) => api.post("/forum/posts", data), + createPost: (formData: FormData) => + api.post("/forum/posts", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }), updatePost: (id: string, data: any) => api.put(`/forum/posts/${id}`, data), deletePost: (id: string) => api.delete(`/forum/posts/${id}`), updatePostStatus: (id: string, status: string) => @@ -266,8 +271,12 @@ export const forumAPI = { api.patch(`/forum/posts/${postId}/accept-answer`, { commentId }), getMyPosts: () => api.get("/forum/my-posts"), getTags: (params?: any) => api.get("/forum/tags", { params }), - createComment: (postId: string, data: any) => - api.post(`/forum/posts/${postId}/comments`, data), + createComment: (postId: string, formData: FormData) => + api.post(`/forum/posts/${postId}/comments`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }), updateComment: (commentId: string, data: any) => api.put(`/forum/comments/${commentId}`, data), deleteComment: (commentId: string) => @@ -322,4 +331,8 @@ export const feedbackAPI = { export const getMessageImageUrl = (imagePath: string) => `${API_BASE_URL}/messages/images/${imagePath}`; +// Helper to construct forum image URLs +export const getForumImageUrl = (imagePath: string) => + `${process.env.REACT_APP_BASE_URL}/uploads/forum/${imagePath}`; + export default api; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ff0630e..3601324 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -266,6 +266,7 @@ export interface ForumPost { commentCount: number; isPinned: boolean; acceptedAnswerId?: string; + images?: string[]; author?: User; tags?: PostTag[]; comments?: ForumComment[]; @@ -280,6 +281,7 @@ export interface ForumComment { content: string; parentCommentId?: string; isDeleted: boolean; + images?: string[]; author?: User; replies?: ForumComment[]; createdAt: string;