forum güncellemeleri

This commit is contained in:
Sedat ÖZTÜRK 2025-06-23 17:58:13 +03:00
parent 6d1516eb0f
commit 4d127032af
34 changed files with 10231 additions and 65 deletions

View 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; }
}

View file

@ -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();
}

View 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
};
}
}

View file

@ -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();
}
}

View file

@ -10,6 +10,7 @@ using Kurs.Platform.Blog;
using Kurs.Platform.Charts.Dto;
using Kurs.Platform.Entities;
using Kurs.Platform.Enums;
using Kurs.Platform.Forum;
using Kurs.Platform.ListForms;
using Kurs.Platform.Seeds;
using Kurs.Settings.Entities;
@ -51,6 +52,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
private readonly IRepository<ContactTitle, Guid> _contactTitleRepository;
private readonly IRepository<BlogCategory, Guid> _blogCategoryRepository;
private readonly IRepository<BlogPost, Guid> _blogPostsRepository;
private readonly IRepository<ForumCategory, Guid> _forumCategoryRepository;
public PlatformDataSeeder(
IRepository<Language, Guid> languages,
@ -78,7 +80,8 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
IRepository<ContactTag, Guid> contactTagRepository,
IRepository<ContactTitle, Guid> contactTitleRepository,
IRepository<BlogCategory, Guid> blogCategoryRepository,
IRepository<BlogPost, Guid> blogPostsRepository
IRepository<BlogPost, Guid> blogPostsRepository,
IRepository<ForumCategory, Guid> forumCategoryRepository
)
{
_languages = languages;
@ -107,6 +110,7 @@ public class PlatformDataSeeder : IDataSeedContributor, ITransientDependency
_contactTitleRepository = contactTitleRepository;
_blogCategoryRepository = blogCategoryRepository;
_blogPostsRepository = blogPostsRepository;
_forumCategoryRepository = forumCategoryRepository;
}
private static IConfigurationRoot BuildConfiguration()
@ -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);
}
}
}
}

View file

