forum güncellemeleri
This commit is contained in:
parent
6d1516eb0f
commit
4d127032af
34 changed files with 10231 additions and 65 deletions
189
api/src/Kurs.Platform.Application.Contracts/Forum/ForumDtos.cs
Normal file
189
api/src/Kurs.Platform.Application.Contracts/Forum/ForumDtos.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
|
||||
namespace Kurs.Platform.Forum;
|
||||
|
||||
// Search DTOs
|
||||
public class SearchForumInput
|
||||
{
|
||||
[Required]
|
||||
public string Query { get; set; }
|
||||
public Guid? CategoryId { get; set; }
|
||||
public Guid? TopicId { get; set; }
|
||||
public bool SearchInCategories { get; set; } = true;
|
||||
public bool SearchInTopics { get; set; } = true;
|
||||
public bool SearchInPosts { get; set; } = true;
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
|
||||
public class ForumSearchResultDto
|
||||
{
|
||||
public List<ForumCategoryDto> Categories { get; set; } = new();
|
||||
public List<ForumTopicDto> Topics { get; set; } = new();
|
||||
public List<ForumPostDto> Posts { get; set; } = new();
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
|
||||
// Category DTOs
|
||||
public class ForumCategoryDto : EntityDto<Guid>
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Slug { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Icon { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
public int TopicCount { get; set; }
|
||||
public int PostCount { get; set; }
|
||||
public DateTime? LastPostDate { get; set; }
|
||||
public Guid? LastPostUserId { get; set; }
|
||||
}
|
||||
|
||||
public class CreateForumCategoryDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Slug { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(500)]
|
||||
public string Description { get; set; }
|
||||
|
||||
[StringLength(10)]
|
||||
public string Icon { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public bool IsLocked { get; set; } = false;
|
||||
}
|
||||
|
||||
public class UpdateForumCategoryDto : CreateForumCategoryDto
|
||||
{
|
||||
}
|
||||
|
||||
public class GetCategoriesInput : PagedAndSortedResultRequestDto
|
||||
{
|
||||
public bool? IsActive { get; set; }
|
||||
public string Search { get; set; }
|
||||
|
||||
public GetCategoriesInput()
|
||||
{
|
||||
MaxResultCount = 10;
|
||||
SkipCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Topic DTOs
|
||||
public class ForumTopicDto : EntityDto<Guid>
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string Content { get; set; }
|
||||
public Guid CategoryId { get; set; }
|
||||
public Guid AuthorId { get; set; }
|
||||
public string AuthorName { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
public int ReplyCount { get; set; }
|
||||
public int LikeCount { get; set; }
|
||||
public bool IsPinned { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
public bool IsSolved { get; set; }
|
||||
public Guid? LastPostId { get; set; }
|
||||
public DateTime? LastPostDate { get; set; }
|
||||
public Guid? LastPostUserId { get; set; }
|
||||
public string LastPostUserName { get; set; }
|
||||
}
|
||||
|
||||
public class CreateForumTopicDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Title { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Content { get; set; }
|
||||
|
||||
[Required]
|
||||
public Guid CategoryId { get; set; }
|
||||
|
||||
public bool IsPinned { get; set; } = false;
|
||||
public bool IsLocked { get; set; } = false;
|
||||
}
|
||||
|
||||
public class UpdateForumTopicDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Title { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Content { get; set; }
|
||||
|
||||
public bool IsPinned { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
public bool IsSolved { get; set; }
|
||||
}
|
||||
|
||||
public class GetTopicsInput : PagedAndSortedResultRequestDto
|
||||
{
|
||||
public Guid? CategoryId { get; set; }
|
||||
public bool? IsPinned { get; set; }
|
||||
public bool? IsSolved { get; set; }
|
||||
public string Search { get; set; }
|
||||
}
|
||||
|
||||
// Post DTOs
|
||||
public class ForumPostDto : EntityDto<Guid>
|
||||
{
|
||||
public Guid TopicId { get; set; }
|
||||
public string Content { get; set; }
|
||||
public Guid AuthorId { get; set; }
|
||||
public string AuthorName { get; set; }
|
||||
public int LikeCount { get; set; }
|
||||
public bool IsAcceptedAnswer { get; set; }
|
||||
|
||||
public Guid? ParentPostId { get; set; }
|
||||
}
|
||||
|
||||
public class CreateForumPostDto
|
||||
{
|
||||
[Required]
|
||||
public Guid TopicId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Content { get; set; }
|
||||
|
||||
public Guid? ParentPostId { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateForumPostDto
|
||||
{
|
||||
[Required]
|
||||
public string Content { get; set; }
|
||||
|
||||
public bool IsAcceptedAnswer { get; set; }
|
||||
}
|
||||
|
||||
public class GetPostsInput : PagedAndSortedResultRequestDto
|
||||
{
|
||||
public Guid? TopicId { get; set; }
|
||||
public bool? IsAcceptedAnswer { get; set; }
|
||||
public string Search { get; set; }
|
||||
}
|
||||
|
||||
// Statistics DTO
|
||||
public class ForumStatsDto
|
||||
{
|
||||
public int TotalCategories { get; set; }
|
||||
public int TotalTopics { get; set; }
|
||||
public int TotalPosts { get; set; }
|
||||
public long TotalUsers { get; set; }
|
||||
public long ActiveUsers { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
||||
namespace Kurs.Platform.Forum;
|
||||
|
||||
public interface IForumAppService : IApplicationService
|
||||
{
|
||||
// Search
|
||||
Task<ForumSearchResultDto> SearchAsync(SearchForumInput input);
|
||||
|
||||
// Categories
|
||||
Task<PagedResultDto<ForumCategoryDto>> GetCategoriesAsync(GetCategoriesInput input);
|
||||
Task<ForumCategoryDto> GetCategoryAsync(Guid id);
|
||||
Task<ForumCategoryDto> GetCategoryBySlugAsync(string slug);
|
||||
Task<ForumCategoryDto> CreateCategoryAsync(CreateForumCategoryDto input);
|
||||
Task<ForumCategoryDto> UpdateCategoryAsync(Guid id, UpdateForumCategoryDto input);
|
||||
Task DeleteCategoryAsync(Guid id);
|
||||
|
||||
// Topics
|
||||
Task<PagedResultDto<ForumTopicDto>> GetTopicsAsync(GetTopicsInput input);
|
||||
Task<ForumTopicDto> GetTopicAsync(Guid id);
|
||||
Task<ForumTopicDto> CreateTopicAsync(CreateForumTopicDto input);
|
||||
Task<ForumTopicDto> UpdateTopicAsync(Guid id, UpdateForumTopicDto input);
|
||||
Task DeleteTopicAsync(Guid id);
|
||||
|
||||
// Posts
|
||||
Task<PagedResultDto<ForumPostDto>> GetPostsAsync(GetPostsInput input);
|
||||
Task<ForumPostDto> GetPostAsync(Guid id);
|
||||
Task<ForumPostDto> CreatePostAsync(CreateForumPostDto input);
|
||||
Task<ForumPostDto> UpdatePostAsync(Guid id, UpdateForumPostDto input);
|
||||
Task DeletePostAsync(Guid id);
|
||||
Task<ForumPostDto> LikePostAsync(Guid id);
|
||||
Task<ForumPostDto> UnlikePostAsync(Guid id);
|
||||
|
||||
// Statistics
|
||||
Task<ForumStatsDto> GetForumStatsAsync();
|
||||
}
|
||||
524
api/src/Kurs.Platform.Application/Forum/ForumAppService.cs
Normal file
524
api/src/Kurs.Platform.Application/Forum/ForumAppService.cs
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Authorization;
|
||||
using Volo.Abp.Domain.Entities;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Identity;
|
||||
|
||||
namespace Kurs.Platform.Forum;
|
||||
|
||||
[Authorize]
|
||||
public class ForumAppService : PlatformAppService, IForumAppService
|
||||
{
|
||||
private readonly IRepository<ForumCategory, Guid> _categoryRepository;
|
||||
private readonly IRepository<ForumTopic, Guid> _topicRepository;
|
||||
private readonly IRepository<ForumPost, Guid> _postRepository;
|
||||
private readonly IIdentityUserRepository _identityUserRepository;
|
||||
|
||||
|
||||
public ForumAppService(
|
||||
IRepository<ForumCategory, Guid> categoryRepository,
|
||||
IRepository<ForumTopic, Guid> topicRepository,
|
||||
IRepository<ForumPost, Guid> postRepository,
|
||||
IIdentityUserRepository identityUserRepository)
|
||||
{
|
||||
_categoryRepository = categoryRepository;
|
||||
_topicRepository = topicRepository;
|
||||
_postRepository = postRepository;
|
||||
_identityUserRepository = identityUserRepository;
|
||||
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
public async Task<ForumSearchResultDto> SearchAsync(SearchForumInput input)
|
||||
{
|
||||
var result = new ForumSearchResultDto
|
||||
{
|
||||
Categories = [],
|
||||
Topics = [],
|
||||
Posts = [],
|
||||
TotalCount = 0
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Query))
|
||||
return result;
|
||||
|
||||
var query = input.Query.ToLower();
|
||||
|
||||
// Search in categories
|
||||
if (input.SearchInCategories)
|
||||
{
|
||||
var categoryQuery = await _categoryRepository.GetQueryableAsync();
|
||||
var categories = await AsyncExecuter.ToListAsync(
|
||||
categoryQuery.Where(c => c.IsActive &&
|
||||
(c.Name.ToLower().Contains(query) ||
|
||||
c.Description.ToLower().Contains(query)))
|
||||
.Take(10)
|
||||
);
|
||||
|
||||
result.Categories = ObjectMapper.Map<List<ForumCategory>, List<ForumCategoryDto>>(categories);
|
||||
}
|
||||
|
||||
// Search in topics
|
||||
if (input.SearchInTopics)
|
||||
{
|
||||
var topicQuery = await _topicRepository.GetQueryableAsync();
|
||||
var topics = await AsyncExecuter.ToListAsync(
|
||||
topicQuery.Where(t =>
|
||||
t.Title.ToLower().Contains(query) ||
|
||||
t.Content.ToLower().Contains(query) ||
|
||||
t.AuthorName.ToLower().Contains(query))
|
||||
.OrderByDescending(t => t.CreationTime)
|
||||
.Take(20)
|
||||
);
|
||||
|
||||
result.Topics = ObjectMapper.Map<List<ForumTopic>, List<ForumTopicDto>>(topics);
|
||||
}
|
||||
|
||||
// Search in posts
|
||||
if (input.SearchInPosts)
|
||||
{
|
||||
var postQuery = await _postRepository.GetQueryableAsync();
|
||||
var posts = await AsyncExecuter.ToListAsync(
|
||||
postQuery.Where(p =>
|
||||
p.Content.ToLower().Contains(query) ||
|
||||
p.AuthorName.ToLower().Contains(query))
|
||||
.OrderByDescending(p => p.CreationTime)
|
||||
.Take(30)
|
||||
);
|
||||
|
||||
result.Posts = ObjectMapper.Map<List<ForumPost>, List<ForumPostDto>>(posts);
|
||||
}
|
||||
|
||||
result.TotalCount = result.Categories.Count + result.Topics.Count + result.Posts.Count;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Category management
|
||||
public async Task<PagedResultDto<ForumCategoryDto>> GetCategoriesAsync(GetCategoriesInput input)
|
||||
{
|
||||
var queryable = await _categoryRepository.GetQueryableAsync();
|
||||
|
||||
if (input.IsActive.HasValue)
|
||||
{
|
||||
queryable = queryable.Where(c => c.IsActive == input.IsActive.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.Search))
|
||||
{
|
||||
var search = input.Search.ToLower();
|
||||
queryable = queryable.Where(c =>
|
||||
c.Name.ToLower().Contains(search) ||
|
||||
c.Description.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
queryable = queryable.OrderBy(c => c.DisplayOrder);
|
||||
|
||||
var totalCount = await AsyncExecuter.CountAsync(queryable);
|
||||
|
||||
var skipCount = input.SkipCount >= 0 ? input.SkipCount : 0;
|
||||
var maxResultCount = input.MaxResultCount > 0 ? input.MaxResultCount : 10;
|
||||
|
||||
var categories = await AsyncExecuter.ToListAsync(
|
||||
queryable.Skip(input.SkipCount).Take(input.MaxResultCount)
|
||||
);
|
||||
|
||||
return new PagedResultDto<ForumCategoryDto>(
|
||||
totalCount,
|
||||
ObjectMapper.Map<List<ForumCategory>, List<ForumCategoryDto>>(categories)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<ForumCategoryDto> GetCategoryAsync(Guid id)
|
||||
{
|
||||
var category = await _categoryRepository.GetAsync(id);
|
||||
|
||||
return ObjectMapper.Map<ForumCategory, ForumCategoryDto>(category);
|
||||
}
|
||||
|
||||
public async Task<ForumCategoryDto> GetCategoryBySlugAsync(string slug)
|
||||
{
|
||||
var category = await _categoryRepository.FirstOrDefaultAsync(c => c.Slug == slug);
|
||||
if (category == null)
|
||||
throw new EntityNotFoundException(typeof(ForumCategory), slug);
|
||||
|
||||
return ObjectMapper.Map<ForumCategory, ForumCategoryDto>(category);
|
||||
}
|
||||
|
||||
[Authorize("App.ForumManagement.Create")]
|
||||
public async Task<ForumCategoryDto> CreateCategoryAsync(CreateForumCategoryDto input)
|
||||
{
|
||||
var category = new ForumCategory(
|
||||
GuidGenerator.Create(),
|
||||
input.Name,
|
||||
input.Slug,
|
||||
input.Description,
|
||||
input.Icon,
|
||||
input.DisplayOrder
|
||||
)
|
||||
{
|
||||
IsActive = input.IsActive,
|
||||
IsLocked = input.IsLocked
|
||||
};
|
||||
|
||||
await _categoryRepository.InsertAsync(category);
|
||||
return ObjectMapper.Map<ForumCategory, ForumCategoryDto>(category);
|
||||
}
|
||||
|
||||
[Authorize("App.ForumManagement.Update")]
|
||||
public async Task<ForumCategoryDto> UpdateCategoryAsync(Guid id, UpdateForumCategoryDto input)
|
||||
{
|
||||
var category = await _categoryRepository.GetAsync(id);
|
||||
|
||||
category.Name = input.Name;
|
||||
category.Slug = input.Slug;
|
||||
category.Description = input.Description;
|
||||
category.Icon = input.Icon;
|
||||
category.DisplayOrder = input.DisplayOrder;
|
||||
category.IsActive = input.IsActive;
|
||||
category.IsLocked = input.IsLocked;
|
||||
|
||||
await _categoryRepository.UpdateAsync(category);
|
||||
return ObjectMapper.Map<ForumCategory, ForumCategoryDto>(category);
|
||||
}
|
||||
|
||||
[Authorize("App.ForumManagement.Delete")]
|
||||
public async Task DeleteCategoryAsync(Guid id)
|
||||
{
|
||||
// Delete all topics and posts in this category
|
||||
var topics = await _topicRepository.GetListAsync(t => t.CategoryId == id);
|
||||
var topicIds = topics.Select(t => t.Id).ToList();
|
||||
|
||||
if (topicIds.Any())
|
||||
{
|
||||
await _postRepository.DeleteAsync(p => topicIds.Contains(p.TopicId));
|
||||
await _topicRepository.DeleteAsync(t => t.CategoryId == id);
|
||||
}
|
||||
|
||||
await _categoryRepository.DeleteAsync(id);
|
||||
}
|
||||
|
||||
// Topic management
|
||||
public async Task<PagedResultDto<ForumTopicDto>> GetTopicsAsync(GetTopicsInput input)
|
||||
{
|
||||
var queryable = await _topicRepository.GetQueryableAsync();
|
||||
|
||||
if (input.CategoryId.HasValue)
|
||||
{
|
||||
queryable = queryable.Where(t => t.CategoryId == input.CategoryId.Value);
|
||||
}
|
||||
|
||||
if (input.IsPinned.HasValue)
|
||||
{
|
||||
queryable = queryable.Where(t => t.IsPinned == input.IsPinned.Value);
|
||||
}
|
||||
|
||||
if (input.IsSolved.HasValue)
|
||||
{
|
||||
queryable = queryable.Where(t => t.IsSolved == input.IsSolved.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.Search))
|
||||
{
|
||||
var search = input.Search.ToLower();
|
||||
queryable = queryable.Where(t =>
|
||||
t.Title.ToLower().Contains(search) ||
|
||||
t.Content.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
queryable = queryable.OrderByDescending(t => t.IsPinned)
|
||||
.ThenByDescending(t => t.CreationTime);
|
||||
|
||||
var totalCount = await AsyncExecuter.CountAsync(queryable);
|
||||
var topics = await AsyncExecuter.ToListAsync(
|
||||
queryable.Skip(input.SkipCount).Take(input.MaxResultCount)
|
||||
);
|
||||
|
||||
return new PagedResultDto<ForumTopicDto>(
|
||||
totalCount,
|
||||
ObjectMapper.Map<List<ForumTopic>, List<ForumTopicDto>>(topics)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<ForumTopicDto> GetTopicAsync(Guid id)
|
||||
{
|
||||
var topic = await _topicRepository.GetAsync(id);
|
||||
|
||||
topic.ViewCount++;
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
|
||||
return ObjectMapper.Map<ForumTopic, ForumTopicDto>(topic);
|
||||
}
|
||||
|
||||
public async Task<ForumTopicDto> CreateTopicAsync(CreateForumTopicDto input)
|
||||
{
|
||||
var topic = new ForumTopic(
|
||||
GuidGenerator.Create(),
|
||||
input.Title,
|
||||
input.Content,
|
||||
input.CategoryId,
|
||||
CurrentUser.Id.Value,
|
||||
CurrentUser.Name
|
||||
)
|
||||
{
|
||||
IsPinned = input.IsPinned,
|
||||
IsLocked = input.IsLocked
|
||||
};
|
||||
|
||||
await _topicRepository.InsertAsync(topic);
|
||||
|
||||
// Update category topic count
|
||||
var category = await _categoryRepository.GetAsync(input.CategoryId);
|
||||
category.TopicCount++;
|
||||
await _categoryRepository.UpdateAsync(category);
|
||||
|
||||
return ObjectMapper.Map<ForumTopic, ForumTopicDto>(topic);
|
||||
}
|
||||
|
||||
public async Task<ForumTopicDto> UpdateTopicAsync(Guid id, UpdateForumTopicDto input)
|
||||
{
|
||||
var topic = await _topicRepository.GetAsync(id);
|
||||
|
||||
topic.Title = input.Title;
|
||||
topic.Content = input.Content;
|
||||
topic.IsPinned = input.IsPinned;
|
||||
topic.IsLocked = input.IsLocked;
|
||||
topic.IsSolved = input.IsSolved;
|
||||
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
return ObjectMapper.Map<ForumTopic, ForumTopicDto>(topic);
|
||||
}
|
||||
|
||||
public async Task DeleteTopicAsync(Guid id)
|
||||
{
|
||||
var topic = await _topicRepository.GetAsync(id);
|
||||
|
||||
// Delete all posts in this topic
|
||||
await _postRepository.DeleteAsync(p => p.TopicId == id);
|
||||
|
||||
// Update category counts
|
||||
var category = await _categoryRepository.GetAsync(topic.CategoryId);
|
||||
category.TopicCount = Math.Max(0, category.TopicCount - 1);
|
||||
var postCount = await _postRepository.CountAsync(p => p.TopicId == id);
|
||||
category.PostCount = Math.Max(0, category.PostCount - postCount);
|
||||
await _categoryRepository.UpdateAsync(category);
|
||||
|
||||
await _topicRepository.DeleteAsync(id);
|
||||
}
|
||||
|
||||
// Post management
|
||||
public async Task<PagedResultDto<ForumPostDto>> GetPostsAsync(GetPostsInput input)
|
||||
{
|
||||
var queryable = await _postRepository.GetQueryableAsync();
|
||||
|
||||
if (input.TopicId.HasValue)
|
||||
{
|
||||
queryable = queryable.Where(p => p.TopicId == input.TopicId.Value);
|
||||
|
||||
// Increment view count
|
||||
var topic = await _topicRepository.GetAsync(input.TopicId.Value);
|
||||
}
|
||||
|
||||
if (input.IsAcceptedAnswer.HasValue)
|
||||
{
|
||||
queryable = queryable.Where(p => p.IsAcceptedAnswer == input.IsAcceptedAnswer.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.Search))
|
||||
{
|
||||
var search = input.Search.ToLower();
|
||||
queryable = queryable.Where(p => p.Content.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
queryable = queryable.OrderBy(p => p.CreationTime);
|
||||
|
||||
var totalCount = await AsyncExecuter.CountAsync(queryable);
|
||||
var posts = await AsyncExecuter.ToListAsync(
|
||||
queryable.Skip(input.SkipCount).Take(input.MaxResultCount)
|
||||
);
|
||||
|
||||
return new PagedResultDto<ForumPostDto>(
|
||||
totalCount,
|
||||
ObjectMapper.Map<List<ForumPost>, List<ForumPostDto>>(posts)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<ForumPostDto> GetPostAsync(Guid id)
|
||||
{
|
||||
var post = await _postRepository.GetAsync(id);
|
||||
return ObjectMapper.Map<ForumPost, ForumPostDto>(post);
|
||||
}
|
||||
|
||||
public async Task<ForumPostDto> CreatePostAsync(CreateForumPostDto input)
|
||||
{
|
||||
var post = new ForumPost(
|
||||
GuidGenerator.Create(),
|
||||
input.TopicId,
|
||||
input.Content,
|
||||
CurrentUser.Id.Value,
|
||||
CurrentUser.Name,
|
||||
input.ParentPostId
|
||||
);
|
||||
|
||||
await _postRepository.InsertAsync(post, autoSave: true);
|
||||
|
||||
// 🔽 Update topic
|
||||
var topic = await _topicRepository.GetAsync(input.TopicId);
|
||||
topic.ReplyCount++;
|
||||
topic.LastPostId = post.Id;
|
||||
topic.LastPostDate = post.CreationTime;
|
||||
topic.LastPostUserId = post.AuthorId;
|
||||
topic.LastPostUserName = post.AuthorName;
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
|
||||
// 🔽 Update category
|
||||
var category = await _categoryRepository.GetAsync(topic.CategoryId);
|
||||
category.PostCount++;
|
||||
category.LastPostId = post.Id;
|
||||
category.LastPostDate = post.CreationTime;
|
||||
category.LastPostUserId = post.AuthorId;
|
||||
category.LastPostUserName = post.AuthorName;
|
||||
await _categoryRepository.UpdateAsync(category);
|
||||
|
||||
return ObjectMapper.Map<ForumPost, ForumPostDto>(post);
|
||||
}
|
||||
|
||||
public async Task<ForumPostDto> UpdatePostAsync(Guid id, UpdateForumPostDto input)
|
||||
{
|
||||
var post = await _postRepository.GetAsync(id);
|
||||
|
||||
// Check if user can edit this post
|
||||
if (post.AuthorId != CurrentUser.Id && !await AuthorizationService.IsGrantedAsync("Forum.Posts.Edit"))
|
||||
{
|
||||
throw new AbpAuthorizationException();
|
||||
}
|
||||
|
||||
post.Content = input.Content;
|
||||
post.IsAcceptedAnswer = input.IsAcceptedAnswer;
|
||||
|
||||
await _postRepository.UpdateAsync(post);
|
||||
return ObjectMapper.Map<ForumPost, ForumPostDto>(post);
|
||||
}
|
||||
|
||||
public async Task DeletePostAsync(Guid id)
|
||||
{
|
||||
var post = await _postRepository.GetAsync(id);
|
||||
var topic = await _topicRepository.GetAsync(post.TopicId);
|
||||
var category = await _categoryRepository.GetAsync(topic.CategoryId);
|
||||
|
||||
await _postRepository.DeleteAsync(id);
|
||||
|
||||
topic.ReplyCount = Math.Max(0, topic.ReplyCount - 1);
|
||||
category.PostCount = Math.Max(0, category.PostCount - 1);
|
||||
|
||||
// 🔁 Last post değişti mi kontrol et
|
||||
var latestPost = await _postRepository
|
||||
.GetQueryableAsync()
|
||||
.ContinueWith(q => q.Result
|
||||
.Where(p => p.TopicId == topic.Id)
|
||||
.OrderByDescending(p => p.CreationTime)
|
||||
.FirstOrDefault()
|
||||
);
|
||||
|
||||
if (latestPost != null)
|
||||
{
|
||||
topic.LastPostId = latestPost.Id;
|
||||
topic.LastPostDate = latestPost.CreationTime;
|
||||
topic.LastPostUserId = latestPost.AuthorId;
|
||||
topic.LastPostUserName = latestPost.AuthorName;
|
||||
|
||||
category.LastPostId = latestPost.Id;
|
||||
category.LastPostDate = latestPost.CreationTime;
|
||||
category.LastPostUserId = latestPost.AuthorId;
|
||||
category.LastPostUserName = latestPost.AuthorName;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Tüm postlar silindiyse
|
||||
topic.LastPostId = null;
|
||||
topic.LastPostDate = null;
|
||||
topic.LastPostUserId = null;
|
||||
topic.LastPostUserName = null;
|
||||
|
||||
category.LastPostId = null;
|
||||
category.LastPostDate = null;
|
||||
category.LastPostUserId = null;
|
||||
category.LastPostUserName = null;
|
||||
}
|
||||
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
await _categoryRepository.UpdateAsync(category);
|
||||
}
|
||||
|
||||
// Like/Unlike topic
|
||||
public async Task<ForumTopicDto> LikeTopicAsync(Guid id)
|
||||
{
|
||||
var topic = await _topicRepository.GetAsync(id);
|
||||
topic.LikeCount++;
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
return ObjectMapper.Map<ForumTopic, ForumTopicDto>(topic);
|
||||
}
|
||||
|
||||
public async Task<ForumTopicDto> UnlikeTopicAsync(Guid id)
|
||||
{
|
||||
var topic = await _topicRepository.GetAsync(id);
|
||||
topic.LikeCount = Math.Max(0, topic.LikeCount - 1);
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
return ObjectMapper.Map<ForumTopic, ForumTopicDto>(topic);
|
||||
}
|
||||
|
||||
// Like/Unlike posts
|
||||
public async Task<ForumPostDto> LikePostAsync(Guid id)
|
||||
{
|
||||
var post = await _postRepository.GetAsync(id);
|
||||
post.LikeCount++;
|
||||
await _postRepository.UpdateAsync(post);
|
||||
|
||||
var topic = await _topicRepository.GetAsync(post.TopicId);
|
||||
var postsInTopic = await _postRepository.GetListAsync(p => p.TopicId == topic.Id);
|
||||
|
||||
topic.LikeCount = postsInTopic.Sum(p => p.LikeCount);
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
|
||||
return ObjectMapper.Map<ForumPost, ForumPostDto>(post);
|
||||
}
|
||||
|
||||
public async Task<ForumPostDto> UnlikePostAsync(Guid id)
|
||||
{
|
||||
var post = await _postRepository.GetAsync(id);
|
||||
post.LikeCount = Math.Max(0, post.LikeCount - 1);
|
||||
await _postRepository.UpdateAsync(post);
|
||||
|
||||
// 🔽 Topic'in toplam beğeni sayısını güncelle
|
||||
var topic = await _topicRepository.GetAsync(post.TopicId);
|
||||
|
||||
var postsInTopic = await _postRepository.GetListAsync(p => p.TopicId == topic.Id);
|
||||
topic.LikeCount = postsInTopic.Sum(p => p.LikeCount);
|
||||
|
||||
await _topicRepository.UpdateAsync(topic);
|
||||
|
||||
return ObjectMapper.Map<ForumPost, ForumPostDto>(post);
|
||||
}
|
||||
|
||||
// Statistics
|
||||
public async Task<ForumStatsDto> GetForumStatsAsync()
|
||||
{
|
||||
var totalCategories = await _categoryRepository.CountAsync();
|
||||
var totalTopics = await _topicRepository.CountAsync();
|
||||
var totalPosts = await _postRepository.CountAsync();
|
||||
var totalUsers = await _identityUserRepository.GetCountAsync();
|
||||
|
||||
return new ForumStatsDto
|
||||
{
|
||||
TotalCategories = totalCategories,
|
||||
TotalTopics = totalTopics,
|
||||
TotalPosts = totalPosts,
|
||||
TotalUsers = totalUsers,
|
||||
ActiveUsers = totalUsers // This could be calculated based on recent activity
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
using AutoMapper;
|
||||
using Kurs.Platform.Forum;
|
||||
|
||||
namespace Kurs.Platform;
|
||||
|
||||
public class ForumAutoMapperProfile : Profile
|
||||
{
|
||||
public ForumAutoMapperProfile()
|
||||
{
|
||||
// Blog mappings
|
||||
CreateMap<ForumCategory, ForumCategoryDto>().ReverseMap();
|
||||
CreateMap<ForumPost, ForumPostDto>().ReverseMap();
|
||||
CreateMap<ForumTopic, ForumTopicDto>().ReverseMap();
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ using Kurs.Platform.Blog;
|
|||
using Kurs.Platform.Charts.Dto;
|
||||
using Kurs.Platform.Entities;
|
||||
using Kurs.Platform.Enums;
|
||||
using Kurs.Platform.Forum;
|
||||
using Kurs.Platform.ListForms;
|
||||
using Kurs.Platform.Seeds;
|
||||
using Kurs.Settings.Entities;
|
||||
|
|
@ -51,6 +52,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
private readonly IRepository<ContactTitle, Guid> _contactTitleRepository;
|
||||
private readonly IRepository<BlogCategory, Guid> _blogCategoryRepository;
|
||||
private readonly IRepository<BlogPost, Guid> _blogPostsRepository;
|
||||
private readonly IRepository<ForumCategory, Guid> _forumCategoryRepository;
|
||||
|
||||
public PlatformDataSeeder(
|
||||
IRepository<Language, Guid> languages,
|
||||
|
|
@ -78,7 +80,8 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
IRepository<ContactTag, Guid> contactTagRepository,
|
||||
IRepository<ContactTitle, Guid> contactTitleRepository,
|
||||
IRepository<BlogCategory, Guid> blogCategoryRepository,
|
||||
IRepository<BlogPost, Guid> blogPostsRepository
|
||||
IRepository<BlogPost, Guid> blogPostsRepository,
|
||||
IRepository<ForumCategory, Guid> forumCategoryRepository
|
||||
)
|
||||
{
|
||||
_languages = languages;
|
||||
|
|
@ -107,6 +110,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
_contactTitleRepository = contactTitleRepository;
|
||||
_blogCategoryRepository = blogCategoryRepository;
|
||||
_blogPostsRepository = blogPostsRepository;
|
||||
_forumCategoryRepository = forumCategoryRepository;
|
||||
}
|
||||
|
||||
private static IConfigurationRoot BuildConfiguration()
|
||||
|
|
@ -565,7 +569,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
)
|
||||
{
|
||||
DisplayOrder = item.DisplayOrder,
|
||||
PostCount = item.PostCount
|
||||
PostCount = item.PostCount
|
||||
};
|
||||
|
||||
await _blogCategoryRepository.InsertAsync(newCategory);
|
||||
|
|
@ -592,5 +596,24 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var item in items.ForumCategories)
|
||||
{
|
||||
var exists = await _forumCategoryRepository.AnyAsync(x => x.Name == item.Name);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
var newCategory = new ForumCategory(
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.Slug,
|
||||
item.Description,
|
||||
item.Icon,
|
||||
item.DisplayOrder
|
||||
);
|
||||
|
||||
await _forumCategoryRepository.InsertAsync(newCategory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -524,13 +524,19 @@
|
|||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Blog",
|
||||
"key": "App.BlogManagement",
|
||||
"en": "Blog Management",
|
||||
"tr": "Blog Yönetimi"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Forum",
|
||||
"en": "Forum",
|
||||
"tr": "Forum"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ForumManagement",
|
||||
"en": "Forum Management",
|
||||
"tr": "Forum Yönetimi"
|
||||
},
|
||||
|
|
@ -605,7 +611,7 @@
|
|||
"key": "Cancel",
|
||||
"en": "Cancel",
|
||||
"tr": "İptal"
|
||||
},
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "Delete",
|
||||
|
|
@ -4769,7 +4775,7 @@
|
|||
"key": "blog.posts.post.create",
|
||||
"tr": "Oluştur",
|
||||
"en": "Create"
|
||||
},
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "blog.posts.categories",
|
||||
|
|
@ -4787,7 +4793,7 @@
|
|||
"key": "blog.posts.categories.edittitle",
|
||||
"tr": "Kategori Düzenle",
|
||||
"en": "Edit Category"
|
||||
},
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "blog.posts.categories.name",
|
||||
|
|
@ -4811,7 +4817,7 @@
|
|||
"key": "blog.posts.categories.icon",
|
||||
"tr": "İkon",
|
||||
"en": "Icon"
|
||||
},
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "blog.posts.categories.count",
|
||||
|
|
@ -4823,7 +4829,7 @@
|
|||
"key": "blog.posts.categories.order",
|
||||
"tr": "Sıralama",
|
||||
"en": "Order"
|
||||
},
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "blog.posts.categories.status",
|
||||
|
|
@ -4841,7 +4847,7 @@
|
|||
"key": "blog.posts.categories.create",
|
||||
"tr": "Oluştur",
|
||||
"en": "Create"
|
||||
},
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "blog.posts.ai.title",
|
||||
|
|
@ -6224,26 +6230,6 @@
|
|||
"RequiredPermissionName": "App.Menus",
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": null,
|
||||
"Code": "App.Administration",
|
||||
"DisplayName": "App.Administration",
|
||||
"Order": 400,
|
||||
"Url": null,
|
||||
"Icon": "FcOrganization",
|
||||
"RequiredPermissionName": null,
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": "App.Administration",
|
||||
"Code": "App.Setting",
|
||||
"DisplayName": "App.Setting",
|
||||
"Order": 1,
|
||||
"Url": "/settings",
|
||||
"Icon": "FcSettings",
|
||||
"RequiredPermissionName": "App.Setting",
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": "App.Saas",
|
||||
"Code": "App.Listforms",
|
||||
|
|
@ -6346,12 +6332,42 @@
|
|||
},
|
||||
{
|
||||
"ParentCode": "App.Saas",
|
||||
"Code": "App.Blog",
|
||||
"DisplayName": "App.Blog",
|
||||
"Code": "AApp.BlogManagement",
|
||||
"DisplayName": "App.BlogManagement",
|
||||
"Order": 10,
|
||||
"Url": "/admin/blog",
|
||||
"Url": "/admin/blogmanagement",
|
||||
"Icon": "FcTemplate",
|
||||
"RequiredPermissionName": "App.Blog",
|
||||
"RequiredPermissionName": "App.BlogManagement",
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": "App.Saas",
|
||||
"Code": "App.ForumManagement",
|
||||
"DisplayName": "App.ForumManagement",
|
||||
"Order": 11,
|
||||
"Url": "/admin/forummanagement",
|
||||
"Icon": "FcReading",
|
||||
"RequiredPermissionName": "App.ForumManagement",
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": null,
|
||||
"Code": "App.Administration",
|
||||
"DisplayName": "App.Administration",
|
||||
"Order": 400,
|
||||
"Url": null,
|
||||
"Icon": "FcOrganization",
|
||||
"RequiredPermissionName": null,
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": "App.Administration",
|
||||
"Code": "App.Setting",
|
||||
"DisplayName": "App.Setting",
|
||||
"Order": 1,
|
||||
"Url": "/settings",
|
||||
"Icon": "FcSettings",
|
||||
"RequiredPermissionName": "App.Setting",
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
|
|
@ -6453,6 +6469,16 @@
|
|||
"Icon": "FcMultipleInputs",
|
||||
"RequiredPermissionName": "App.AuditLogs",
|
||||
"IsDisabled": false
|
||||
},
|
||||
{
|
||||
"ParentCode": "App.Administration",
|
||||
"Code": "App.Forum",
|
||||
"DisplayName": "App.Forum",
|
||||
"Order": 4,
|
||||
"Url": "/admin/forum",
|
||||
"Icon": "FcLink",
|
||||
"RequiredPermissionName": "App.ForumManagement.Publish",
|
||||
"IsDisabled": false
|
||||
}
|
||||
],
|
||||
"PermissionGroupDefinitionRecords": [
|
||||
|
|
@ -6513,8 +6539,12 @@
|
|||
"DisplayName": "App.AuditLogs"
|
||||
},
|
||||
{
|
||||
"Name": "App.Blog",
|
||||
"DisplayName": "App.Blog"
|
||||
"Name": "App.BlogManagement",
|
||||
"DisplayName": "App.BlogManagement"
|
||||
},
|
||||
{
|
||||
"Name": "App.ForumManagement",
|
||||
"DisplayName": "App.ForumManagement"
|
||||
}
|
||||
],
|
||||
"PermissionDefinitionRecords": [
|
||||
|
|
@ -6711,13 +6741,21 @@
|
|||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Blog",
|
||||
"Name": "App.Blog",
|
||||
"GroupName": "App.BlogManagement",
|
||||
"Name": "App.BlogManagement",
|
||||
"ParentName": null,
|
||||
"DisplayName": "App.Blog",
|
||||
"DisplayName": "App.BlogManagement",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.ForumManagement",
|
||||
"Name": "App.ForumManagement",
|
||||
"ParentName": null,
|
||||
"DisplayName": "App.ForumManagement",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 3
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Setting",
|
||||
"Name": "Abp.Account",
|
||||
|
|
@ -7719,41 +7757,81 @@
|
|||
"MultiTenancySide": 3
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Blog",
|
||||
"Name": "App.Blog.Create",
|
||||
"ParentName": "App.Blog",
|
||||
"GroupName": "App.BlogManagement",
|
||||
"Name": "App.BlogManagement.Create",
|
||||
"ParentName": "App.BlogManagement",
|
||||
"DisplayName": "Create",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Blog",
|
||||
"Name": "App.Blog.Delete",
|
||||
"ParentName": "App.Blog",
|
||||
"GroupName": "App.BlogManagement",
|
||||
"Name": "App.BlogManagement.Delete",
|
||||
"ParentName": "App.BlogManagement",
|
||||
"DisplayName": "Delete",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Blog",
|
||||
"Name": "App.Blog.Export",
|
||||
"ParentName": "App.Blog",
|
||||
"GroupName": "App.BlogManagement",
|
||||
"Name": "App.BlogManagement.Export",
|
||||
"ParentName": "App.BlogManagement",
|
||||
"DisplayName": "Export",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Blog",
|
||||
"Name": "App.Blog.Publish",
|
||||
"ParentName": "App.Blog",
|
||||
"GroupName": "App.BlogManagement",
|
||||
"Name": "App.BlogManagement.Publish",
|
||||
"ParentName": "App.BlogManagement",
|
||||
"DisplayName": "Publish",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Blog",
|
||||
"Name": "App.Blog.Update",
|
||||
"ParentName": "App.Blog",
|
||||
"GroupName": "App.BlogManagement",
|
||||
"Name": "App.BlogManagement.Update",
|
||||
"ParentName": "App.BlogManagement",
|
||||
"DisplayName": "Update",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.ForumManagement",
|
||||
"Name": "App.ForumManagement.Publish",
|
||||
"ParentName": "App.ForumManagement",
|
||||
"DisplayName": "Publish",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 3
|
||||
},
|
||||
{
|
||||
"GroupName": "App.ForumManagement",
|
||||
"Name": "App.ForumManagement.Create",
|
||||
"ParentName": "App.ForumManagement",
|
||||
"DisplayName": "Create",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.ForumManagement",
|
||||
"Name": "App.ForumManagement.Delete",
|
||||
"ParentName": "App.ForumManagement",
|
||||
"DisplayName": "Delete",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.ForumManagement",
|
||||
"Name": "App.ForumManagement.Export",
|
||||
"ParentName": "App.ForumManagement",
|
||||
"DisplayName": "Export",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
},
|
||||
{
|
||||
"GroupName": "App.ForumManagement",
|
||||
"Name": "App.ForumManagement.Update",
|
||||
"ParentName": "App.ForumManagement",
|
||||
"DisplayName": "Update",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2
|
||||
|
|
@ -20284,5 +20362,43 @@
|
|||
"CategoryId": "dbc8578c-1a99-594a-8997-bddd0eac8571",
|
||||
"AuthorId": "727ec3f0-75dd-54e2-8ae6-13d49727ff58"
|
||||
}
|
||||
],
|
||||
"ForumCategories": [
|
||||
{
|
||||
"Id": "1a79a36e-e062-4335-9ddf-0557c60f3ea9",
|
||||
"Name": "Genel Tartışma",
|
||||
"Slug": "genel-tartisma",
|
||||
"Description": "Her türlü konunun tartışılabileceği genel forum alanı",
|
||||
"Icon": "💬",
|
||||
"DisplayOrder": 1,
|
||||
"IsActive": true
|
||||
},
|
||||
{
|
||||
"Id": "e7d6f581-60ba-44d4-be37-c5d13e5c2fda",
|
||||
"Name": "Teknik Destek",
|
||||
"Slug": "teknik-destek",
|
||||
"Description": "Teknik sorunlar ve çözümler için destek forumu",
|
||||
"Icon": "🔧",
|
||||
"DisplayOrder": 2,
|
||||
"IsActive": true
|
||||
},
|
||||
{
|
||||
"Id": "54ac1095-0a95-467e-9f86-01efa8af136b",
|
||||
"Name": "Öneriler",
|
||||
"Slug": "oneriler",
|
||||
"Description": "Platform geliştirmeleri için öneri ve istekler",
|
||||
"Icon": "💡",
|
||||
"DisplayOrder": 3,
|
||||
"IsActive": true
|
||||
},
|
||||
{
|
||||
"Id": "3dfbb220-9a2d-49e4-835a-213f47c60939",
|
||||
"Name": "Duyurular",
|
||||
"Slug": "duyurular",
|
||||
"Description": "Platform duyuruları ve güncellemeler",
|
||||
"Icon": "📢",
|
||||
"DisplayOrder": 4,
|
||||
"IsActive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public class SeederDto
|
|||
public List<ContactTitleSeedDto> ContactTitles { get; set; }
|
||||
public List<BlogCategorySeedDto> BlogCategories { get; set; }
|
||||
public List<BlogPostSeedDto> BlogPosts { get; set; }
|
||||
public List<ForumCategorySeedDto> ForumCategories { get; set; }
|
||||
}
|
||||
|
||||
public class ChartsSeedDto
|
||||
|
|
@ -212,14 +213,25 @@ public class BlogCategorySeedDto
|
|||
|
||||
public class BlogPostSeedDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Slug { get; set; }
|
||||
public string ContentTr { get; set; }
|
||||
public string ContentEn { get; set; }
|
||||
public string ReadTime { get; set; }
|
||||
public string Summary { get; set; }
|
||||
public string CoverImage { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Slug { get; set; }
|
||||
public string ContentTr { get; set; }
|
||||
public string ContentEn { get; set; }
|
||||
public string ReadTime { get; set; }
|
||||
public string Summary { get; set; }
|
||||
public string CoverImage { get; set; }
|
||||
public Guid CategoryId { get; set; }
|
||||
public Guid AuthorId { get; set; }
|
||||
public Guid AuthorId { get; set; }
|
||||
}
|
||||
|
||||
public class ForumCategorySeedDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Slug { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Icon { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
109
api/src/Kurs.Platform.Domain/Entities/Forum.cs
Normal file
109
api/src/Kurs.Platform.Domain/Entities/Forum.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Volo.Abp.Domain.Entities.Auditing;
|
||||
|
||||
namespace Kurs.Platform.Forum;
|
||||
|
||||
public class ForumCategory : FullAuditedEntity<Guid>
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Slug { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Icon { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
public int TopicCount { get; set; }
|
||||
public int PostCount { get; set; }
|
||||
public Guid? LastPostId { get; set; }
|
||||
public DateTime? LastPostDate { get; set; }
|
||||
public Guid? LastPostUserId { get; set; }
|
||||
public string LastPostUserName { get; set; }
|
||||
|
||||
public ICollection<ForumTopic> Topics { get; set; }
|
||||
|
||||
protected ForumCategory() { }
|
||||
|
||||
public ForumCategory(Guid id, string name, string slug, string description, string icon, int displayOrder) : base(id)
|
||||
{
|
||||
Name = name;
|
||||
Slug = slug;
|
||||
Description = description;
|
||||
Icon = icon;
|
||||
DisplayOrder = displayOrder;
|
||||
IsActive = true;
|
||||
IsLocked = false;
|
||||
TopicCount = 0;
|
||||
PostCount = 0;
|
||||
Topics = [];
|
||||
}
|
||||
}
|
||||
|
||||
public class ForumTopic : FullAuditedEntity<Guid>
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string Content { get; set; }
|
||||
public Guid CategoryId { get; set; }
|
||||
public Guid AuthorId { get; set; }
|
||||
public string AuthorName { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
public int ReplyCount { get; set; }
|
||||
public int LikeCount { get; set; }
|
||||
public bool IsPinned { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
public bool IsSolved { get; set; }
|
||||
public Guid? LastPostId { get; set; }
|
||||
public DateTime? LastPostDate { get; set; }
|
||||
public Guid? LastPostUserId { get; set; }
|
||||
public string LastPostUserName { get; set; }
|
||||
|
||||
public ForumCategory Category { get; set; }
|
||||
public ICollection<ForumPost> Posts { get; set; }
|
||||
|
||||
protected ForumTopic() { }
|
||||
|
||||
public ForumTopic(Guid id, string title, string content, Guid categoryId, Guid authorId, string authorName) : base(id)
|
||||
{
|
||||
Title = title;
|
||||
Content = content;
|
||||
CategoryId = categoryId;
|
||||
AuthorId = authorId;
|
||||
AuthorName = authorName;
|
||||
ViewCount = 0;
|
||||
ReplyCount = 0;
|
||||
LikeCount = 0;
|
||||
IsPinned = false;
|
||||
IsLocked = false;
|
||||
IsSolved = false;
|
||||
Posts = [];
|
||||
}
|
||||
}
|
||||
|
||||
public class ForumPost : FullAuditedEntity<Guid>
|
||||
{
|
||||
public Guid TopicId { get; set; }
|
||||
public string Content { get; set; }
|
||||
public Guid AuthorId { get; set; }
|
||||
public string AuthorName { get; set; }
|
||||
public int LikeCount { get; set; }
|
||||
public bool IsAcceptedAnswer { get; set; }
|
||||
public Guid? ParentPostId { get; set; }
|
||||
|
||||
public ForumTopic Topic { get; set; }
|
||||
public ForumPost ParentPost { get; set; }
|
||||
public ICollection<ForumPost> Replies { get; set; }
|
||||
|
||||
protected ForumPost() { }
|
||||
|
||||
public ForumPost(Guid id, Guid topicId, string content, Guid authorId, string authorName, Guid? parentPostId = null) : base(id)
|
||||
{
|
||||
TopicId = topicId;
|
||||
Content = content;
|
||||
AuthorId = authorId;
|
||||
AuthorName = authorName;
|
||||
ParentPostId = parentPostId;
|
||||
LikeCount = 0;
|
||||
IsAcceptedAnswer = false;
|
||||
Replies = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ using Volo.Abp.TenantManagement;
|
|||
using Volo.Abp.TenantManagement.EntityFrameworkCore;
|
||||
using Kurs.Notifications.EntityFrameworkCore;
|
||||
using static Kurs.Settings.SettingsConsts;
|
||||
using Kurs.Platform.Forum;
|
||||
|
||||
namespace Kurs.Platform.EntityFrameworkCore;
|
||||
|
||||
|
|
@ -60,6 +61,11 @@ public class PlatformDbContext :
|
|||
public DbSet<BlogPost> BlogPosts { get; set; }
|
||||
public DbSet<BlogCategory> BlogCategories { get; set; }
|
||||
|
||||
// Forum Entities
|
||||
public DbSet<ForumCategory> ForumCategories { get; set; }
|
||||
public DbSet<ForumTopic> ForumTopics { get; set; }
|
||||
public DbSet<ForumPost> ForumPosts { get; set; }
|
||||
|
||||
#region Entities from the modules
|
||||
|
||||
/* Notice: We only implemented IIdentityDbContext and ITenantManagementDbContext
|
||||
|
|
@ -428,5 +434,64 @@ public class PlatformDbContext :
|
|||
.HasForeignKey(x => x.CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// Forum Entity Configurations
|
||||
// ForumCategory
|
||||
builder.Entity<ForumCategory>(b =>
|
||||
{
|
||||
b.ToTable(PlatformConsts.DbTablePrefix + "ForumCategories", PlatformConsts.DbSchema);
|
||||
b.ConfigureByConvention();
|
||||
|
||||
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||
b.Property(x => x.Description).HasMaxLength(512);
|
||||
b.Property(x => x.Icon).HasMaxLength(64);
|
||||
|
||||
b.HasIndex(x => x.DisplayOrder);
|
||||
b.HasMany(x => x.Topics)
|
||||
.WithOne(x => x.Category)
|
||||
.HasForeignKey(x => x.CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ForumTopic
|
||||
builder.Entity<ForumTopic>(b =>
|
||||
{
|
||||
b.ToTable(PlatformConsts.DbTablePrefix + "ForumTopics", PlatformConsts.DbSchema);
|
||||
b.ConfigureByConvention();
|
||||
|
||||
b.Property(x => x.Title).IsRequired().HasMaxLength(256);
|
||||
b.Property(x => x.Content).IsRequired();
|
||||
|
||||
b.HasIndex(x => x.CategoryId);
|
||||
b.HasIndex(x => x.IsPinned);
|
||||
b.HasIndex(x => x.LastPostDate);
|
||||
|
||||
b.HasMany(x => x.Posts)
|
||||
.WithOne(x => x.Topic)
|
||||
.HasForeignKey(x => x.TopicId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ForumPost
|
||||
builder.Entity<ForumPost>(b =>
|
||||
{
|
||||
b.ToTable(PlatformConsts.DbTablePrefix + "ForumPosts", PlatformConsts.DbSchema);
|
||||
b.ConfigureByConvention();
|
||||
|
||||
b.Property(x => x.Content).IsRequired();
|
||||
|
||||
b.HasIndex(x => x.TopicId);
|
||||
|
||||
b.HasOne(x => x.Topic)
|
||||
.WithMany(x => x.Posts)
|
||||
.HasForeignKey(x => x.TopicId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne(x => x.ParentPost)
|
||||
.WithMany(x => x.Replies)
|
||||
.HasForeignKey(x => x.ParentPostId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5109
api/src/Kurs.Platform.EntityFrameworkCore/Migrations/20250623140407_AddForum.Designer.cs
generated
Normal file
5109
api/src/Kurs.Platform.EntityFrameworkCore/Migrations/20250623140407_AddForum.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,165 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Kurs.Platform.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddForum : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PForumCategories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Slug = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
Icon = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
DisplayOrder = table.Column<int>(type: "int", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
TopicCount = table.Column<int>(type: "int", nullable: false),
|
||||
PostCount = table.Column<int>(type: "int", nullable: false),
|
||||
LastPostId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastPostDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastPostUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastPostUserName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PForumCategories", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PForumTopics",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Title = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Content = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CategoryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
AuthorId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
AuthorName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ViewCount = table.Column<int>(type: "int", nullable: false),
|
||||
ReplyCount = table.Column<int>(type: "int", nullable: false),
|
||||
LikeCount = table.Column<int>(type: "int", nullable: false),
|
||||
IsPinned = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsSolved = table.Column<bool>(type: "bit", nullable: false),
|
||||
LastPostId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastPostDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastPostUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastPostUserName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PForumTopics", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PForumTopics_PForumCategories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "PForumCategories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PForumPosts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
TopicId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Content = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
AuthorId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
AuthorName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LikeCount = table.Column<int>(type: "int", nullable: false),
|
||||
IsAcceptedAnswer = table.Column<bool>(type: "bit", nullable: false),
|
||||
ParentPostId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PForumPosts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PForumPosts_PForumPosts_ParentPostId",
|
||||
column: x => x.ParentPostId,
|
||||
principalTable: "PForumPosts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PForumPosts_PForumTopics_TopicId",
|
||||
column: x => x.TopicId,
|
||||
principalTable: "PForumTopics",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PForumCategories_DisplayOrder",
|
||||
table: "PForumCategories",
|
||||
column: "DisplayOrder");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PForumPosts_ParentPostId",
|
||||
table: "PForumPosts",
|
||||
column: "ParentPostId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PForumPosts_TopicId",
|
||||
table: "PForumPosts",
|
||||
column: "TopicId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PForumTopics_CategoryId",
|
||||
table: "PForumTopics",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PForumTopics_IsPinned",
|
||||
table: "PForumTopics",
|
||||
column: "IsPinned");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PForumTopics_LastPostDate",
|
||||
table: "PForumTopics",
|
||||
column: "LastPostDate");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PForumPosts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PForumTopics");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PForumCategories");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2436,6 +2436,251 @@ namespace Kurs.Platform.Migrations
|
|||
b.ToTable("PUomCategory", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumCategory", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("CreationTime");
|
||||
|
||||
b.Property<Guid?>("CreatorId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("CreatorId");
|
||||
|
||||
b.Property<Guid?>("DeleterId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("DeleterId");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("DeletionTime");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<int>("DisplayOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("IsDeleted");
|
||||
|
||||
b.Property<bool>("IsLocked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("LastModificationTime");
|
||||
|
||||
b.Property<Guid?>("LastModifierId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("LastModifierId");
|
||||
|
||||
b.Property<DateTime?>("LastPostDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("LastPostId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("LastPostUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("LastPostUserName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<int>("PostCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("TopicCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DisplayOrder");
|
||||
|
||||
b.ToTable("PForumCategories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumPost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("AuthorId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AuthorName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("CreationTime");
|
||||
|
||||
b.Property<Guid?>("CreatorId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("CreatorId");
|
||||
|
||||
b.Property<Guid?>("DeleterId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("DeleterId");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("DeletionTime");
|
||||
|
||||
b.Property<bool>("IsAcceptedAnswer")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("IsDeleted");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("LastModificationTime");
|
||||
|
||||
b.Property<Guid?>("LastModifierId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("LastModifierId");
|
||||
|
||||
b.Property<int>("LikeCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("ParentPostId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("TopicId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentPostId");
|
||||
|
||||
b.HasIndex("TopicId");
|
||||
|
||||
b.ToTable("PForumPosts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumTopic", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("AuthorId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AuthorName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("CategoryId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("CreationTime");
|
||||
|
||||
b.Property<Guid?>("CreatorId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("CreatorId");
|
||||
|
||||
b.Property<Guid?>("DeleterId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("DeleterId");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("DeletionTime");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("IsDeleted");
|
||||
|
||||
b.Property<bool>("IsLocked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsPinned")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsSolved")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2")
|
||||
.HasColumnName("LastModificationTime");
|
||||
|
||||
b.Property<Guid?>("LastModifierId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("LastModifierId");
|
||||
|
||||
b.Property<DateTime?>("LastPostDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("LastPostId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("LastPostUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("LastPostUserName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("LikeCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("ReplyCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("IsPinned");
|
||||
|
||||
b.HasIndex("LastPostDate");
|
||||
|
||||
b.ToTable("PForumTopics", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Settings.Entities.SettingDefinition", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -4589,6 +4834,35 @@ namespace Kurs.Platform.Migrations
|
|||
b.Navigation("UomCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumPost", b =>
|
||||
{
|
||||
b.HasOne("Kurs.Platform.Forum.ForumPost", "ParentPost")
|
||||
.WithMany("Replies")
|
||||
.HasForeignKey("ParentPostId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Kurs.Platform.Forum.ForumTopic", "Topic")
|
||||
.WithMany("Posts")
|
||||
.HasForeignKey("TopicId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentPost");
|
||||
|
||||
b.Navigation("Topic");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumTopic", b =>
|
||||
{
|
||||
b.HasOne("Kurs.Platform.Forum.ForumCategory", "Category")
|
||||
.WithMany("Topics")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Skill", b =>
|
||||
{
|
||||
b.HasOne("SkillType", null)
|
||||
|
|
@ -4765,6 +5039,21 @@ namespace Kurs.Platform.Migrations
|
|||
b.Navigation("Units");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumCategory", b =>
|
||||
{
|
||||
b.Navigation("Topics");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumPost", b =>
|
||||
{
|
||||
b.Navigation("Replies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Kurs.Platform.Forum.ForumTopic", b =>
|
||||
{
|
||||
b.Navigation("Posts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SkillType", b =>
|
||||
{
|
||||
b.Navigation("Levels");
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
|||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.pd7p0avqcno"
|
||||
"revision": "0.ouca2h9ms1"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,18 @@ const adminRoutes: Routes = [
|
|||
component: lazy(() => import('@/views/blog/BlogManagement')),
|
||||
authority: [],
|
||||
},
|
||||
{
|
||||
key: ROUTES_ENUM.admin.forum.management,
|
||||
path: ROUTES_ENUM.admin.forum.management,
|
||||
component: lazy(() => import('@/views/forum/Management')),
|
||||
authority: [],
|
||||
},
|
||||
{
|
||||
key: ROUTES_ENUM.admin.forum.view,
|
||||
path: ROUTES_ENUM.admin.forum.view,
|
||||
component: lazy(() => import('@/views/forum/Forum')),
|
||||
authority: [],
|
||||
},
|
||||
]
|
||||
|
||||
export { adminRoutes }
|
||||
|
|
|
|||
|
|
@ -36,10 +36,11 @@ export const ROUTES_ENUM = {
|
|||
},
|
||||
chart: '/admin/chart/edit/:chartCode',
|
||||
blog: {
|
||||
management: '/admin/blog',
|
||||
management: '/admin/blogmanagement',
|
||||
},
|
||||
forum: {
|
||||
management: '/admin/forum',
|
||||
view: '/admin/forum',
|
||||
management: '/admin/forummanagement',
|
||||
},
|
||||
},
|
||||
settings: '/settings',
|
||||
|
|
|
|||
50
ui/src/proxy/forum/forum.ts
Normal file
50
ui/src/proxy/forum/forum.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export interface ForumCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
isLocked: boolean;
|
||||
topicCount: number;
|
||||
postCount: number;
|
||||
lastPostId?: string;
|
||||
lastPostDate?: Date;
|
||||
lastPostUserId?: string;
|
||||
creationTime: Date;
|
||||
}
|
||||
|
||||
export interface ForumTopic {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
viewCount: number;
|
||||
replyCount: number;
|
||||
likeCount: number;
|
||||
isPinned: boolean;
|
||||
isLocked: boolean;
|
||||
isSolved: boolean;
|
||||
lastPostId?: string;
|
||||
lastPostDate?: Date;
|
||||
lastPostUserId?: string;
|
||||
lastPostUserName?: string;
|
||||
creationTime: Date;
|
||||
}
|
||||
|
||||
export interface ForumPost {
|
||||
id: string;
|
||||
topicId: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
likeCount: number;
|
||||
isAcceptedAnswer: boolean;
|
||||
parentPostId?: string;
|
||||
creationTime: Date;
|
||||
}
|
||||
|
||||
export type ViewMode = 'forum' | 'admin';
|
||||
347
ui/src/services/forumService.ts
Normal file
347
ui/src/services/forumService.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import apiService from '@/services/api.service'
|
||||
|
||||
export interface ForumSearchResult {
|
||||
categories: ForumCategory[]
|
||||
topics: ForumTopic[]
|
||||
posts: ForumPost[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
query: string
|
||||
categoryId?: string
|
||||
topicId?: string
|
||||
searchInCategories?: boolean
|
||||
searchInTopics?: boolean
|
||||
searchInPosts?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
totalCount: number
|
||||
pageNumber: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
icon: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
export interface CreateTopicRequest {
|
||||
title: string
|
||||
content: string
|
||||
categoryId: string
|
||||
isPinned?: boolean
|
||||
isLocked?: boolean
|
||||
}
|
||||
|
||||
export interface CreatePostRequest {
|
||||
topicId: string
|
||||
content: string
|
||||
parentPostId?: string
|
||||
}
|
||||
|
||||
export interface CategoryListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
isActive?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface TopicListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
categoryId?: string
|
||||
isPinned?: boolean
|
||||
isSolved?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface PostListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
topicId?: string
|
||||
isAcceptedAnswer?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
class ForumService {
|
||||
// Search
|
||||
async search(params: SearchParams): Promise<ForumSearchResult> {
|
||||
const response = await apiService.fetchData<ForumSearchResult>({
|
||||
url: '/api/app/forum/search',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Categories
|
||||
async getCategories(params: CategoryListParams = {}): Promise<PaginatedResponse<ForumCategory>> {
|
||||
const response = await apiService.fetchData<PaginatedResponse<ForumCategory>>({
|
||||
url: '/api/app/forum/categories',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getCategoryById(id: string): Promise<ForumCategory> {
|
||||
const response = await apiService.fetchData<ForumCategory>({
|
||||
url: `/api/app/forum/categories/${id}`,
|
||||
method: 'GET',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getCategoryBySlug(slug: string): Promise<ForumCategory> {
|
||||
const response = await apiService.fetchData<ForumCategory>({
|
||||
url: `/api/app/forum/categories/by-slug`,
|
||||
method: 'GET',
|
||||
params: { slug },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createCategory(data: CreateCategoryRequest): Promise<ForumCategory> {
|
||||
const response = await apiService.fetchData<ForumCategory>({
|
||||
url: '/api/app/forum/categories',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async updateCategory(id: string, data: Partial<CreateCategoryRequest>): Promise<ForumCategory> {
|
||||
const response = await apiService.fetchData<ForumCategory>({
|
||||
url: `/api/app/forum/categories/${id}`,
|
||||
method: 'PUT',
|
||||
data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
await apiService.fetchData({
|
||||
url: `/api/app/forum/categories/${id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async toggleCategoryStatus(id: string): Promise<ForumCategory> {
|
||||
const response = await apiService.fetchData<ForumCategory>({
|
||||
url: `/api/app/forum/categories/${id}/toggle-status`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Topics
|
||||
async getTopics(params: TopicListParams = {}): Promise<PaginatedResponse<ForumTopic>> {
|
||||
const response = await apiService.fetchData<PaginatedResponse<ForumTopic>>({
|
||||
url: '/api/app/forum/topics',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getTopicById(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/${id}/topic`,
|
||||
method: 'GET',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createTopic(data: CreateTopicRequest): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: '/api/app/forum/topic',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async updateTopic(id: string, data: Partial<CreateTopicRequest>): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/topics/${id}`,
|
||||
method: 'PUT',
|
||||
data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteTopic(id: string): Promise<void> {
|
||||
await apiService.fetchData({
|
||||
url: `/api/app/forum/topics/${id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async pinTopic(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/topics/${id}/pin`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async unpinTopic(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/topics/${id}/unpin`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async lockTopic(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/topics/${id}/lock`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async unlockTopic(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/topics/${id}/unlock`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async markTopicAsSolved(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/topics/${id}/mark-solved`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async markTopicAsUnsolved(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/topics/${id}/mark-unsolved`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Posts
|
||||
async getPosts(params: PostListParams = {}): Promise<PaginatedResponse<ForumPost>> {
|
||||
const response = await apiService.fetchData<PaginatedResponse<ForumPost>>({
|
||||
url: '/api/app/forum/posts',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getPostById(id: string): Promise<ForumPost> {
|
||||
const response = await apiService.fetchData<ForumPost>({
|
||||
url: `/api/app/forum/posts/${id}`,
|
||||
method: 'GET',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createPost(data: CreatePostRequest): Promise<ForumPost> {
|
||||
const response = await apiService.fetchData<ForumPost>({
|
||||
url: '/api/app/forum/post',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async updatePost(id: string, data: Partial<CreatePostRequest>): Promise<ForumPost> {
|
||||
const response = await apiService.fetchData<ForumPost>({
|
||||
url: `/api/app/forum/posts/${id}`,
|
||||
method: 'PUT',
|
||||
data,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<void> {
|
||||
await apiService.fetchData({
|
||||
url: `/api/app/forum/posts/${id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async likePost(id: string): Promise<ForumPost> {
|
||||
const response = await apiService.fetchData<ForumPost>({
|
||||
url: `/api/app/forum/${id}/like-post`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async unlikePost(id: string): Promise<ForumPost> {
|
||||
const response = await apiService.fetchData<ForumPost>({
|
||||
url: `/api/app/forum/${id}/unlike-post`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async likeTopic(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/${id}/like-topic`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async unlikeTopic(id: string): Promise<ForumTopic> {
|
||||
const response = await apiService.fetchData<ForumTopic>({
|
||||
url: `/api/app/forum/${id}/unlike-topic`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async markPostAsAcceptedAnswer(id: string): Promise<ForumPost> {
|
||||
const response = await apiService.fetchData<ForumPost>({
|
||||
url: `/api/app/forum/posts/${id}/mark-accepted`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async unmarkPostAsAcceptedAnswer(id: string): Promise<ForumPost> {
|
||||
const response = await apiService.fetchData<ForumPost>({
|
||||
url: `/api/app/forum/posts/${id}/unmark-accepted`,
|
||||
method: 'POST',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Forum statistics
|
||||
async getForumStats(): Promise<{
|
||||
totalCategories: number
|
||||
totalTopics: number
|
||||
totalPosts: number
|
||||
totalUsers: number
|
||||
activeUsers: number
|
||||
}> {
|
||||
const response = await apiService.fetchData({
|
||||
url: '/api/app/forum/stats',
|
||||
method: 'GET',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
export const forumService = new ForumService()
|
||||
80
ui/src/utils/hooks/useForumSearch.ts
Normal file
80
ui/src/utils/hooks/useForumSearch.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
interface UseSearchProps {
|
||||
categories: ForumCategory[];
|
||||
topics: ForumTopic[];
|
||||
posts: ForumPost[];
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
categories: ForumCategory[];
|
||||
topics: ForumTopic[];
|
||||
posts: ForumPost[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export function useForumSearch({ categories, topics, posts }: UseSearchProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult>({
|
||||
categories: [],
|
||||
topics: [],
|
||||
posts: [],
|
||||
totalCount: 0
|
||||
});
|
||||
|
||||
const performSearch = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return {
|
||||
categories: [],
|
||||
topics: [],
|
||||
posts: [],
|
||||
totalCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
// Search in categories
|
||||
const matchedCategories = categories.filter(category =>
|
||||
category.name.toLowerCase().includes(query) ||
|
||||
category.description.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
// Search in topics
|
||||
const matchedTopics = topics.filter(topic =>
|
||||
topic.title.toLowerCase().includes(query) ||
|
||||
topic.content.toLowerCase().includes(query) ||
|
||||
topic.authorName.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
// Search in posts
|
||||
const matchedPosts = posts.filter(post =>
|
||||
post.content.toLowerCase().includes(query) ||
|
||||
post.authorName.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
return {
|
||||
categories: matchedCategories,
|
||||
topics: matchedTopics,
|
||||
posts: matchedPosts,
|
||||
totalCount: matchedCategories.length + matchedTopics.length + matchedPosts.length
|
||||
};
|
||||
}, [searchQuery, categories, topics, posts]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchResults(performSearch);
|
||||
}, [performSearch]);
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
clearSearch,
|
||||
hasResults: searchResults.totalCount > 0
|
||||
};
|
||||
}
|
||||
83
ui/src/views/forum/Forum.tsx
Normal file
83
ui/src/views/forum/Forum.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useForumData } from './useForumData'
|
||||
import { ForumView } from './forum/ForumView'
|
||||
|
||||
export function Forum() {
|
||||
const { user, tenantId } = useStoreState((state) => state.auth)
|
||||
const {
|
||||
categories,
|
||||
topics,
|
||||
posts,
|
||||
loading,
|
||||
error,
|
||||
createTopic,
|
||||
createPost,
|
||||
likePost,
|
||||
unlikePost,
|
||||
clearError,
|
||||
} = useForumData()
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<ForumCategory | null>(null)
|
||||
const [selectedTopic, setSelectedTopic] = useState<ForumTopic | null>(null)
|
||||
const [forumViewState, setForumViewState] = useState<'categories' | 'topics' | 'posts'>(
|
||||
'categories',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => {
|
||||
clearError()
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [error, clearError])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{error && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Error: </strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
<button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3">
|
||||
<span className="sr-only">Dismiss</span>×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ForumView
|
||||
categories={categories}
|
||||
topics={topics}
|
||||
posts={posts}
|
||||
loading={loading}
|
||||
onCreateTopic={(topicData) => createTopic(topicData).then(() => {})}
|
||||
onCreatePost={(postData) => createPost(postData).then(() => {})}
|
||||
onLikePost={(id) => likePost(id).then(() => {})}
|
||||
onUnlikePost={(id) => unlikePost(id).then(() => {})}
|
||||
currentUserId={user.id}
|
||||
currentUserName={user.name}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedTopic={selectedTopic}
|
||||
viewState={forumViewState}
|
||||
onCategorySelect={setSelectedCategory}
|
||||
onTopicSelect={setSelectedTopic}
|
||||
onViewStateChange={setForumViewState}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Forum
|
||||
102
ui/src/views/forum/Management.tsx
Normal file
102
ui/src/views/forum/Management.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useForumData } from './useForumData'
|
||||
import { AdminView } from './admin/AdminView'
|
||||
|
||||
export function Management() {
|
||||
const { user, tenantId } = useStoreState((state) => state.auth)
|
||||
const {
|
||||
categories,
|
||||
topics,
|
||||
posts,
|
||||
loading,
|
||||
error,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
createTopic,
|
||||
updateTopic,
|
||||
deleteTopic,
|
||||
pinTopic,
|
||||
unpinTopic,
|
||||
lockTopic,
|
||||
unlockTopic,
|
||||
markTopicAsSolved,
|
||||
markTopicAsUnsolved,
|
||||
createPost,
|
||||
updatePost,
|
||||
deletePost,
|
||||
markPostAsAcceptedAnswer,
|
||||
unmarkPostAsAcceptedAnswer,
|
||||
clearError,
|
||||
} = useForumData()
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<ForumCategory | null>(null)
|
||||
const [selectedTopic, setSelectedTopic] = useState<ForumTopic | null>(null)
|
||||
const [forumViewState, setForumViewState] = useState<'categories' | 'topics' | 'posts'>(
|
||||
'categories',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
// Search modal will be opened by Header component
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => {
|
||||
clearError()
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [error, clearError])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{error && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Error: </strong>
|
||||
<span className="block sm:inline">{error}</span>
|
||||
<button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3">
|
||||
<span className="sr-only">Dismiss</span>×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminView
|
||||
categories={categories}
|
||||
topics={topics}
|
||||
posts={posts}
|
||||
loading={loading}
|
||||
onCreateCategory={(data) => createCategory(data).then(() => {})}
|
||||
onUpdateCategory={(id, data) => updateCategory(id, data).then(() => {})}
|
||||
onDeleteCategory={(id) => deleteCategory(id).then(() => {})}
|
||||
onCreateTopic={(data) => createTopic(data).then(() => {})}
|
||||
onUpdateTopic={(id, data) => updateTopic(id, data).then(() => {})}
|
||||
onDeleteTopic={(id) => deleteTopic(id).then(() => {})}
|
||||
onPinTopic={(id) => pinTopic(id).then(() => {})}
|
||||
onUnpinTopic={(id) => unpinTopic(id).then(() => {})}
|
||||
onLockTopic={(id) => lockTopic(id).then(() => {})}
|
||||
onUnlockTopic={(id) => unlockTopic(id).then(() => {})}
|
||||
onMarkTopicAsSolved={(id) => markTopicAsSolved(id).then(() => {})}
|
||||
onMarkTopicAsUnsolved={(id) => markTopicAsUnsolved(id).then(() => {})}
|
||||
onCreatePost={(data) => createPost(data).then(() => {})}
|
||||
onUpdatePost={(id, data) => updatePost(id, data).then(() => {})}
|
||||
onDeletePost={(id) => deletePost(id).then(() => {})}
|
||||
onMarkPostAsAcceptedAnswer={(id) => markPostAsAcceptedAnswer(id).then(() => {})}
|
||||
onUnmarkPostAsAcceptedAnswer={(id) => unmarkPostAsAcceptedAnswer(id).then(() => {})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Management
|
||||
105
ui/src/views/forum/admin/AdminStats.tsx
Normal file
105
ui/src/views/forum/admin/AdminStats.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import { Folder, MessageSquare, FileText, TrendingUp } from 'lucide-react';
|
||||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum';
|
||||
|
||||
interface AdminStatsProps {
|
||||
categories: ForumCategory[];
|
||||
topics: ForumTopic[];
|
||||
posts: ForumPost[];
|
||||
}
|
||||
|
||||
export function AdminStats({ categories, topics, posts }: AdminStatsProps) {
|
||||
const totalCategories = categories.length;
|
||||
const activeCategories = categories.filter(c => c.isActive).length;
|
||||
const totalTopics = topics.length;
|
||||
const solvedTopics = topics.filter(t => t.isSolved).length;
|
||||
const totalPosts = posts.length;
|
||||
const acceptedAnswers = posts.filter(p => p.isAcceptedAnswer).length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Categories',
|
||||
value: totalCategories,
|
||||
subtitle: `${activeCategories} active`,
|
||||
icon: Folder,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'Total Topics',
|
||||
value: totalTopics,
|
||||
subtitle: `${solvedTopics} solved`,
|
||||
icon: MessageSquare,
|
||||
color: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
title: 'Total Posts',
|
||||
value: totalPosts,
|
||||
subtitle: `${acceptedAnswers} accepted answers`,
|
||||
icon: FileText,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Engagement Rate',
|
||||
value: totalTopics > 0 ? Math.round((totalPosts / totalTopics) * 100) / 100 : 0,
|
||||
subtitle: 'posts per topic',
|
||||
icon: TrendingUp,
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Forum Statistics</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 ${stat.color} rounded-lg flex items-center justify-center`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-1">{stat.value}</h3>
|
||||
<p className="text-sm font-medium text-gray-600 mb-1">{stat.title}</p>
|
||||
<p className="text-xs text-gray-500">{stat.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mt-2"></div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-900">New topic created in General Discussion</p>
|
||||
<p className="text-xs text-gray-500">2 hours ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full mt-2"></div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-900">Post marked as accepted answer</p>
|
||||
<p className="text-xs text-gray-500">4 hours ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full mt-2"></div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-900">New category created: Feature Requests</p>
|
||||
<p className="text-xs text-gray-500">1 day ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
ui/src/views/forum/admin/AdminView.tsx
Normal file
160
ui/src/views/forum/admin/AdminView.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Folder, MessageSquare, FileText, Plus, BarChart3 } from 'lucide-react';
|
||||
import { CategoryManagement } from './CategoryManagement';
|
||||
import { TopicManagement } from './TopicManagement';
|
||||
import { PostManagement } from './PostManagement';
|
||||
import { AdminStats } from './AdminStats';
|
||||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum';
|
||||
|
||||
interface AdminViewProps {
|
||||
categories: ForumCategory[];
|
||||
topics: ForumTopic[];
|
||||
posts: ForumPost[];
|
||||
loading: boolean;
|
||||
onCreateCategory: (category: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
isLocked: boolean;
|
||||
}) => Promise<void>;
|
||||
onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<void>;
|
||||
onDeleteCategory: (id: string) => Promise<void>;
|
||||
onCreateTopic: (topic: {
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: string;
|
||||
isPinned?: boolean;
|
||||
isLocked?: boolean;
|
||||
}) => Promise<void>;
|
||||
onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<void>;
|
||||
onDeleteTopic: (id: string) => Promise<void>;
|
||||
onPinTopic: (id: string) => Promise<void>;
|
||||
onUnpinTopic: (id: string) => Promise<void>;
|
||||
onLockTopic: (id: string) => Promise<void>;
|
||||
onUnlockTopic: (id: string) => Promise<void>;
|
||||
onMarkTopicAsSolved: (id: string) => Promise<void>;
|
||||
onMarkTopicAsUnsolved: (id: string) => Promise<void>;
|
||||
onCreatePost: (post: {
|
||||
topicId: string;
|
||||
content: string;
|
||||
parentPostId?: string;
|
||||
}) => Promise<void>;
|
||||
onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<void>;
|
||||
onDeletePost: (id: string) => Promise<void>;
|
||||
onMarkPostAsAcceptedAnswer: (id: string) => Promise<void>;
|
||||
onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type AdminSection = 'stats' | 'categories' | 'topics' | 'posts';
|
||||
|
||||
export function AdminView({
|
||||
categories,
|
||||
topics,
|
||||
posts,
|
||||
loading,
|
||||
onCreateCategory,
|
||||
onUpdateCategory,
|
||||
onDeleteCategory,
|
||||
onCreateTopic,
|
||||
onUpdateTopic,
|
||||
onDeleteTopic,
|
||||
onPinTopic,
|
||||
onUnpinTopic,
|
||||
onLockTopic,
|
||||
onUnlockTopic,
|
||||
onMarkTopicAsSolved,
|
||||
onMarkTopicAsUnsolved,
|
||||
onCreatePost,
|
||||
onUpdatePost,
|
||||
onDeletePost,
|
||||
onMarkPostAsAcceptedAnswer,
|
||||
onUnmarkPostAsAcceptedAnswer
|
||||
}: AdminViewProps) {
|
||||
const [activeSection, setActiveSection] = useState<AdminSection>('stats');
|
||||
|
||||
const navigationItems = [
|
||||
{ id: 'stats' as AdminSection, label: 'Dashboard', icon: BarChart3 },
|
||||
{ id: 'categories' as AdminSection, label: 'Categories', icon: Folder },
|
||||
{ id: 'topics' as AdminSection, label: 'Topics', icon: MessageSquare },
|
||||
{ id: 'posts' as AdminSection, label: 'Posts', icon: FileText },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="lg:w-64 flex-shrink-0">
|
||||
<nav className="space-y-2">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-left transition-colors ${
|
||||
activeSection === item.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
{activeSection === 'stats' && (
|
||||
<AdminStats categories={categories} topics={topics} posts={posts} />
|
||||
)}
|
||||
|
||||
{activeSection === 'categories' && (
|
||||
<CategoryManagement
|
||||
categories={categories}
|
||||
loading={loading}
|
||||
onCreateCategory={onCreateCategory}
|
||||
onUpdateCategory={onUpdateCategory}
|
||||
onDeleteCategory={onDeleteCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'topics' && (
|
||||
<TopicManagement
|
||||
topics={topics}
|
||||
categories={categories}
|
||||
loading={loading}
|
||||
onCreateTopic={onCreateTopic}
|
||||
onUpdateTopic={onUpdateTopic}
|
||||
onDeleteTopic={onDeleteTopic}
|
||||
onPinTopic={onPinTopic}
|
||||
onUnpinTopic={onUnpinTopic}
|
||||
onLockTopic={onLockTopic}
|
||||
onUnlockTopic={onUnlockTopic}
|
||||
onMarkTopicAsSolved={onMarkTopicAsSolved}
|
||||
onMarkTopicAsUnsolved={onMarkTopicAsUnsolved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'posts' && (
|
||||
<PostManagement
|
||||
posts={posts}
|
||||
topics={topics}
|
||||
loading={loading}
|
||||
onCreatePost={onCreatePost}
|
||||
onUpdatePost={onUpdatePost}
|
||||
onDeletePost={onDeletePost}
|
||||
onMarkPostAsAcceptedAnswer={onMarkPostAsAcceptedAnswer}
|
||||
onUnmarkPostAsAcceptedAnswer={onUnmarkPostAsAcceptedAnswer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
321
ui/src/views/forum/admin/CategoryManagement.tsx
Normal file
321
ui/src/views/forum/admin/CategoryManagement.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Plus, Edit2, Trash2, Lock, Unlock, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { ForumCategory } from '@/proxy/forum/forum';
|
||||
|
||||
interface CategoryManagementProps {
|
||||
categories: ForumCategory[];
|
||||
loading: boolean;
|
||||
onCreateCategory: (category: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
isLocked: boolean;
|
||||
}) => Promise<void>;
|
||||
onUpdateCategory: (id: string, category: Partial<ForumCategory>) => Promise<void>;
|
||||
onDeleteCategory: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CategoryManagement({
|
||||
categories,
|
||||
loading,
|
||||
onCreateCategory,
|
||||
onUpdateCategory,
|
||||
onDeleteCategory
|
||||
}: CategoryManagementProps) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<ForumCategory | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
displayOrder: 0,
|
||||
isActive: true,
|
||||
isLocked: false,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
displayOrder: 0,
|
||||
isActive: true,
|
||||
isLocked: false,
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
setEditingCategory(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editingCategory) {
|
||||
await onUpdateCategory(editingCategory.id, formData);
|
||||
} else {
|
||||
await onCreateCategory(formData);
|
||||
}
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (category: ForumCategory) => {
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
displayOrder: category.displayOrder,
|
||||
isActive: category.isActive,
|
||||
isLocked: category.isLocked,
|
||||
});
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (category: ForumCategory) => {
|
||||
try {
|
||||
await onUpdateCategory(category.id, { isActive: !category.isActive });
|
||||
} catch (error) {
|
||||
console.error('Error toggling category status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleLocked = async (category: ForumCategory) => {
|
||||
try {
|
||||
await onUpdateCategory(category.id, { isLocked: !category.isLocked });
|
||||
} catch (error) {
|
||||
console.error('Error toggling category lock:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Are you sure you want to delete this category? This will also delete all topics and posts in this category.')) {
|
||||
try {
|
||||
await onDeleteCategory(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Category Management</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Category</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{editingCategory ? 'Edit Category' : 'Create New Category'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Icon (Emoji)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="💬"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.displayOrder}
|
||||
onChange={(e) => setFormData({ ...formData, displayOrder: parseInt(e.target.value) })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 pt-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isLocked}
|
||||
onChange={(e) => setFormData({ ...formData, isLocked: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Locked
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
<span>{editingCategory ? 'Update' : 'Create'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories List */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Categories ({categories.length})</h3>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-blue-600" />
|
||||
<p className="text-gray-500">Loading categories...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{categories
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((category) => (
|
||||
<div key={category.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-2xl">{category.icon}</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h4 className="text-lg font-semibold text-gray-900">{category.name}</h4>
|
||||
{!category.isActive && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full">Inactive</span>
|
||||
)}
|
||||
{category.isLocked && (
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 text-xs rounded-full">Locked</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 mb-2">{category.description}</p>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{category.topicCount} topics</span>
|
||||
<span>{category.postCount} posts</span>
|
||||
<span>Order: {category.displayOrder}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleToggleActive(category)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
category.isActive
|
||||
? 'text-green-600 hover:bg-green-100'
|
||||
: 'text-red-600 hover:bg-red-100'
|
||||
}`}
|
||||
title={category.isActive ? 'Hide Category' : 'Show Category'}
|
||||
>
|
||||
{category.isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggleLocked(category)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
category.isLocked
|
||||
? 'text-yellow-600 hover:bg-yellow-100'
|
||||
: 'text-green-600 hover:bg-green-100'
|
||||
}`}
|
||||
title={category.isLocked ? 'Unlock Category' : 'Lock Category'}
|
||||
>
|
||||
{category.isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
title="Edit Category"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(category.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
|
||||
title="Delete Category"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
ui/src/views/forum/admin/PostManagement.tsx
Normal file
282
ui/src/views/forum/admin/PostManagement.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Plus, Edit2, Trash2, CheckCircle, Circle, Heart, Loader2 } from 'lucide-react';
|
||||
import { ForumPost, ForumTopic } from '@/proxy/forum/forum';
|
||||
|
||||
interface PostManagementProps {
|
||||
posts: ForumPost[];
|
||||
topics: ForumTopic[];
|
||||
loading: boolean;
|
||||
onCreatePost: (post: {
|
||||
topicId: string;
|
||||
content: string;
|
||||
parentPostId?: string;
|
||||
}) => Promise<void>;
|
||||
onUpdatePost: (id: string, post: Partial<ForumPost>) => Promise<void>;
|
||||
onDeletePost: (id: string) => Promise<void>;
|
||||
onMarkPostAsAcceptedAnswer: (id: string) => Promise<void>;
|
||||
onUnmarkPostAsAcceptedAnswer: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PostManagement({
|
||||
posts,
|
||||
topics,
|
||||
loading,
|
||||
onCreatePost,
|
||||
onUpdatePost,
|
||||
onDeletePost,
|
||||
onMarkPostAsAcceptedAnswer,
|
||||
onUnmarkPostAsAcceptedAnswer
|
||||
}: PostManagementProps) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingPost, setEditingPost] = useState<ForumPost | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
topicId: '',
|
||||
content: '',
|
||||
isAcceptedAnswer: false,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
topicId: '',
|
||||
content: '',
|
||||
isAcceptedAnswer: false,
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
setEditingPost(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editingPost) {
|
||||
await onUpdatePost(editingPost.id, formData);
|
||||
} else {
|
||||
await onCreatePost(formData);
|
||||
}
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (post: ForumPost) => {
|
||||
setEditingPost(post);
|
||||
setFormData({
|
||||
topicId: post.topicId,
|
||||
content: post.content,
|
||||
isAcceptedAnswer: post.isAcceptedAnswer,
|
||||
});
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleToggleAcceptedAnswer = async (post: ForumPost) => {
|
||||
try {
|
||||
if (post.isAcceptedAnswer) {
|
||||
await onUnmarkPostAsAcceptedAnswer(post.id);
|
||||
} else {
|
||||
await onMarkPostAsAcceptedAnswer(post.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling accepted answer:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Are you sure you want to delete this post?')) {
|
||||
try {
|
||||
await onDeletePost(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting post:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTopicTitle = (topicId: string) => {
|
||||
const topic = topics.find(t => t.id === topicId);
|
||||
return topic ? topic.title : 'Unknown Topic';
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Post Management</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Post</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{editingPost ? 'Edit Post' : 'Create New Post'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Topic</label>
|
||||
<select
|
||||
value={formData.topicId}
|
||||
onChange={(e) => setFormData({ ...formData, topicId: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a topic</option>
|
||||
{topics.map(topic => (
|
||||
<option key={topic.id} value={topic.id}>
|
||||
{topic.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Content</label>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isAcceptedAnswer}
|
||||
onChange={(e) => setFormData({ ...formData, isAcceptedAnswer: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Mark as Accepted Answer
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
<span>{editingPost ? 'Update' : 'Create'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts List */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Posts ({posts.length})</h3>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-blue-600" />
|
||||
<p className="text-gray-500">Loading posts...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{posts
|
||||
.sort((a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime())
|
||||
.map((post) => (
|
||||
<div key={post.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{post.authorName}</h4>
|
||||
{post.isAcceptedAnswer && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-full text-xs">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Accepted Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
Reply to: <span className="font-medium">{getTopicTitle(post.topicId)}</span>
|
||||
</p>
|
||||
<p className="text-gray-700 line-clamp-3">{post.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>{formatDate(post.creationTime)}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Heart className="w-4 h-4" />
|
||||
<span>{post.likeCount} likes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
onClick={() => handleToggleAcceptedAnswer(post)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
post.isAcceptedAnswer
|
||||
? 'text-emerald-600 hover:bg-emerald-100'
|
||||
: 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
title={post.isAcceptedAnswer ? 'Remove Accepted Answer' : 'Mark as Accepted Answer'}
|
||||
>
|
||||
{post.isAcceptedAnswer ? <CheckCircle className="w-4 h-4" /> : <Circle className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleEdit(post)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
title="Edit Post"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(post.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
|
||||
title="Delete Post"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
374
ui/src/views/forum/admin/TopicManagement.tsx
Normal file
374
ui/src/views/forum/admin/TopicManagement.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Plus, Edit2, Trash2, Lock, Unlock, Pin, PinOff, CheckCircle, Circle, Eye, Loader2 } from 'lucide-react';
|
||||
import { ForumCategory, ForumTopic } from '@/proxy/forum/forum';
|
||||
|
||||
interface TopicManagementProps {
|
||||
topics: ForumTopic[];
|
||||
categories: ForumCategory[];
|
||||
loading: boolean;
|
||||
onCreateTopic: (topic: {
|
||||
title: string;
|
||||
content: string;
|
||||
categoryId: string;
|
||||
isPinned?: boolean;
|
||||
isLocked?: boolean;
|
||||
}) => Promise<void>;
|
||||
onUpdateTopic: (id: string, topic: Partial<ForumTopic>) => Promise<void>;
|
||||
onDeleteTopic: (id: string) => Promise<void>;
|
||||
onPinTopic: (id: string) => Promise<void>;
|
||||
onUnpinTopic: (id: string) => Promise<void>;
|
||||
onLockTopic: (id: string) => Promise<void>;
|
||||
onUnlockTopic: (id: string) => Promise<void>;
|
||||
onMarkTopicAsSolved: (id: string) => Promise<void>;
|
||||
onMarkTopicAsUnsolved: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TopicManagement({
|
||||
topics,
|
||||
categories,
|
||||
loading,
|
||||
onCreateTopic,
|
||||
onUpdateTopic,
|
||||
onDeleteTopic,
|
||||
onPinTopic,
|
||||
onUnpinTopic,
|
||||
onLockTopic,
|
||||
onUnlockTopic,
|
||||
onMarkTopicAsSolved,
|
||||
onMarkTopicAsUnsolved
|
||||
}: TopicManagementProps) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingTopic, setEditingTopic] = useState<ForumTopic | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
categoryId: '',
|
||||
isPinned: false,
|
||||
isLocked: false,
|
||||
isSolved: false,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
content: '',
|
||||
categoryId: '',
|
||||
isPinned: false,
|
||||
isLocked: false,
|
||||
isSolved: false,
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
setEditingTopic(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (editingTopic) {
|
||||
await onUpdateTopic(editingTopic.id, formData);
|
||||
} else {
|
||||
await onCreateTopic(formData);
|
||||
}
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (topic: ForumTopic) => {
|
||||
setEditingTopic(topic);
|
||||
setFormData({
|
||||
title: topic.title,
|
||||
content: topic.content,
|
||||
categoryId: topic.categoryId,
|
||||
isPinned: topic.isPinned,
|
||||
isLocked: topic.isLocked,
|
||||
isSolved: topic.isSolved,
|
||||
});
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handlePin = async (topic: ForumTopic) => {
|
||||
try {
|
||||
if (topic.isPinned) {
|
||||
await onUnpinTopic(topic.id);
|
||||
} else {
|
||||
await onPinTopic(topic.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling pin:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = async (topic: ForumTopic) => {
|
||||
try {
|
||||
if (topic.isLocked) {
|
||||
await onUnlockTopic(topic.id);
|
||||
} else {
|
||||
await onLockTopic(topic.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling lock:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSolved = async (topic: ForumTopic) => {
|
||||
try {
|
||||
if (topic.isSolved) {
|
||||
await onMarkTopicAsUnsolved(topic.id);
|
||||
} else {
|
||||
await onMarkTopicAsSolved(topic.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling solved status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Are you sure you want to delete this topic? This will also delete all posts in this topic.')) {
|
||||
try {
|
||||
await onDeleteTopic(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting topic:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryName = (categoryId: string) => {
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
return category ? category.name : 'Unknown Category';
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Topic Management</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Topic</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{editingTopic ? 'Edit Topic' : 'Create New Topic'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={formData.categoryId}
|
||||
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Content</label>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isPinned}
|
||||
onChange={(e) => setFormData({ ...formData, isPinned: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Pinned
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isLocked}
|
||||
onChange={(e) => setFormData({ ...formData, isLocked: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Locked
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isSolved}
|
||||
onChange={(e) => setFormData({ ...formData, isSolved: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
Solved
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
<span>{editingTopic ? 'Update' : 'Create'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topics List */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Topics ({topics.length})</h3>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-blue-600" />
|
||||
<p className="text-gray-500">Loading topics...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{topics
|
||||
.sort((a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime())
|
||||
.map((topic) => (
|
||||
<div key={topic.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
{topic.isPinned && <Pin className="w-4 h-4 text-orange-500" />}
|
||||
{topic.isLocked && <Lock className="w-4 h-4 text-gray-400" />}
|
||||
{topic.isSolved && <CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
<h4 className="text-lg font-semibold text-gray-900 line-clamp-1">{topic.title}</h4>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-3 line-clamp-2">{topic.content}</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="font-medium">{getCategoryName(topic.categoryId)}</span>
|
||||
<span>by {topic.authorName}</span>
|
||||
<span>{formatDate(topic.creationTime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{topic.viewCount}</span>
|
||||
</div>
|
||||
<span>{topic.replyCount} replies</span>
|
||||
<span>{topic.likeCount} likes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
onClick={() => handlePin(topic)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
topic.isPinned
|
||||
? 'text-orange-600 hover:bg-orange-100'
|
||||
: 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
title={topic.isPinned ? 'Unpin Topic' : 'Pin Topic'}
|
||||
>
|
||||
{topic.isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleLock(topic)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
topic.isLocked
|
||||
? 'text-yellow-600 hover:bg-yellow-100'
|
||||
: 'text-green-600 hover:bg-green-100'
|
||||
}`}
|
||||
title={topic.isLocked ? 'Unlock Topic' : 'Lock Topic'}
|
||||
>
|
||||
{topic.isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleSolved(topic)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
topic.isSolved
|
||||
? 'text-emerald-600 hover:bg-emerald-100'
|
||||
: 'text-gray-400 hover:bg-gray-100'
|
||||
}`}
|
||||
title={topic.isSolved ? 'Mark as Unsolved' : 'Mark as Solved'}
|
||||
>
|
||||
{topic.isSolved ? <CheckCircle className="w-4 h-4" /> : <Circle className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleEdit(topic)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
title="Edit Topic"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(topic.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
|
||||
title="Delete Topic"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
ui/src/views/forum/forum/CategoryCard.tsx
Normal file
61
ui/src/views/forum/forum/CategoryCard.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import { MessageSquare, Lock, TrendingUp } from 'lucide-react';
|
||||
import { ForumCategory } from '@/proxy/forum/forum';
|
||||
|
||||
interface CategoryCardProps {
|
||||
category: ForumCategory;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function CategoryCard({ category, onClick }: CategoryCardProps) {
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Never';
|
||||
|
||||
const date = new Date(dateString)
|
||||
const diffDays = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
return new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(diffDays, 'day')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md hover:border-blue-200 transition-all duration-200 cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className="text-3xl">{category.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
|
||||
{category.name}
|
||||
</h3>
|
||||
{category.isLocked && (
|
||||
<Lock className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||
{category.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>{category.topicCount} topics</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>{category.postCount} posts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500 ml-4">
|
||||
<div>Last post</div>
|
||||
<div className="font-medium text-gray-700">
|
||||
{formatDate(category.lastPostDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
ui/src/views/forum/forum/CreatePostModal.tsx
Normal file
69
ui/src/views/forum/forum/CreatePostModal.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface CreatePostModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { content: string }) => void;
|
||||
}
|
||||
|
||||
export function CreatePostModal({ onClose, onSubmit }: CreatePostModalProps) {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim()) {
|
||||
onSubmit({ content: content.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Reply to Topic</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Your Reply
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Write your reply..."
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50"
|
||||
disabled={!content.trim()}
|
||||
>
|
||||
Post Reply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
ui/src/views/forum/forum/CreateTopicModal.tsx
Normal file
85
ui/src/views/forum/forum/CreateTopicModal.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface CreateTopicModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { title: string; content: string }) => void;
|
||||
}
|
||||
|
||||
export function CreateTopicModal({ onClose, onSubmit }: CreateTopicModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim() && content.trim()) {
|
||||
onSubmit({ title: title.trim(), content: content.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Create New Topic</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter topic title..."
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Write your topic content..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
disabled={!title.trim() || !content.trim()}
|
||||
>
|
||||
Create Topic
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
493
ui/src/views/forum/forum/ForumView.tsx
Normal file
493
ui/src/views/forum/forum/ForumView.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import React, { useState } from 'react'
|
||||
import { ArrowLeft, Plus, Loader2, Search } from 'lucide-react'
|
||||
import { CategoryCard } from './CategoryCard'
|
||||
import { TopicCard } from './TopicCard'
|
||||
import { PostCard } from './PostCard'
|
||||
import { CreateTopicModal } from './CreateTopicModal'
|
||||
import { CreatePostModal } from './CreatePostModal'
|
||||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { SearchModal } from './SearchModal'
|
||||
import { forumService } from '@/services/forumService'
|
||||
import { buildPostTree } from './utils'
|
||||
|
||||
interface ForumViewProps {
|
||||
categories: ForumCategory[]
|
||||
topics: ForumTopic[]
|
||||
posts: ForumPost[]
|
||||
loading: boolean
|
||||
onCreateTopic: (topic: {
|
||||
title: string
|
||||
content: string
|
||||
categoryId: string
|
||||
isPinned?: boolean
|
||||
isLocked?: boolean
|
||||
}) => Promise<void>
|
||||
onCreatePost: (post: { topicId: string; content: string; parentPostId?: string }) => Promise<void>
|
||||
onLikePost: (id: string) => Promise<void>
|
||||
onUnlikePost: (id: string) => Promise<void>
|
||||
currentUserId: string
|
||||
currentUserName: string
|
||||
selectedCategory?: ForumCategory | null
|
||||
selectedTopic?: ForumTopic | null
|
||||
viewState?: 'categories' | 'topics' | 'posts'
|
||||
onCategorySelect?: (category: ForumCategory | null) => void
|
||||
onTopicSelect?: (topic: ForumTopic | null) => void
|
||||
onViewStateChange?: (state: 'categories' | 'topics' | 'posts') => void
|
||||
}
|
||||
|
||||
export function ForumView({
|
||||
categories,
|
||||
topics,
|
||||
posts,
|
||||
loading,
|
||||
onCreateTopic,
|
||||
onCreatePost,
|
||||
onLikePost,
|
||||
onUnlikePost,
|
||||
selectedCategory: propSelectedCategory,
|
||||
selectedTopic: propSelectedTopic,
|
||||
viewState: propViewState,
|
||||
onCategorySelect,
|
||||
onTopicSelect,
|
||||
onViewStateChange,
|
||||
}: ForumViewProps) {
|
||||
const [localViewState, setLocalViewState] = useState<'categories' | 'topics' | 'posts'>(
|
||||
'categories',
|
||||
)
|
||||
const [localSelectedCategory, setLocalSelectedCategory] = useState<ForumCategory | null>(null)
|
||||
const [localSelectedTopic, setLocalSelectedTopic] = useState<ForumTopic | null>(null)
|
||||
|
||||
const viewState = propViewState || localViewState
|
||||
const selectedCategory = propSelectedCategory || localSelectedCategory
|
||||
const selectedTopic = propSelectedTopic || localSelectedTopic
|
||||
|
||||
const [showCreateTopic, setShowCreateTopic] = useState(false)
|
||||
const [showCreatePost, setShowCreatePost] = useState(false)
|
||||
const [likedPosts, setLikedPosts] = useState<Set<string>>(new Set())
|
||||
const [replyToPostId, setReplyToPostId] = useState<string | undefined>()
|
||||
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
|
||||
|
||||
const handleSearchCategorySelect = (category: ForumCategory) => {
|
||||
if (onCategorySelect) onCategorySelect(category)
|
||||
else setLocalSelectedCategory(category)
|
||||
|
||||
if (onViewStateChange) onViewStateChange('topics')
|
||||
else setLocalViewState('topics')
|
||||
|
||||
setIsSearchModalOpen(false)
|
||||
}
|
||||
|
||||
const handleSearchTopicSelect = (topic: ForumTopic) => {
|
||||
if (onTopicSelect) onTopicSelect(topic)
|
||||
else setLocalSelectedTopic(topic)
|
||||
|
||||
if (onCategorySelect) {
|
||||
const category = categories.find((cat) => cat.id === topic.categoryId)
|
||||
onCategorySelect(category || null)
|
||||
} else {
|
||||
const category = categories.find((cat) => cat.id === topic.categoryId)
|
||||
setLocalSelectedCategory(category || null)
|
||||
}
|
||||
|
||||
if (onViewStateChange) onViewStateChange('posts')
|
||||
else setLocalViewState('posts')
|
||||
|
||||
setIsSearchModalOpen(false)
|
||||
}
|
||||
|
||||
const handleSearchPostSelect = (post: ForumPost) => {
|
||||
const topic = topics.find((t) => t.id === post.topicId)
|
||||
const category = categories.find((c) => c.id === topic?.categoryId)
|
||||
|
||||
if (onCategorySelect) onCategorySelect(category || null)
|
||||
else setLocalSelectedCategory(category || null)
|
||||
|
||||
if (onTopicSelect) onTopicSelect(topic || null)
|
||||
else setLocalSelectedTopic(topic || null)
|
||||
|
||||
if (onViewStateChange) onViewStateChange('posts')
|
||||
else setLocalViewState('posts')
|
||||
|
||||
setIsSearchModalOpen(false)
|
||||
}
|
||||
|
||||
const handleCategoryClick = (category: ForumCategory) => {
|
||||
if (onCategorySelect) {
|
||||
onCategorySelect(category)
|
||||
} else {
|
||||
setLocalSelectedCategory(category)
|
||||
}
|
||||
|
||||
if (onViewStateChange) {
|
||||
onViewStateChange('topics')
|
||||
} else {
|
||||
setLocalViewState('topics')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTopicClick = async (topic: ForumTopic) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.getTopicById(topic.id)
|
||||
if (onTopicSelect) {
|
||||
onTopicSelect(updatedTopic)
|
||||
} else {
|
||||
setLocalSelectedTopic(updatedTopic)
|
||||
}
|
||||
|
||||
if (onViewStateChange) {
|
||||
onViewStateChange('posts')
|
||||
} else {
|
||||
setLocalViewState('posts')
|
||||
}
|
||||
|
||||
const category = categories.find((c) => c.id === updatedTopic.categoryId)
|
||||
if (onCategorySelect) onCategorySelect(category || null)
|
||||
else setLocalSelectedCategory(category || null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load topic:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (viewState === 'posts') {
|
||||
onTopicSelect?.(null) // 🔧 seçili topic'i temizle
|
||||
if (onViewStateChange) {
|
||||
onViewStateChange('topics')
|
||||
} else {
|
||||
setLocalViewState('topics')
|
||||
setLocalSelectedTopic(null) // 🔧 local state için de temizle
|
||||
}
|
||||
} else if (viewState === 'topics') {
|
||||
onCategorySelect?.(null) // 🔧 seçili category'yi temizle
|
||||
onTopicSelect?.(null) // 🔧 topic'i de temizlik amaçlı sıfırla
|
||||
if (onViewStateChange) {
|
||||
onViewStateChange('categories')
|
||||
} else {
|
||||
setLocalViewState('categories')
|
||||
setLocalSelectedCategory(null)
|
||||
setLocalSelectedTopic(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleBreadcrumbClick = (target: 'forum' | 'category') => {
|
||||
if (target === 'forum') {
|
||||
onViewStateChange?.('categories')
|
||||
onCategorySelect?.(null)
|
||||
onTopicSelect?.(null)
|
||||
} else if (target === 'category' && selectedCategory) {
|
||||
onViewStateChange?.('topics')
|
||||
onTopicSelect?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
// const handleBreadcrumbClick = (target: 'forum' | 'category') => {
|
||||
// if (target === 'forum') {
|
||||
// if (onViewStateChange) {
|
||||
// onViewStateChange('categories')
|
||||
// } else {
|
||||
// setLocalViewState('categories')
|
||||
// setLocalSelectedCategory(null)
|
||||
// setLocalSelectedTopic(null)
|
||||
// }
|
||||
// } else if (target === 'category' && selectedCategory) {
|
||||
// if (onViewStateChange) {
|
||||
// onViewStateChange('topics')
|
||||
// } else {
|
||||
// setLocalViewState('topics')
|
||||
// setLocalSelectedTopic(null)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
const filteredTopics = selectedCategory
|
||||
? topics.filter((topic) => topic.categoryId === selectedCategory.id)
|
||||
: []
|
||||
|
||||
const filteredPosts = selectedTopic
|
||||
? posts.filter((post) => post.topicId === selectedTopic.id)
|
||||
: []
|
||||
|
||||
const handleCreateTopic = async (topicData: { title: string; content: string }) => {
|
||||
if (!selectedCategory) return
|
||||
|
||||
try {
|
||||
await onCreateTopic({
|
||||
title: topicData.title,
|
||||
content: topicData.content,
|
||||
categoryId: selectedCategory.id,
|
||||
isPinned: false,
|
||||
isLocked: false,
|
||||
})
|
||||
setShowCreateTopic(false)
|
||||
} catch (error) {
|
||||
console.error('Error creating topic:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreatePost = async (postData: { content: string }) => {
|
||||
if (!selectedTopic) return
|
||||
|
||||
try {
|
||||
await onCreatePost({
|
||||
topicId: selectedTopic.id,
|
||||
content: postData.content,
|
||||
parentPostId: replyToPostId, // buraya dikkat
|
||||
})
|
||||
setShowCreatePost(false)
|
||||
setReplyToPostId(undefined) // temizle
|
||||
} catch (error) {
|
||||
console.error('Error creating post:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const threadedPosts = buildPostTree(filteredPosts)
|
||||
|
||||
function renderPosts(posts: (ForumPost & { children: ForumPost[] })[]) {
|
||||
return posts.map((post) => (
|
||||
<div key={post.id}>
|
||||
<PostCard
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onReply={handleReply}
|
||||
isLiked={likedPosts.has(post.id)}
|
||||
/>
|
||||
{post.children.length > 0 && (
|
||||
<div className="pl-6 border-l border-gray-200 mt-4">{renderPosts(post.children)}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
const handleLike = async (postId: string, isFirst: boolean = false) => {
|
||||
try {
|
||||
if (likedPosts.has(postId)) {
|
||||
isFirst ? await forumService.unlikeTopic(postId) : await onUnlikePost(postId)
|
||||
|
||||
setLikedPosts((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(postId)
|
||||
return newSet
|
||||
})
|
||||
} else {
|
||||
isFirst ? await forumService.likeTopic(postId) : await onLikePost(postId)
|
||||
|
||||
setLikedPosts((prev) => new Set(prev).add(postId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error liking/unliking post or topic:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReply = (postId: string) => {
|
||||
setReplyToPostId(postId)
|
||||
setShowCreatePost(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-2 text-gray-600">Loading forum data...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb + Actions + Search Row */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
{/* Left Side: Breadcrumb */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{viewState !== 'categories' && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
)}
|
||||
<nav className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick('forum')}
|
||||
className={`transition-colors ${
|
||||
viewState === 'categories'
|
||||
? 'text-gray-900 font-medium cursor-default'
|
||||
: 'hover:text-blue-600 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
Forum
|
||||
</button>
|
||||
{selectedCategory && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick('category')}
|
||||
className={`transition-colors ${
|
||||
viewState === 'topics'
|
||||
? 'text-gray-900 font-medium cursor-default'
|
||||
: 'hover:text-blue-600 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{selectedCategory.name}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selectedTopic && (
|
||||
<>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 font-medium">{selectedTopic.title}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Actions + Search */}
|
||||
<div className="flex items-center space-x-2 ml-auto">
|
||||
{viewState === 'topics' && selectedCategory && !selectedCategory.isLocked && (
|
||||
<button
|
||||
onClick={() => setShowCreateTopic(true)}
|
||||
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Topic</span>
|
||||
</button>
|
||||
)}
|
||||
{viewState === 'posts' && selectedTopic && !selectedTopic.isLocked && (
|
||||
<button
|
||||
onClick={() => setShowCreatePost(true)}
|
||||
className="flex items-center space-x-2 bg-emerald-600 text-white px-4 py-2 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Post</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
className="hidden md:flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Search className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-500">Search topics...</span>
|
||||
<kbd className="hidden sm:inline-block px-2 py-1 text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
className="md:hidden p-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories View */}
|
||||
{viewState === 'categories' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{categories
|
||||
.filter((cat) => cat.isActive)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((category) => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topics View */}
|
||||
{viewState === 'topics' && selectedCategory && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{selectedCategory.name}</h2>
|
||||
<div className="space-y-4">
|
||||
{filteredTopics
|
||||
.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
return new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime()
|
||||
})
|
||||
.map((topic) => (
|
||||
<TopicCard
|
||||
key={topic.id}
|
||||
topic={topic}
|
||||
onClick={() => handleTopicClick(topic)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts View */}
|
||||
{viewState === 'posts' && selectedTopic && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{selectedTopic.title}</h2>
|
||||
|
||||
{/* Topic Ana İçeriği */}
|
||||
<PostCard
|
||||
post={{
|
||||
id: selectedTopic.id,
|
||||
topicId: selectedTopic.id,
|
||||
content: selectedTopic.content,
|
||||
authorId: selectedTopic.authorId,
|
||||
authorName: selectedTopic.authorName,
|
||||
likeCount: selectedTopic.likeCount,
|
||||
isAcceptedAnswer: false,
|
||||
creationTime: selectedTopic.creationTime,
|
||||
parentPostId: undefined
|
||||
}}
|
||||
onLike={handleLike}
|
||||
onReply={handleReply}
|
||||
isFirst={true}
|
||||
isLiked={likedPosts.has(selectedTopic.id)}
|
||||
/>
|
||||
|
||||
{/* Hiyerarşik Postlar */}
|
||||
<div className="mt-4 space-y-4">{renderPosts(threadedPosts)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Topic Modal */}
|
||||
{showCreateTopic && (
|
||||
<CreateTopicModal
|
||||
onClose={() => setShowCreateTopic(false)}
|
||||
onSubmit={handleCreateTopic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Post Modal */}
|
||||
{showCreatePost && (
|
||||
<CreatePostModal onClose={() => setShowCreatePost(false)} onSubmit={handleCreatePost} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
categories={categories}
|
||||
topics={topics}
|
||||
posts={posts}
|
||||
onCategorySelect={handleSearchCategorySelect}
|
||||
onTopicSelect={handleSearchTopicSelect}
|
||||
onPostSelect={handleSearchPostSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
81
ui/src/views/forum/forum/PostCard.tsx
Normal file
81
ui/src/views/forum/forum/PostCard.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React from 'react';
|
||||
import { Heart, User, CheckCircle, Reply } from 'lucide-react';
|
||||
import { ForumPost } from '@/proxy/forum/forum';
|
||||
|
||||
interface PostCardProps {
|
||||
post: ForumPost;
|
||||
onLike: (postId: string, isFirst: boolean) => void;
|
||||
onReply: (postId: string) => void;
|
||||
isFirst?: boolean;
|
||||
isLiked?: boolean;
|
||||
}
|
||||
|
||||
export function PostCard({ post, onLike, onReply, isFirst = false, isLiked = false }: PostCardProps) {
|
||||
const handleLike = () => {
|
||||
onLike(post.id, isFirst);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${isFirst ? 'border-l-4 border-l-blue-500' : ''}`}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
|
||||
<User className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{post.authorName}</h4>
|
||||
{post.isAcceptedAnswer && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-full text-xs">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>Accepted Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{formatDate(post.creationTime)}</span>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-sm max-w-none mb-4">
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{post.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => onLike(post.id, isFirst)}
|
||||
className={`flex items-center space-x-1 px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
isLiked
|
||||
? 'bg-red-100 text-red-600 hover:bg-red-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isLiked ? 'fill-current' : ''}`} />
|
||||
<span>{post.likeCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onReply(post.id)}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-full text-sm bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Reply className="w-4 h-4" />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
ui/src/views/forum/forum/SearchModal.tsx
Normal file
268
ui/src/views/forum/forum/SearchModal.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Search, Folder, MessageSquare, FileText, User } from 'lucide-react';
|
||||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum';
|
||||
import { useForumSearch } from '@/utils/hooks/useForumSearch';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: ForumCategory[];
|
||||
topics: ForumTopic[];
|
||||
posts: ForumPost[];
|
||||
onCategorySelect: (category: ForumCategory) => void;
|
||||
onTopicSelect: (topic: ForumTopic) => void;
|
||||
onPostSelect: (post: ForumPost) => void;
|
||||
}
|
||||
|
||||
export function SearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
topics,
|
||||
posts,
|
||||
onCategorySelect,
|
||||
onTopicSelect,
|
||||
onPostSelect
|
||||
}: SearchModalProps) {
|
||||
const { searchQuery, setSearchQuery, searchResults, clearSearch, hasResults } = useForumSearch({
|
||||
categories,
|
||||
topics,
|
||||
posts
|
||||
});
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [isOpen, searchResults]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const totalResults = searchResults.categories.length + searchResults.topics.length + searchResults.posts.length;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev + 1) % totalResults);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev - 1 + totalResults) % totalResults);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSelectResult(selectedIndex);
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectResult = (index: number) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
// Check categories
|
||||
if (index < searchResults.categories.length) {
|
||||
onCategorySelect(searchResults.categories[index]);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
currentIndex += searchResults.categories.length;
|
||||
|
||||
// Check topics
|
||||
if (index < currentIndex + searchResults.topics.length) {
|
||||
onTopicSelect(searchResults.topics[index - currentIndex]);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
currentIndex += searchResults.topics.length;
|
||||
|
||||
// Check posts
|
||||
if (index < currentIndex + searchResults.posts.length) {
|
||||
onPostSelect(searchResults.posts[index - currentIndex]);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getTopicTitle = (topicId: string) => {
|
||||
const topic = topics.find(t => t.id === topicId);
|
||||
return topic ? topic.title : 'Unknown Topic';
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-20 p-4 z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[70vh] overflow-hidden">
|
||||
<div className="flex items-center p-4 border-b border-gray-200">
|
||||
<Search className="w-5 h-5 text-gray-400 mr-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search categories, topics, and posts..."
|
||||
className="flex-1 outline-none text-lg"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors ml-3"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-96">
|
||||
{!searchQuery.trim() ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>Start typing to search categories, topics, and posts...</p>
|
||||
</div>
|
||||
) : !hasResults ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<p>No results found for "{searchQuery}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{/* Categories */}
|
||||
{searchResults.categories.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide bg-gray-50">
|
||||
Categories ({searchResults.categories.length})
|
||||
</div>
|
||||
{searchResults.categories.map((category, index) => (
|
||||
<button
|
||||
key={`category-${category.id}`}
|
||||
onClick={() => {
|
||||
onCategorySelect(category);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full flex items-center px-4 py-3 hover:bg-gray-50 transition-colors ${
|
||||
selectedIndex === index ? 'bg-blue-50 border-r-2 border-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Folder className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-900">{category.name}</div>
|
||||
<div className="text-sm text-gray-500 line-clamp-1">{category.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{category.topicCount} topics
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topics */}
|
||||
{searchResults.topics.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide bg-gray-50">
|
||||
Topics ({searchResults.topics.length})
|
||||
</div>
|
||||
{searchResults.topics.map((topic, index) => {
|
||||
const globalIndex = searchResults.categories.length + index;
|
||||
return (
|
||||
<button
|
||||
key={`topic-${topic.id}`}
|
||||
onClick={() => {
|
||||
onTopicSelect(topic);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full flex items-center px-4 py-3 hover:bg-gray-50 transition-colors ${
|
||||
selectedIndex === globalIndex ? 'bg-blue-50 border-r-2 border-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-900 line-clamp-1">{topic.title}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
by {topic.authorName} • {formatDate(topic.creationTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{topic.replyCount} replies
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{searchResults.posts.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide bg-gray-50">
|
||||
Posts ({searchResults.posts.length})
|
||||
</div>
|
||||
{searchResults.posts.map((post, index) => {
|
||||
const globalIndex = searchResults.categories.length + searchResults.topics.length + index;
|
||||
return (
|
||||
<button
|
||||
key={`post-${post.id}`}
|
||||
onClick={() => {
|
||||
onPostSelect(post);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full flex items-center px-4 py-3 hover:bg-gray-50 transition-colors ${
|
||||
selectedIndex === globalIndex ? 'bg-blue-50 border-r-2 border-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-900 text-sm line-clamp-1">
|
||||
{getTopicTitle(post.topicId)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 line-clamp-2 mt-1">
|
||||
{post.content}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
by {post.authorName} • {formatDate(post.creationTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{post.likeCount} likes
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasResults && (
|
||||
<div className="px-4 py-2 border-t border-gray-200 text-xs text-gray-500 bg-gray-50">
|
||||
Use ↑↓ to navigate, Enter to select, Esc to close
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
ui/src/views/forum/forum/TopicCard.tsx
Normal file
75
ui/src/views/forum/forum/TopicCard.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react'
|
||||
import { MessageSquare, Heart, Eye, Pin, Lock, CheckCircle } from 'lucide-react'
|
||||
import { ForumTopic } from '@/proxy/forum/forum'
|
||||
|
||||
interface TopicCardProps {
|
||||
topic: ForumTopic
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function TopicCard({ topic, onClick }: TopicCardProps) {
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Never';
|
||||
|
||||
const date = new Date(dateString)
|
||||
const diffDays = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
return new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(diffDays, 'day')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md hover:border-blue-200 transition-all duration-200 cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
{topic.isPinned && <Pin className="w-4 h-4 text-orange-500" />}
|
||||
{topic.isLocked && <Lock className="w-4 h-4 text-gray-400" />}
|
||||
{topic.isSolved && <CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-1">
|
||||
{topic.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">{topic.content}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{topic.viewCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>{topic.replyCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Heart className="w-4 h-4" />
|
||||
<span>{topic.likeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
<div className="font-medium text-gray-700">{topic.authorName}</div>
|
||||
<div>{formatDate(topic.creationTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topic.lastPostDate && topic.lastPostUserName && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>
|
||||
Last reply by{' '}
|
||||
<span className="font-medium text-gray-700">{topic.lastPostUserName}</span>
|
||||
</span>
|
||||
<span>{formatDate(topic.lastPostDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
ui/src/views/forum/forum/utils.tsx
Normal file
24
ui/src/views/forum/forum/utils.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ForumPost } from '@/proxy/forum/forum'
|
||||
|
||||
export function buildPostTree(posts: ForumPost[]): (ForumPost & { children: ForumPost[] })[] {
|
||||
const postMap = new Map<string, ForumPost & { children: ForumPost[] }>();
|
||||
|
||||
// 1. Her post için children array'i eklenmiş yeni bir nesne oluştur
|
||||
posts.forEach((post) => {
|
||||
postMap.set(post.id, { ...post, children: [] });
|
||||
});
|
||||
|
||||
const roots: (ForumPost & { children: ForumPost[] })[] = [];
|
||||
|
||||
// 2. Her post'un parent'ı varsa ilgili parent'ın children listesine ekle
|
||||
postMap.forEach((post) => {
|
||||
if (post.parentPostId && postMap.has(post.parentPostId)) {
|
||||
postMap.get(post.parentPostId)!.children.push(post);
|
||||
} else {
|
||||
roots.push(post);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
438
ui/src/views/forum/useForumData.ts
Normal file
438
ui/src/views/forum/useForumData.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
|
||||
import { forumService } from '@/services/forumService'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useForumData() {
|
||||
const [categories, setCategories] = useState<ForumCategory[]>([])
|
||||
const [topics, setTopics] = useState<ForumTopic[]>([])
|
||||
const [posts, setPosts] = useState<ForumPost[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
loadTopics()
|
||||
loadPosts()
|
||||
}, [])
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await forumService.getCategories()
|
||||
setCategories(response.items)
|
||||
} catch (err) {
|
||||
setError('Failed to load categories')
|
||||
console.error('Error loading categories:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTopics = async (categoryId?: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await forumService.getTopics({ categoryId })
|
||||
setTopics(response.items)
|
||||
} catch (err) {
|
||||
setError('Failed to load topics')
|
||||
console.error('Error loading topics:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPosts = async (topicId?: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await forumService.getPosts({ topicId })
|
||||
setPosts(response.items)
|
||||
} catch (err) {
|
||||
setError('Failed to load posts')
|
||||
console.error('Error loading posts:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Category operations
|
||||
const createCategory = async (categoryData: {
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
icon: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
isLocked: boolean
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const newCategory = await forumService.createCategory(categoryData)
|
||||
setCategories((prev) => [...prev, newCategory])
|
||||
return newCategory
|
||||
} catch (err) {
|
||||
setError('Failed to create category')
|
||||
console.error('Error creating category:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateCategory = async (id: string, updates: Partial<ForumCategory>) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const updatedCategory = await forumService.updateCategory(id, updates)
|
||||
setCategories((prev) => prev.map((cat) => (cat.id === id ? updatedCategory : cat)))
|
||||
return updatedCategory
|
||||
} catch (err) {
|
||||
setError('Failed to update category')
|
||||
console.error('Error updating category:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCategory = async (id: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await forumService.deleteCategory(id)
|
||||
setCategories((prev) => prev.filter((cat) => cat.id !== id))
|
||||
// Also remove related topics and posts
|
||||
const topicsToDelete = topics.filter((topic) => topic.categoryId === id)
|
||||
const topicIds = topicsToDelete.map((t) => t.id)
|
||||
setTopics((prev) => prev.filter((topic) => topic.categoryId !== id))
|
||||
setPosts((prev) => prev.filter((post) => !topicIds.includes(post.topicId)))
|
||||
} catch (err) {
|
||||
setError('Failed to delete category')
|
||||
console.error('Error deleting category:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Topic operations
|
||||
const createTopic = async (topicData: {
|
||||
title: string
|
||||
content: string
|
||||
categoryId: string
|
||||
isPinned?: boolean
|
||||
isLocked?: boolean
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const newTopic = await forumService.createTopic(topicData)
|
||||
setTopics((prev) => [...prev, newTopic])
|
||||
// Update category topic count
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topicData.categoryId ? { ...cat, topicCount: cat.topicCount + 1 } : cat,
|
||||
),
|
||||
)
|
||||
return newTopic
|
||||
} catch (err) {
|
||||
setError('Failed to create topic')
|
||||
console.error('Error creating topic:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateTopic = async (id: string, updates: Partial<ForumTopic>) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const updatedTopic = await forumService.updateTopic(id, updates)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to update topic')
|
||||
console.error('Error updating topic:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTopic = async (id: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const topic = topics.find((t) => t.id === id)
|
||||
await forumService.deleteTopic(id)
|
||||
setTopics((prev) => prev.filter((t) => t.id !== id))
|
||||
setPosts((prev) => prev.filter((post) => post.topicId !== id))
|
||||
|
||||
// Update category counts
|
||||
if (topic) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topic.categoryId
|
||||
? {
|
||||
...cat,
|
||||
topicCount: Math.max(0, cat.topicCount - 1),
|
||||
postCount: Math.max(
|
||||
0,
|
||||
cat.postCount - posts.filter((p) => p.topicId === id).length,
|
||||
),
|
||||
}
|
||||
: cat,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete topic')
|
||||
console.error('Error deleting topic:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pinTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.pinTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to pin topic')
|
||||
console.error('Error pinning topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const unpinTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.unpinTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to unpin topic')
|
||||
console.error('Error unpinning topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const lockTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.lockTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to lock topic')
|
||||
console.error('Error locking topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const unlockTopic = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.unlockTopic(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to unlock topic')
|
||||
console.error('Error unlocking topic:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const markTopicAsSolved = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.markTopicAsSolved(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to mark topic as solved')
|
||||
console.error('Error marking topic as solved:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const markTopicAsUnsolved = async (id: string) => {
|
||||
try {
|
||||
const updatedTopic = await forumService.markTopicAsUnsolved(id)
|
||||
setTopics((prev) => prev.map((topic) => (topic.id === id ? updatedTopic : topic)))
|
||||
return updatedTopic
|
||||
} catch (err) {
|
||||
setError('Failed to mark topic as unsolved')
|
||||
console.error('Error marking topic as unsolved:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Post operations
|
||||
const createPost = async (postData: {
|
||||
topicId: string
|
||||
content: string
|
||||
parentPostId?: string
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const newPost = await forumService.createPost(postData)
|
||||
setPosts((prev) => [...prev, newPost])
|
||||
|
||||
// Update topic and category post counts
|
||||
const topic = topics.find((t) => t.id === postData.topicId)
|
||||
if (topic) {
|
||||
setTopics((prev) =>
|
||||
prev.map((t) => (t.id === postData.topicId ? { ...t, replyCount: t.replyCount + 1 } : t)),
|
||||
)
|
||||
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topic.categoryId ? { ...cat, postCount: cat.postCount + 1 } : cat,
|
||||
),
|
||||
)
|
||||
}
|
||||
return newPost
|
||||
} catch (err) {
|
||||
setError('Failed to create post')
|
||||
console.error('Error creating post:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePost = async (id: string, updates: Partial<ForumPost>) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const updatedPost = await forumService.updatePost(id, updates)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to update post')
|
||||
console.error('Error updating post:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deletePost = async (id: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const post = posts.find((p) => p.id === id)
|
||||
await forumService.deletePost(id)
|
||||
setPosts((prev) => prev.filter((p) => p.id !== id))
|
||||
|
||||
// Update topic and category counts
|
||||
if (post) {
|
||||
const topic = topics.find((t) => t.id === post.topicId)
|
||||
if (topic) {
|
||||
setTopics((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === post.topicId ? { ...t, replyCount: Math.max(0, t.replyCount - 1) } : t,
|
||||
),
|
||||
)
|
||||
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
cat.id === topic.categoryId
|
||||
? { ...cat, postCount: Math.max(0, cat.postCount - 1) }
|
||||
: cat,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to delete post')
|
||||
console.error('Error deleting post:', err)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const likePost = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.likePost(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to like post')
|
||||
console.error('Error liking post:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const unlikePost = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.unlikePost(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to unlike post')
|
||||
console.error('Error unliking post:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const markPostAsAcceptedAnswer = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.markPostAsAcceptedAnswer(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to mark post as accepted answer')
|
||||
console.error('Error marking post as accepted answer:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const unmarkPostAsAcceptedAnswer = async (id: string) => {
|
||||
try {
|
||||
const updatedPost = await forumService.unmarkPostAsAcceptedAnswer(id)
|
||||
setPosts((prev) => prev.map((post) => (post.id === id ? updatedPost : post)))
|
||||
return updatedPost
|
||||
} catch (err) {
|
||||
setError('Failed to unmark post as accepted answer')
|
||||
console.error('Error unmarking post as accepted answer:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Data
|
||||
categories,
|
||||
topics,
|
||||
posts,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Load functions
|
||||
loadCategories,
|
||||
loadTopics,
|
||||
loadPosts,
|
||||
|
||||
// Category operations
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
|
||||
// Topic operations
|
||||
createTopic,
|
||||
updateTopic,
|
||||
deleteTopic,
|
||||
pinTopic,
|
||||
unpinTopic,
|
||||
lockTopic,
|
||||
unlockTopic,
|
||||
markTopicAsSolved,
|
||||
markTopicAsUnsolved,
|
||||
|
||||
// Post operations
|
||||
createPost,
|
||||
updatePost,
|
||||
deletePost,
|
||||
likePost,
|
||||
unlikePost,
|
||||
markPostAsAcceptedAnswer,
|
||||
unmarkPostAsAcceptedAnswer,
|
||||
|
||||
// Utility
|
||||
clearError: () => setError(null),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue