images for forum and forum comments

This commit is contained in:
jackiettran
2025-12-13 20:32:25 -05:00
parent 55e08e14b8
commit 5e01bb8cff
7 changed files with 294 additions and 92 deletions

View File

@@ -1111,7 +1111,7 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res, next) =>
// PUT /api/forum/comments/:id - Edit comment // PUT /api/forum/comments/:id - Edit comment
router.put('/comments/:id', authenticateToken, async (req, res, next) => { router.put('/comments/:id', authenticateToken, async (req, res, next) => {
try { try {
const { content } = req.body; const { content, imageFilenames: rawImageFilenames } = req.body;
const comment = await ForumComment.findByPk(req.params.id); const comment = await ForumComment.findByPk(req.params.id);
if (!comment) { if (!comment) {
@@ -1126,7 +1126,19 @@ router.put('/comments/:id', authenticateToken, async (req, res, next) => {
return res.status(400).json({ error: 'Cannot edit deleted comment' }); return res.status(400).json({ error: 'Cannot edit deleted comment' });
} }
await comment.update({ content }); const updateData = { content };
// Handle image filenames if provided
if (rawImageFilenames !== undefined) {
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
if (!keyValidation.valid) {
return res.status(400).json({ error: keyValidation.error });
}
updateData.imageFilenames = imageFilenamesArray;
}
await comment.update(updateData);
const updatedComment = await ForumComment.findByPk(comment.id, { const updatedComment = await ForumComment.findByPk(comment.id, {
include: [ include: [

View File

@@ -159,6 +159,14 @@ const AppContent: React.FC = () => {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/forum/:id/edit"
element={
<PrivateRoute>
<CreateForumPost />
</PrivateRoute>
}
/>
<Route <Route
path="/my-posts" path="/my-posts"
element={ element={

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import ForumImageUpload from './ForumImageUpload'; import ForumImageUpload from './ForumImageUpload';
import { IMAGE_LIMITS } from '../config/imageLimits';
interface CommentFormProps { interface CommentFormProps {
onSubmit: (content: string, images: File[]) => Promise<void>; onSubmit: (content: string, images: File[]) => Promise<void>;
@@ -24,7 +25,7 @@ const CommentForm: React.FC<CommentFormProps> = ({
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const remainingSlots = 3 - imageFiles.length; const remainingSlots = IMAGE_LIMITS.forum - imageFiles.length;
const filesToAdd = files.slice(0, remainingSlots); const filesToAdd = files.slice(0, remainingSlots);
setImageFiles((prev) => [...prev, ...filesToAdd]); setImageFiles((prev) => [...prev, ...filesToAdd]);
@@ -81,7 +82,6 @@ const CommentForm: React.FC<CommentFormProps> = ({
/> />
{error && <div className="invalid-feedback">{error}</div>} {error && <div className="invalid-feedback">{error}</div>}
</div> </div>
{!isReply && (
<ForumImageUpload <ForumImageUpload
imageFiles={imageFiles} imageFiles={imageFiles}
imagePreviews={imagePreviews} imagePreviews={imagePreviews}
@@ -89,7 +89,6 @@ const CommentForm: React.FC<CommentFormProps> = ({
onRemoveImage={handleRemoveImage} onRemoveImage={handleRemoveImage}
compact={true} compact={true}
/> />
)}
<div className="d-flex gap-2"> <div className="d-flex gap-2">
<button <button
type="submit" type="submit"

View File

@@ -1,12 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ForumComment } from "../types"; import { ForumComment } from "../types";
import CommentForm from "./CommentForm"; import CommentForm from "./CommentForm";
import ForumImageUpload from "./ForumImageUpload";
import { getPublicImageUrl } from "../services/uploadService"; import { getPublicImageUrl } from "../services/uploadService";
import { IMAGE_LIMITS } from "../config/imageLimits";
interface CommentThreadProps { interface CommentThreadProps {
comment: ForumComment; comment: ForumComment;
onReply: (commentId: string, content: string) => Promise<void>; onReply: (commentId: string, content: string, images?: File[]) => Promise<void>;
onEdit?: (commentId: string, content: string) => Promise<void>; onEdit?: (commentId: string, content: string, existingImageKeys: string[], newImageFiles: File[]) => Promise<void>;
onDelete?: (commentId: string) => Promise<void>; onDelete?: (commentId: string) => Promise<void>;
onMarkAsAnswer?: (commentId: string) => Promise<void>; onMarkAsAnswer?: (commentId: string) => Promise<void>;
currentUserId?: string; currentUserId?: string;
@@ -37,6 +39,11 @@ const CommentThread: React.FC<CommentThreadProps> = ({
const [editContent, setEditContent] = useState(comment.content); const [editContent, setEditContent] = useState(comment.content);
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
// Image editing state
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]);
const [editImageFiles, setEditImageFiles] = useState<File[]>([]);
const [editImagePreviews, setEditImagePreviews] = useState<string[]>([]);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -59,17 +66,67 @@ const CommentThread: React.FC<CommentThreadProps> = ({
}; };
const handleReply = async (content: string, images: File[]) => { const handleReply = async (content: string, images: File[]) => {
// Replies don't support images, so we ignore the images parameter await onReply(comment.id, content, images);
await onReply(comment.id, content);
setShowReplyForm(false); setShowReplyForm(false);
}; };
const handleEdit = async () => { const startEditing = () => {
if (onEdit && editContent.trim() !== comment.content) { setIsEditing(true);
await onEdit(comment.id, editContent); setEditContent(comment.content);
const existingKeys = comment.imageFilenames || [];
setExistingImageKeys(existingKeys);
setEditImagePreviews(existingKeys.map((key) => getPublicImageUrl(key)));
setEditImageFiles([]);
};
const cancelEditing = () => {
setIsEditing(false); setIsEditing(false);
setEditContent(comment.content);
setExistingImageKeys([]);
setEditImageFiles([]);
setEditImagePreviews([]);
};
const handleEditImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const totalImages = existingImageKeys.length + editImageFiles.length;
const remainingSlots = IMAGE_LIMITS.forum - totalImages;
const filesToAdd = files.slice(0, remainingSlots);
setEditImageFiles((prev) => [...prev, ...filesToAdd]);
filesToAdd.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setEditImagePreviews((prev) => [...prev, reader.result as string]);
};
reader.readAsDataURL(file);
});
e.target.value = "";
};
const handleRemoveEditImage = (index: number) => {
if (index < existingImageKeys.length) {
// Removing an existing S3 image
setExistingImageKeys((prev) => prev.filter((_, i) => i !== index));
} else { } else {
// Removing a new upload
const newFileIndex = index - existingImageKeys.length;
setEditImageFiles((prev) => prev.filter((_, i) => i !== newFileIndex));
}
setEditImagePreviews((prev) => prev.filter((_, i) => i !== index));
};
const handleEdit = async () => {
if (onEdit && editContent.trim()) {
await onEdit(comment.id, editContent, existingImageKeys, editImageFiles);
setIsEditing(false); setIsEditing(false);
setExistingImageKeys([]);
setEditImageFiles([]);
setEditImagePreviews([]);
} else {
cancelEditing();
} }
}; };
@@ -188,7 +245,14 @@ const CommentThread: React.FC<CommentThreadProps> = ({
value={editContent} value={editContent}
onChange={(e) => setEditContent(e.target.value)} onChange={(e) => setEditContent(e.target.value)}
/> />
<div className="d-flex gap-2"> <ForumImageUpload
imageFiles={editImageFiles}
imagePreviews={editImagePreviews}
onImageChange={handleEditImageChange}
onRemoveImage={handleRemoveEditImage}
compact={true}
/>
<div className="d-flex gap-2 mt-2">
<button <button
className="btn btn-sm btn-primary" className="btn btn-sm btn-primary"
onClick={handleEdit} onClick={handleEdit}
@@ -198,10 +262,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
</button> </button>
<button <button
className="btn btn-sm btn-secondary" className="btn btn-sm btn-secondary"
onClick={() => { onClick={cancelEditing}
setIsEditing(false);
setEditContent(comment.content);
}}
> >
Cancel Cancel
</button> </button>
@@ -222,8 +283,8 @@ const CommentThread: React.FC<CommentThreadProps> = ({
className="img-fluid rounded" className="img-fluid rounded"
style={{ style={{
width: "100%", width: "100%",
height: "100px", maxHeight: "300px",
objectFit: "cover", objectFit: "contain",
cursor: "pointer", cursor: "pointer",
}} }}
onClick={() => onClick={() =>
@@ -267,7 +328,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
{isAuthor && onEdit && !isEditing && ( {isAuthor && onEdit && !isEditing && (
<button <button
className="btn btn-sm btn-link text-decoration-none p-0" className="btn btn-sm btn-link text-decoration-none p-0"
onClick={() => setIsEditing(true)} onClick={startEditing}
> >
<i className="bi bi-pencil me-1"></i> <i className="bi bi-pencil me-1"></i>
Edit Edit

View File

@@ -1,18 +1,22 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link, useParams } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { forumAPI, addressAPI } from "../services/api"; import { forumAPI, addressAPI } from "../services/api";
import { uploadFiles } from "../services/uploadService"; import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
import TagInput from "../components/TagInput"; import TagInput from "../components/TagInput";
import ForumImageUpload from "../components/ForumImageUpload"; import ForumImageUpload from "../components/ForumImageUpload";
import { Address } from "../types"; import { Address, ForumPost } from "../types";
const CreateForumPost: React.FC = () => { const CreateForumPost: React.FC = () => {
const { id } = useParams<{ id: string }>();
const isEditMode = !!id;
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(isEditMode);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [userAddresses, setUserAddresses] = useState<Address[]>([]); const [userAddresses, setUserAddresses] = useState<Address[]>([]);
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: "", title: "",
@@ -33,7 +37,48 @@ const CreateForumPost: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchUserAddresses(); fetchUserAddresses();
}, []); if (isEditMode && id) {
fetchPost();
}
}, [id, isEditMode]);
const fetchPost = async () => {
try {
setLoading(true);
const response = await forumAPI.getPost(id!);
const post: ForumPost = response.data;
// Verify user is the author
if (post.authorId !== user?.id) {
setError("You are not authorized to edit this post");
setLoading(false);
return;
}
// Populate form with existing data
setFormData({
title: post.title,
content: post.content,
category: post.category as any,
tags: (post.tags || []).map((t) => t.tagName),
zipCode: post.zipCode || user?.zipCode || "",
latitude: post.latitude,
longitude: post.longitude,
});
// Set existing images
if (post.imageFilenames && post.imageFilenames.length > 0) {
setExistingImageKeys(post.imageFilenames);
setImagePreviews(
post.imageFilenames.map((key: string) => getPublicImageUrl(key))
);
}
} catch (err: any) {
setError(err.response?.data?.error || "Failed to fetch post");
} finally {
setLoading(false);
}
};
const fetchUserAddresses = async () => { const fetchUserAddresses = async () => {
try { try {
@@ -96,7 +141,8 @@ const CreateForumPost: React.FC = () => {
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const remainingSlots = 5 - imageFiles.length; const totalImages = existingImageKeys.length + imageFiles.length;
const remainingSlots = 5 - totalImages;
const filesToAdd = files.slice(0, remainingSlots); const filesToAdd = files.slice(0, remainingSlots);
setImageFiles((prev) => [...prev, ...filesToAdd]); setImageFiles((prev) => [...prev, ...filesToAdd]);
@@ -115,7 +161,14 @@ const CreateForumPost: React.FC = () => {
}; };
const handleRemoveImage = (index: number) => { const handleRemoveImage = (index: number) => {
setImageFiles((prev) => prev.filter((_, i) => i !== index)); if (index < existingImageKeys.length) {
// Removing an existing S3 image
setExistingImageKeys((prev) => prev.filter((_, i) => i !== index));
} else {
// Removing a new upload
const newFileIndex = index - existingImageKeys.length;
setImageFiles((prev) => prev.filter((_, i) => i !== newFileIndex));
}
setImagePreviews((prev) => prev.filter((_, i) => i !== index)); setImagePreviews((prev) => prev.filter((_, i) => i !== index));
}; };
@@ -123,24 +176,12 @@ const CreateForumPost: React.FC = () => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
// Validation // Validation - character minimums shown inline, just prevent submission
if (!formData.title.trim()) { if (!formData.title.trim() || formData.title.length < 10) {
setError("Title is required");
return; return;
} }
if (formData.title.length < 10) { if (!formData.content.trim() || formData.content.length < 20) {
setError("Title must be at least 10 characters long");
return;
}
if (!formData.content.trim()) {
setError("Content is required");
return;
}
if (formData.content.length < 20) {
setError("Content must be at least 20 characters long");
return; return;
} }
@@ -190,25 +231,58 @@ const CreateForumPost: React.FC = () => {
} }
} }
// Add S3 image keys // Combine existing and new S3 image keys
if (imageFilenames.length > 0) { const allImageKeys = [...existingImageKeys, ...imageFilenames];
postData.imageFilenames = imageFilenames; if (allImageKeys.length > 0) {
postData.imageFilenames = allImageKeys;
} }
if (isEditMode) {
await forumAPI.updatePost(id!, postData);
navigate(`/forum/${id}`);
} else {
const response = await forumAPI.createPost(postData); const response = await forumAPI.createPost(postData);
navigate(`/forum/${response.data.id}`); navigate(`/forum/${response.data.id}`);
}
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || err.message || "Failed to create post"); setError(err.response?.data?.error || err.message || `Failed to ${isEditMode ? 'update' : 'create'} post`);
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
if (loading) {
return (
<div className="container mt-4">
<div className="text-center py-5">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (!user) { if (!user) {
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<div className="alert alert-warning" role="alert"> <div className="alert alert-warning" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i> <i className="bi bi-exclamation-triangle me-2"></i>
You must be logged in to create a post. You must be logged in to {isEditMode ? 'edit' : 'create'} a post.
</div>
<Link to="/forum" className="btn btn-secondary">
<i className="bi bi-arrow-left me-2"></i>
Back to Forum
</Link>
</div>
);
}
if (error && error.includes("authorized")) {
return (
<div className="container mt-4">
<div className="alert alert-danger" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i>
{error}
</div> </div>
<Link to="/forum" className="btn btn-secondary"> <Link to="/forum" className="btn btn-secondary">
<i className="bi bi-arrow-left me-2"></i> <i className="bi bi-arrow-left me-2"></i>
@@ -225,15 +299,21 @@ const CreateForumPost: React.FC = () => {
<li className="breadcrumb-item"> <li className="breadcrumb-item">
<Link to="/forum">Forum</Link> <Link to="/forum">Forum</Link>
</li> </li>
{isEditMode && (
<li className="breadcrumb-item">
<Link to={`/forum/${id}`}>Post</Link>
</li>
)}
<li className="breadcrumb-item active" aria-current="page"> <li className="breadcrumb-item active" aria-current="page">
Create Post {isEditMode ? 'Edit' : 'Create Post'}
</li> </li>
</ol> </ol>
</nav> </nav>
<div className="row"> <div className="row">
<div className="col-lg-8 mx-auto"> <div className="col-lg-8 mx-auto">
{/* Guidelines Card */} {/* Guidelines Card - only show for new posts */}
{!isEditMode && (
<div className="card mb-3"> <div className="card mb-3">
<div className="card-header"> <div className="card-header">
<h6 className="mb-0"> <h6 className="mb-0">
@@ -252,10 +332,11 @@ const CreateForumPost: React.FC = () => {
</ul> </ul>
</div> </div>
</div> </div>
)}
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
<h3 className="mb-0">Create New Post</h3> <h3 className="mb-0">{isEditMode ? 'Edit Post' : 'Create New Post'}</h3>
</div> </div>
<div className="card-body"> <div className="card-body">
{error && ( {error && (
@@ -272,7 +353,7 @@ const CreateForumPost: React.FC = () => {
</label> </label>
<input <input
type="text" type="text"
className="form-control" className={`form-control ${formData.title.length > 0 && formData.title.length < 10 ? 'is-invalid' : ''}`}
id="title" id="title"
name="title" name="title"
value={formData.title} value={formData.title}
@@ -282,9 +363,15 @@ const CreateForumPost: React.FC = () => {
disabled={isSubmitting} disabled={isSubmitting}
required required
/> />
{formData.title.length > 0 && formData.title.length < 10 ? (
<div className="invalid-feedback d-block">
{10 - formData.title.length} more characters needed (minimum 10)
</div>
) : (
<div className="form-text"> <div className="form-text">
{formData.title.length}/200 characters (minimum 10) {formData.title.length}/200 characters (minimum 10)
</div> </div>
)}
</div> </div>
{/* Category */} {/* Category */}
@@ -343,7 +430,7 @@ const CreateForumPost: React.FC = () => {
Content <span className="text-danger">*</span> Content <span className="text-danger">*</span>
</label> </label>
<textarea <textarea
className="form-control" className={`form-control ${formData.content.length > 0 && formData.content.length < 20 ? 'is-invalid' : ''}`}
id="content" id="content"
name="content" name="content"
rows={10} rows={10}
@@ -353,9 +440,15 @@ const CreateForumPost: React.FC = () => {
disabled={isSubmitting} disabled={isSubmitting}
required required
/> />
{formData.content.length > 0 && formData.content.length < 20 ? (
<div className="invalid-feedback d-block">
{20 - formData.content.length} more characters needed (minimum 20)
</div>
) : (
<div className="form-text"> <div className="form-text">
{formData.content.length} characters (minimum 20) {formData.content.length} characters (minimum 20)
</div> </div>
)}
</div> </div>
{/* Tags */} {/* Tags */}
@@ -426,17 +519,17 @@ const CreateForumPost: React.FC = () => {
role="status" role="status"
aria-hidden="true" aria-hidden="true"
></span> ></span>
Creating... {isEditMode ? 'Saving...' : 'Creating...'}
</> </>
) : ( ) : (
<> <>
<i className="bi bi-send me-2"></i> <i className={`bi ${isEditMode ? 'bi-check-lg' : 'bi-send'} me-2`}></i>
Create Post {isEditMode ? 'Save Changes' : 'Create Post'}
</> </>
)} )}
</button> </button>
<Link <Link
to="/forum" to={isEditMode ? `/forum/${id}` : '/forum'}
className={`btn btn-secondary ${ className={`btn btn-secondary ${
isSubmitting ? "disabled" : "" isSubmitting ? "disabled" : ""
}`} }`}

View File

@@ -72,16 +72,24 @@ const ForumPostDetail: React.FC = () => {
} }
}; };
const handleReply = async (parentCommentId: string, content: string) => { const handleReply = async (parentCommentId: string, content: string, images: File[] = []) => {
if (!user) { if (!user) {
alert('Please log in to reply'); alert('Please log in to reply');
return; return;
} }
try { try {
// Upload images to S3 first (if any)
let imageFilenames: string[] = [];
if (images.length > 0) {
const uploadResults = await uploadFiles("forum", images);
imageFilenames = uploadResults.map((result) => result.key);
}
await forumAPI.createComment(id!, { await forumAPI.createComment(id!, {
content, content,
parentId: parentCommentId, parentId: parentCommentId,
imageFilenames: imageFilenames.length > 0 ? imageFilenames : undefined,
}); });
await fetchPost(); // Refresh to get new reply await fetchPost(); // Refresh to get new reply
} catch (err: any) { } catch (err: any) {
@@ -89,9 +97,27 @@ const ForumPostDetail: React.FC = () => {
} }
}; };
const handleEditComment = async (commentId: string, content: string) => { const handleEditComment = async (
commentId: string,
content: string,
existingImageKeys: string[],
newImageFiles: File[]
) => {
try { try {
await forumAPI.updateComment(commentId, { content }); // Upload new images to S3
let newImageFilenames: string[] = [];
if (newImageFiles.length > 0) {
const uploadResults = await uploadFiles("forum", newImageFiles);
newImageFilenames = uploadResults.map((result) => result.key);
}
// Combine existing and new image keys
const allImageKeys = [...existingImageKeys, ...newImageFilenames];
await forumAPI.updateComment(commentId, {
content,
imageFilenames: allImageKeys,
});
await fetchPost(); // Refresh to get updated comment await fetchPost(); // Refresh to get updated comment
} catch (err: any) { } catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to update comment'); throw new Error(err.response?.data?.error || 'Failed to update comment');
@@ -354,7 +380,7 @@ const ForumPostDetail: React.FC = () => {
src={getPublicImageUrl(image)} src={getPublicImageUrl(image)}
alt={`Post image`} alt={`Post image`}
className="img-fluid rounded" className="img-fluid rounded"
style={{ width: '100%', height: '200px', objectFit: 'cover', cursor: 'pointer' }} style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', cursor: 'pointer' }}
onClick={() => window.open(getPublicImageUrl(image), '_blank')} onClick={() => window.open(getPublicImageUrl(image), '_blank')}
/> />
</div> </div>

View File

@@ -266,6 +266,9 @@ export interface ForumPost {
isPinned: boolean; isPinned: boolean;
acceptedAnswerId?: string; acceptedAnswerId?: string;
imageFilenames?: string[]; imageFilenames?: string[];
zipCode?: string;
latitude?: number;
longitude?: number;
isDeleted?: boolean; isDeleted?: boolean;
deletedBy?: string; deletedBy?: string;
deletedAt?: string; deletedAt?: string;