@ -524,13 +524,19 @@
},
{
"resourceName": "Platform",
"key": "App.Blog",
"key": "App.BlogManagement",
"en": "Blog Management",
"tr": "Blog Yönetimi"
},
{
"resourceName": "Platform",
"key": "App.Forum",
"en": "Forum",
"tr": "Forum"
},
{
"resourceName": "Platform",
"key": "App.ForumManagement",
"en": "Forum Management",
"tr": "Forum Yönetimi"
},
@ -6224,26 +6230,6 @@
"RequiredPermissionName": "App.Menus",
"IsDisabled": false
},
{
"ParentCode": null,
"Code": "App.Administration",
"DisplayName": "App.Administration",
"Order": 400,
"Url": null,
"Icon": "FcOrganization",
"RequiredPermissionName": null,
"IsDisabled": false
},
{
"ParentCode": "App.Administration",
"Code": "App.Setting",
"DisplayName": "App.Setting",
"Order": 1,
"Url": "/settings",
"Icon": "FcSettings",
"RequiredPermissionName": "App.Setting",
"IsDisabled": false
},
{
"ParentCode": "App.Saas",
"Code": "App.Listforms",
@ -6346,12 +6332,42 @@
},
{
"ParentCode": "App.Saas",
"Code": "App.Blog",
"DisplayName": "App.Blog",
"Code": "AApp.BlogManagement",
"DisplayName": "App.BlogManagement",
"Order": 10,
"Url": "/admin/blog",
"Url": "/admin/blogmanagement",
"Icon": "FcTemplate",
"RequiredPermissionName": "App.Blog",
"RequiredPermissionName": "App.BlogManagement",
"IsDisabled": false
},
{
"ParentCode": "App.Saas",
"Code": "App.ForumManagement",
"DisplayName": "App.ForumManagement",
"Order": 11,
"Url": "/admin/forummanagement",
"Icon": "FcReading",
"RequiredPermissionName": "App.ForumManagement",
"IsDisabled": false
},
{
"ParentCode": null,
"Code": "App.Administration",
"DisplayName": "App.Administration",
"Order": 400,
"Url": null,
"Icon": "FcOrganization",
"RequiredPermissionName": null,
"IsDisabled": false
},
{
"ParentCode": "App.Administration",
"Code": "App.Setting",
"DisplayName": "App.Setting",
"Order": 1,
"Url": "/settings",
"Icon": "FcSettings",
"RequiredPermissionName": "App.Setting",
"IsDisabled": false
},
{
@ -6453,6 +6469,16 @@
"Icon": "FcMultipleInputs",
"RequiredPermissionName": "App.AuditLogs",
"IsDisabled": false
},
{
"ParentCode": "App.Administration",
"Code": "App.Forum",
"DisplayName": "App.Forum",
"Order": 4,
"Url": "/admin/forum",
"Icon": "FcLink",
"RequiredPermissionName": "App.ForumManagement.Publish",
"IsDisabled": false
}
],
"PermissionGroupDefinitionRecords": [
@ -6513,8 +6539,12 @@
"DisplayName": "App.AuditLogs"
},
{
"Name": "App.Blog",
"DisplayName": "App.Blog"
"Name": "App.BlogManagement",
"DisplayName": "App.BlogManagement"
},
{
"Name": "App.ForumManagement",
"DisplayName": "App.ForumManagement"
}
],
"PermissionDefinitionRecords": [
@ -6711,13 +6741,21 @@
"MultiTenancySide": 2
},
{
"GroupName": "App.Blog",
"Name": "App.Blog",
"GroupName": "App.BlogManagement",
"Name": "App.BlogManagement",
"ParentName": null,
"DisplayName": "App.Blog",
"DisplayName": "App.BlogManagement",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.ForumManagement",
"Name": "App.ForumManagement",
"ParentName": null,
"DisplayName": "App.ForumManagement",
"IsEnabled": true,
"MultiTenancySide": 3
},
{
"GroupName": "App.Setting",
"Name": "Abp.Account",
@ -7719,41 +7757,81 @@
"MultiTenancySide": 3
},
{
"GroupName": "App.Blog",
"Name": "App.Blog.Create",
"ParentName": "App.Blog",
"GroupName": "App.BlogManagement",
"Name": "App.BlogManagement.Create",
"ParentName": "App.BlogManagement",
"DisplayName": "Create",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Blog",
"Name": "App.Blog.Delete",
"ParentName": "App.Blog",
"GroupName": "App.BlogManagement",
"Name": "App.BlogManagement.Delete",
"ParentName": "App.BlogManagement",
"DisplayName": "Delete",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Blog",
"Name": "App.Blog.Export",
"ParentName": "App.Blog",
"GroupName": "App.BlogManagement",
"Name": "App.BlogManagement.Export",
"ParentName": "App.BlogManagement",
"DisplayName": "Export",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Blog",
"Name": "App.Blog.Publish",
"ParentName": "App.Blog",
"GroupName": "App.BlogManagement",
"Name": "App.BlogManagement.Publish",
"ParentName": "App.BlogManagement",
"DisplayName": "Publish",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Blog",
"Name": "App.Blog.Update",
"ParentName": "App.Blog",
"GroupName": "App.BlogManagement",
"Name": "App.BlogManagement.Update",
"ParentName": "App.BlogManagement",
"DisplayName": "Update",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.ForumManagement",
"Name": "App.ForumManagement.Publish",
"ParentName": "App.ForumManagement",
"DisplayName": "Publish",
"IsEnabled": true,
"MultiTenancySide": 3
},
{
"GroupName": "App.ForumManagement",
"Name": "App.ForumManagement.Create",
"ParentName": "App.ForumManagement",
"DisplayName": "Create",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.ForumManagement",
"Name": "App.ForumManagement.Delete",
"ParentName": "App.ForumManagement",
"DisplayName": "Delete",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.ForumManagement",
"Name": "App.ForumManagement.Export",
"ParentName": "App.ForumManagement",
"DisplayName": "Export",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.ForumManagement",
"Name": "App.ForumManagement.Update",
"ParentName": "App.ForumManagement",
"DisplayName": "Update",
"IsEnabled": true,
"MultiTenancySide": 2
@ -20284,5 +20362,43 @@
"CategoryId": "dbc8578c-1a99-594a-8997-bddd0eac8571",
"AuthorId": "727ec3f0-75dd-54e2-8ae6-13d49727ff58"
}
],
"ForumCategories": [
{
"Id": "1a79a36e-e062-4335-9ddf-0557c60f3ea9",
"Name": "Genel Tartışma",
"Slug": "genel-tartisma",
"Description": "Her türlü konunun tartışılabileceği genel forum alanı",
"Icon": "💬",
"DisplayOrder": 1,
"IsActive": true
},
{
"Id": "e7d6f581-60ba-44d4-be37-c5d13e5c2fda",
"Name": "Teknik Destek",
"Slug": "teknik-destek",
"Description": "Teknik sorunlar ve çözümler için destek forumu",
"Icon": "🔧",
"DisplayOrder": 2,
"IsActive": true
},
{
"Id": "54ac1095-0a95-467e-9f86-01efa8af136b",
"Name": "Öneriler",
"Slug": "oneriler",
"Description": "Platform geliştirmeleri için öneri ve istekler",
"Icon": "💡",
"DisplayOrder": 3,
"IsActive": true
},
{
"Id": "3dfbb220-9a2d-49e4-835a-213f47c60939",
"Name": "Duyurular",
"Slug": "duyurular",
"Description": "Platform duyuruları ve güncellemeler",
"Icon": "📢",
"DisplayOrder": 4,
"IsActive": true
}
]
}

