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.Charts.Dto;
|
||||||
using Kurs.Platform.Entities;
|
using Kurs.Platform.Entities;
|
||||||
using Kurs.Platform.Enums;
|
using Kurs.Platform.Enums;
|
||||||
|
using Kurs.Platform.Forum;
|
||||||
using Kurs.Platform.ListForms;
|
using Kurs.Platform.ListForms;
|
||||||
using Kurs.Platform.Seeds;
|
using Kurs.Platform.Seeds;
|
||||||
using Kurs.Settings.Entities;
|
using Kurs.Settings.Entities;
|
||||||
|
|
@ -51,6 +52,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
private readonly IRepository<ContactTitle, Guid> _contactTitleRepository;
|
private readonly IRepository<ContactTitle, Guid> _contactTitleRepository;
|
||||||
private readonly IRepository<BlogCategory, Guid> _blogCategoryRepository;
|
private readonly IRepository<BlogCategory, Guid> _blogCategoryRepository;
|
||||||
private readonly IRepository<BlogPost, Guid> _blogPostsRepository;
|
private readonly IRepository<BlogPost, Guid> _blogPostsRepository;
|
||||||
|
private readonly IRepository<ForumCategory, Guid> _forumCategoryRepository;
|
||||||
|
|
||||||
public PlatformDataSeeder(
|
public PlatformDataSeeder(
|
||||||
IRepository<Language, Guid> languages,
|
IRepository<Language, Guid> languages,
|
||||||
|
|
@ -78,7 +80,8 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
IRepository<ContactTag, Guid> contactTagRepository,
|
IRepository<ContactTag, Guid> contactTagRepository,
|
||||||
IRepository<ContactTitle, Guid> contactTitleRepository,
|
IRepository<ContactTitle, Guid> contactTitleRepository,
|
||||||
IRepository<BlogCategory, Guid> blogCategoryRepository,
|
IRepository<BlogCategory, Guid> blogCategoryRepository,
|
||||||
IRepository<BlogPost, Guid> blogPostsRepository
|
IRepository<BlogPost, Guid> blogPostsRepository,
|
||||||
|
IRepository<ForumCategory, Guid> forumCategoryRepository
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_languages = languages;
|
_languages = languages;
|
||||||
|
|
@ -107,6 +110,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
_contactTitleRepository = contactTitleRepository;
|
_contactTitleRepository = contactTitleRepository;
|
||||||
_blogCategoryRepository = blogCategoryRepository;
|
_blogCategoryRepository = blogCategoryRepository;
|
||||||
_blogPostsRepository = blogPostsRepository;
|
_blogPostsRepository = blogPostsRepository;
|
||||||
|
_forumCategoryRepository = forumCategoryRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IConfigurationRoot BuildConfiguration()
|
private static IConfigurationRoot BuildConfiguration()
|
||||||
|
|
@ -565,7 +569,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
DisplayOrder = item.DisplayOrder,
|
DisplayOrder = item.DisplayOrder,
|
||||||
PostCount = item.PostCount
|
PostCount = item.PostCount
|
||||||
};
|
};
|
||||||
|
|
||||||
await _blogCategoryRepository.InsertAsync(newCategory);
|
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",
|
"resourceName": "Platform",
|
||||||
"key": "App.Blog",
|
"key": "App.BlogManagement",
|
||||||
"en": "Blog Management",
|
"en": "Blog Management",
|
||||||
"tr": "Blog Yönetimi"
|
"tr": "Blog Yönetimi"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Forum",
|
"key": "App.Forum",
|
||||||
|
"en": "Forum",
|
||||||
|
"tr": "Forum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ForumManagement",
|
||||||
"en": "Forum Management",
|
"en": "Forum Management",
|
||||||
"tr": "Forum Yönetimi"
|
"tr": "Forum Yönetimi"
|
||||||
},
|
},
|
||||||
|
|
@ -605,7 +611,7 @@
|
||||||
"key": "Cancel",
|
"key": "Cancel",
|
||||||
"en": "Cancel",
|
"en": "Cancel",
|
||||||
"tr": "İptal"
|
"tr": "İptal"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "Delete",
|
"key": "Delete",
|
||||||
|
|
@ -4769,7 +4775,7 @@
|
||||||
"key": "blog.posts.post.create",
|
"key": "blog.posts.post.create",
|
||||||
"tr": "Oluştur",
|
"tr": "Oluştur",
|
||||||
"en": "Create"
|
"en": "Create"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "blog.posts.categories",
|
"key": "blog.posts.categories",
|
||||||
|
|
@ -4787,7 +4793,7 @@
|
||||||
"key": "blog.posts.categories.edittitle",
|
"key": "blog.posts.categories.edittitle",
|
||||||
"tr": "Kategori Düzenle",
|
"tr": "Kategori Düzenle",
|
||||||
"en": "Edit Category"
|
"en": "Edit Category"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "blog.posts.categories.name",
|
"key": "blog.posts.categories.name",
|
||||||
|
|
@ -4811,7 +4817,7 @@
|
||||||
"key": "blog.posts.categories.icon",
|
"key": "blog.posts.categories.icon",
|
||||||
"tr": "İkon",
|
"tr": "İkon",
|
||||||
"en": "Icon"
|
"en": "Icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "blog.posts.categories.count",
|
"key": "blog.posts.categories.count",
|
||||||
|
|
@ -4823,7 +4829,7 @@
|
||||||
"key": "blog.posts.categories.order",
|
"key": "blog.posts.categories.order",
|
||||||
"tr": "Sıralama",
|
"tr": "Sıralama",
|
||||||
"en": "Order"
|
"en": "Order"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "blog.posts.categories.status",
|
"key": "blog.posts.categories.status",
|
||||||
|
|
@ -4841,7 +4847,7 @@
|
||||||
"key": "blog.posts.categories.create",
|
"key": "blog.posts.categories.create",
|
||||||
"tr": "Oluştur",
|
"tr": "Oluştur",
|
||||||
"en": "Create"
|
"en": "Create"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "blog.posts.ai.title",
|
"key": "blog.posts.ai.title",
|
||||||
|
|
@ -6224,26 +6230,6 @@
|
||||||
"RequiredPermissionName": "App.Menus",
|
"RequiredPermissionName": "App.Menus",
|
||||||
"IsDisabled": false
|
"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",
|
"ParentCode": "App.Saas",
|
||||||
"Code": "App.Listforms",
|
"Code": "App.Listforms",
|
||||||
|
|
@ -6346,12 +6332,42 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ParentCode": "App.Saas",
|
"ParentCode": "App.Saas",
|
||||||
"Code": "App.Blog",
|
"Code": "AApp.BlogManagement",
|
||||||
"DisplayName": "App.Blog",
|
"DisplayName": "App.BlogManagement",
|
||||||
"Order": 10,
|
"Order": 10,
|
||||||
"Url": "/admin/blog",
|
"Url": "/admin/blogmanagement",
|
||||||
"Icon": "FcTemplate",
|
"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
|
"IsDisabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -6453,6 +6469,16 @@
|
||||||
"Icon": "FcMultipleInputs",
|
"Icon": "FcMultipleInputs",
|
||||||
"RequiredPermissionName": "App.AuditLogs",
|
"RequiredPermissionName": "App.AuditLogs",
|
||||||
"IsDisabled": false
|
"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": [
|
"PermissionGroupDefinitionRecords": [
|
||||||
|
|
@ -6513,8 +6539,12 @@
|
||||||
"DisplayName": "App.AuditLogs"
|
"DisplayName": "App.AuditLogs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "App.Blog",
|
"Name": "App.BlogManagement",
|
||||||
"DisplayName": "App.Blog"
|
"DisplayName": "App.BlogManagement"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "App.ForumManagement",
|
||||||
|
"DisplayName": "App.ForumManagement"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PermissionDefinitionRecords": [
|
"PermissionDefinitionRecords": [
|
||||||
|
|
@ -6711,13 +6741,21 @@
|
||||||
"MultiTenancySide": 2
|
"MultiTenancySide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Blog",
|
"GroupName": "App.BlogManagement",
|
||||||
"Name": "App.Blog",
|
"Name": "App.BlogManagement",
|
||||||
"ParentName": null,
|
"ParentName": null,
|
||||||
"DisplayName": "App.Blog",
|
"DisplayName": "App.BlogManagement",
|
||||||
"IsEnabled": true,
|
"IsEnabled": true,
|
||||||
"MultiTenancySide": 2
|
"MultiTenancySide": 2
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.ForumManagement",
|
||||||
|
"Name": "App.ForumManagement",
|
||||||
|
"ParentName": null,
|
||||||
|
"DisplayName": "App.ForumManagement",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Setting",
|
"GroupName": "App.Setting",
|
||||||
"Name": "Abp.Account",
|
"Name": "Abp.Account",
|
||||||
|
|
@ -7719,41 +7757,81 @@
|
||||||
"MultiTenancySide": 3
|
"MultiTenancySide": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Blog",
|
"GroupName": "App.BlogManagement",
|
||||||
"Name": "App.Blog.Create",
|
"Name": "App.BlogManagement.Create",
|
||||||
"ParentName": "App.Blog",
|
"ParentName": "App.BlogManagement",
|
||||||
"DisplayName": "Create",
|
"DisplayName": "Create",
|
||||||
"IsEnabled": true,
|
"IsEnabled": true,
|
||||||
"MultiTenancySide": 2
|
"MultiTenancySide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Blog",
|
"GroupName": "App.BlogManagement",
|
||||||
"Name": "App.Blog.Delete",
|
"Name": "App.BlogManagement.Delete",
|
||||||
"ParentName": "App.Blog",
|
"ParentName": "App.BlogManagement",
|
||||||
"DisplayName": "Delete",
|
"DisplayName": "Delete",
|
||||||
"IsEnabled": true,
|
"IsEnabled": true,
|
||||||
"MultiTenancySide": 2
|
"MultiTenancySide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Blog",
|
"GroupName": "App.BlogManagement",
|
||||||
"Name": "App.Blog.Export",
|
"Name": "App.BlogManagement.Export",
|
||||||
"ParentName": "App.Blog",
|
"ParentName": "App.BlogManagement",
|
||||||
"DisplayName": "Export",
|
"DisplayName": "Export",
|
||||||
"IsEnabled": true,
|
"IsEnabled": true,
|
||||||
"MultiTenancySide": 2
|
"MultiTenancySide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Blog",
|
"GroupName": "App.BlogManagement",
|
||||||
"Name": "App.Blog.Publish",
|
"Name": "App.BlogManagement.Publish",
|
||||||
"ParentName": "App.Blog",
|
"ParentName": "App.BlogManagement",
|
||||||
"DisplayName": "Publish",
|
"DisplayName": "Publish",
|
||||||
"IsEnabled": true,
|
"IsEnabled": true,
|
||||||
"MultiTenancySide": 2
|
"MultiTenancySide": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Blog",
|
"GroupName": "App.BlogManagement",
|
||||||
"Name": "App.Blog.Update",
|
"Name": "App.BlogManagement.Update",
|
||||||
"ParentName": "App.Blog",
|
"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",
|
"DisplayName": "Update",
|
||||||
"IsEnabled": true,
|
"IsEnabled": true,
|
||||||
"MultiTenancySide": 2
|
"MultiTenancySide": 2
|
||||||
|
|
@ -20284,5 +20362,43 @@
|
||||||
"CategoryId": "dbc8578c-1a99-594a-8997-bddd0eac8571",
|
"CategoryId": "dbc8578c-1a99-594a-8997-bddd0eac8571",
|
||||||
"AuthorId": "727ec3f0-75dd-54e2-8ae6-13d49727ff58"
|
"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<ContactTitleSeedDto> ContactTitles { get; set; }
|
||||||
public List<BlogCategorySeedDto> BlogCategories { get; set; }
|
public List<BlogCategorySeedDto> BlogCategories { get; set; }
|
||||||
public List<BlogPostSeedDto> BlogPosts { get; set; }
|
public List<BlogPostSeedDto> BlogPosts { get; set; }
|
||||||
|
public List<ForumCategorySeedDto> ForumCategories { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ChartsSeedDto
|
public class ChartsSeedDto
|
||||||
|
|
@ -212,14 +213,25 @@ public class BlogCategorySeedDto
|
||||||
|
|
||||||
public class BlogPostSeedDto
|
public class BlogPostSeedDto
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Slug { get; set; }
|
public string Slug { get; set; }
|
||||||
public string ContentTr { get; set; }
|
public string ContentTr { get; set; }
|
||||||
public string ContentEn { get; set; }
|
public string ContentEn { get; set; }
|
||||||
public string ReadTime { get; set; }
|
public string ReadTime { get; set; }
|
||||||
public string Summary { get; set; }
|
public string Summary { get; set; }
|
||||||
public string CoverImage { get; set; }
|
public string CoverImage { get; set; }
|
||||||
public Guid CategoryId { 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 Volo.Abp.TenantManagement.EntityFrameworkCore;
|
||||||
using Kurs.Notifications.EntityFrameworkCore;
|
using Kurs.Notifications.EntityFrameworkCore;
|
||||||
using static Kurs.Settings.SettingsConsts;
|
using static Kurs.Settings.SettingsConsts;
|
||||||
|
using Kurs.Platform.Forum;
|
||||||
|
|
||||||
namespace Kurs.Platform.EntityFrameworkCore;
|
namespace Kurs.Platform.EntityFrameworkCore;
|
||||||
|
|
||||||
|
|
@ -60,6 +61,11 @@ public class PlatformDbContext :
|
||||||
public DbSet<BlogPost> BlogPosts { get; set; }
|
public DbSet<BlogPost> BlogPosts { get; set; }
|
||||||
public DbSet<BlogCategory> BlogCategories { 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
|
#region Entities from the modules
|
||||||
|
|
||||||
/* Notice: We only implemented IIdentityDbContext and ITenantManagementDbContext
|
/* Notice: We only implemented IIdentityDbContext and ITenantManagementDbContext
|
||||||
|
|
@ -428,5 +434,64 @@ public class PlatformDbContext :
|
||||||
.HasForeignKey(x => x.CategoryId)
|
.HasForeignKey(x => x.CategoryId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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);
|
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 =>
|
modelBuilder.Entity("Kurs.Settings.Entities.SettingDefinition", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -4589,6 +4834,35 @@ namespace Kurs.Platform.Migrations
|
||||||
b.Navigation("UomCategory");
|
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 =>
|
modelBuilder.Entity("Skill", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SkillType", null)
|
b.HasOne("SkillType", null)
|
||||||
|
|
@ -4765,6 +5039,21 @@ namespace Kurs.Platform.Migrations
|
||||||
b.Navigation("Units");
|
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 =>
|
modelBuilder.Entity("SkillType", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Levels");
|
b.Navigation("Levels");
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.pd7p0avqcno"
|
"revision": "0.ouca2h9ms1"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,18 @@ const adminRoutes: Routes = [
|
||||||
component: lazy(() => import('@/views/blog/BlogManagement')),
|
component: lazy(() => import('@/views/blog/BlogManagement')),
|
||||||
authority: [],
|
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 }
|
export { adminRoutes }
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,11 @@ export const ROUTES_ENUM = {
|
||||||
},
|
},
|
||||||
chart: '/admin/chart/edit/:chartCode',
|
chart: '/admin/chart/edit/:chartCode',
|
||||||
blog: {
|
blog: {
|
||||||
management: '/admin/blog',
|
management: '/admin/blogmanagement',
|
||||||
},
|
},
|
||||||
forum: {
|
forum: {
|
||||||
management: '/admin/forum',
|
view: '/admin/forum',
|
||||||
|
management: '/admin/forummanagement',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: '/settings',
|
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