Changeset 73b69b2


Ignore:
Timestamp:
03/24/26 22:13:36 (3 months ago)
Author:
kikisrbinoska <srbinoskakristina07@…>
Branches:
main
Children:
7fbb91c
Parents:
acf690c
Message:

Fixed reading lists,comments and likes

Files:
32 edited

Legend:

Unmodified
Added
Removed
  • ChapterX.API/Controllers/CommentsController.cs

    racf690c r73b69b2  
    11using ChapterX.Application.Comment.Commands;
    22using ChapterX.Application.Comment.Queries;
     3using ChapterX.Domain.Repositories;
    34using MediatR;
    45using Microsoft.AspNetCore.Authorization;
     
    1314    {
    1415        private readonly IMediator _mediator;
     16        private readonly ICommentRepository _commentRepository;
    1517        private readonly ILogger<CommentsController> _logger;
    1618
    17         public CommentsController(IMediator mediator, ILogger<CommentsController> logger)
     19        public CommentsController(IMediator mediator, ICommentRepository commentRepository, ILogger<CommentsController> logger)
    1820        {
    1921            _mediator = mediator;
     22            _commentRepository = commentRepository;
    2023            _logger = logger;
     24        }
     25
     26        [HttpGet("story/{storyId:int}")]
     27        [AllowAnonymous]
     28        public async Task<ActionResult> GetByStory(int storyId)
     29        {
     30            var comments = await _commentRepository.GetByStoryIdAsync(storyId);
     31            var result = comments.Select(c => new
     32            {
     33                id = c.Id,
     34                content = c.Content,
     35                userId = c.UserId,
     36                storyId = c.StoryId,
     37                username = c.User?.Username ?? "",
     38                createdAt = c.CreatedAt,
     39            });
     40            return Ok(result);
    2141        }
    2242
  • ChapterX.API/Controllers/LikesController.cs

    racf690c r73b69b2  
    11using ChapterX.Application.Likes.Commands;
    22using ChapterX.Application.Likes.Queries;
     3using ChapterX.Domain.Repositories;
    34using MediatR;
    45using Microsoft.AspNetCore.Authorization;
     
    1314    {
    1415        private readonly IMediator _mediator;
     16        private readonly ILikesRepository _likesRepository;
    1517        private readonly ILogger<LikesController> _logger;
    1618
    17         public LikesController(IMediator mediator, ILogger<LikesController> logger)
     19        public LikesController(IMediator mediator, ILikesRepository likesRepository, ILogger<LikesController> logger)
    1820        {
    1921            _mediator = mediator;
     22            _likesRepository = likesRepository;
    2023            _logger = logger;
     24        }
     25
     26        [HttpGet("story/{storyId:int}")]
     27        [AllowAnonymous]
     28        public async Task<ActionResult> GetByStory(int storyId)
     29        {
     30            var likes = await _likesRepository.GetByStoryIdAsync(storyId);
     31            return Ok(new { count = likes.Count(), userIds = likes.Select(l => l.UserId).ToList() });
     32        }
     33
     34        [HttpDelete("user/{userId:int}/story/{storyId:int}")]
     35        [Authorize]
     36        public async Task<ActionResult> DeleteByUserAndStory(int userId, int storyId)
     37        {
     38            var deleted = await _likesRepository.DeleteByUserAndStoryAsync(userId, storyId);
     39            if (!deleted) return NotFound();
     40            return Ok();
    2141        }
    2242
     
    4363        public async Task<ActionResult> Add([FromBody] AddRequest request)
    4464        {
    45             _logger.LogInformation("Adding a new like for ChapterId: {ChapterId}", request.ChapterId);
     65            _logger.LogInformation("Adding a new like for StoryId: {StoryId}", request.StoryId);
     66            var exists = await _likesRepository.ExistsAsync(request.UserId, request.StoryId);
     67            if (exists) return Ok();
    4668            var response = await _mediator.Send(request);
    4769            return Ok(response);
  • ChapterX.API/Controllers/NotificationsController.cs

    racf690c r73b69b2  
    11using ChapterX.Application.Notification.Commands;
    22using ChapterX.Application.Notification.Queries;
     3using ChapterX.Domain.Repositories;
    34using MediatR;
    45using Microsoft.AspNetCore.Authorization;
     
    1314    {
    1415        private readonly IMediator _mediator;
     16        private readonly INotificationRepository _notificationRepository;
    1517        private readonly ILogger<NotificationsController> _logger;
    1618
    17         public NotificationsController(IMediator mediator, ILogger<NotificationsController> logger)
     19        public NotificationsController(IMediator mediator, INotificationRepository notificationRepository, ILogger<NotificationsController> logger)
    1820        {
    1921            _mediator = mediator;
     22            _notificationRepository = notificationRepository;
    2023            _logger = logger;
     24        }
     25
     26        [HttpGet("user/{userId:int}")]
     27        [Authorize]
     28        public async Task<ActionResult> GetByUser(int userId)
     29        {
     30            var notifications = await _notificationRepository.GetByUserIdAsync(userId);
     31            var result = notifications.Select(n => new
     32            {
     33                id = n.Id,
     34                content = n.Content,
     35                isRead = n.IsRead,
     36                createdAt = n.CreatedAt,
     37                type = n.Type ?? "info",
     38                link = n.Link,
     39            });
     40            return Ok(result);
     41        }
     42
     43        [HttpPut("{id:int}/read")]
     44        [Authorize]
     45        public async Task<ActionResult> MarkRead(int id)
     46        {
     47            var notification = await _notificationRepository.GetByIdAsync(id);
     48            if (notification == null) return NotFound();
     49            notification.IsRead = true;
     50            await _notificationRepository.UpdateAsync(notification);
     51            return Ok();
    2152        }
    2253
  • ChapterX.API/Controllers/ReadingListItemsController.cs

    racf690c r73b69b2  
    11using ChapterX.Application.ReadingListItems.Commands;
    22using ChapterX.Application.ReadingListItems.Queries;
     3using ChapterX.Domain.Repositories;
    34using MediatR;
    45using Microsoft.AspNetCore.Authorization;
     
    1314    {
    1415        private readonly IMediator _mediator;
     16        private readonly IReadingListItemsRepository _readingListItemsRepository;
    1517        private readonly ILogger<ReadingListItemsController> _logger;
    1618
    17         public ReadingListItemsController(IMediator mediator, ILogger<ReadingListItemsController> logger)
     19        public ReadingListItemsController(IMediator mediator, IReadingListItemsRepository readingListItemsRepository, ILogger<ReadingListItemsController> logger)
    1820        {
    1921            _mediator = mediator;
     22            _readingListItemsRepository = readingListItemsRepository;
    2023            _logger = logger;
    2124        }
     
    7073            return Ok(response);
    7174        }
     75
     76        [HttpDelete("{listId:int}/story/{storyId:int}")]
     77        [Authorize]
     78        public async Task<ActionResult> DeleteByListAndStory(int listId, int storyId)
     79        {
     80            _logger.LogInformation("Removing story {StoryId} from list {ListId}", storyId, listId);
     81            var deleted = await _readingListItemsRepository.DeleteByListAndStoryAsync(listId, storyId);
     82            return deleted ? Ok() : NotFound();
     83        }
    7284    }
    7385}
  • ChapterX.API/Controllers/ReadingListsController.cs

    racf690c r73b69b2  
    11using ChapterX.Application.ReadingList.Commands;
    22using ChapterX.Application.ReadingList.Queries;
     3using ChapterX.Domain.Repositories;
    34using MediatR;
    45using Microsoft.AspNetCore.Authorization;
     
    1314    {
    1415        private readonly IMediator _mediator;
     16        private readonly IReadingListRepository _readingListRepository;
    1517        private readonly ILogger<ReadingListsController> _logger;
    1618
    17         public ReadingListsController(IMediator mediator, ILogger<ReadingListsController> logger)
     19        public ReadingListsController(IMediator mediator, IReadingListRepository readingListRepository, ILogger<ReadingListsController> logger)
    1820        {
    1921            _mediator = mediator;
     22            _readingListRepository = readingListRepository;
    2023            _logger = logger;
     24        }
     25
     26        [HttpGet("user/{userId:int}")]
     27        [Authorize]
     28        public async Task<ActionResult> GetByUser(int userId)
     29        {
     30            _logger.LogInformation("Fetching reading lists for user {UserId}", userId);
     31            var lists = await _readingListRepository.GetByUserIdAsync(userId);
     32            var result = lists.Select(l => MapList(l));
     33            return Ok(result);
    2134        }
    2235
     
    2639        {
    2740            _logger.LogInformation("Fetching all reading lists");
    28             var response = await _mediator.Send(new GetAllRequest());
    29             return Ok(response);
     41            var lists = await _readingListRepository.GetAllAsync();
     42            var result = lists.Select(l => MapList(l));
     43            return Ok(result);
    3044        }
     45
     46        private static object MapList(Domain.Entities.ReadingList l) => new
     47        {
     48            id = l.Id,
     49            name = l.Name,
     50            content = l.Content,
     51            isPublic = l.IsPublic,
     52            userId = l.UserId,
     53            createdAt = l.CreatedAt,
     54            username = l.User?.Username ?? "",
     55            readingListItems = l.ReadingListItems.Select(i => new
     56            {
     57                listId = i.ListId,
     58                storyId = i.StoryId,
     59                addedAt = i.AddedAt,
     60                storyTitle = i.Story?.ShortDescription ?? "",
     61                authorUsername = i.Story?.Writer?.User?.Username ?? "",
     62                genres = i.Story?.HasGenres.Select(hg => hg.Genre?.Name).Where(n => n != null).ToList() ?? []
     63            }).ToList()
     64        };
    3165
    3266        [HttpGet("{id:int}")]
  • ChapterX.Application/Likes/Commands/AddHandler.cs

    racf690c r73b69b2  
    2121            {
    2222                UserId = request.UserId,
    23                 StoryId = request.ChapterId,
     23                StoryId = request.StoryId,
    2424                CreatedAt = DateTime.UtcNow
    2525            };
  • ChapterX.Application/Likes/Commands/AddRequest.cs

    racf690c r73b69b2  
    33namespace ChapterX.Application.Likes.Commands
    44{
    5     public record AddRequest(int UserId, int ChapterId) : IRequest<AddResponse>;
     5    public record AddRequest(int UserId, int StoryId) : IRequest<AddResponse>;
    66}
  • ChapterX.Application/Notification/Commands/AddHandler.cs

    racf690c r73b69b2  
    2222                Content = request.Content,
    2323                IsRead = false,
    24                 CreatedAt = DateTime.UtcNow
     24                CreatedAt = DateTime.UtcNow,
     25                RecipientUserId = request.RecipientUserId,
     26                Type = request.Type,
     27                Link = request.Link,
    2528            };
    2629
  • ChapterX.Application/Notification/Commands/AddRequest.cs

    racf690c r73b69b2  
    33namespace ChapterX.Application.Notification.Commands
    44{
    5     public record AddRequest(string Content) : IRequest<AddResponse>;
     5    public record AddRequest(string Content, int? RecipientUserId = null, string? Type = null, string? Link = null) : IRequest<AddResponse>;
    66}
  • ChapterX.Application/Story/Commands/AddHandler.cs

    racf690c r73b69b2  
    88    {
    99        private readonly IStoryRepository _storyRepository;
     10        private readonly IGenreRepository _genreRepository;
     11        private readonly IHasGenreRepository _hasGenreRepository;
    1012        private readonly ILogger<AddHandler> _logger;
    1113
    12         public AddHandler(IStoryRepository storyRepository, ILogger<AddHandler> logger)
     14        public AddHandler(IStoryRepository storyRepository, IGenreRepository genreRepository, IHasGenreRepository hasGenreRepository, ILogger<AddHandler> logger)
    1315        {
    1416            _storyRepository = storyRepository;
     17            _genreRepository = genreRepository;
     18            _hasGenreRepository = hasGenreRepository;
    1519            _logger = logger;
    1620        }
     
    3135            await _storyRepository.AddAsync(story, cancellationToken);
    3236
     37            foreach (var genreName in request.Genres ?? [])
     38            {
     39                var genre = await _genreRepository.GetByNameAsync(genreName, cancellationToken);
     40                if (genre == null) continue;
     41                await _hasGenreRepository.AddAsync(new Domain.Entities.HasGenre { StoryId = story.Id, GenreId = genre.Id }, cancellationToken);
     42            }
     43
    3344            return new AddResponse(story.Id);
    3445        }
  • ChapterX.Application/Story/Commands/AddRequest.cs

    racf690c r73b69b2  
    33namespace ChapterX.Application.Story.Commands
    44{
    5     public record AddRequest(bool MatureContent, string ShortDescription, string? Image, string Content, int UserId) : IRequest<AddResponse>;
     5    public record AddRequest(bool MatureContent, string ShortDescription, string? Image, string Content, int UserId, List<string> Genres) : IRequest<AddResponse>;
    66}
  • ChapterX.Domain/Entities/Notification.cs

    racf690c r73b69b2  
    1414        public bool IsRead { get; set; }
    1515        public DateTime CreatedAt { get; set; }
     16        public int? RecipientUserId { get; set; }
     17        public string? Type { get; set; }
     18        public string? Link { get; set; }
    1619
    1720        public ICollection<ContentType> ContentTypes { get; set; } = [];
  • ChapterX.Domain/Repositories/ICommentRepository.cs

    racf690c r73b69b2  
    66    {
    77        Task<IEnumerable<Comment>> GetByChapterIdAsync(int chapterId, CancellationToken cancellationToken = default);
     8        Task<IEnumerable<Comment>> GetByStoryIdAsync(int storyId, CancellationToken cancellationToken = default);
    89    }
    910}
  • ChapterX.Domain/Repositories/ILikesRepository.cs

    racf690c r73b69b2  
    55    public interface ILikesRepository : IRepository<Likes>
    66    {
     7        Task<IEnumerable<Likes>> GetByStoryIdAsync(int storyId, CancellationToken cancellationToken = default);
     8        Task<bool> DeleteByUserAndStoryAsync(int userId, int storyId, CancellationToken cancellationToken = default);
     9        Task<bool> ExistsAsync(int userId, int storyId, CancellationToken cancellationToken = default);
    710    }
    811}
  • ChapterX.Domain/Repositories/IReadingListItemsRepository.cs

    racf690c r73b69b2  
    66    {
    77        Task<bool> ExistsAsync(int listId, int storyId, CancellationToken cancellationToken = default);
     8        Task<bool> DeleteByListAndStoryAsync(int listId, int storyId, CancellationToken cancellationToken = default);
    89    }
    910}
  • ChapterX.Infrastructure/Data/DataContext/ApplicationDbContext.cs

    racf690c r73b69b2  
    210210                e.Property(x => x.IsRead).HasColumnName("is_read");
    211211                e.Property(x => x.CreatedAt).HasColumnName("created_at");
     212                e.Property(x => x.RecipientUserId).HasColumnName("recipient_user_id");
     213                e.Property(x => x.Type).HasColumnName("type");
     214                e.Property(x => x.Link).HasColumnName("link");
    212215            });
    213216
  • ChapterX.Infrastructure/Repositories/CommentRepository.cs

    racf690c r73b69b2  
    1919
    2020        public async Task<IEnumerable<Comment>> GetByChapterIdAsync(int chapterId, CancellationToken cancellationToken = default)
    21         {
    22             return await _dbSet
    23                 .Where(c => c.StoryId == chapterId)
     21            => await _dbSet.Where(c => c.StoryId == chapterId).ToListAsync(cancellationToken);
     22
     23        public async Task<IEnumerable<Comment>> GetByStoryIdAsync(int storyId, CancellationToken cancellationToken = default)
     24            => await _dbSet
     25                .Include(c => c.User)
     26                .Where(c => c.StoryId == storyId)
     27                .OrderByDescending(c => c.CreatedAt)
    2428                .ToListAsync(cancellationToken);
    25         }
    2629    }
    2730}
  • ChapterX.Infrastructure/Repositories/LikesRepository.cs

    racf690c r73b69b2  
    22using ChapterX.Domain.Repositories;
    33using ChapterX.Infrastructure.Data.DataContext;
    4 using System;
    5 using System.Collections.Generic;
    6 using System.Linq;
    7 using System.Text;
    8 using System.Threading.Tasks;
     4using Microsoft.EntityFrameworkCore;
    95
    106namespace ChapterX.Infrastructure.Repositories
     
    1511        {
    1612        }
     13
     14        public async Task<IEnumerable<Likes>> GetByStoryIdAsync(int storyId, CancellationToken cancellationToken = default)
     15            => await _dbSet.Where(l => l.StoryId == storyId).ToListAsync(cancellationToken);
     16
     17        public async Task<bool> ExistsAsync(int userId, int storyId, CancellationToken cancellationToken = default)
     18            => await _dbSet.AnyAsync(l => l.UserId == userId && l.StoryId == storyId, cancellationToken);
     19
     20        public async Task<bool> DeleteByUserAndStoryAsync(int userId, int storyId, CancellationToken cancellationToken = default)
     21        {
     22            var like = await _dbSet.FirstOrDefaultAsync(l => l.UserId == userId && l.StoryId == storyId, cancellationToken);
     23            if (like == null) return false;
     24            _dbSet.Remove(like);
     25            await _context.SaveChangesAsync(cancellationToken);
     26            return true;
     27        }
    1728    }
    1829}
  • ChapterX.Infrastructure/Repositories/NotificationRepository.cs

    racf690c r73b69b2  
    1515        // This method returns all notifications; adjust filter when a User relation is added.
    1616        public async Task<IEnumerable<Notification>> GetByUserIdAsync(int userId, CancellationToken cancellationToken = default)
    17         {
    18             return await _dbSet.ToListAsync(cancellationToken);
    19         }
     17            => await _dbSet
     18                .Where(n => n.RecipientUserId == userId)
     19                .OrderByDescending(n => n.CreatedAt)
     20                .ToListAsync(cancellationToken);
    2021    }
    2122}
  • ChapterX.Infrastructure/Repositories/ReadingListItemsRepository.cs

    racf690c r73b69b2  
    1414        public async Task<bool> ExistsAsync(int listId, int storyId, CancellationToken cancellationToken = default)
    1515            => await _dbSet.AnyAsync(i => i.ListId == listId && i.StoryId == storyId, cancellationToken);
     16
     17        public async Task<bool> DeleteByListAndStoryAsync(int listId, int storyId, CancellationToken cancellationToken = default)
     18        {
     19            var item = await _dbSet.FirstOrDefaultAsync(i => i.ListId == listId && i.StoryId == storyId, cancellationToken);
     20            if (item == null) return false;
     21            _dbSet.Remove(item);
     22            await _context.SaveChangesAsync(cancellationToken);
     23            return true;
     24        }
    1625    }
    1726}
  • ChapterX.Infrastructure/Repositories/ReadingListRepository.cs

    racf690c r73b69b2  
    1515        {
    1616            return await _dbSet
     17                .Include(r => r.User)
    1718                .Include(r => r.ReadingListItems)
     19                    .ThenInclude(i => i.Story)
     20                        .ThenInclude(s => s.Writer)
     21                            .ThenInclude(w => w!.User)
     22                .Include(r => r.ReadingListItems)
     23                    .ThenInclude(i => i.Story)
     24                        .ThenInclude(s => s.HasGenres)
     25                            .ThenInclude(hg => hg.Genre)
    1826                .ToListAsync(cancellationToken);
    1927        }
     
    2331            return await _dbSet
    2432                .Where(r => r.UserId == userId)
     33                .Include(r => r.User)
    2534                .Include(r => r.ReadingListItems)
     35                    .ThenInclude(i => i.Story)
     36                        .ThenInclude(s => s.Writer)
     37                            .ThenInclude(w => w!.User)
     38                .Include(r => r.ReadingListItems)
     39                    .ThenInclude(i => i.Story)
     40                        .ThenInclude(s => s.HasGenres)
     41                            .ThenInclude(hg => hg.Genre)
    2642                .ToListAsync(cancellationToken);
    2743        }
  • ChapterX.Infrastructure/Repositories/StoryRepository.cs

    racf690c r73b69b2  
    1212        }
    1313
     14        public override async Task<IEnumerable<Story>> GetAllAsync(CancellationToken cancellationToken = default)
     15        {
     16            return await _dbSet
     17                .Include(s => s.HasGenres)
     18                    .ThenInclude(hg => hg.Genre)
     19                .Include(s => s.Writer)
     20                    .ThenInclude(w => w!.User)
     21                .ToListAsync(cancellationToken);
     22        }
     23
    1424        public async Task<IEnumerable<Story>> GetByWriterIdAsync(int writerId, CancellationToken cancellationToken = default)
    1525        {
  • chapterx-frontend/src/components/layout/Navbar.tsx

    racf690c r73b69b2  
    1414  const location = useLocation()
    1515  const { currentUser, logout } = useAuthStore()
    16   const { notifications } = useNotificationStore()
     16  const { notifications, fetchUserNotifications } = useNotificationStore()
    1717  const unread = notifications.filter(n => !n.is_read).length
     18
     19  useEffect(() => {
     20    if (currentUser) fetchUserNotifications(currentUser.user_id)
     21  }, [currentUser?.user_id])
    1822
    1923  const [mobileOpen, setMobileOpen] = useState(false)
     
    100104                <div className="relative" ref={notifRef}>
    101105                  <button
    102                     onClick={() => { setNotifOpen(!notifOpen); setUserMenuOpen(false) }}
     106                    onClick={() => { const opening = !notifOpen; setNotifOpen(opening); setUserMenuOpen(false); if (opening && currentUser) fetchUserNotifications(currentUser.user_id) }}
    103107                    className="relative p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
    104108                  >
  • chapterx-frontend/src/components/notifications/NotificationDropdown.tsx

    racf690c r73b69b2  
    1414    case 'system': return <Settings {...props} className="text-slate-400" />
    1515    default: return <Bell {...props} className="text-slate-400" />
     16  }
     17}
     18
     19const typeTitle = (type: NotificationType) => {
     20  switch (type) {
     21    case 'like': return 'New Like'
     22    case 'comment': return 'New Comment'
     23    case 'collaboration': return 'Collaboration'
     24    case 'follow': return 'New Follower'
     25    case 'system': return 'System'
     26    default: return 'Notification'
    1627  }
    1728}
     
    6980              </div>
    7081              <div className="flex-1 min-w-0">
    71                 <p className="text-xs font-medium text-white">{n.title}</p>
     82                <p className="text-xs font-medium text-white">{typeTitle(n.type)}</p>
    7283                <p className="text-xs text-slate-400 line-clamp-2">{n.message}</p>
    7384                <p className="text-xs text-slate-600 mt-0.5">{timeAgo(n.created_at)}</p>
  • chapterx-frontend/src/components/story/CommentSection.tsx

    racf690c r73b69b2  
    1 import React, { useState } from 'react'
     1import React, { useEffect, useState } from 'react'
    22import { MessageCircle, Trash2, Send } from 'lucide-react'
     3import axios from 'axios'
    34import { useAuthStore } from '../../store/authStore'
    4 import { useStoryStore } from '../../store/storyStore'
    55import { useNotificationStore } from '../../store/notificationStore'
    66import { useUIStore } from '../../store/uiStore'
    7 import { Comment } from '../../types'
    87import { Avatar } from '../ui/Avatar'
    98import { Button } from '../ui/Button'
    109
    11 interface CommentSectionProps {
    12   storyId: number
    13   authorUserId: number
     10const API = 'https://localhost:7125/api'
     11
     12function getAuthHeaders() {
     13  try {
     14    const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token
     15    return token ? { Authorization: `Bearer ${token}` } : {}
     16  } catch { return {} }
    1417}
    1518
     
    2427}
    2528
    26 export const CommentSection: React.FC<CommentSectionProps> = ({ storyId, authorUserId }) => {
     29interface BackendComment {
     30  id: number
     31  content: string
     32  userId: number
     33  storyId: number
     34  username: string
     35  createdAt: string
     36}
     37
     38interface CommentSectionProps {
     39  storyId: number
     40  authorUserId: number
     41  onCountChange?: (count: number) => void
     42}
     43
     44export const CommentSection: React.FC<CommentSectionProps> = ({ storyId, authorUserId, onCountChange }) => {
    2745  const { currentUser } = useAuthStore()
    28   const { comments, addComment, deleteComment } = useStoryStore()
    2946  const { addNotification } = useNotificationStore()
    3047  const { addToast } = useUIStore()
     48  const [comments, setComments] = useState<BackendComment[]>([])
    3149  const [text, setText] = useState('')
    3250  const [submitting, setSubmitting] = useState(false)
    3351
    34   const storyComments = comments
    35     .filter(c => c.story_id === storyId)
    36     .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
     52  useEffect(() => {
     53    axios.get(`${API}/comments/story/${storyId}`)
     54      .then(res => {
     55        const data = res.data ?? []
     56        setComments(data)
     57        onCountChange?.(data.length)
     58      })
     59      .catch(() => {})
     60  }, [storyId])
    3761
    3862  const handleSubmit = async () => {
    3963    if (!text.trim() || !currentUser) return
    4064    setSubmitting(true)
    41     await new Promise(r => setTimeout(r, 300))
    42     const comment: Comment = {
    43       comment_id: Date.now(),
    44       story_id: storyId,
    45       user_id: currentUser.user_id,
    46       username: currentUser.username,
    47       content: text.trim(),
    48       created_at: new Date().toISOString(),
     65    try {
     66      const res = await axios.post(`${API}/comments`, {
     67        content: text.trim(),
     68        userId: currentUser.user_id,
     69        storyId,
     70      }, { headers: getAuthHeaders() })
     71      const newComment: BackendComment = {
     72        id: res.data?.id ?? Date.now(),
     73        content: text.trim(),
     74        userId: currentUser.user_id,
     75        storyId,
     76        username: currentUser.username,
     77        createdAt: new Date().toISOString(),
     78      }
     79      setComments(prev => { const next = [newComment, ...prev]; onCountChange?.(next.length); return next })
     80      if (currentUser.user_id !== authorUserId) {
     81        await addNotification({
     82          recipientUserId: authorUserId,
     83          type: 'comment',
     84          content: `${currentUser.username} commented: "${text.trim().slice(0, 60)}${text.length > 60 ? '...' : ''}"`,
     85          link: `/story/${storyId}`,
     86        })
     87      }
     88      setText('')
     89      addToast('Comment posted!')
     90    } catch {
     91      addToast('Failed to post comment.', 'error')
    4992    }
    50     addComment(comment)
    51     if (currentUser.user_id !== authorUserId) {
    52       addNotification({
    53         user_id: authorUserId,
    54         type: 'comment',
    55         title: 'New Comment',
    56         message: `${currentUser.username} commented: "${text.trim().slice(0, 60)}..."`,
    57         link: `/story/${storyId}`,
    58       })
     93    setSubmitting(false)
     94  }
     95
     96  const handleDelete = async (commentId: number) => {
     97    try {
     98      await axios.delete(`${API}/comments/${commentId}`, { headers: getAuthHeaders() })
     99      setComments(prev => { const next = prev.filter(c => c.id !== commentId); onCountChange?.(next.length); return next })
     100      addToast('Comment deleted', 'info')
     101    } catch {
     102      addToast('Failed to delete comment.', 'error')
    59103    }
    60     setText('')
    61     setSubmitting(false)
    62     addToast('Comment posted!')
    63104  }
    64105
     
    67108      <div className="flex items-center gap-2 mb-4">
    68109        <MessageCircle size={18} className="text-indigo-400" />
    69         <h3 className="text-white font-semibold">Comments ({storyComments.length})</h3>
     110        <h3 className="text-white font-semibold">Comments ({comments.length})</h3>
    70111      </div>
    71112
    72       {/* Input */}
    73113      {currentUser ? (
    74114        <div className="flex gap-3">
     
    83123            />
    84124            <div className="flex justify-end mt-2">
    85               <Button
    86                 size="sm"
    87                 onClick={handleSubmit}
    88                 loading={submitting}
    89                 disabled={!text.trim()}
    90               >
     125              <Button size="sm" onClick={handleSubmit} loading={submitting} disabled={!text.trim()}>
    91126                <Send size={14} />
    92127                Post Comment
     
    103138      )}
    104139
    105       {/* Comment list */}
    106140      <div className="space-y-3">
    107         {storyComments.length === 0 ? (
     141        {comments.length === 0 ? (
    108142          <div className="text-center py-8 text-slate-500 text-sm">
    109143            No comments yet. Be the first to share your thoughts!
    110144          </div>
    111145        ) : (
    112           storyComments.map(comment => (
    113             <CommentCard
    114               key={comment.comment_id}
    115               comment={comment}
    116               canDelete={currentUser?.user_id === comment.user_id || currentUser?.role === 'admin'}
    117               onDelete={() => { deleteComment(comment.comment_id); addToast('Comment deleted', 'info') }}
    118             />
     146          comments.map(comment => (
     147            <div key={comment.id} className="flex gap-3 p-4 bg-slate-800 rounded-xl border border-slate-700">
     148              <Avatar name={comment.username} size="sm" />
     149              <div className="flex-1 min-w-0">
     150                <div className="flex items-center justify-between">
     151                  <span className="text-sm font-medium text-white">{comment.username}</span>
     152                  <div className="flex items-center gap-2">
     153                    <span className="text-xs text-slate-500">{timeAgo(comment.createdAt)}</span>
     154                    {(currentUser?.user_id === comment.userId || currentUser?.role === 'admin') && (
     155                      <button onClick={() => handleDelete(comment.id)} className="text-slate-500 hover:text-rose-400 transition-colors">
     156                        <Trash2 size={13} />
     157                      </button>
     158                    )}
     159                  </div>
     160                </div>
     161                <p className="text-slate-300 text-sm mt-1 leading-relaxed">{comment.content}</p>
     162              </div>
     163            </div>
    119164          ))
    120165        )}
     
    123168  )
    124169}
    125 
    126 const CommentCard: React.FC<{
    127   comment: Comment
    128   canDelete: boolean
    129   onDelete: () => void
    130 }> = ({ comment, canDelete, onDelete }) => (
    131   <div className="flex gap-3 p-4 bg-slate-800 rounded-xl border border-slate-700">
    132     <Avatar name={comment.username} size="sm" />
    133     <div className="flex-1 min-w-0">
    134       <div className="flex items-center justify-between">
    135         <span className="text-sm font-medium text-white">{comment.username}</span>
    136         <div className="flex items-center gap-2">
    137           <span className="text-xs text-slate-500">{timeAgo(comment.created_at)}</span>
    138           {canDelete && (
    139             <button onClick={onDelete} className="text-slate-500 hover:text-rose-400 transition-colors">
    140               <Trash2 size={13} />
    141             </button>
    142           )}
    143         </div>
    144       </div>
    145       <p className="text-slate-300 text-sm mt-1 leading-relaxed">{comment.content}</p>
    146     </div>
    147   </div>
    148 )
  • chapterx-frontend/src/components/story/LikeButton.tsx

    racf690c r73b69b2  
    1 import React from 'react'
     1import React, { useEffect, useState } from 'react'
    22import { Heart } from 'lucide-react'
    33import { useNavigate } from 'react-router-dom'
     4import axios from 'axios'
    45import { useAuthStore } from '../../store/authStore'
    5 import { useStoryStore } from '../../store/storyStore'
    66import { useNotificationStore } from '../../store/notificationStore'
    77import { useUIStore } from '../../store/uiStore'
     8
     9const API = 'https://localhost:7125/api'
     10
     11function getAuthHeaders() {
     12  try {
     13    const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token
     14    return token ? { Authorization: `Bearer ${token}` } : {}
     15  } catch { return {} }
     16}
    817
    918interface LikeButtonProps {
     
    1120  authorUserId: number
    1221  totalLikes: number
     22  onCountChange?: (count: number) => void
    1323}
    1424
    15 export const LikeButton: React.FC<LikeButtonProps> = ({ storyId, authorUserId, totalLikes }) => {
     25export const LikeButton: React.FC<LikeButtonProps> = ({ storyId, authorUserId, totalLikes, onCountChange }) => {
    1626  const navigate = useNavigate()
    1727  const { currentUser } = useAuthStore()
    18   const { toggleLike, isLiked } = useStoryStore()
    1928  const { addNotification } = useNotificationStore()
    2029  const { addToast } = useUIStore()
     30  const [liked, setLiked] = useState(false)
     31  const [count, setCount] = useState(totalLikes)
     32  const [loading, setLoading] = useState(false)
    2133
    22   const liked = currentUser ? isLiked(currentUser.user_id, storyId) : false
     34  useEffect(() => {
     35    axios.get(`${API}/likes/story/${storyId}`)
     36      .then(res => {
     37        const c = res.data?.count ?? totalLikes
     38        setCount(c)
     39        onCountChange?.(c)
     40        if (currentUser) {
     41          setLiked((res.data?.userIds ?? []).includes(currentUser.user_id))
     42        }
     43      })
     44      .catch(() => {})
     45  }, [storyId, currentUser?.user_id])
    2346
    24   const handleClick = () => {
     47  const handleClick = async () => {
    2548    if (!currentUser) {
    2649      addToast('Please sign in to like stories.', 'info')
     
    2851      return
    2952    }
    30     toggleLike(currentUser.user_id, storyId)
    31     if (!liked && currentUser.user_id !== authorUserId) {
    32       addNotification({
    33         user_id: authorUserId,
    34         type: 'like',
    35         title: 'New Like',
    36         message: `${currentUser.username} liked your story.`,
    37         link: `/story/${storyId}`,
    38       })
     53    if (loading) return
     54    setLoading(true)
     55    try {
     56      if (liked) {
     57        await axios.delete(`${API}/likes/user/${currentUser.user_id}/story/${storyId}`, { headers: getAuthHeaders() })
     58        setLiked(false)
     59        setCount(c => { const n = c - 1; onCountChange?.(n); return n })
     60        addToast('Removed from likes', 'info')
     61      } else {
     62        await axios.post(`${API}/likes`, { userId: currentUser.user_id, storyId }, { headers: getAuthHeaders() })
     63        setLiked(true)
     64        setCount(c => { const n = c + 1; onCountChange?.(n); return n })
     65        if (currentUser.user_id !== authorUserId) {
     66          await addNotification({
     67            recipientUserId: authorUserId,
     68            type: 'like',
     69            content: `${currentUser.username} liked your story.`,
     70            link: `/story/${storyId}`,
     71          })
     72        }
     73        addToast('Added to likes!', 'success')
     74      }
     75    } catch {
     76      addToast('Something went wrong.', 'error')
    3977    }
    40     addToast(liked ? 'Removed from likes' : 'Added to likes!', liked ? 'info' : 'success')
     78    setLoading(false)
    4179  }
    4280
     
    4482    <button
    4583      onClick={handleClick}
     84      disabled={loading}
    4685      className={`flex items-center gap-2 px-4 py-2 rounded-xl border transition-all duration-200 ${
    4786        liked
     
    5190    >
    5291      <Heart size={16} className={liked ? 'fill-rose-400' : ''} />
    53       <span className="text-sm font-medium">{totalLikes.toLocaleString()}</span>
     92      <span className="text-sm font-medium">{count.toLocaleString()}</span>
    5493    </button>
    5594  )
  • chapterx-frontend/src/pages/admin/AdminGenresPage.tsx

    racf690c r73b69b2  
    1 import React, { useState } from 'react'
     1import React, { useState, useEffect } from 'react'
    22import { useNavigate } from 'react-router-dom'
    33import { ArrowLeft, Plus, Trash2, Tag } from 'lucide-react'
     
    1010export const AdminGenresPage: React.FC = () => {
    1111  const navigate = useNavigate()
    12   const { genres, addGenre, deleteGenre } = useStoryStore()
     12  const { genres, fetchGenres, addGenre, deleteGenre } = useStoryStore()
    1313  const { addToast } = useUIStore()
    1414  const [addOpen, setAddOpen] = useState(false)
     
    1616  const [deleteTarget, setDeleteTarget] = useState<Genre | null>(null)
    1717
    18   const handleAdd = () => {
     18  useEffect(() => { fetchGenres() }, [])
     19
     20  const handleAdd = async () => {
    1921    if (!newName.trim()) return
    2022    if (genres.some(g => g.name.toLowerCase() === newName.trim().toLowerCase())) {
     
    2224      return
    2325    }
    24     addGenre({ genre_id: Date.now(), name: newName.trim(), story_count: 0 })
    25     addToast(`Genre "${newName.trim()}" added!`)
    26     setNewName('')
    27     setAddOpen(false)
     26    try {
     27      await addGenre(newName.trim())
     28      addToast(`Genre "${newName.trim()}" added!`)
     29      setNewName('')
     30      setAddOpen(false)
     31    } catch {
     32      addToast('Failed to add genre', 'error')
     33    }
    2834  }
    2935
    30   const handleDelete = (genre: Genre) => {
    31     if (genre.story_count > 0) {
    32       addToast('Cannot delete genre with associated stories', 'error')
    33       return
     36  const handleDelete = async (genre: Genre) => {
     37    try {
     38      await deleteGenre(genre.genre_id)
     39      addToast(`Genre "${genre.name}" deleted`, 'info')
     40    } catch {
     41      addToast('Failed to delete genre', 'error')
    3442    }
    35     deleteGenre(genre.genre_id)
    36     addToast(`Genre "${genre.name}" deleted`, 'info')
    3743    setDeleteTarget(null)
    3844  }
  • chapterx-frontend/src/pages/reading-list/CommunityListsPage.tsx

    racf690c r73b69b2  
    1 import React, { useState } from 'react'
     1import React, { useState, useEffect } from 'react'
    22import { useNavigate } from 'react-router-dom'
    33import { Globe, BookOpen, User } from 'lucide-react'
     
    1010export const CommunityListsPage: React.FC = () => {
    1111  const navigate = useNavigate()
    12   const { readingLists } = useStoryStore()
     12  const { readingLists, fetchReadingLists } = useStoryStore()
    1313  const [selectedList, setSelectedList] = useState<ReadingList | null>(null)
     14
     15  useEffect(() => { fetchReadingLists() }, [])
    1416
    1517  const publicLists = readingLists.filter(l => l.is_public)
  • chapterx-frontend/src/pages/reading-list/ReadingListPage.tsx

    racf690c r73b69b2  
    1212  const navigate = useNavigate()
    1313  const { currentUser } = useAuthStore()
    14   const { readingLists, fetchReadingLists, createReadingList, deleteReadingList, removeStoryFromList } = useStoryStore()
     14  const { readingLists, fetchUserReadingLists, createReadingList, deleteReadingList, removeStoryFromList } = useStoryStore()
     15  const [fetching, setFetching] = useState(false)
     16  const [confirmRemove, setConfirmRemove] = useState<{ listId: number; storyId: number; title: string } | null>(null)
    1517
    1618  useEffect(() => {
    17     fetchReadingLists()
    18   }, [])
     19    if (!currentUser) return
     20    setFetching(true)
     21    fetchUserReadingLists(currentUser.user_id).finally(() => setFetching(false))
     22  }, [currentUser?.user_id])
    1923  const { addToast } = useUIStore()
    2024  const [createOpen, setCreateOpen] = useState(false)
     
    3337  }
    3438
    35   const myLists = readingLists.filter(l => l.user_id === currentUser.user_id)
     39  const myLists = readingLists.filter(l => l.user_id === currentUser!.user_id)
    3640
    3741  const handleCreate = async () => {
     
    7377      </div>
    7478
    75       {myLists.length === 0 ? (
     79      {fetching ? (
     80        <div className="flex flex-col items-center py-20 text-slate-500">
     81          <BookOpen size={48} className="mb-4 opacity-40 animate-pulse" />
     82          <p className="text-sm">Loading your lists...</p>
     83        </div>
     84      ) : myLists.length === 0 ? (
    7685        <div className="flex flex-col items-center py-20 text-slate-500">
    7786          <BookOpen size={48} className="mb-4 opacity-40" />
     
    124133                  <div className="space-y-2">
    125134                    {list.stories.slice(0, 4).map(item => (
    126                       <div key={item.item_id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-700/50 group">
    127                         <div className="flex-1 min-w-0">
     135                      <div key={item.item_id} className="flex items-center gap-2 p-2 rounded-lg hover:bg-slate-700/50">
     136                        <div className="flex-1 min-w-0 overflow-hidden">
    128137                          <button
    129138                            onClick={() => navigate(`/story/${item.story_id}`)}
    130                             className="text-white text-sm font-medium hover:text-indigo-300 transition-colors truncate block text-left"
     139                            className="text-white text-sm font-medium hover:text-indigo-300 transition-colors truncate block text-left w-full"
    131140                          >
    132141                            {item.story_title}
    133142                          </button>
    134143                          <div className="flex items-center gap-2 mt-0.5">
    135                             <span className="text-slate-500 text-xs">by {item.author_username}</span>
     144                            <span className="text-slate-500 text-xs truncate">by {item.author_username}</span>
    136145                            {item.genres.slice(0, 1).map(g => <GenreBadge key={g} genre={g} />)}
    137146                          </div>
    138147                        </div>
    139148                        <button
    140                           onClick={() => removeStoryFromList(list.list_id, item.story_id)}
    141                           className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-rose-400 transition-all"
     149                          onClick={() => setConfirmRemove({ listId: list.list_id, storyId: item.story_id, title: item.story_title })}
     150                          className="flex-shrink-0 text-slate-500 hover:text-rose-400 hover:bg-rose-400/10 transition-all p-1.5 rounded-lg"
     151                          title="Remove from list"
    142152                        >
    143                           <X size={14} />
     153                          <Trash2 size={14} />
    144154                        </button>
    145155                      </div>
     
    165175      )}
    166176
     177      {/* Confirm remove modal */}
     178      <Modal isOpen={!!confirmRemove} onClose={() => setConfirmRemove(null)} title="Remove Story">
     179        <div className="space-y-4">
     180          <p className="text-slate-300 text-sm">
     181            Are you sure you want to remove <span className="text-white font-medium">"{confirmRemove?.title}"</span> from this list?
     182          </p>
     183          <div className="flex gap-3">
     184            <Button variant="secondary" className="flex-1" onClick={() => setConfirmRemove(null)}>Cancel</Button>
     185            <Button variant="danger" className="flex-1" onClick={async () => {
     186              if (!confirmRemove) return
     187              await removeStoryFromList(confirmRemove.listId, confirmRemove.storyId)
     188              addToast('Story removed from list', 'info')
     189              setConfirmRemove(null)
     190            }}>Remove</Button>
     191          </div>
     192        </div>
     193      </Modal>
     194
    167195      {/* Create modal */}
    168196      <Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Reading List">
  • chapterx-frontend/src/pages/story/StoryDetailPage.tsx

    racf690c r73b69b2  
    2323  const [listModalOpen, setListModalOpen] = useState(false)
    2424  const [newListName, setNewListName] = useState('')
     25  const [liveLikes, setLiveLikes] = useState<number | null>(null)
     26  const [liveComments, setLiveComments] = useState<number | null>(null)
    2527
    2628  const story = stories.find(s => s.story_id === Number(id))
     
    153155          {/* Action bar */}
    154156          <div className="flex items-center gap-3 flex-wrap">
    155             <LikeButton storyId={story.story_id} authorUserId={story.user_id} totalLikes={story.total_likes} />
     157            <LikeButton storyId={story.story_id} authorUserId={story.user_id} totalLikes={story.total_likes} onCountChange={setLiveLikes} />
    156158            {currentUser && (
    157159              <Button variant="secondary" size="sm" onClick={() => setListModalOpen(true)}>
     
    183185          {/* Comments */}
    184186          <div className="border-t border-slate-700 pt-8">
    185             <CommentSection storyId={story.story_id} authorUserId={story.user_id} />
     187            <CommentSection storyId={story.story_id} authorUserId={story.user_id} onCountChange={setLiveComments} />
    186188          </div>
    187189        </div>
     
    203205              <div className="flex justify-between">
    204206                <span className="text-slate-400">Likes</span>
    205                 <span className="text-white">{story.total_likes.toLocaleString()}</span>
     207                <span className="text-white">{(liveLikes ?? story.total_likes).toLocaleString()}</span>
    206208              </div>
    207209              <div className="flex justify-between">
     
    211213              <div className="flex justify-between">
    212214                <span className="text-slate-400">Comments</span>
    213                 <span className="text-white">{story.total_comments}</span>
     215                <span className="text-white">{liveComments ?? story.total_comments}</span>
    214216              </div>
    215217              <div className="flex justify-between">
  • chapterx-frontend/src/store/notificationStore.ts

    racf690c r73b69b2  
    11import { create } from 'zustand'
     2import axios from 'axios'
    23import { Notification } from '../types'
    3 import { mockNotifications } from '../data/mockData'
     4
     5const API = 'https://localhost:7125/api'
     6
     7function getAuthHeaders() {
     8  try {
     9    const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token
     10    return token ? { Authorization: `Bearer ${token}` } : {}
     11  } catch {
     12    return {}
     13  }
     14}
    415
    516interface NotificationStore {
    617  notifications: Notification[]
    7   addNotification: (n: Omit<Notification, 'notification_id' | 'created_at' | 'is_read'>) => void
    8   markAllRead: () => void
    9   markRead: (id: number) => void
     18  fetchUserNotifications: (userId: number) => Promise<void>
     19  addNotification: (n: { recipientUserId: number; type: string; content: string; link?: string }) => Promise<void>
     20  markAllRead: () => Promise<void>
     21  markRead: (id: number) => Promise<void>
    1022  getUnreadCount: () => number
    1123}
    1224
    1325export const useNotificationStore = create<NotificationStore>((set, get) => ({
    14   notifications: [...mockNotifications],
     26  notifications: [],
    1527
    16   addNotification: (n) =>
    17     set(state => ({
    18       notifications: [
    19         {
    20           ...n,
    21           notification_id: Date.now(),
    22           created_at: new Date().toISOString(),
    23           is_read: false,
    24         },
    25         ...state.notifications,
    26       ],
    27     })),
     28  fetchUserNotifications: async (userId) => {
     29    try {
     30      const res = await axios.get(`${API}/notifications/user/${userId}`, { headers: getAuthHeaders() })
     31      const data: any[] = res.data ?? []
     32      const notifications: Notification[] = data.map(n => ({
     33        notification_id: n.id,
     34        user_id: userId,
     35        type: n.type ?? 'info',
     36        title: n.type ?? 'Notification',
     37        message: n.content,
     38        link: n.link,
     39        is_read: n.isRead,
     40        created_at: n.createdAt,
     41      }))
     42      set({ notifications })
     43    } catch {
     44      // keep existing
     45    }
     46  },
    2847
    29   markAllRead: () =>
    30     set(state => ({
    31       notifications: state.notifications.map(n => ({ ...n, is_read: true })),
    32     })),
     48  addNotification: async ({ recipientUserId, type, content, link }) => {
     49    try {
     50      await axios.post(`${API}/notifications`, {
     51        content,
     52        recipientUserId,
     53        type,
     54        link,
     55      }, { headers: getAuthHeaders() })
     56    } catch {
     57      // silent — notification is for recipient, not current user
     58    }
     59  },
    3360
    34   markRead: (id) =>
     61  markAllRead: async () => {
     62    const unread = get().notifications.filter(n => !n.is_read)
     63    set(state => ({ notifications: state.notifications.map(n => ({ ...n, is_read: true })) }))
     64    try {
     65      await Promise.all(unread.map(n =>
     66        axios.put(`${API}/notifications/${n.notification_id}/read`, {}, { headers: getAuthHeaders() })
     67      ))
     68    } catch {
     69      // keep optimistic
     70    }
     71  },
     72
     73  markRead: async (id) => {
    3574    set(state => ({
    3675      notifications: state.notifications.map(n =>
    3776        n.notification_id === id ? { ...n, is_read: true } : n
    3877      ),
    39     })),
     78    }))
     79    try {
     80      await axios.put(`${API}/notifications/${id}/read`, {}, { headers: getAuthHeaders() })
     81    } catch {
     82      // keep optimistic
     83    }
     84  },
    4085
    4186  getUnreadCount: () => get().notifications.filter(n => !n.is_read).length,
  • chapterx-frontend/src/store/storyStore.ts

    racf690c r73b69b2  
    2525const API = 'https://localhost:7125/api'
    2626
     27function mapReadingList(l: any): ReadingList {
     28  return {
     29    list_id: l.id,
     30    user_id: l.userId,
     31    username: l.username ?? '',
     32    name: l.name,
     33    description: l.content ?? '',
     34    is_public: l.isPublic,
     35    created_at: l.createdAt,
     36    stories: (l.readingListItems ?? []).map((i: any) => ({
     37      item_id: i.listId ?? 0,
     38      list_id: l.id,
     39      story_id: i.storyId,
     40      story_title: i.storyTitle ?? `Story #${i.storyId}`,
     41      author_username: i.authorUsername ?? '',
     42      added_at: i.addedAt ?? new Date().toISOString(),
     43      genres: i.genres ?? [],
     44    })),
     45  }
     46}
     47
    2748function getAuthHeaders() {
    2849  try {
     
    5374  fetchChapters: () => Promise<void>
    5475  fetchReadingLists: () => Promise<void>
     76  fetchUserReadingLists: (userId: number) => Promise<void>
     77  fetchGenres: () => Promise<void>
    5578
    5679  // Story actions
     
    86109
    87110  // Genre actions
    88   addGenre: (genre: Genre) => void
    89   deleteGenre: (id: number) => void
     111  addGenre: (name: string) => Promise<void>
     112  deleteGenre: (id: number) => Promise<void>
    90113
    91114  // Reading list actions
     
    103126  aiSuggestions: [...mockAISuggestions],
    104127  genres: [...mockGenres],
    105   readingLists: [...mockReadingLists],
     128  readingLists: [],
    106129  likedStories: [],
    107130
     
    118141        mature_content: s.matureContent,
    119142        status: 'published' as StoryStatus,
    120         author_username: '',
     143        author_username: s.writer?.user?.username ?? '',
    121144        created_at: s.createdAt,
    122145        updated_at: s.updatedAt,
     
    125148        total_chapters: 0,
    126149        total_views: 0,
    127         genres: [],
     150        genres: (s.hasGenres ?? []).map((hg: any) => hg.genre?.name ?? hg.name).filter(Boolean),
    128151      }))
    129152      if (stories.length > 0) set({ stories })
     
    159182    const res = await axios.post(`${API}/stories`, {
    160183      matureContent: story.mature_content,
    161       shortDescription: story.title || story.short_description,
     184      shortDescription: story.short_description || story.title,
    162185      image: null,
    163186      content: story.content,
    164187      userId: story.user_id,
     188      genres: story.genres ?? [],
    165189    }, { headers: getAuthHeaders() })
    166190    const backendId = res.data?.id ?? res.data
     
    443467  },
    444468
    445   addGenre: (genre) =>
    446     set(state => ({ genres: [...state.genres, genre] })),
    447 
    448   deleteGenre: (id) =>
    449     set(state => ({ genres: state.genres.filter(g => g.genre_id !== id) })),
     469  fetchGenres: async () => {
     470    try {
     471      const res = await axios.get(`${API}/genres`)
     472      const data: any[] = res.data?.genres ?? res.data ?? []
     473      const genres: Genre[] = data.map((g: any) => ({ genre_id: g.id, name: g.name }))
     474      if (genres.length > 0) set({ genres })
     475    } catch {
     476      // keep mock data on failure
     477    }
     478  },
     479
     480  addGenre: async (name) => {
     481    const res = await axios.post(`${API}/genres`, { name }, { headers: getAuthHeaders() })
     482    const id = res.data?.id ?? res.data
     483    set(state => ({ genres: [...state.genres, { genre_id: id, name }] }))
     484  },
     485
     486  deleteGenre: async (id) => {
     487    set(state => ({ genres: state.genres.filter(g => g.genre_id !== id) }))
     488    try {
     489      await axios.delete(`${API}/genres/${id}`, { headers: getAuthHeaders() })
     490    } catch {
     491      // optimistic delete already applied
     492    }
     493  },
    450494
    451495  fetchReadingLists: async () => {
    452496    try {
    453       const [listsRes, storiesRes] = await Promise.all([
    454         axios.get(`${API}/readinglists`),
    455         axios.get(`${API}/stories`),
    456       ])
    457       const data: any[] = listsRes.data?.readingLists ?? listsRes.data ?? []
    458       const storiesData: any[] = storiesRes.data?.stories ?? storiesRes.data ?? []
    459       const lists: ReadingList[] = data.map((l: any) => ({
    460         list_id: l.id,
    461         user_id: l.userId,
    462         username: '',
    463         name: l.name,
    464         description: l.content ?? '',
    465         is_public: l.isPublic,
    466         created_at: l.createdAt,
    467         stories: (l.readingListItems ?? []).map((i: any) => {
    468           const story = storiesData.find((s: any) => s.id === i.storyId)
    469           return {
    470             item_id: i.id ?? 0,
    471             list_id: l.id,
    472             story_id: i.storyId,
    473             story_title: story?.title ?? story?.shortDescription ?? `Story #${i.storyId}`,
    474             author_username: story?.authorUsername ?? '',
    475             added_at: i.addedAt ?? new Date().toISOString(),
    476             genres: story?.genres ?? [],
    477           }
    478         }),
    479       }))
    480       if (lists.length > 0) set({ readingLists: lists })
    481     } catch {
    482       // keep mock data on failure
     497      const res = await axios.get(`${API}/readinglists`)
     498      const data: any[] = res.data ?? []
     499      const lists: ReadingList[] = data.map(mapReadingList)
     500      set({ readingLists: lists })
     501    } catch {
     502      // keep existing data on failure
     503    }
     504  },
     505
     506  fetchUserReadingLists: async (userId) => {
     507    try {
     508      const res = await axios.get(`${API}/readinglists/user/${userId}`, { headers: getAuthHeaders() })
     509      const data: any[] = res.data ?? []
     510      const lists: ReadingList[] = data.map(mapReadingList)
     511      set(state => ({
     512        readingLists: [
     513          ...state.readingLists.filter(l => l.user_id !== userId),
     514          ...lists,
     515        ]
     516      }))
     517    } catch (err) {
     518      console.error('fetchUserReadingLists failed:', err)
    483519    }
    484520  },
     
    525561    }))
    526562    try {
    527       // find the item id from backend list items to delete
    528       const res = await axios.get(`${API}/readinglistitems`)
    529       const items: any[] = res.data?.readingListItems ?? res.data ?? []
    530       const item = items.find((i: any) => i.readingListId === listId && i.storyId === storyId)
    531       if (item) await axios.delete(`${API}/readinglistitems/${item.id}`, { headers: getAuthHeaders() })
     563      await axios.delete(`${API}/readinglistitems/${listId}/story/${storyId}`, { headers: getAuthHeaders() })
    532564    } catch {
    533565      // optimistic update already applied
Note: See TracChangeset for help on using the changeset viewer.