View file

@ -35,6 +35,7 @@ public class SeederDto
public List<ContactTitleSeedDto> ContactTitles { get; set; }
public List<BlogCategorySeedDto> BlogCategories { get; set; }
public List<BlogPostSeedDto> BlogPosts { get; set; }
public List<ForumCategorySeedDto> ForumCategories { get; set; }
}
public class ChartsSeedDto
@ -212,14 +213,25 @@ public class BlogCategorySeedDto
public class BlogPostSeedDto
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Slug { get; set; }
public string ContentTr { get; set; }
public string ContentEn { get; set; }
public Guid Id { get; set; }
public string Title { get; set; }
public string Slug { get; set; }
public string ContentTr { get; set; }
public string ContentEn { get; set; }
public string ReadTime { get; set; }
public string Summary { get; set; }
public string CoverImage { get; set; }
public Guid CategoryId { get; set; }
public Guid AuthorId { get; set; }
}
public 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; }
}

View 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 = [];
}
}

View file

@ -19,6 +19,7 @@ using Volo.Abp.TenantManagement;
using Volo.Abp.TenantManagement.EntityFrameworkCore;
using Kurs.Notifications.EntityFrameworkCore;
using static Kurs.Settings.SettingsConsts;
using Kurs.Platform.Forum;
namespace Kurs.Platform.EntityFrameworkCore;
@ -60,6 +61,11 @@ public class PlatformDbContext :
public DbSet<BlogPost> BlogPosts { get; set; }
public DbSet<BlogCategory> BlogCategories { get; set; }
// Forum Entities
public DbSet<ForumCategory> ForumCategories { get; set; }
public DbSet<ForumTopic> ForumTopics { get; set; }
public DbSet<ForumPost> ForumPosts { get; set; }
#region Entities from the modules
/* Notice: We only implemented IIdentityDbContext and ITenantManagementDbContext
@ -428,5 +434,64 @@ public class PlatformDbContext :
.HasForeignKey(x => x.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
// Forum Entity Configurations
// ForumCategory
builder.Entity<ForumCategory>(b =>
{
b.ToTable(PlatformConsts.DbTablePrefix + "ForumCategories", PlatformConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
b.Property(x => x.Description).HasMaxLength(512);
b.Property(x => x.Icon).HasMaxLength(64);
b.HasIndex(x => x.DisplayOrder);
b.HasMany(x => x.Topics)
.WithOne(x => x.Category)
.HasForeignKey(x => x.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
// ForumTopic
builder.Entity<ForumTopic>(b =>
{
b.ToTable(PlatformConsts.DbTablePrefix + "ForumTopics", PlatformConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.Title).IsRequired().HasMaxLength(256);
b.Property(x => x.Content).IsRequired();
b.HasIndex(x => x.CategoryId);
b.HasIndex(x => x.IsPinned);
b.HasIndex(x => x.LastPostDate);
b.HasMany(x => x.Posts)
.WithOne(x => x.Topic)
.HasForeignKey(x => x.TopicId)
.OnDelete(DeleteBehavior.Cascade);
});
// ForumPost
builder.Entity<ForumPost>(b =>
{
b.ToTable(PlatformConsts.DbTablePrefix + "ForumPosts", PlatformConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.Content).IsRequired();
b.HasIndex(x => x.TopicId);
b.HasOne(x => x.Topic)
.WithMany(x => x.Posts)
.HasForeignKey(x => x.TopicId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.ParentPost)
.WithMany(x => x.Replies)
.HasForeignKey(x => x.ParentPostId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -2436,6 +2436,251 @@ namespace Kurs.Platform.Migrations
b.ToTable("PUomCategory", (string)null);
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumCategory", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<string>("Description")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("Icon")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsLocked")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<DateTime?>("LastPostDate")
.HasColumnType("datetime2");
b.Property<Guid?>("LastPostId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("LastPostUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LastPostUserName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<int>("PostCount")
.HasColumnType("int");
b.Property<string>("Slug")
.HasColumnType("nvarchar(max)");
b.Property<int>("TopicCount")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("DisplayOrder");
b.ToTable("PForumCategories", (string)null);
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumPost", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AuthorId")
.HasColumnType("uniqueidentifier");
b.Property<string>("AuthorName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsAcceptedAnswer")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<int>("LikeCount")
.HasColumnType("int");
b.Property<Guid?>("ParentPostId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("TopicId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ParentPostId");
b.HasIndex("TopicId");
b.ToTable("PForumPosts", (string)null);
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumTopic", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("AuthorId")
.HasColumnType("uniqueidentifier");
b.Property<string>("AuthorName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("CategoryId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsLocked")
.HasColumnType("bit");
b.Property<bool>("IsPinned")
.HasColumnType("bit");
b.Property<bool>("IsSolved")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<DateTime?>("LastPostDate")
.HasColumnType("datetime2");
b.Property<Guid?>("LastPostId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("LastPostUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LastPostUserName")
.HasColumnType("nvarchar(max)");
b.Property<int>("LikeCount")
.HasColumnType("int");
b.Property<int>("ReplyCount")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("ViewCount")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("IsPinned");
b.HasIndex("LastPostDate");
b.ToTable("PForumTopics", (string)null);
});
modelBuilder.Entity("Kurs.Settings.Entities.SettingDefinition", b =>
{
b.Property<Guid>("Id")
@ -4589,6 +4834,35 @@ namespace Kurs.Platform.Migrations
b.Navigation("UomCategory");
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumPost", b =>
{
b.HasOne("Kurs.Platform.Forum.ForumPost", "ParentPost")
.WithMany("Replies")
.HasForeignKey("ParentPostId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Kurs.Platform.Forum.ForumTopic", "Topic")
.WithMany("Posts")
.HasForeignKey("TopicId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentPost");
b.Navigation("Topic");
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumTopic", b =>
{
b.HasOne("Kurs.Platform.Forum.ForumCategory", "Category")
.WithMany("Topics")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Category");
});
modelBuilder.Entity("Skill", b =>
{
b.HasOne("SkillType", null)
@ -4765,6 +5039,21 @@ namespace Kurs.Platform.Migrations
b.Navigation("Units");
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumCategory", b =>
{
b.Navigation("Topics");
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumPost", b =>
{
b.Navigation("Replies");
});
modelBuilder.Entity("Kurs.Platform.Forum.ForumTopic", b =>
{
b.Navigation("Posts");
});
modelBuilder.Entity("SkillType", b =>
{
b.Navigation("Levels");

View file

@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.pd7p0avqcno"
"revision": "0.ouca2h9ms1"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View file

@ -63,6 +63,18 @@ const adminRoutes: Routes = [
component: lazy(() => import('@/views/blog/BlogManagement')),
authority: [],
},
{
key: ROUTES_ENUM.admin.forum.management,
path: ROUTES_ENUM.admin.forum.management,
component: lazy(() => import('@/views/forum/Management')),
authority: [],
},
{
key: ROUTES_ENUM.admin.forum.view,
path: ROUTES_ENUM.admin.forum.view,
component: lazy(() => import('@/views/forum/Forum')),
authority: [],
},
]
export { adminRoutes }

View file

@ -36,10 +36,11 @@ export const ROUTES_ENUM = {
},
chart: '/admin/chart/edit/:chartCode',
blog: {
management: '/admin/blog',
management: '/admin/blogmanagement',
},
forum: {
management: '/admin/forum',
view: '/admin/forum',
management: '/admin/forummanagement',
},
},
settings: '/settings',

View 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';

View 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()

View 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
};
}

View 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

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
</>
)
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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;
}

View 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),
}
}