can add images to forum posts and comments
This commit is contained in:
@@ -57,7 +57,38 @@ const uploadMessageImage = multer({
|
|||||||
}
|
}
|
||||||
}).single('image');
|
}).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 = {
|
module.exports = {
|
||||||
uploadProfileImage,
|
uploadProfileImage,
|
||||||
uploadMessageImage
|
uploadMessageImage,
|
||||||
|
uploadForumPostImages,
|
||||||
|
uploadForumCommentImages
|
||||||
};
|
};
|
||||||
@@ -38,6 +38,11 @@ const ForumComment = sequelize.define('ForumComment', {
|
|||||||
isDeleted: {
|
isDeleted: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ const ForumPost = sequelize.define('ForumPost', {
|
|||||||
model: 'ForumComments',
|
model: 'ForumComments',
|
||||||
key: 'id'
|
key: 'id'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ router.get('/posts', async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
as: 'tags',
|
||||||
attributes: ['tagName']
|
attributes: ['id', 'tagName']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ router.get('/posts/:id', async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
as: 'tags',
|
||||||
attributes: ['tagName']
|
attributes: ['id', 'tagName']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: ForumComment,
|
model: ForumComment,
|
||||||
@@ -195,15 +196,28 @@ router.get('/posts/:id', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/forum/posts - Create new post
|
// POST /api/forum/posts - Create new post
|
||||||
router.post('/posts', authenticateToken, async (req, res) => {
|
router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => {
|
||||||
try {
|
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({
|
const post = await ForumPost.create({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
category,
|
category,
|
||||||
authorId: req.user.id
|
authorId: req.user.id,
|
||||||
|
images
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create tags if provided
|
// Create tags if provided
|
||||||
@@ -227,7 +241,7 @@ router.post('/posts', authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
as: 'tags',
|
||||||
attributes: ['tagName']
|
attributes: ['id', 'tagName']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -297,7 +311,7 @@ router.put('/posts/:id', authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
as: 'tags',
|
||||||
attributes: ['tagName']
|
attributes: ['id', 'tagName']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -388,7 +402,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
as: 'tags',
|
||||||
attributes: ['tagName']
|
attributes: ['id', 'tagName']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -464,7 +478,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
|
|||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
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
|
// 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 {
|
try {
|
||||||
const { content, parentCommentId } = req.body;
|
const { content, parentCommentId } = req.body;
|
||||||
const post = await ForumPost.findByPk(req.params.id);
|
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({
|
const comment = await ForumComment.create({
|
||||||
postId: req.params.id,
|
postId: req.params.id,
|
||||||
authorId: req.user.id,
|
authorId: req.user.id,
|
||||||
content,
|
content,
|
||||||
parentCommentId: parentCommentId || null
|
parentCommentId: parentCommentId || null,
|
||||||
|
images
|
||||||
});
|
});
|
||||||
|
|
||||||
// Increment comment count
|
// Increment comment count
|
||||||
@@ -653,7 +671,7 @@ router.get('/my-posts', authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
as: 'tags',
|
||||||
attributes: ['tagName']
|
attributes: ['id', 'tagName']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
|
|||||||
@@ -131,8 +131,12 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Serve static files from uploads directory
|
// Serve static files from uploads directory with CORS headers
|
||||||
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
|
app.use(
|
||||||
|
"/uploads",
|
||||||
|
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
|
||||||
|
express.static(path.join(__dirname, "uploads"))
|
||||||
|
);
|
||||||
|
|
||||||
// Public routes (no alpha access required)
|
// Public routes (no alpha access required)
|
||||||
app.use("/api/alpha", alphaRoutes);
|
app.use("/api/alpha", alphaRoutes);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import ForumImageUpload from './ForumImageUpload';
|
||||||
|
|
||||||
interface CommentFormProps {
|
interface CommentFormProps {
|
||||||
onSubmit: (content: string) => Promise<void>;
|
onSubmit: (content: string, images: File[]) => Promise<void>;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
@@ -16,9 +17,34 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
|||||||
isReply = false,
|
isReply = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -31,8 +57,10 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(content);
|
await onSubmit(content, imageFiles);
|
||||||
setContent('');
|
setContent('');
|
||||||
|
setImageFiles([]);
|
||||||
|
setImagePreviews([]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to post comment');
|
setError(err.message || 'Failed to post comment');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,6 +81,16 @@ const CommentForm: React.FC<CommentFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
{error && <div className="invalid-feedback">{error}</div>}
|
{error && <div className="invalid-feedback">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
{!isReply && (
|
||||||
|
<ForumImageUpload
|
||||||
|
imageFiles={imageFiles}
|
||||||
|
imagePreviews={imagePreviews}
|
||||||
|
onImageChange={handleImageChange}
|
||||||
|
onRemoveImage={handleRemoveImage}
|
||||||
|
maxImages={3}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { getForumImageUrl } from "../services/api";
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
comment: ForumComment;
|
comment: ForumComment;
|
||||||
@@ -51,7 +52,8 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReply = async (content: string) => {
|
const handleReply = async (content: string, images: File[]) => {
|
||||||
|
// Replies don't support images, so we ignore the images parameter
|
||||||
await onReply(comment.id, content);
|
await onReply(comment.id, content);
|
||||||
setShowReplyForm(false);
|
setShowReplyForm(false);
|
||||||
};
|
};
|
||||||
@@ -188,9 +190,33 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
|
<>
|
||||||
{comment.content}
|
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
</p>
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
{comment.images && comment.images.length > 0 && (
|
||||||
|
<div className="row g-2 mb-2">
|
||||||
|
{comment.images.map((image, index) => (
|
||||||
|
<div key={index} className="col-4 col-md-3">
|
||||||
|
<img
|
||||||
|
src={getForumImageUrl(image)}
|
||||||
|
alt={`Comment image`}
|
||||||
|
className="img-fluid rounded"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100px",
|
||||||
|
objectFit: "cover",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
window.open(getForumImageUrl(image), "_blank")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
|
|||||||
72
frontend/src/components/ForumImageUpload.tsx
Normal file
72
frontend/src/components/ForumImageUpload.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ForumImageUploadProps {
|
||||||
|
imageFiles: File[];
|
||||||
|
imagePreviews: string[];
|
||||||
|
onImageChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onRemoveImage: (index: number) => void;
|
||||||
|
maxImages?: number;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ForumImageUpload: React.FC<ForumImageUploadProps> = ({
|
||||||
|
imageFiles,
|
||||||
|
imagePreviews,
|
||||||
|
onImageChange,
|
||||||
|
onRemoveImage,
|
||||||
|
maxImages = 5,
|
||||||
|
compact = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={compact ? 'mb-2' : 'mb-3'}>
|
||||||
|
<label className="form-label mb-1">
|
||||||
|
<i className="bi bi-image me-1"></i>
|
||||||
|
Add Images (Max {maxImages})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
onChange={onImageChange}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
disabled={imageFiles.length >= maxImages}
|
||||||
|
/>
|
||||||
|
{imageFiles.length > 0 && (
|
||||||
|
<small className="text-muted d-block mt-1">
|
||||||
|
{imageFiles.length} / {maxImages} images selected
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imagePreviews.length > 0 && (
|
||||||
|
<div className="row mt-2 g-2">
|
||||||
|
{imagePreviews.map((preview, index) => (
|
||||||
|
<div key={`${imageFiles[index]?.name}-${index}`} className={compact ? 'col-4' : 'col-6 col-md-4 col-lg-3'}>
|
||||||
|
<div className="position-relative border rounded p-1" style={{ backgroundColor: '#f8f9fa' }}>
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt={`Preview ${index + 1}`}
|
||||||
|
className="img-fluid rounded"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: compact ? '150px' : '200px',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
|
||||||
|
onClick={() => onRemoveImage(index)}
|
||||||
|
style={{ padding: '2px 6px', fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForumImageUpload;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from "react-router-dom";
|
||||||
import { ForumPost } from '../types';
|
import { ForumPost } from "../types";
|
||||||
import CategoryBadge from './CategoryBadge';
|
import CategoryBadge from "./CategoryBadge";
|
||||||
import PostStatusBadge from './PostStatusBadge';
|
import PostStatusBadge from "./PostStatusBadge";
|
||||||
|
|
||||||
interface ForumPostListItemProps {
|
interface ForumPostListItemProps {
|
||||||
post: ForumPost;
|
post: ForumPost;
|
||||||
@@ -18,7 +18,7 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
|||||||
const diffDays = Math.floor(diffHours / 24);
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
if (diffMinutes < 1) {
|
if (diffMinutes < 1) {
|
||||||
return 'Just now';
|
return "Just now";
|
||||||
} else if (diffMinutes < 60) {
|
} else if (diffMinutes < 60) {
|
||||||
return `${diffMinutes}m ago`;
|
return `${diffMinutes}m ago`;
|
||||||
} else if (diffHours < 24) {
|
} else if (diffHours < 24) {
|
||||||
@@ -32,8 +32,10 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
|||||||
|
|
||||||
// Strip HTML tags for preview
|
// Strip HTML tags for preview
|
||||||
const getTextPreview = (html: string, maxLength: number = 100) => {
|
const getTextPreview = (html: string, maxLength: number = 100) => {
|
||||||
const text = html.replace(/<[^>]*>/g, '');
|
const text = html.replace(/<[^>]*>/g, "");
|
||||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
return text.length > maxLength
|
||||||
|
? `${text.substring(0, maxLength)}...`
|
||||||
|
: text;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,32 +45,30 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
|||||||
{/* Main content - 60% */}
|
{/* Main content - 60% */}
|
||||||
<div className="col-md-7">
|
<div className="col-md-7">
|
||||||
<div className="d-flex align-items-center gap-2 mb-1">
|
<div className="d-flex align-items-center gap-2 mb-1">
|
||||||
{post.isPinned && (
|
<div className="d-flex gap-2">
|
||||||
<span className="badge bg-danger badge-sm">
|
{post.isPinned && (
|
||||||
<i className="bi bi-pin-angle-fill"></i>
|
<span className="badge bg-danger badge-sm">
|
||||||
</span>
|
<i className="bi bi-pin-angle-fill"></i>
|
||||||
)}
|
</span>
|
||||||
<CategoryBadge category={post.category} />
|
)}
|
||||||
<PostStatusBadge status={post.status} />
|
<CategoryBadge category={post.category} />
|
||||||
{post.tags && post.tags.length > 0 && (
|
<PostStatusBadge status={post.status} />
|
||||||
<>
|
{post.tags &&
|
||||||
{post.tags.slice(0, 2).map((tag) => (
|
post.tags.length > 0 &&
|
||||||
|
post.tags.slice(0, 2).map((tag) => (
|
||||||
<span key={tag.id} className="badge bg-light text-dark">
|
<span key={tag.id} className="badge bg-light text-dark">
|
||||||
#{tag.tagName}
|
#{tag.tagName}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{post.tags.length > 2 && (
|
{post.tags && post.tags.length > 2 && (
|
||||||
<span className="badge bg-light text-dark">
|
<span className="badge bg-light text-dark">
|
||||||
+{post.tags.length - 2}
|
+{post.tags.length - 2}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 className="mb-1 text-dark">
|
<h6 className="mb-1 text-dark">{post.title}</h6>
|
||||||
{post.title}
|
|
||||||
</h6>
|
|
||||||
|
|
||||||
<p className="text-muted small mb-0">
|
<p className="text-muted small mb-0">
|
||||||
{getTextPreview(post.content)}
|
{getTextPreview(post.content)}
|
||||||
@@ -78,13 +78,21 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
|||||||
{/* Author - 20% */}
|
{/* Author - 20% */}
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<div className="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"
|
<div
|
||||||
style={{ width: '32px', height: '32px', fontSize: '14px', flexShrink: 0 }}>
|
className="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||||
{post.author?.firstName?.charAt(0) || '?'}
|
style={{
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
fontSize: "14px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.author?.firstName?.charAt(0) || "?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-truncate">
|
<div className="text-truncate">
|
||||||
<small className="text-dark d-block text-truncate">
|
<small className="text-dark d-block text-truncate">
|
||||||
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
|
{post.author?.firstName || "Unknown"}{" "}
|
||||||
|
{post.author?.lastName || ""}
|
||||||
</small>
|
</small>
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
{formatDate(post.updatedAt)}
|
{formatDate(post.updatedAt)}
|
||||||
@@ -98,7 +106,8 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
|||||||
<div className="d-flex flex-column align-items-end gap-1">
|
<div className="d-flex flex-column align-items-end gap-1">
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
<i className="bi bi-chat me-1"></i>
|
<i className="bi bi-chat me-1"></i>
|
||||||
{post.commentCount || 0} {post.commentCount === 1 ? 'reply' : 'replies'}
|
{post.commentCount || 0}{" "}
|
||||||
|
{post.commentCount === 1 ? "reply" : "replies"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, Link } from "react-router-dom";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { forumAPI } from "../services/api";
|
import { forumAPI } from "../services/api";
|
||||||
import TagInput from "../components/TagInput";
|
import TagInput from "../components/TagInput";
|
||||||
|
import ForumImageUpload from "../components/ForumImageUpload";
|
||||||
|
|
||||||
const CreateForumPost: React.FC = () => {
|
const CreateForumPost: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -21,6 +22,9 @@ const CreateForumPost: React.FC = () => {
|
|||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
value: "item_request",
|
value: "item_request",
|
||||||
@@ -57,6 +61,31 @@ const CreateForumPost: React.FC = () => {
|
|||||||
setFormData((prev) => ({ ...prev, tags }));
|
setFormData((prev) => ({ ...prev, tags }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -84,7 +113,24 @@ const CreateForumPost: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
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}`);
|
navigate(`/forum/${response.data.id}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || "Failed to create post");
|
setError(err.response?.data?.error || "Failed to create post");
|
||||||
@@ -223,7 +269,7 @@ const CreateForumPost: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="mb-4">
|
<div className="mb-3">
|
||||||
<label className="form-label">
|
<label className="form-label">
|
||||||
Tags <span className="text-muted">(optional)</span>
|
Tags <span className="text-muted">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -237,6 +283,17 @@ const CreateForumPost: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<ForumImageUpload
|
||||||
|
imageFiles={imageFiles}
|
||||||
|
imagePreviews={imagePreviews}
|
||||||
|
onImageChange={handleImageChange}
|
||||||
|
onRemoveImage={handleRemoveImage}
|
||||||
|
maxImages={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Category-specific guidelines */}
|
{/* Category-specific guidelines */}
|
||||||
{formData.category === "item_request" && (
|
{formData.category === "item_request" && (
|
||||||
<div className="alert alert-info mb-3">
|
<div className="alert alert-info mb-3">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { forumAPI } from '../services/api';
|
import { forumAPI, getForumImageUrl } from '../services/api';
|
||||||
import { ForumPost, ForumComment } from '../types';
|
import { ForumPost, ForumComment } from '../types';
|
||||||
import CategoryBadge from '../components/CategoryBadge';
|
import CategoryBadge from '../components/CategoryBadge';
|
||||||
import PostStatusBadge from '../components/PostStatusBadge';
|
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) {
|
if (!user) {
|
||||||
alert('Please log in to comment');
|
alert('Please log in to comment');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
await fetchPost(); // Refresh to get new comment
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new Error(err.response?.data?.error || 'Failed to post comment');
|
throw new Error(err.response?.data?.error || 'Failed to post comment');
|
||||||
@@ -57,7 +64,11 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
await fetchPost(); // Refresh to get new reply
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new Error(err.response?.data?.error || 'Failed to post reply');
|
throw new Error(err.response?.data?.error || 'Failed to post reply');
|
||||||
@@ -180,21 +191,19 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
<h1 className="h3 mb-2">{post.title}</h1>
|
<h1 className="h3 mb-2">{post.title}</h1>
|
||||||
|
|
||||||
<div className="d-flex gap-2 mb-2 flex-wrap">
|
<div className="d-flex gap-2 mb-2 flex-wrap">
|
||||||
<CategoryBadge category={post.category} />
|
<div className="d-flex gap-2">
|
||||||
<PostStatusBadge status={post.status} />
|
<CategoryBadge category={post.category} />
|
||||||
{post.tags && post.tags.length > 0 && (
|
<PostStatusBadge status={post.status} />
|
||||||
<>
|
</div>
|
||||||
{post.tags.map((tag) => (
|
{(post.tags || []).map((tag) => (
|
||||||
<Link
|
<Link
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
to={`/forum?tag=${tag.tagName}`}
|
to={`/forum?tag=${tag.tagName}`}
|
||||||
className="badge bg-light text-dark text-decoration-none"
|
className="badge bg-light text-dark text-decoration-none"
|
||||||
>
|
>
|
||||||
#{tag.tagName}
|
#{tag.tagName}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted small mb-3">
|
<div className="text-muted small mb-3">
|
||||||
@@ -210,6 +219,22 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
{post.content}
|
{post.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{post.images && post.images.length > 0 && (
|
||||||
|
<div className="row g-2 mb-3">
|
||||||
|
{post.images.map((image, index) => (
|
||||||
|
<div key={index} className="col-6 col-md-4">
|
||||||
|
<img
|
||||||
|
src={getForumImageUrl(image)}
|
||||||
|
alt={`Post image`}
|
||||||
|
className="img-fluid rounded"
|
||||||
|
style={{ width: '100%', height: '200px', objectFit: 'cover', cursor: 'pointer' }}
|
||||||
|
onClick={() => window.open(getForumImageUrl(image), '_blank')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAuthor && (
|
{isAuthor && (
|
||||||
<div className="d-flex gap-2 flex-wrap mb-3">
|
<div className="d-flex gap-2 flex-wrap mb-3">
|
||||||
{post.status !== 'closed' && (
|
{post.status !== 'closed' && (
|
||||||
|
|||||||
@@ -257,7 +257,12 @@ export const messageAPI = {
|
|||||||
export const forumAPI = {
|
export const forumAPI = {
|
||||||
getPosts: (params?: any) => api.get("/forum/posts", { params }),
|
getPosts: (params?: any) => api.get("/forum/posts", { params }),
|
||||||
getPost: (id: string) => api.get(`/forum/posts/${id}`),
|
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),
|
updatePost: (id: string, data: any) => api.put(`/forum/posts/${id}`, data),
|
||||||
deletePost: (id: string) => api.delete(`/forum/posts/${id}`),
|
deletePost: (id: string) => api.delete(`/forum/posts/${id}`),
|
||||||
updatePostStatus: (id: string, status: string) =>
|
updatePostStatus: (id: string, status: string) =>
|
||||||
@@ -266,8 +271,12 @@ export const forumAPI = {
|
|||||||
api.patch(`/forum/posts/${postId}/accept-answer`, { commentId }),
|
api.patch(`/forum/posts/${postId}/accept-answer`, { commentId }),
|
||||||
getMyPosts: () => api.get("/forum/my-posts"),
|
getMyPosts: () => api.get("/forum/my-posts"),
|
||||||
getTags: (params?: any) => api.get("/forum/tags", { params }),
|
getTags: (params?: any) => api.get("/forum/tags", { params }),
|
||||||
createComment: (postId: string, data: any) =>
|
createComment: (postId: string, formData: FormData) =>
|
||||||
api.post(`/forum/posts/${postId}/comments`, data),
|
api.post(`/forum/posts/${postId}/comments`, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
}),
|
||||||
updateComment: (commentId: string, data: any) =>
|
updateComment: (commentId: string, data: any) =>
|
||||||
api.put(`/forum/comments/${commentId}`, data),
|
api.put(`/forum/comments/${commentId}`, data),
|
||||||
deleteComment: (commentId: string) =>
|
deleteComment: (commentId: string) =>
|
||||||
@@ -322,4 +331,8 @@ export const feedbackAPI = {
|
|||||||
export const getMessageImageUrl = (imagePath: string) =>
|
export const getMessageImageUrl = (imagePath: string) =>
|
||||||
`${API_BASE_URL}/messages/images/${imagePath}`;
|
`${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;
|
export default api;
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export interface ForumPost {
|
|||||||
commentCount: number;
|
commentCount: number;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
acceptedAnswerId?: string;
|
acceptedAnswerId?: string;
|
||||||
|
images?: string[];
|
||||||
author?: User;
|
author?: User;
|
||||||
tags?: PostTag[];
|
tags?: PostTag[];
|
||||||
comments?: ForumComment[];
|
comments?: ForumComment[];
|
||||||
@@ -280,6 +281,7 @@ export interface ForumComment {
|
|||||||
content: string;
|
content: string;
|
||||||
parentCommentId?: string;
|
parentCommentId?: string;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
images?: string[];
|
||||||
author?: User;
|
author?: User;
|
||||||
replies?: ForumComment[];
|
replies?: ForumComment[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user