admin soft delete functionality, also fixed google sign in when user doesn't have first and last name
This commit is contained in:
@@ -127,4 +127,23 @@ const requireVerifiedEmail = (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { authenticateToken, optionalAuth, requireVerifiedEmail };
|
// Require admin role middleware - must be used after authenticateToken
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: "Authentication required",
|
||||||
|
code: "NO_AUTH",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user.role !== "admin") {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Admin access required",
|
||||||
|
code: "INSUFFICIENT_PERMISSIONS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { authenticateToken, optionalAuth, requireVerifiedEmail, requireAdmin };
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ const ForumComment = sequelize.define('ForumComment', {
|
|||||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
|
},
|
||||||
|
deletedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ const ForumPost = sequelize.define('ForumPost', {
|
|||||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
|
},
|
||||||
|
isDeleted: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
deletedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ const User = sequelize.define(
|
|||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
role: {
|
||||||
|
type: DataTypes.ENUM("user", "admin"),
|
||||||
|
defaultValue: "user",
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hooks: {
|
hooks: {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ router.post(
|
|||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
isVerified: user.isVerified,
|
isVerified: user.isVerified,
|
||||||
|
role: user.role,
|
||||||
},
|
},
|
||||||
verificationEmailSent,
|
verificationEmailSent,
|
||||||
// Don't send token in response body for security
|
// Don't send token in response body for security
|
||||||
@@ -269,6 +270,7 @@ router.post(
|
|||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
isVerified: user.isVerified,
|
isVerified: user.isVerified,
|
||||||
|
role: user.role,
|
||||||
},
|
},
|
||||||
// Don't send token in response body for security
|
// Don't send token in response body for security
|
||||||
});
|
});
|
||||||
@@ -318,15 +320,36 @@ router.post(
|
|||||||
const {
|
const {
|
||||||
sub: googleId,
|
sub: googleId,
|
||||||
email,
|
email,
|
||||||
given_name: firstName,
|
given_name: givenName,
|
||||||
family_name: lastName,
|
family_name: familyName,
|
||||||
picture,
|
picture,
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
if (!email || !firstName || !lastName) {
|
if (!email) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Required user information not provided by Google" });
|
.json({ error: "Email not provided by Google" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cases where Google doesn't provide name fields
|
||||||
|
// Generate fallback values from email or use placeholder
|
||||||
|
let firstName = givenName;
|
||||||
|
let lastName = familyName;
|
||||||
|
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
const emailUsername = email.split('@')[0];
|
||||||
|
// Try to split email username by common separators
|
||||||
|
const nameParts = emailUsername.split(/[._-]/);
|
||||||
|
|
||||||
|
if (!firstName) {
|
||||||
|
firstName = nameParts[0] ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1) : 'Google';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastName) {
|
||||||
|
lastName = nameParts.length > 1
|
||||||
|
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1)
|
||||||
|
: 'User';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists by Google ID first
|
// Check if user exists by Google ID first
|
||||||
@@ -422,6 +445,7 @@ router.post(
|
|||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
profileImage: user.profileImage,
|
profileImage: user.profileImage,
|
||||||
isVerified: user.isVerified,
|
isVerified: user.isVerified,
|
||||||
|
role: user.role,
|
||||||
},
|
},
|
||||||
// Don't send token in response body for security
|
// Don't send token in response body for security
|
||||||
});
|
});
|
||||||
@@ -658,6 +682,7 @@ router.post("/refresh", async (req, res) => {
|
|||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
isVerified: user.isVerified,
|
isVerified: user.isVerified,
|
||||||
|
role: user.role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
const express = require('express');
|
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, requireAdmin, optionalAuth } = require('../middleware/auth');
|
||||||
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const emailServices = require('../services/email');
|
const emailServices = require('../services/email');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper function to build nested comment tree
|
// Helper function to build nested comment tree
|
||||||
const buildCommentTree = (comments) => {
|
const buildCommentTree = (comments, isAdmin = false) => {
|
||||||
const commentMap = {};
|
const commentMap = {};
|
||||||
const rootComments = [];
|
const rootComments = [];
|
||||||
|
|
||||||
// Create a map of all comments
|
// Create a map of all comments
|
||||||
comments.forEach(comment => {
|
comments.forEach(comment => {
|
||||||
commentMap[comment.id] = { ...comment.toJSON(), replies: [] };
|
const commentJson = comment.toJSON();
|
||||||
|
|
||||||
|
// Sanitize deleted comments for non-admin users
|
||||||
|
if (commentJson.isDeleted && !isAdmin) {
|
||||||
|
commentJson.content = '';
|
||||||
|
commentJson.images = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
commentMap[comment.id] = { ...commentJson, replies: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build the tree structure
|
// Build the tree structure
|
||||||
@@ -30,7 +38,7 @@ const buildCommentTree = (comments) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// GET /api/forum/posts - Browse all posts
|
// GET /api/forum/posts - Browse all posts
|
||||||
router.get('/posts', async (req, res) => {
|
router.get('/posts', optionalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
@@ -44,6 +52,11 @@ router.get('/posts', async (req, res) => {
|
|||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
|
|
||||||
|
// Filter out deleted posts unless user is admin
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
where.isDeleted = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
where.category = category;
|
where.category = category;
|
||||||
}
|
}
|
||||||
@@ -86,6 +99,12 @@ router.get('/posts', async (req, res) => {
|
|||||||
model: PostTag,
|
model: PostTag,
|
||||||
as: 'tags',
|
as: 'tags',
|
||||||
attributes: ['id', 'tagName']
|
attributes: ['id', 'tagName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: ForumComment,
|
||||||
|
as: 'comments',
|
||||||
|
attributes: ['id', 'isDeleted'],
|
||||||
|
required: false
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -104,6 +123,15 @@ router.get('/posts', async (req, res) => {
|
|||||||
distinct: true
|
distinct: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add hasDeletedComments flag to each post
|
||||||
|
const postsWithFlags = rows.map(post => {
|
||||||
|
const postJson = post.toJSON();
|
||||||
|
postJson.hasDeletedComments = postJson.comments?.some(c => c.isDeleted) || false;
|
||||||
|
// Remove comments array to reduce response size (only needed the flag)
|
||||||
|
delete postJson.comments;
|
||||||
|
return postJson;
|
||||||
|
});
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Forum posts fetched", {
|
reqLogger.info("Forum posts fetched", {
|
||||||
search,
|
search,
|
||||||
@@ -116,7 +144,7 @@ router.get('/posts', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
posts: rows,
|
posts: postsWithFlags,
|
||||||
totalPages: Math.ceil(count / limit),
|
totalPages: Math.ceil(count / limit),
|
||||||
currentPage: parseInt(page),
|
currentPage: parseInt(page),
|
||||||
totalPosts: count
|
totalPosts: count
|
||||||
@@ -133,14 +161,14 @@ router.get('/posts', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/forum/posts/:id - Get single post with all comments
|
// GET /api/forum/posts/:id - Get single post with all comments
|
||||||
router.get('/posts/:id', async (req, res) => {
|
router.get('/posts/:id', optionalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const post = await ForumPost.findByPk(req.params.id, {
|
const post = await ForumPost.findByPk(req.params.id, {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'author',
|
as: 'author',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
attributes: ['id', 'username', 'firstName', 'lastName', 'role']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: PostTag,
|
model: PostTag,
|
||||||
@@ -150,13 +178,12 @@ router.get('/posts/:id', async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: ForumComment,
|
model: ForumComment,
|
||||||
as: 'comments',
|
as: 'comments',
|
||||||
where: { isDeleted: false },
|
|
||||||
required: false,
|
required: false,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'author',
|
as: 'author',
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
attributes: ['id', 'username', 'firstName', 'lastName', 'role']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -170,12 +197,18 @@ router.get('/posts/:id', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Post not found' });
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide deleted posts from non-admins
|
||||||
|
if (post.isDeleted && (!req.user || req.user.role !== 'admin')) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
// Increment view count
|
// Increment view count
|
||||||
await post.increment('viewCount');
|
await post.increment('viewCount');
|
||||||
|
|
||||||
// Build nested comment tree
|
// Build nested comment tree
|
||||||
|
const isAdmin = req.user && req.user.role === 'admin';
|
||||||
const postData = post.toJSON();
|
const postData = post.toJSON();
|
||||||
postData.comments = buildCommentTree(post.comments);
|
postData.comments = buildCommentTree(post.comments, isAdmin);
|
||||||
postData.viewCount += 1; // Reflect the increment
|
postData.viewCount += 1; // Reflect the increment
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
@@ -765,8 +798,8 @@ router.delete('/comments/:id', authenticateToken, async (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Unauthorized' });
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete
|
// Soft delete (preserve content for potential restoration)
|
||||||
await comment.update({ isDeleted: true, content: '[deleted]' });
|
await comment.update({ isDeleted: true });
|
||||||
|
|
||||||
// Decrement comment count
|
// Decrement comment count
|
||||||
const post = await ForumPost.findByPk(comment.postId);
|
const post = await ForumPost.findByPk(comment.postId);
|
||||||
@@ -871,4 +904,173 @@ router.get('/tags', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ ADMIN ROUTES ============
|
||||||
|
|
||||||
|
// DELETE /api/forum/admin/posts/:id - Admin soft-delete post
|
||||||
|
router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete the post
|
||||||
|
await post.update({
|
||||||
|
isDeleted: true,
|
||||||
|
deletedBy: req.user.id,
|
||||||
|
deletedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Admin deleted post", {
|
||||||
|
postId: req.params.id,
|
||||||
|
adminId: req.user.id,
|
||||||
|
originalAuthorId: post.authorId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Post deleted successfully', post });
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin post deletion failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/forum/admin/posts/:id/restore - Admin restore deleted post
|
||||||
|
router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the post
|
||||||
|
await post.update({
|
||||||
|
isDeleted: false,
|
||||||
|
deletedBy: null,
|
||||||
|
deletedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Admin restored post", {
|
||||||
|
postId: req.params.id,
|
||||||
|
adminId: req.user.id,
|
||||||
|
originalAuthorId: post.authorId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Post restored successfully', post });
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin post restoration failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/forum/admin/comments/:id - Admin soft-delete comment
|
||||||
|
router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const comment = await ForumComment.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).json({ error: 'Comment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete the comment (preserve original content for potential restoration)
|
||||||
|
await comment.update({
|
||||||
|
isDeleted: true,
|
||||||
|
deletedBy: req.user.id,
|
||||||
|
deletedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decrement comment count if not already deleted
|
||||||
|
if (!comment.isDeleted) {
|
||||||
|
const post = await ForumPost.findByPk(comment.postId);
|
||||||
|
if (post && post.commentCount > 0) {
|
||||||
|
await post.decrement('commentCount');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Admin deleted comment", {
|
||||||
|
commentId: req.params.id,
|
||||||
|
adminId: req.user.id,
|
||||||
|
originalAuthorId: comment.authorId,
|
||||||
|
postId: comment.postId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Comment deleted successfully', comment });
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin comment deletion failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
commentId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/forum/admin/comments/:id/restore - Admin restore deleted comment
|
||||||
|
router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const comment = await ForumComment.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).json({ error: 'Comment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!comment.isDeleted) {
|
||||||
|
return res.status(400).json({ error: 'Comment is not deleted' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the comment with its original content
|
||||||
|
await comment.update({
|
||||||
|
isDeleted: false,
|
||||||
|
deletedBy: null,
|
||||||
|
deletedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment comment count
|
||||||
|
const post = await ForumPost.findByPk(comment.postId);
|
||||||
|
if (post) {
|
||||||
|
await post.increment('commentCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Admin restored comment", {
|
||||||
|
commentId: req.params.id,
|
||||||
|
adminId: req.user.id,
|
||||||
|
originalAuthorId: comment.authorId,
|
||||||
|
postId: comment.postId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'Comment restored successfully',
|
||||||
|
comment
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin comment restoration failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
commentId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
const clientId = process.env.REACT_APP_GOOGLE_CLIENT_ID;
|
const clientId = process.env.REACT_APP_GOOGLE_CLIENT_ID;
|
||||||
const redirectUri = `${window.location.origin}/auth/google/callback`;
|
const redirectUri = `${window.location.origin}/auth/google/callback`;
|
||||||
const scope = 'email profile';
|
const scope = 'openid email profile';
|
||||||
const responseType = 'code';
|
const responseType = 'code';
|
||||||
|
|
||||||
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ interface CommentThreadProps {
|
|||||||
isPostAuthor?: boolean;
|
isPostAuthor?: boolean;
|
||||||
acceptedAnswerId?: string;
|
acceptedAnswerId?: string;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
onAdminDelete?: (commentId: string) => Promise<void>;
|
||||||
|
onAdminRestore?: (commentId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommentThread: React.FC<CommentThreadProps> = ({
|
const CommentThread: React.FC<CommentThreadProps> = ({
|
||||||
@@ -25,6 +28,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
isPostAuthor = false,
|
isPostAuthor = false,
|
||||||
acceptedAnswerId,
|
acceptedAnswerId,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
isAdmin = false,
|
||||||
|
onAdminDelete,
|
||||||
|
onAdminRestore,
|
||||||
}) => {
|
}) => {
|
||||||
const [showReplyForm, setShowReplyForm] = useState(false);
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -89,7 +95,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (comment.isDeleted) {
|
if (comment.isDeleted && !isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className={`comment-deleted ${depth > 0 ? "ms-4" : ""} mb-3`}>
|
<div className={`comment-deleted ${depth > 0 ? "ms-4" : ""} mb-3`}>
|
||||||
<div className="card bg-light">
|
<div className="card bg-light">
|
||||||
@@ -111,6 +117,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
isPostAuthor={isPostAuthor}
|
isPostAuthor={isPostAuthor}
|
||||||
acceptedAnswerId={acceptedAnswerId}
|
acceptedAnswerId={acceptedAnswerId}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onAdminDelete={onAdminDelete}
|
||||||
|
onAdminRestore={onAdminRestore}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,8 +130,17 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`comment ${depth > 0 ? "ms-4" : ""} mb-3`}>
|
<div className={`comment ${depth > 0 ? "ms-4" : ""} mb-3`}>
|
||||||
<div className={`card ${isAcceptedAnswer ? "border-success" : ""}`}>
|
<div className={`card ${isAcceptedAnswer ? "border-success" : ""} ${comment.isDeleted && isAdmin ? "border-danger" : ""}`}>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
{comment.isDeleted && isAdmin && (
|
||||||
|
<div className="alert alert-danger alert-sm py-1 px-2 mb-2">
|
||||||
|
<small>
|
||||||
|
<i className="bi bi-trash me-1"></i>
|
||||||
|
<strong>Deleted by Admin</strong>
|
||||||
|
{comment.deletedAt && ` on ${formatDate(comment.deletedAt)}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isAcceptedAnswer && (
|
{isAcceptedAnswer && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<span className="badge bg-success">
|
<span className="badge bg-success">
|
||||||
@@ -264,6 +282,24 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && onAdminDelete && !comment.isDeleted && !isEditing && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-link text-danger text-decoration-none p-0"
|
||||||
|
onClick={() => onAdminDelete(comment.id)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash me-1"></i>
|
||||||
|
Delete (Admin)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && onAdminRestore && comment.isDeleted && !isEditing && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-link text-success text-decoration-none p-0"
|
||||||
|
onClick={() => onAdminRestore(comment.id)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-counterclockwise me-1"></i>
|
||||||
|
Restore (Admin)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,6 +330,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
isPostAuthor={isPostAuthor}
|
isPostAuthor={isPostAuthor}
|
||||||
acceptedAnswerId={acceptedAnswerId}
|
acceptedAnswerId={acceptedAnswerId}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onAdminDelete={onAdminDelete}
|
||||||
|
onAdminRestore={onAdminRestore}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ 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";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
interface ForumPostListItemProps {
|
interface ForumPostListItemProps {
|
||||||
post: ForumPost;
|
post: ForumPost;
|
||||||
|
filter?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post, filter }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
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();
|
||||||
@@ -38,9 +42,12 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
|||||||
: text;
|
: text;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build link with filter param if admin and filter is set
|
||||||
|
const linkTo = filter && isAdmin ? `/forum/${post.id}?filter=${filter}` : `/forum/${post.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="list-group-item list-group-item-action p-3">
|
<div className="list-group-item list-group-item-action p-3">
|
||||||
<Link to={`/forum/${post.id}`} className="text-decoration-none d-block">
|
<Link to={linkTo} className="text-decoration-none d-block">
|
||||||
<div className="row align-items-center">
|
<div className="row align-items-center">
|
||||||
{/* Main content - 60% */}
|
{/* Main content - 60% */}
|
||||||
<div className="col-md-7">
|
<div className="col-md-7">
|
||||||
@@ -51,6 +58,12 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
|
|||||||
<i className="bi bi-pin-angle-fill"></i>
|
<i className="bi bi-pin-angle-fill"></i>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{post.isDeleted && isAdmin && (
|
||||||
|
<span className="badge bg-danger badge-sm">
|
||||||
|
<i className="bi bi-trash me-1"></i>
|
||||||
|
Deleted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<CategoryBadge category={post.category} />
|
<CategoryBadge category={post.category} />
|
||||||
<PostStatusBadge status={post.status} />
|
<PostStatusBadge status={post.status} />
|
||||||
{post.tags &&
|
{post.tags &&
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { forumAPI, getForumImageUrl } from '../services/api';
|
import { forumAPI, getForumImageUrl } from '../services/api';
|
||||||
import { ForumPost, ForumComment } from '../types';
|
import { ForumPost, ForumComment } from '../types';
|
||||||
@@ -13,10 +13,20 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [post, setPost] = useState<ForumPost | null>(null);
|
const [post, setPost] = useState<ForumPost | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [showAdminModal, setShowAdminModal] = useState(false);
|
||||||
|
const [adminAction, setAdminAction] = useState<{
|
||||||
|
type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment';
|
||||||
|
id?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Read filter from URL query param
|
||||||
|
const filter = searchParams.get('filter') || 'active';
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -131,6 +141,62 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdminDeletePost = async () => {
|
||||||
|
setAdminAction({ type: 'deletePost' });
|
||||||
|
setShowAdminModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminRestorePost = async () => {
|
||||||
|
setAdminAction({ type: 'restorePost' });
|
||||||
|
setShowAdminModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminDeleteComment = async (commentId: string) => {
|
||||||
|
setAdminAction({ type: 'deleteComment', id: commentId });
|
||||||
|
setShowAdminModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminRestoreComment = async (commentId: string) => {
|
||||||
|
setAdminAction({ type: 'restoreComment', id: commentId });
|
||||||
|
setShowAdminModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAdminAction = async () => {
|
||||||
|
if (!adminAction) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
setShowAdminModal(false);
|
||||||
|
|
||||||
|
switch (adminAction.type) {
|
||||||
|
case 'deletePost':
|
||||||
|
await forumAPI.adminDeletePost(id!);
|
||||||
|
break;
|
||||||
|
case 'restorePost':
|
||||||
|
await forumAPI.adminRestorePost(id!);
|
||||||
|
break;
|
||||||
|
case 'deleteComment':
|
||||||
|
await forumAPI.adminDeleteComment(adminAction.id!);
|
||||||
|
break;
|
||||||
|
case 'restoreComment':
|
||||||
|
await forumAPI.adminRestoreComment(adminAction.id!);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchPost(); // Refresh to show updated status
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to perform admin action');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
setAdminAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAdminAction = () => {
|
||||||
|
setShowAdminModal(false);
|
||||||
|
setAdminAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
@@ -180,8 +246,15 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-8">
|
<div className="col-lg-8">
|
||||||
{/* Post Content */}
|
{/* Post Content */}
|
||||||
<div className="card mb-4">
|
<div className={`card mb-4 ${post.isDeleted && isAdmin ? 'border-danger' : ''}`}>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
{post.isDeleted && isAdmin && (
|
||||||
|
<div className="alert alert-danger mb-3">
|
||||||
|
<i className="bi bi-eye-slash me-2"></i>
|
||||||
|
<strong>Deleted by Admin</strong> - This post is deleted and hidden from regular users
|
||||||
|
{post.deletedAt && ` on ${formatDate(post.deletedAt)}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{post.isPinned && (
|
{post.isPinned && (
|
||||||
<span className="badge bg-danger me-2 mb-2">
|
<span className="badge bg-danger me-2 mb-2">
|
||||||
<i className="bi bi-pin-angle-fill me-1"></i>
|
<i className="bi bi-pin-angle-fill me-1"></i>
|
||||||
@@ -275,6 +348,30 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="d-flex gap-2 flex-wrap mb-3">
|
||||||
|
{!post.isDeleted ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleAdminDeletePost}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash me-1"></i>
|
||||||
|
Delete Post (Admin)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={handleAdminRestorePost}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-counterclockwise me-1"></i>
|
||||||
|
Restore Post (Admin)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
{/* Comments Section */}
|
{/* Comments Section */}
|
||||||
@@ -293,6 +390,12 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
if (b.id === post.acceptedAnswerId) return 1;
|
if (b.id === post.acceptedAnswerId) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
|
.filter((comment: ForumComment) => {
|
||||||
|
// Filter comments based on deletion status (admin only)
|
||||||
|
if (!isAdmin) return true; // Non-admins see all non-deleted (backend already filters)
|
||||||
|
if (filter === 'deleted') return comment.isDeleted; // Only show deleted comments
|
||||||
|
return true; // 'active' and 'all' show all comments
|
||||||
|
})
|
||||||
.map((comment: ForumComment) => (
|
.map((comment: ForumComment) => (
|
||||||
<CommentThread
|
<CommentThread
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
@@ -304,6 +407,9 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
isPostAuthor={isAuthor}
|
isPostAuthor={isAuthor}
|
||||||
acceptedAnswerId={post.acceptedAnswerId}
|
acceptedAnswerId={post.acceptedAnswerId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onAdminDelete={handleAdminDeleteComment}
|
||||||
|
onAdminRestore={handleAdminRestoreComment}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -336,6 +442,69 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Action Confirmation Modal */}
|
||||||
|
{showAdminModal && (
|
||||||
|
<div className="modal fade show d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">
|
||||||
|
<i className="bi bi-shield-exclamation me-2"></i>
|
||||||
|
Confirm Admin Action
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={cancelAdminAction}
|
||||||
|
disabled={actionLoading}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{adminAction?.type === 'deletePost' && (
|
||||||
|
<p>Are you sure you want to delete this post? It will be deleted and hidden from regular users but can be restored later.</p>
|
||||||
|
)}
|
||||||
|
{adminAction?.type === 'restorePost' && (
|
||||||
|
<p>Are you sure you want to restore this post? It will become visible to all users again.</p>
|
||||||
|
)}
|
||||||
|
{adminAction?.type === 'deleteComment' && (
|
||||||
|
<p>Are you sure you want to delete this comment? It will be deleted and hidden from regular users but can be restored later.</p>
|
||||||
|
)}
|
||||||
|
{adminAction?.type === 'restoreComment' && (
|
||||||
|
<p>Are you sure you want to restore this comment? It will become visible to all users again.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={cancelAdminAction}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${adminAction?.type.includes('delete') ? 'btn-danger' : 'btn-success'}`}
|
||||||
|
onClick={confirmAdminAction}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
{actionLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{adminAction?.type.includes('delete') ? 'Delete' : 'Restore'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useSearchParams } 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 { ForumPost } from '../types';
|
import { ForumPost } from '../types';
|
||||||
@@ -8,6 +8,8 @@ import AuthButton from '../components/AuthButton';
|
|||||||
|
|
||||||
const ForumPosts: React.FC = () => {
|
const ForumPosts: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [posts, setPosts] = useState<ForumPost[]>([]);
|
const [posts, setPosts] = useState<ForumPost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -15,12 +17,16 @@ const ForumPosts: React.FC = () => {
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalPosts, setTotalPosts] = useState(0);
|
const [totalPosts, setTotalPosts] = useState(0);
|
||||||
|
|
||||||
|
// Initialize filter from URL param or default to 'active'
|
||||||
|
const initialFilter = searchParams.get('filter') || 'active';
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
search: '',
|
search: '',
|
||||||
category: '',
|
category: '',
|
||||||
tag: '',
|
tag: '',
|
||||||
status: '',
|
status: '',
|
||||||
sort: 'recent'
|
sort: 'recent',
|
||||||
|
deletionFilter: initialFilter // all, active, deleted
|
||||||
});
|
});
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
@@ -65,6 +71,11 @@ const ForumPosts: React.FC = () => {
|
|||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFilters(prev => ({ ...prev, [name]: value }));
|
setFilters(prev => ({ ...prev, [name]: value }));
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
|
||||||
|
// Update URL param when deletion filter changes
|
||||||
|
if (name === 'deletionFilter' && isAdmin) {
|
||||||
|
setSearchParams({ filter: value });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
@@ -81,6 +92,16 @@ const ForumPosts: React.FC = () => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter posts based on deletion status (admin only)
|
||||||
|
const filteredPosts = isAdmin ? posts.filter(post => {
|
||||||
|
if (filters.deletionFilter === 'active') {
|
||||||
|
return !post.isDeleted; // Show active posts regardless of deleted comments
|
||||||
|
} else if (filters.deletionFilter === 'deleted') {
|
||||||
|
return post.isDeleted || post.hasDeletedComments; // Show deleted posts OR posts with deleted comments
|
||||||
|
}
|
||||||
|
return true; // 'all' shows everything
|
||||||
|
}) : posts;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
@@ -112,7 +133,7 @@ const ForumPosts: React.FC = () => {
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="row mb-4">
|
<div className="row mb-4">
|
||||||
<div className="col-md-6">
|
<div className={isAdmin ? "col-md-5" : "col-md-6"}>
|
||||||
<form onSubmit={handleSearch}>
|
<form onSubmit={handleSearch}>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
@@ -129,7 +150,21 @@ const ForumPosts: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
{isAdmin && (
|
||||||
|
<div className="col-md-2">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
name="deletionFilter"
|
||||||
|
value={filters.deletionFilter}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="deleted">Deleted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={isAdmin ? "col-md-2" : "col-md-3"}>
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
name="status"
|
name="status"
|
||||||
@@ -142,7 +177,7 @@ const ForumPosts: React.FC = () => {
|
|||||||
<option value="closed">Closed</option>
|
<option value="closed">Closed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className={isAdmin ? "col-md-3" : "col-md-3"}>
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
name="sort"
|
name="sort"
|
||||||
@@ -171,11 +206,11 @@ const ForumPosts: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
<p className="text-muted mb-0">
|
<p className="text-muted mb-0">
|
||||||
Showing {posts.length} of {totalPosts} posts
|
Showing {filteredPosts.length} of {totalPosts} posts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{posts.length === 0 ? (
|
{filteredPosts.length === 0 ? (
|
||||||
<div className="text-center py-5">
|
<div className="text-center py-5">
|
||||||
<i className="bi bi-inbox display-1 text-muted"></i>
|
<i className="bi bi-inbox display-1 text-muted"></i>
|
||||||
<h3 className="mt-3">No posts found</h3>
|
<h3 className="mt-3">No posts found</h3>
|
||||||
@@ -194,8 +229,8 @@ const ForumPosts: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="list-group list-group-flush mb-4">
|
<div className="list-group list-group-flush mb-4">
|
||||||
{posts.map((post) => (
|
{filteredPosts.map((post) => (
|
||||||
<ForumPostListItem key={post.id} post={post} />
|
<ForumPostListItem key={post.id} post={post} filter={isAdmin ? filters.deletionFilter : undefined} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,11 @@ export const forumAPI = {
|
|||||||
api.put(`/forum/comments/${commentId}`, data),
|
api.put(`/forum/comments/${commentId}`, data),
|
||||||
deleteComment: (commentId: string) =>
|
deleteComment: (commentId: string) =>
|
||||||
api.delete(`/forum/comments/${commentId}`),
|
api.delete(`/forum/comments/${commentId}`),
|
||||||
|
// Admin endpoints
|
||||||
|
adminDeletePost: (id: string) => api.delete(`/forum/admin/posts/${id}`),
|
||||||
|
adminRestorePost: (id: string) => api.patch(`/forum/admin/posts/${id}/restore`),
|
||||||
|
adminDeleteComment: (id: string) => api.delete(`/forum/admin/comments/${id}`),
|
||||||
|
adminRestoreComment: (id: string) => api.patch(`/forum/admin/comments/${id}/restore`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stripeAPI = {
|
export const stripeAPI = {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface User {
|
|||||||
country?: string;
|
country?: string;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
|
role?: "user" | "admin";
|
||||||
stripeConnectedAccountId?: string;
|
stripeConnectedAccountId?: string;
|
||||||
addresses?: Address[];
|
addresses?: Address[];
|
||||||
}
|
}
|
||||||
@@ -267,6 +268,10 @@ export interface ForumPost {
|
|||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
acceptedAnswerId?: string;
|
acceptedAnswerId?: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
isDeleted?: boolean;
|
||||||
|
deletedBy?: string;
|
||||||
|
deletedAt?: string;
|
||||||
|
hasDeletedComments?: boolean;
|
||||||
author?: User;
|
author?: User;
|
||||||
tags?: PostTag[];
|
tags?: PostTag[];
|
||||||
comments?: ForumComment[];
|
comments?: ForumComment[];
|
||||||
@@ -282,6 +287,8 @@ export interface ForumComment {
|
|||||||
parentCommentId?: string;
|
parentCommentId?: string;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
deletedBy?: string;
|
||||||
|
deletedAt?: string;
|
||||||
author?: User;
|
author?: User;
|
||||||
replies?: ForumComment[];
|
replies?: ForumComment[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user