Changeset 99c1e45


Ignore:
Timestamp:
06/24/26 16:28:50 (11 days ago)
Author:
kikisrbinoska <srbinoskakristina07@…>
Branches:
main
Children:
a8f4a2d
Parents:
0b502c2
Message:

Fixed writer section and admin management

Files:
26 edited

Legend:

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

    r0b502c2 r99c1e45  
    11using ChapterX.Application.Chapter.Commands;
    22using ChapterX.Application.Chapter.Queries;
     3using ChapterX.Domain.Repositories;
    34using MediatR;
    45using Microsoft.AspNetCore.Authorization;
     
    1415    {
    1516        private readonly IMediator _mediator;
     17        private readonly IChapterRepository _chapterRepository;
    1618        private readonly ILogger<ChaptersController> _logger;
    1719
    18         public ChaptersController(IMediator mediator, ILogger<ChaptersController> logger)
     20        public ChaptersController(IMediator mediator, IChapterRepository chapterRepository, ILogger<ChaptersController> logger)
    1921        {
    2022            _mediator = mediator;
     23            _chapterRepository = chapterRepository;
    2124            _logger = logger;
    2225        }
     
    3841            var response = await _mediator.Send(new GetRequest(id));
    3942            return Ok(response);
     43        }
     44
     45        [HttpPatch("{id:int}/view")]
     46        [AllowAnonymous]
     47        public async Task<ActionResult> IncrementView(int id)
     48        {
     49            await _chapterRepository.IncrementViewCountAsync(id);
     50            return NoContent();
    4051        }
    4152
  • ChapterX.API/Controllers/StoriesController.cs

    r0b502c2 r99c1e45  
    2929            _logger.LogInformation("Fetching all stories");
    3030            var response = await _mediator.Send(request);
    31             return Ok(response);
     31            var stories = response.Stories.Select(s => new
     32            {
     33                id = s.Id,
     34                userId = s.UserId,
     35                title = s.Title,
     36                shortDescription = s.ShortDescription,
     37                image = s.Image,
     38                content = s.Content,
     39                matureContent = s.MatureContent,
     40                createdAt = s.CreatedAt,
     41                updatedAt = s.UpdatedAt,
     42                writer = s.Writer == null ? null : new { user = s.Writer.User == null ? null : new { username = s.Writer.User.Username } },
     43                hasGenres = s.HasGenres.Select(hg => new { genre = new { name = hg.Genre?.Name ?? "" } }),
     44                likes = s.Likes.Select(l => new { userId = l.UserId }),
     45                comments = s.Comments.Select(c => new { id = c.Id }),
     46                chapters = s.Chapters.Select(c => new { id = c.Id, viewCount = c.ViewCount }),
     47            });
     48            return Ok(new { stories });
    3249        }
    3350
     
    3956            _logger.LogInformation("Fetching story with ID: {StoryId}", id);
    4057            var response = await _mediator.Send(new GetRequest(id));
    41             return Ok(response);
     58            if (response.Story == null) return NotFound();
     59            var s = response.Story;
     60            return Ok(new
     61            {
     62                id = s.Id,
     63                userId = s.UserId,
     64                title = s.Title,
     65                shortDescription = s.ShortDescription,
     66                image = s.Image,
     67                content = s.Content,
     68                matureContent = s.MatureContent,
     69                createdAt = s.CreatedAt,
     70                updatedAt = s.UpdatedAt,
     71                writer = s.Writer == null ? null : new { user = s.Writer.User == null ? null : new { username = s.Writer.User.Username } },
     72                hasGenres = s.HasGenres.Select(hg => new { genre = new { name = hg.Genre?.Name ?? "" } }),
     73                likes = s.Likes.Select(l => new { userId = l.UserId }),
     74                comments = s.Comments.Select(c => new { id = c.Id }),
     75                chapters = s.Chapters.Select(c => new { id = c.Id, viewCount = c.ViewCount }),
     76            });
    4277        }
    4378
     
    76111            _logger.LogInformation("Deleting story with ID: {StoryId}", id);
    77112            var callerId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
    78             var response = await _mediator.Send(new DeleteRequest(id, callerId));
     113            var isAdmin = User.IsInRole("Admin");
     114            var response = await _mediator.Send(new DeleteRequest(id, callerId, isAdmin));
    79115            return Ok(response);
    80116        }
  • ChapterX.API/Program.cs

    r0b502c2 r99c1e45  
    7777var app = builder.Build();
    7878
    79 app.UseCors("Frontend");
    80 
    81 if (app.Environment.IsDevelopment())
    82 {
    83     app.UseSwagger();
    84     app.UseSwaggerUI();
    85 }
    86 
    8779app.UseExceptionHandler(err => err.Run(async ctx =>
    8880{
     
    130122}));
    131123
     124app.UseCors("Frontend");
     125
     126if (app.Environment.IsDevelopment())
     127{
     128    app.UseSwagger();
     129    app.UseSwaggerUI();
     130}
     131
    132132app.UseAuthentication();
    133133app.UseAuthorization();
  • ChapterX.Application/Story/Commands/AddHandler.cs

    r0b502c2 r99c1e45  
    3131                {
    3232                    MatureContent = request.MatureContent,
     33                    Title = request.Title,
    3334                    ShortDescription = request.ShortDescription,
    3435                    Image = request.Image,
  • ChapterX.Application/Story/Commands/AddRequest.cs

    r0b502c2 r99c1e45  
    66    public record AddRequest(
    77        bool MatureContent,
     8        [Required][MaxLength(200)] string Title,
    89        [Required][MaxLength(500)] string ShortDescription,
    910        [MaxLength(2048)] string? Image,
  • ChapterX.Application/Story/Commands/DeleteHandler.cs

    r0b502c2 r99c1e45  
    2222                return new DeleteResponse(false);
    2323
    24             if (story.UserId != request.CallerId)
     24            if (!request.IsAdmin && story.UserId != request.CallerId)
    2525                throw new UnauthorizedAccessException("You do not own this story.");
    2626
  • ChapterX.Application/Story/Commands/DeleteRequest.cs

    r0b502c2 r99c1e45  
    33namespace ChapterX.Application.Story.Commands
    44{
    5     public record DeleteRequest(int Id, int CallerId) : IRequest<DeleteResponse>;
     5    public record DeleteRequest(int Id, int CallerId, bool IsAdmin = false) : IRequest<DeleteResponse>;
    66}
  • ChapterX.Application/Story/Commands/UpdateHandler.cs

    r0b502c2 r99c1e45  
    2626
    2727            story.MatureContent = request.MatureContent;
     28            story.Title = request.Title;
    2829            story.ShortDescription = request.ShortDescription;
    2930            story.Image = request.Image;
  • ChapterX.Application/Story/Commands/UpdateRequest.cs

    r0b502c2 r99c1e45  
    33namespace ChapterX.Application.Story.Commands
    44{
    5     public record UpdateRequest(int Id, bool MatureContent, string ShortDescription, string? Image, string Content, int CallerId = 0) : IRequest<UpdateResponse>;
     5    public record UpdateRequest(int Id, bool MatureContent, string Title, string ShortDescription, string? Image, string Content, int CallerId = 0) : IRequest<UpdateResponse>;
    66}
  • ChapterX.Domain/Entities/Story.cs

    r0b502c2 r99c1e45  
    1212        public int Id { get; set; }
    1313        public bool MatureContent { get; set; }
     14        public string Title { get; set; } = string.Empty;
    1415        public string ShortDescription { get; set; } = string.Empty;
    1516        public string? Image { get; set; }
  • ChapterX.Domain/Repositories/IChapterRepository.cs

    r0b502c2 r99c1e45  
    77        Task<IEnumerable<Chapter>> GetByStoryIdAsync(int storyId, CancellationToken cancellationToken = default);
    88        Task<Chapter?> GetByIdWithStoryAsync(int id, CancellationToken cancellationToken = default);
     9        Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default);
    910    }
    1011}
  • ChapterX.Infrastructure/Data/DataContext/ApplicationDbContext.cs

    r0b502c2 r99c1e45  
    8888                e.Property(x => x.Id).HasColumnName("story_id");
    8989                e.Property(x => x.MatureContent).HasColumnName("mature_content");
     90                e.Property(x => x.Title).HasColumnName("title");
    9091                e.Property(x => x.ShortDescription).HasColumnName("short_description");
    9192                e.Property(x => x.Image).HasColumnName("image");
  • ChapterX.Infrastructure/Repositories/ChapterRepository.cs

    r0b502c2 r99c1e45  
    3131                .FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
    3232        }
     33
     34        public async Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default)
     35        {
     36            await _dbSet
     37                .Where(c => c.Id == id)
     38                .ExecuteUpdateAsync(s => s.SetProperty(c => c.ViewCount, c => c.ViewCount + 1), cancellationToken);
     39        }
    3340    }
    3441}
  • ChapterX.Infrastructure/Repositories/GenericRepository.cs

    r0b502c2 r99c1e45  
    1818        }
    1919
    20         public async Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
     20        public virtual async Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
    2121            => await _dbSet.FindAsync([id], cancellationToken);
    2222
  • ChapterX.Infrastructure/Repositories/StoryRepository.cs

    r0b502c2 r99c1e45  
    2525        }
    2626
     27        public override async Task<Story?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
     28        {
     29            return await _dbSet
     30                .Include(s => s.HasGenres)
     31                    .ThenInclude(hg => hg.Genre)
     32                .Include(s => s.Writer)
     33                    .ThenInclude(w => w!.User)
     34                .Include(s => s.Likes)
     35                .Include(s => s.Comments)
     36                .Include(s => s.Chapters)
     37                .FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
     38        }
     39
    2740        public async Task<IEnumerable<Story>> GetByWriterIdAsync(int writerId, CancellationToken cancellationToken = default)
    2841        {
  • chapterx-frontend/src/components/admin/PlatformStats.tsx

    r0b502c2 r99c1e45  
    1 import React from 'react'
    2 import { Users, BookOpen, FileText, Heart, Eye, MessageCircle } from 'lucide-react'
     1import React, { useEffect } from 'react'
     2import { Users, BookOpen, Heart, MessageCircle } from 'lucide-react'
    33import { useAuthStore } from '../../store/authStore'
    44import { useStoryStore } from '../../store/storyStore'
    55
    66export const PlatformStats: React.FC = () => {
    7   const { allUsers } = useAuthStore()
    8   const { stories, comments } = useStoryStore()
     7  const { allUsers, fetchAllUsers } = useAuthStore()
     8  const { stories, fetchStories } = useStoryStore()
    99
    10   const totalViews = stories.reduce((acc, s) => acc + s.total_views, 0)
     10  useEffect(() => { fetchStories(); fetchAllUsers() }, [])
     11
    1112  const totalLikes = stories.reduce((acc, s) => acc + s.total_likes, 0)
     13  const totalComments = stories.reduce((acc, s) => acc + s.total_comments, 0)
    1214  const published = stories.filter(s => s.status === 'published').length
    1315
    1416  const stats = [
    15     { icon: <Users size={24} className="text-blue-300" />, label: 'Total Users', value: allUsers.length, color: 'bg-blue-500/20', change: '+12 this month' },
     17    { icon: <Users size={24} className="text-blue-300" />, label: 'Total Users', value: allUsers.length, color: 'bg-blue-500/20', change: `${allUsers.filter(u => u.role === 'writer').length} writers` },
    1618    { icon: <BookOpen size={24} className="text-violet-300" />, label: 'Total Stories', value: stories.length, color: 'bg-violet-500/20', change: `${published} published` },
    17     { icon: <FileText size={24} className="text-emerald-300" />, label: 'Comments', value: comments.length, color: 'bg-emerald-500/20', change: 'Platform-wide' },
    1819    { icon: <Heart size={24} className="text-rose-300" />, label: 'Total Likes', value: totalLikes.toLocaleString(), color: 'bg-rose-500/20', change: 'Across all stories' },
    19     { icon: <Eye size={24} className="text-amber-300" />, label: 'Total Views', value: totalViews.toLocaleString(), color: 'bg-amber-500/20', change: 'All time' },
    20     { icon: <MessageCircle size={24} className="text-cyan-300" />, label: 'Writers', value: allUsers.filter(u => u.role === 'writer').length, color: 'bg-cyan-500/20', change: 'Active creators' },
     20    { icon: <MessageCircle size={24} className="text-emerald-300" />, label: 'Total Comments', value: totalComments.toLocaleString(), color: 'bg-emerald-500/20', change: 'Platform-wide' },
    2121  ]
    2222
    2323  return (
    24     <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
     24    <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
    2525      {stats.map(stat => (
    2626        <div key={stat.label} className="bg-slate-800 border border-slate-700 rounded-2xl p-6">
  • chapterx-frontend/src/components/admin/UserTable.tsx

    r0b502c2 r99c1e45  
    11import React, { useState } from 'react'
    22import { Search, Shield, UserX, UserCheck } from 'lucide-react'
     3import axios from 'axios'
    34import { useAuthStore } from '../../store/authStore'
    4 import { useNotificationStore } from '../../store/notificationStore'
    55import { useUIStore } from '../../store/uiStore'
    66import { User, UserRole } from '../../types'
     
    1010import { Modal } from '../ui/Modal'
    1111
     12const API = 'https://localhost:7125/api'
     13
    1214export const UserTable: React.FC = () => {
    13   const { allUsers, updateUserRole, currentUser } = useAuthStore()
    14   const { addNotification } = useNotificationStore()
     15  const { allUsers, updateUserRole, currentUser, token } = useAuthStore()
    1516  const { addToast } = useUIStore()
    1617  const [search, setSearch] = useState('')
    1718  const [confirmUser, setConfirmUser] = useState<User | null>(null)
    1819  const [confirmAction, setConfirmAction] = useState<'promote' | 'demote' | null>(null)
     20  const [loading, setLoading] = useState(false)
     21
     22  const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}
    1923
    2024  const filtered = allUsers.filter(
     
    2529  )
    2630
    27   const handlePromote = (user: User) => {
    28     const newRole: UserRole = user.role === 'regular' ? 'writer' : user.role === 'writer' ? 'admin' : 'admin'
    29     updateUserRole(user.user_id, newRole)
    30     addNotification({
    31       user_id: user.user_id,
    32       type: 'system',
    33       title: 'Role Updated',
    34       message: `Your account has been promoted to ${newRole}.`,
    35     })
    36     addToast(`${user.username} promoted to ${newRole}`)
    37     setConfirmUser(null)
     31  const handlePromote = async (user: User) => {
     32    setLoading(true)
     33    try {
     34      await axios.post(`${API}/admins`, { userId: user.user_id }, { headers: authHeaders })
     35      updateUserRole(user.user_id, 'admin')
     36      addToast(`${user.username} promoted to admin`)
     37    } catch (err: any) {
     38      addToast(err?.response?.data?.message || 'Failed to promote user.', 'error')
     39    } finally {
     40      setLoading(false)
     41      setConfirmUser(null)
     42    }
    3843  }
    3944
    40   const handleDemote = (user: User) => {
    41     const newRole: UserRole = user.role === 'admin' ? 'writer' : 'regular'
    42     updateUserRole(user.user_id, newRole)
    43     addToast(`${user.username} role changed to ${newRole}`, 'info')
    44     setConfirmUser(null)
     45  const handleDemote = async (user: User) => {
     46    setLoading(true)
     47    try {
     48      await axios.delete(`${API}/admins/${user.user_id}`, { headers: authHeaders })
     49      updateUserRole(user.user_id, 'writer')
     50      addToast(`${user.username} removed from admin`, 'info')
     51    } catch (err: any) {
     52      addToast(err?.response?.data?.message || 'Failed to demote user.', 'error')
     53    } finally {
     54      setLoading(false)
     55      setConfirmUser(null)
     56    }
    4557  }
    4658
     
    140152                variant={confirmAction === 'promote' ? 'primary' : 'danger'}
    141153                className="flex-1"
     154                loading={loading}
    142155                onClick={() => confirmAction === 'promote' ? handlePromote(confirmUser) : handleDemote(confirmUser)}
    143156              >
  • chapterx-frontend/src/components/story/LikeButton.tsx

    r0b502c2 r99c1e45  
    99const API = 'https://localhost:7125/api'
    1010
    11 function getAuthHeaders() {
    12   try {
    13     const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token
    14     return token ? { Authorization: `Bearer ${token}` } : {}
    15   } catch { return {} }
    16 }
    17 
    1811interface LikeButtonProps {
    1912  storyId: number
     
    2518export const LikeButton: React.FC<LikeButtonProps> = ({ storyId, authorUserId, totalLikes, onCountChange }) => {
    2619  const navigate = useNavigate()
    27   const { currentUser } = useAuthStore()
     20  const { currentUser, token } = useAuthStore()
    2821  const { addNotification } = useNotificationStore()
     22  const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}
    2923  const { addToast } = useUIStore()
    3024  const [liked, setLiked] = useState(false)
     
    5549    try {
    5650      if (liked) {
    57         await axios.delete(`${API}/likes/user/${currentUser.user_id}/story/${storyId}`, { headers: getAuthHeaders() })
     51        await axios.delete(`${API}/likes/user/${currentUser.user_id}/story/${storyId}`, { headers: authHeaders })
    5852        setLiked(false)
    5953        setCount(c => { const n = c - 1; onCountChange?.(n); return n })
    6054        addToast('Removed from likes', 'info')
    6155      } else {
    62         await axios.post(`${API}/likes`, { userId: currentUser.user_id, storyId }, { headers: getAuthHeaders() })
     56        await axios.post(`${API}/likes`, { userId: currentUser.user_id, storyId }, { headers: authHeaders })
    6357        setLiked(true)
    6458        setCount(c => { const n = c + 1; onCountChange?.(n); return n })
  • chapterx-frontend/src/components/ui/StoryCard.tsx

    r0b502c2 r99c1e45  
    11import React from 'react'
    22import { useNavigate } from 'react-router-dom'
    3 import { Heart, MessageCircle, Eye, Lock } from 'lucide-react'
     3import { Heart, MessageCircle, Lock } from 'lucide-react'
    44import { Story } from '../../types'
    55import { useAuthStore } from '../../store/authStore'
     
    8686            {story.total_comments}
    8787          </span>
    88           <span className="flex items-center gap-1 ml-auto">
    89             <Eye size={12} />
    90             {story.total_views.toLocaleString()}
    91           </span>
    9288        </div>
    9389      </div>
  • chapterx-frontend/src/components/writer/StoryAnalytics.tsx

    r0b502c2 r99c1e45  
    11import React from 'react'
    22import {
    3   LineChart,
    4   Line,
    53  BarChart,
    64  Bar,
     
    119  ResponsiveContainer,
    1210} from 'recharts'
    13 import { Eye, Heart, MessageCircle, TrendingUp, Clock, BarChart2 } from 'lucide-react'
    14 import { mockAnalytics } from '../../data/mockData'
     11import { Heart, MessageCircle, BookOpen } from 'lucide-react'
     12import { Story } from '../../types'
     13
     14interface Props {
     15  stories: Story[]
     16}
    1517
    1618const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: string | number; color: string }> = ({
    1719  icon, label, value, color,
    1820}) => (
    19   <div className={`p-4 bg-slate-800 rounded-xl border border-slate-700`}>
     21  <div className="p-4 bg-slate-800 rounded-xl border border-slate-700">
    2022    <div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center mb-3`}>
    2123      {icon}
    2224    </div>
    23     <p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
     25    <p className="text-2xl font-bold text-white">{typeof value === 'number' ? value.toLocaleString() : value}</p>
    2426    <p className="text-slate-400 text-sm mt-0.5">{label}</p>
    2527  </div>
     
    4244}
    4345
    44 export const StoryAnalytics: React.FC = () => {
    45   const analytics = mockAnalytics
    46   const viewsData = analytics.views_over_time.filter((_, i) => i % 5 === 0)
    47   const likesData = analytics.likes_over_time.filter((_, i) => i % 5 === 0)
     46export const StoryAnalytics: React.FC<Props> = ({ stories }) => {
     47  const published = stories.filter(s => s.status === 'published')
     48
     49  const totalLikes = stories.reduce((a, s) => a + s.total_likes, 0)
     50  const totalComments = stories.reduce((a, s) => a + s.total_comments, 0)
     51  const totalChapters = stories.reduce((a, s) => a + s.total_chapters, 0)
     52
     53  // Likes per story (sorted by created_at)
     54  const likesData = [...published]
     55    .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
     56    .map(s => ({
     57      date: new Date(s.created_at).toLocaleDateString('en-US', { month: 'short', year: '2-digit' }),
     58      likes: s.total_likes,
     59      story: s.title,
     60    }))
     61
     62  if (published.length === 0) {
     63    return (
     64      <div className="text-center py-12 text-slate-500">
     65        <p>No published stories yet — analytics will appear here once you publish.</p>
     66      </div>
     67    )
     68  }
    4869
    4970  return (
    5071    <div className="space-y-6">
    5172      {/* Stat cards */}
    52       <div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
    53         <StatCard icon={<Eye size={18} className="text-blue-300" />} label="Total Views" value={analytics.total_views} color="bg-blue-500/20" />
    54         <StatCard icon={<Heart size={18} className="text-rose-300" />} label="Total Likes" value={analytics.total_likes} color="bg-rose-500/20" />
    55         <StatCard icon={<MessageCircle size={18} className="text-violet-300" />} label="Comments" value={analytics.total_comments} color="bg-violet-500/20" />
    56         <StatCard icon={<Clock size={18} className="text-amber-300" />} label="Avg Read (min)" value={analytics.avg_read_time} color="bg-amber-500/20" />
    57         <StatCard icon={<BarChart2 size={18} className="text-emerald-300" />} label="Completion %" value={`${analytics.completion_rate}%`} color="bg-emerald-500/20" />
    58       </div>
    59 
    60       {/* Views chart */}
    61       <div className="bg-slate-800 border border-slate-700 rounded-2xl p-6">
    62         <div className="flex items-center gap-2 mb-4">
    63           <TrendingUp size={16} className="text-blue-400" />
    64           <h3 className="text-white font-semibold">Views Over Time</h3>
    65         </div>
    66         <ResponsiveContainer width="100%" height={200}>
    67           <LineChart data={viewsData}>
    68             <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
    69             <XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickLine={false} axisLine={false} />
    70             <YAxis tick={{ fill: '#64748b', fontSize: 11 }} tickLine={false} axisLine={false} />
    71             <Tooltip content={<CustomTooltip />} />
    72             <Line type="monotone" dataKey="views" stroke="#6366f1" strokeWidth={2} dot={false} name="Views" />
    73           </LineChart>
    74         </ResponsiveContainer>
     73      <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
     74        <StatCard icon={<Heart size={18} className="text-rose-300" />} label="Total Likes" value={totalLikes} color="bg-rose-500/20" />
     75        <StatCard icon={<MessageCircle size={18} className="text-violet-300" />} label="Total Comments" value={totalComments} color="bg-violet-500/20" />
     76        <StatCard icon={<BookOpen size={18} className="text-emerald-300" />} label="Total Chapters" value={totalChapters} color="bg-emerald-500/20" />
    7577      </div>
    7678
     
    7981        <div className="flex items-center gap-2 mb-4">
    8082          <Heart size={16} className="text-rose-400" />
    81           <h3 className="text-white font-semibold">Likes Over Time</h3>
     83          <h3 className="text-white font-semibold">Likes per Story</h3>
    8284        </div>
    83         <ResponsiveContainer width="100%" height={200}>
    84           <BarChart data={likesData}>
    85             <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
    86             <XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickLine={false} axisLine={false} />
    87             <YAxis tick={{ fill: '#64748b', fontSize: 11 }} tickLine={false} axisLine={false} />
    88             <Tooltip content={<CustomTooltip />} />
    89             <Bar dataKey="likes" fill="#f43f5e" radius={[4, 4, 0, 0]} name="Likes" />
    90           </BarChart>
    91         </ResponsiveContainer>
     85        {likesData.length > 0 ? (
     86          <ResponsiveContainer width="100%" height={200}>
     87            <BarChart data={likesData}>
     88              <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
     89              <XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickLine={false} axisLine={false} />
     90              <YAxis tick={{ fill: '#64748b', fontSize: 11 }} tickLine={false} axisLine={false} />
     91              <Tooltip content={<CustomTooltip />} />
     92              <Bar dataKey="likes" fill="#f43f5e" radius={[4, 4, 0, 0]} name="Likes" />
     93            </BarChart>
     94          </ResponsiveContainer>
     95        ) : (
     96          <p className="text-slate-500 text-sm text-center py-8">No likes data yet</p>
     97        )}
    9298      </div>
     99
     100      {/* Per-story breakdown */}
     101      {published.length > 1 && (
     102        <div className="bg-slate-800 border border-slate-700 rounded-2xl p-6">
     103          <h3 className="text-white font-semibold mb-4">Story Breakdown</h3>
     104          <div className="space-y-3">
     105            {[...published]
     106              .sort((a, b) => b.total_likes - a.total_likes)
     107              .map(s => (
     108                <div key={s.story_id} className="flex items-center justify-between text-sm">
     109                  <span className="text-slate-300 truncate max-w-xs">{s.title}</span>
     110                  <div className="flex items-center gap-4 text-slate-400 flex-shrink-0">
     111                    <span className="flex items-center gap-1"><Heart size={12} /> {s.total_likes}</span>
     112                    <span className="flex items-center gap-1"><MessageCircle size={12} /> {s.total_comments}</span>
     113                  </div>
     114                </div>
     115              ))}
     116          </div>
     117        </div>
     118      )}
    93119    </div>
    94120  )
  • chapterx-frontend/src/pages/profile/ProfilePage.tsx

    r0b502c2 r99c1e45  
    11import React, { useState, useEffect } from 'react'
    22import { useParams, useNavigate } from 'react-router-dom'
    3 import { BookOpen, Heart, Users, Calendar, MessageCircle, Eye } from 'lucide-react'
     3import { BookOpen, Heart, Calendar, MessageCircle } from 'lucide-react'
    44import { useAuthStore } from '../../store/authStore'
    55import { useStoryStore } from '../../store/storyStore'
     
    1818  const navigate = useNavigate()
    1919  const { allUsers, currentUser, fetchAllUsers, updateUser } = useAuthStore()
    20   const { stories, comments } = useStoryStore()
     20  const { stories, fetchStories } = useStoryStore()
    2121  const { addToast } = useUIStore()
    2222  const [tab, setTab] = useState<Tab>('stories')
     
    2727
    2828  useEffect(() => {
     29    fetchStories()
    2930    if (allUsers.length === 0) {
    3031      setLoading(true)
     
    7475  const userStories = stories.filter(s => s.user_id === user.user_id && s.status === 'published')
    7576  const totalLikes = userStories.reduce((acc, s) => acc + s.total_likes, 0)
    76   const totalViews = userStories.reduce((acc, s) => acc + s.total_views, 0)
    77   const userComments = comments.filter(c => c.user_id === user.user_id)
     77  const totalComments = userStories.reduce((acc, s) => acc + s.total_comments, 0)
    7878  const allGenres = [...new Set(userStories.flatMap(s => s.genres))]
    7979
     
    116116            Joined {new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
    117117          </span>
    118           <span className="flex items-center gap-1 text-slate-400 text-sm">
    119             <Users size={14} />
    120             {user.follower_count} followers · {user.following_count} following
    121           </span>
    122118        </div>
    123119      </div>
    124120
    125121      {/* Stats */}
    126       <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
     122      <div className="grid grid-cols-3 gap-4 mb-8">
    127123        {[
    128124          { icon: <BookOpen size={16} className="text-indigo-400" />, value: userStories.length, label: 'Stories' },
    129125          { icon: <Heart size={16} className="text-rose-400" />, value: totalLikes.toLocaleString(), label: 'Likes' },
    130           { icon: <Eye size={16} className="text-blue-400" />, value: totalViews.toLocaleString(), label: 'Views' },
    131           { icon: <MessageCircle size={16} className="text-amber-400" />, value: userComments.length, label: 'Comments' },
     126          { icon: <MessageCircle size={16} className="text-amber-400" />, value: totalComments, label: 'Comments' },
    132127        ].map(s => (
    133128          <div key={s.label} className="bg-slate-800 border border-slate-700 rounded-xl p-4 text-center">
     
    172167      ) : (
    173168        <div className="space-y-6">
    174           <div className="bg-slate-800 border border-slate-700 rounded-2xl p-6">
    175             <h3 className="text-white font-semibold mb-3">About</h3>
    176             <p className="text-slate-300">{user.bio || 'No bio provided.'}</p>
    177           </div>
    178169          {allGenres.length > 0 && (
    179170            <div className="bg-slate-800 border border-slate-700 rounded-2xl p-6">
  • chapterx-frontend/src/pages/story/StoryDetailPage.tsx

    r0b502c2 r99c1e45  
    11import React, { useState } from 'react'
    22import { useParams, useNavigate } from 'react-router-dom'
    3 import { ArrowLeft, BookOpen, Eye, Users, Calendar, Plus, BookmarkPlus } from 'lucide-react'
     3import { ArrowLeft, BookOpen, Users, Calendar, Plus, BookmarkPlus } from 'lucide-react'
    44import { useStoryStore } from '../../store/storyStore'
    55import { useAuthStore } from '../../store/authStore'
     
    117117      {/* Hero */}
    118118      <div className={`relative rounded-2xl overflow-hidden mb-8 bg-gradient-to-br ${gradient}`}>
     119        {story.cover_image && (
     120          <img src={story.cover_image} alt={story.title} className="absolute inset-0 w-full h-full object-cover opacity-30" />
     121        )}
    119122        <div className="absolute inset-0 bg-gradient-to-t from-slate-950/90 via-slate-950/30 to-transparent" />
    120123        <div className="relative p-8 sm:p-12">
     
    134137              <Avatar name={story.author_username} size="sm" />
    135138              <span className="text-white text-sm font-medium">{story.author_username}</span>
    136             </div>
    137             <div className="flex items-center gap-1 text-slate-400 text-sm">
    138               <Eye size={14} />
    139               {story.total_views.toLocaleString()} views
    140139            </div>
    141140            <div className="flex items-center gap-1 text-slate-400 text-sm">
     
    170169          </div>
    171170
     171          {/* Story content */}
     172          {story.content && (
     173            <div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6">
     174              <p className="text-slate-200 leading-relaxed font-serif whitespace-pre-wrap">{story.content}</p>
     175            </div>
     176          )}
     177
    172178          {/* Chapters */}
    173179          <div>
     
    207213                <span className="text-slate-400">Likes</span>
    208214                <span className="text-white">{(liveLikes ?? story.total_likes).toLocaleString()}</span>
    209               </div>
    210               <div className="flex justify-between">
    211                 <span className="text-slate-400">Views</span>
    212                 <span className="text-white">{story.total_views.toLocaleString()}</span>
    213215              </div>
    214216              <div className="flex justify-between">
  • chapterx-frontend/src/pages/writer/CreateStoryPage.tsx

    r0b502c2 r99c1e45  
    2121    short_description: '',
    2222    content: '',
     23    cover_image: '',
    2324    genres: [] as string[],
    2425    mature_content: false,
     
    4546      story_id: Date.now(),
    4647      ...form,
     48      cover_image: form.cover_image || undefined,
    4749      user_id: currentUser.user_id,
    4850      author_username: currentUser.username,
     
    124126              <span className="text-slate-600 text-xs">{form.short_description.length}/280</span>
    125127            </div>
     128          </div>
     129
     130          {/* Cover image */}
     131          <div>
     132            <label className="block text-sm font-medium text-slate-300 mb-1.5">Cover Image URL <span className="text-slate-500 font-normal">(optional)</span></label>
     133            <input
     134              value={form.cover_image}
     135              onChange={e => {
     136                const val = e.target.value
     137                if (!val || val.startsWith('http')) setField('cover_image', val)
     138              }}
     139              placeholder="https://example.com/image.jpg"
     140              className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
     141            />
     142            {form.cover_image?.startsWith('http') && (
     143              <img src={form.cover_image} alt="Cover preview" className="mt-2 h-32 w-full object-cover rounded-xl opacity-80" onError={e => (e.currentTarget.style.display = 'none')} />
     144            )}
    126145          </div>
    127146
  • chapterx-frontend/src/pages/writer/WriterDashboard.tsx

    r0b502c2 r99c1e45  
    1 import React from 'react'
     1import React, { useEffect } from 'react'
    22import { useNavigate } from 'react-router-dom'
    3 import { Plus, BookOpen, Eye, Heart, MessageCircle, TrendingUp, Bell } from 'lucide-react'
     3import { Plus, BookOpen, Heart, MessageCircle, TrendingUp, Bell } from 'lucide-react'
    44import { useAuthStore } from '../../store/authStore'
    55import { useStoryStore } from '../../store/storyStore'
     
    2222  const navigate = useNavigate()
    2323  const { currentUser } = useAuthStore()
    24   const { stories, collaborations } = useStoryStore()
     24  const { stories, collaborations, fetchStories } = useStoryStore()
    2525  const { notifications } = useNotificationStore()
     26
     27  useEffect(() => { fetchStories() }, [])
    2628
    2729  if (!currentUser) return null
     
    3436  const published = myStories.filter(s => s.status === 'published')
    3537  const drafts = myStories.filter(s => s.status === 'draft')
    36   const totalViews = myStories.reduce((acc, s) => acc + s.total_views, 0)
    3738  const totalLikes = myStories.reduce((acc, s) => acc + s.total_likes, 0)
    3839
     
    5657
    5758      {/* Stats */}
    58       <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
     59      <div className="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
    5960        {[
    6061          { icon: <BookOpen size={20} className="text-indigo-300" />, label: 'Total Stories', value: myStories.length, sub: `${published.length} published`, color: 'bg-indigo-500/20' },
    61           { icon: <Eye size={20} className="text-blue-300" />, label: 'Total Views', value: totalViews.toLocaleString(), sub: 'All time', color: 'bg-blue-500/20' },
    6262          { icon: <Heart size={20} className="text-rose-300" />, label: 'Total Likes', value: totalLikes.toLocaleString(), sub: 'Across all stories', color: 'bg-rose-500/20' },
    6363          { icon: <TrendingUp size={20} className="text-emerald-300" />, label: 'Drafts', value: drafts.length, sub: 'In progress', color: 'bg-emerald-500/20' },
     
    108108                      <div className="flex items-center gap-3 text-slate-500 text-xs">
    109109                        {isCollab && <span className="text-slate-500">by {story.author_username}</span>}
    110                         <span className="flex items-center gap-1"><Eye size={11} /> {story.total_views.toLocaleString()}</span>
    111110                        <span className="flex items-center gap-1"><Heart size={11} /> {story.total_likes}</span>
    112111                        <span className="flex items-center gap-1"><MessageCircle size={11} /> {story.total_comments}</span>
     
    155154            <TrendingUp size={18} className="text-indigo-400" />
    156155            <h2 className="font-serif text-xl font-bold text-white">Analytics</h2>
    157             <span className="text-slate-500 text-sm">(Story: The Chronicles of Eldoria)</span>
    158156          </div>
    159           <StoryAnalytics />
     157          <StoryAnalytics stories={myStories} />
    160158        </div>
    161159      )}
  • chapterx-frontend/src/store/authStore.ts

    r0b502c2 r99c1e45  
    22import { persist } from 'zustand/middleware'
    33import { User, UserRole } from '../types'
    4 import { mockUsers } from '../data/mockData'
    54import axios from 'axios'
    65
     
    153152          const res = await axios.get(`${API_BASE}/users`)
    154153          const data: any[] = res.data?.users ?? res.data ?? []
     154          const existing = get().allUsers
    155155          const users: User[] = data.map((u: any) => ({
    156156            user_id: u.id,
     
    163163            follower_count: 0,
    164164            following_count: 0,
     165            bio: existing.find(e => e.user_id === u.id)?.bio,
    165166          }))
    166167          set({ allUsers: users })
  • chapterx-frontend/src/store/storyStore.ts

    r0b502c2 r99c1e45  
    138138        story_id: s.id,
    139139        user_id: s.userId,
    140         title: s.shortDescription,
    141         short_description: s.shortDescription,
    142         content: s.content,
     140        title: s.title ?? '',
     141        short_description: s.shortDescription ?? '',
     142        content: s.content ?? '',
     143        cover_image: s.image ?? undefined,
    143144        mature_content: s.matureContent,
    144145        status: 'published' as StoryStatus,
     
    203204  addStory: async (story) => {
    204205    set(state => ({ stories: [...state.stories, story] }))
     206    const imageUrl = story.cover_image?.startsWith('http') ? story.cover_image : null
    205207    const res = await axios.post(`${API}/stories`, {
    206208      matureContent: story.mature_content,
    207       shortDescription: story.short_description || story.title,
    208       image: null,
     209      title: story.title,
     210      shortDescription: story.short_description,
     211      image: imageUrl,
    209212      content: story.content,
    210213      userId: story.user_id,
     
    233236      const story = get().stories.find(s => s.story_id === id)
    234237      if (!story) return
     238      const rawImage = partial.cover_image ?? story.cover_image ?? null
     239      const imageUrl = rawImage?.startsWith('http') ? rawImage : null
    235240      await axios.put(`${API}/stories/${id}`, {
    236241        id,
    237242        matureContent: partial.mature_content ?? story.mature_content,
    238         shortDescription: partial.title ?? partial.short_description ?? story.title ?? story.short_description,
    239         image: null,
     243        title: partial.title ?? story.title,
     244        shortDescription: partial.short_description ?? story.short_description,
     245        image: imageUrl,
    240246        content: partial.content ?? story.content,
    241247      }, { headers: getAuthHeaders() })
     
    335341  },
    336342
    337   incrementViewCount: (chapterId) =>
     343  incrementViewCount: (chapterId) => {
     344    const chapter = get().chapters.find(c => c.chapter_id === chapterId)
    338345    set(state => ({
    339346      chapters: state.chapters.map(c =>
    340347        c.chapter_id === chapterId ? { ...c, view_count: c.view_count + 1 } : c
    341348      ),
    342     })),
     349      stories: state.stories.map(s =>
     350        s.story_id === chapter?.story_id ? { ...s, total_views: s.total_views + 1 } : s
     351      ),
     352    }))
     353    axios.patch(`${API}/chapters/${chapterId}/view`, null, { headers: getAuthHeaders() }).catch(() => {})
     354  },
    343355
    344356  addComment: (comment) =>
Note: See TracChangeset for help on using the changeset viewer.