Changeset 99c1e45
- Timestamp:
- 06/24/26 16:28:50 (11 days ago)
- Branches:
- main
- Children:
- a8f4a2d
- Parents:
- 0b502c2
- Files:
-
- 26 edited
-
ChapterX.API/Controllers/ChaptersController.cs (modified) (3 diffs)
-
ChapterX.API/Controllers/StoriesController.cs (modified) (3 diffs)
-
ChapterX.API/Program.cs (modified) (2 diffs)
-
ChapterX.Application/Story/Commands/AddHandler.cs (modified) (1 diff)
-
ChapterX.Application/Story/Commands/AddRequest.cs (modified) (1 diff)
-
ChapterX.Application/Story/Commands/DeleteHandler.cs (modified) (1 diff)
-
ChapterX.Application/Story/Commands/DeleteRequest.cs (modified) (1 diff)
-
ChapterX.Application/Story/Commands/UpdateHandler.cs (modified) (1 diff)
-
ChapterX.Application/Story/Commands/UpdateRequest.cs (modified) (1 diff)
-
ChapterX.Domain/Entities/Story.cs (modified) (1 diff)
-
ChapterX.Domain/Repositories/IChapterRepository.cs (modified) (1 diff)
-
ChapterX.Infrastructure/Data/DataContext/ApplicationDbContext.cs (modified) (1 diff)
-
ChapterX.Infrastructure/Repositories/ChapterRepository.cs (modified) (1 diff)
-
ChapterX.Infrastructure/Repositories/GenericRepository.cs (modified) (1 diff)
-
ChapterX.Infrastructure/Repositories/StoryRepository.cs (modified) (1 diff)
-
chapterx-frontend/src/components/admin/PlatformStats.tsx (modified) (1 diff)
-
chapterx-frontend/src/components/admin/UserTable.tsx (modified) (4 diffs)
-
chapterx-frontend/src/components/story/LikeButton.tsx (modified) (3 diffs)
-
chapterx-frontend/src/components/ui/StoryCard.tsx (modified) (2 diffs)
-
chapterx-frontend/src/components/writer/StoryAnalytics.tsx (modified) (4 diffs)
-
chapterx-frontend/src/pages/profile/ProfilePage.tsx (modified) (6 diffs)
-
chapterx-frontend/src/pages/story/StoryDetailPage.tsx (modified) (5 diffs)
-
chapterx-frontend/src/pages/writer/CreateStoryPage.tsx (modified) (3 diffs)
-
chapterx-frontend/src/pages/writer/WriterDashboard.tsx (modified) (6 diffs)
-
chapterx-frontend/src/store/authStore.ts (modified) (3 diffs)
-
chapterx-frontend/src/store/storyStore.ts (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
ChapterX.API/Controllers/ChaptersController.cs
r0b502c2 r99c1e45 1 1 using ChapterX.Application.Chapter.Commands; 2 2 using ChapterX.Application.Chapter.Queries; 3 using ChapterX.Domain.Repositories; 3 4 using MediatR; 4 5 using Microsoft.AspNetCore.Authorization; … … 14 15 { 15 16 private readonly IMediator _mediator; 17 private readonly IChapterRepository _chapterRepository; 16 18 private readonly ILogger<ChaptersController> _logger; 17 19 18 public ChaptersController(IMediator mediator, I Logger<ChaptersController> logger)20 public ChaptersController(IMediator mediator, IChapterRepository chapterRepository, ILogger<ChaptersController> logger) 19 21 { 20 22 _mediator = mediator; 23 _chapterRepository = chapterRepository; 21 24 _logger = logger; 22 25 } … … 38 41 var response = await _mediator.Send(new GetRequest(id)); 39 42 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(); 40 51 } 41 52 -
ChapterX.API/Controllers/StoriesController.cs
r0b502c2 r99c1e45 29 29 _logger.LogInformation("Fetching all stories"); 30 30 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 }); 32 49 } 33 50 … … 39 56 _logger.LogInformation("Fetching story with ID: {StoryId}", id); 40 57 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 }); 42 77 } 43 78 … … 76 111 _logger.LogInformation("Deleting story with ID: {StoryId}", id); 77 112 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)); 79 115 return Ok(response); 80 116 } -
ChapterX.API/Program.cs
r0b502c2 r99c1e45 77 77 var app = builder.Build(); 78 78 79 app.UseCors("Frontend");80 81 if (app.Environment.IsDevelopment())82 {83 app.UseSwagger();84 app.UseSwaggerUI();85 }86 87 79 app.UseExceptionHandler(err => err.Run(async ctx => 88 80 { … … 130 122 })); 131 123 124 app.UseCors("Frontend"); 125 126 if (app.Environment.IsDevelopment()) 127 { 128 app.UseSwagger(); 129 app.UseSwaggerUI(); 130 } 131 132 132 app.UseAuthentication(); 133 133 app.UseAuthorization(); -
ChapterX.Application/Story/Commands/AddHandler.cs
r0b502c2 r99c1e45 31 31 { 32 32 MatureContent = request.MatureContent, 33 Title = request.Title, 33 34 ShortDescription = request.ShortDescription, 34 35 Image = request.Image, -
ChapterX.Application/Story/Commands/AddRequest.cs
r0b502c2 r99c1e45 6 6 public record AddRequest( 7 7 bool MatureContent, 8 [Required][MaxLength(200)] string Title, 8 9 [Required][MaxLength(500)] string ShortDescription, 9 10 [MaxLength(2048)] string? Image, -
ChapterX.Application/Story/Commands/DeleteHandler.cs
r0b502c2 r99c1e45 22 22 return new DeleteResponse(false); 23 23 24 if ( story.UserId != request.CallerId)24 if (!request.IsAdmin && story.UserId != request.CallerId) 25 25 throw new UnauthorizedAccessException("You do not own this story."); 26 26 -
ChapterX.Application/Story/Commands/DeleteRequest.cs
r0b502c2 r99c1e45 3 3 namespace ChapterX.Application.Story.Commands 4 4 { 5 public record DeleteRequest(int Id, int CallerId ) : IRequest<DeleteResponse>;5 public record DeleteRequest(int Id, int CallerId, bool IsAdmin = false) : IRequest<DeleteResponse>; 6 6 } -
ChapterX.Application/Story/Commands/UpdateHandler.cs
r0b502c2 r99c1e45 26 26 27 27 story.MatureContent = request.MatureContent; 28 story.Title = request.Title; 28 29 story.ShortDescription = request.ShortDescription; 29 30 story.Image = request.Image; -
ChapterX.Application/Story/Commands/UpdateRequest.cs
r0b502c2 r99c1e45 3 3 namespace ChapterX.Application.Story.Commands 4 4 { 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>; 6 6 } -
ChapterX.Domain/Entities/Story.cs
r0b502c2 r99c1e45 12 12 public int Id { get; set; } 13 13 public bool MatureContent { get; set; } 14 public string Title { get; set; } = string.Empty; 14 15 public string ShortDescription { get; set; } = string.Empty; 15 16 public string? Image { get; set; } -
ChapterX.Domain/Repositories/IChapterRepository.cs
r0b502c2 r99c1e45 7 7 Task<IEnumerable<Chapter>> GetByStoryIdAsync(int storyId, CancellationToken cancellationToken = default); 8 8 Task<Chapter?> GetByIdWithStoryAsync(int id, CancellationToken cancellationToken = default); 9 Task IncrementViewCountAsync(int id, CancellationToken cancellationToken = default); 9 10 } 10 11 } -
ChapterX.Infrastructure/Data/DataContext/ApplicationDbContext.cs
r0b502c2 r99c1e45 88 88 e.Property(x => x.Id).HasColumnName("story_id"); 89 89 e.Property(x => x.MatureContent).HasColumnName("mature_content"); 90 e.Property(x => x.Title).HasColumnName("title"); 90 91 e.Property(x => x.ShortDescription).HasColumnName("short_description"); 91 92 e.Property(x => x.Image).HasColumnName("image"); -
ChapterX.Infrastructure/Repositories/ChapterRepository.cs
r0b502c2 r99c1e45 31 31 .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); 32 32 } 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 } 33 40 } 34 41 } -
ChapterX.Infrastructure/Repositories/GenericRepository.cs
r0b502c2 r99c1e45 18 18 } 19 19 20 public async Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default)20 public virtual async Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default) 21 21 => await _dbSet.FindAsync([id], cancellationToken); 22 22 -
ChapterX.Infrastructure/Repositories/StoryRepository.cs
r0b502c2 r99c1e45 25 25 } 26 26 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 27 40 public async Task<IEnumerable<Story>> GetByWriterIdAsync(int writerId, CancellationToken cancellationToken = default) 28 41 { -
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'1 import React, { useEffect } from 'react' 2 import { Users, BookOpen, Heart, MessageCircle } from 'lucide-react' 3 3 import { useAuthStore } from '../../store/authStore' 4 4 import { useStoryStore } from '../../store/storyStore' 5 5 6 6 export const PlatformStats: React.FC = () => { 7 const { allUsers } = useAuthStore()8 const { stories, comments } = useStoryStore()7 const { allUsers, fetchAllUsers } = useAuthStore() 8 const { stories, fetchStories } = useStoryStore() 9 9 10 const totalViews = stories.reduce((acc, s) => acc + s.total_views, 0) 10 useEffect(() => { fetchStories(); fetchAllUsers() }, []) 11 11 12 const totalLikes = stories.reduce((acc, s) => acc + s.total_likes, 0) 13 const totalComments = stories.reduce((acc, s) => acc + s.total_comments, 0) 12 14 const published = stories.filter(s => s.status === 'published').length 13 15 14 16 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` }, 16 18 { 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' },18 19 { 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' }, 21 21 ] 22 22 23 23 return ( 24 <div className="grid grid-cols-2 md:grid-cols- 3gap-4">24 <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 25 25 {stats.map(stat => ( 26 26 <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 1 1 import React, { useState } from 'react' 2 2 import { Search, Shield, UserX, UserCheck } from 'lucide-react' 3 import axios from 'axios' 3 4 import { useAuthStore } from '../../store/authStore' 4 import { useNotificationStore } from '../../store/notificationStore'5 5 import { useUIStore } from '../../store/uiStore' 6 6 import { User, UserRole } from '../../types' … … 10 10 import { Modal } from '../ui/Modal' 11 11 12 const API = 'https://localhost:7125/api' 13 12 14 export const UserTable: React.FC = () => { 13 const { allUsers, updateUserRole, currentUser } = useAuthStore() 14 const { addNotification } = useNotificationStore() 15 const { allUsers, updateUserRole, currentUser, token } = useAuthStore() 15 16 const { addToast } = useUIStore() 16 17 const [search, setSearch] = useState('') 17 18 const [confirmUser, setConfirmUser] = useState<User | null>(null) 18 19 const [confirmAction, setConfirmAction] = useState<'promote' | 'demote' | null>(null) 20 const [loading, setLoading] = useState(false) 21 22 const authHeaders = token ? { Authorization: `Bearer ${token}` } : {} 19 23 20 24 const filtered = allUsers.filter( … … 25 29 ) 26 30 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 } 38 43 } 39 44 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 } 45 57 } 46 58 … … 140 152 variant={confirmAction === 'promote' ? 'primary' : 'danger'} 141 153 className="flex-1" 154 loading={loading} 142 155 onClick={() => confirmAction === 'promote' ? handlePromote(confirmUser) : handleDemote(confirmUser)} 143 156 > -
chapterx-frontend/src/components/story/LikeButton.tsx
r0b502c2 r99c1e45 9 9 const API = 'https://localhost:7125/api' 10 10 11 function getAuthHeaders() {12 try {13 const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token14 return token ? { Authorization: `Bearer ${token}` } : {}15 } catch { return {} }16 }17 18 11 interface LikeButtonProps { 19 12 storyId: number … … 25 18 export const LikeButton: React.FC<LikeButtonProps> = ({ storyId, authorUserId, totalLikes, onCountChange }) => { 26 19 const navigate = useNavigate() 27 const { currentUser } = useAuthStore()20 const { currentUser, token } = useAuthStore() 28 21 const { addNotification } = useNotificationStore() 22 const authHeaders = token ? { Authorization: `Bearer ${token}` } : {} 29 23 const { addToast } = useUIStore() 30 24 const [liked, setLiked] = useState(false) … … 55 49 try { 56 50 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 }) 58 52 setLiked(false) 59 53 setCount(c => { const n = c - 1; onCountChange?.(n); return n }) 60 54 addToast('Removed from likes', 'info') 61 55 } 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 }) 63 57 setLiked(true) 64 58 setCount(c => { const n = c + 1; onCountChange?.(n); return n }) -
chapterx-frontend/src/components/ui/StoryCard.tsx
r0b502c2 r99c1e45 1 1 import React from 'react' 2 2 import { useNavigate } from 'react-router-dom' 3 import { Heart, MessageCircle, Eye,Lock } from 'lucide-react'3 import { Heart, MessageCircle, Lock } from 'lucide-react' 4 4 import { Story } from '../../types' 5 5 import { useAuthStore } from '../../store/authStore' … … 86 86 {story.total_comments} 87 87 </span> 88 <span className="flex items-center gap-1 ml-auto">89 <Eye size={12} />90 {story.total_views.toLocaleString()}91 </span>92 88 </div> 93 89 </div> -
chapterx-frontend/src/components/writer/StoryAnalytics.tsx
r0b502c2 r99c1e45 1 1 import React from 'react' 2 2 import { 3 LineChart,4 Line,5 3 BarChart, 6 4 Bar, … … 11 9 ResponsiveContainer, 12 10 } from 'recharts' 13 import { Eye, Heart, MessageCircle, TrendingUp, Clock, BarChart2 } from 'lucide-react' 14 import { mockAnalytics } from '../../data/mockData' 11 import { Heart, MessageCircle, BookOpen } from 'lucide-react' 12 import { Story } from '../../types' 13 14 interface Props { 15 stories: Story[] 16 } 15 17 16 18 const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: string | number; color: string }> = ({ 17 19 icon, label, value, color, 18 20 }) => ( 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"> 20 22 <div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center mb-3`}> 21 23 {icon} 22 24 </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> 24 26 <p className="text-slate-400 text-sm mt-0.5">{label}</p> 25 27 </div> … … 42 44 } 43 45 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) 46 export 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 } 48 69 49 70 return ( 50 71 <div className="space-y-6"> 51 72 {/* 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" /> 75 77 </div> 76 78 … … 79 81 <div className="flex items-center gap-2 mb-4"> 80 82 <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> 82 84 </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 )} 92 98 </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 )} 93 119 </div> 94 120 ) -
chapterx-frontend/src/pages/profile/ProfilePage.tsx
r0b502c2 r99c1e45 1 1 import React, { useState, useEffect } from 'react' 2 2 import { useParams, useNavigate } from 'react-router-dom' 3 import { BookOpen, Heart, Users, Calendar, MessageCircle, Eye } from 'lucide-react'3 import { BookOpen, Heart, Calendar, MessageCircle } from 'lucide-react' 4 4 import { useAuthStore } from '../../store/authStore' 5 5 import { useStoryStore } from '../../store/storyStore' … … 18 18 const navigate = useNavigate() 19 19 const { allUsers, currentUser, fetchAllUsers, updateUser } = useAuthStore() 20 const { stories, comments } = useStoryStore()20 const { stories, fetchStories } = useStoryStore() 21 21 const { addToast } = useUIStore() 22 22 const [tab, setTab] = useState<Tab>('stories') … … 27 27 28 28 useEffect(() => { 29 fetchStories() 29 30 if (allUsers.length === 0) { 30 31 setLoading(true) … … 74 75 const userStories = stories.filter(s => s.user_id === user.user_id && s.status === 'published') 75 76 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) 78 78 const allGenres = [...new Set(userStories.flatMap(s => s.genres))] 79 79 … … 116 116 Joined {new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} 117 117 </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} following121 </span>122 118 </div> 123 119 </div> 124 120 125 121 {/* Stats */} 126 <div className="grid grid-cols- 2 sm:grid-cols-4gap-4 mb-8">122 <div className="grid grid-cols-3 gap-4 mb-8"> 127 123 {[ 128 124 { icon: <BookOpen size={16} className="text-indigo-400" />, value: userStories.length, label: 'Stories' }, 129 125 { 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' }, 132 127 ].map(s => ( 133 128 <div key={s.label} className="bg-slate-800 border border-slate-700 rounded-xl p-4 text-center"> … … 172 167 ) : ( 173 168 <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>178 169 {allGenres.length > 0 && ( 179 170 <div className="bg-slate-800 border border-slate-700 rounded-2xl p-6"> -
chapterx-frontend/src/pages/story/StoryDetailPage.tsx
r0b502c2 r99c1e45 1 1 import React, { useState } from 'react' 2 2 import { useParams, useNavigate } from 'react-router-dom' 3 import { ArrowLeft, BookOpen, Eye,Users, Calendar, Plus, BookmarkPlus } from 'lucide-react'3 import { ArrowLeft, BookOpen, Users, Calendar, Plus, BookmarkPlus } from 'lucide-react' 4 4 import { useStoryStore } from '../../store/storyStore' 5 5 import { useAuthStore } from '../../store/authStore' … … 117 117 {/* Hero */} 118 118 <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 )} 119 122 <div className="absolute inset-0 bg-gradient-to-t from-slate-950/90 via-slate-950/30 to-transparent" /> 120 123 <div className="relative p-8 sm:p-12"> … … 134 137 <Avatar name={story.author_username} size="sm" /> 135 138 <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()} views140 139 </div> 141 140 <div className="flex items-center gap-1 text-slate-400 text-sm"> … … 170 169 </div> 171 170 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 172 178 {/* Chapters */} 173 179 <div> … … 207 213 <span className="text-slate-400">Likes</span> 208 214 <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>213 215 </div> 214 216 <div className="flex justify-between"> -
chapterx-frontend/src/pages/writer/CreateStoryPage.tsx
r0b502c2 r99c1e45 21 21 short_description: '', 22 22 content: '', 23 cover_image: '', 23 24 genres: [] as string[], 24 25 mature_content: false, … … 45 46 story_id: Date.now(), 46 47 ...form, 48 cover_image: form.cover_image || undefined, 47 49 user_id: currentUser.user_id, 48 50 author_username: currentUser.username, … … 124 126 <span className="text-slate-600 text-xs">{form.short_description.length}/280</span> 125 127 </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 )} 126 145 </div> 127 146 -
chapterx-frontend/src/pages/writer/WriterDashboard.tsx
r0b502c2 r99c1e45 1 import React from 'react'1 import React, { useEffect } from 'react' 2 2 import { useNavigate } from 'react-router-dom' 3 import { Plus, BookOpen, Eye,Heart, MessageCircle, TrendingUp, Bell } from 'lucide-react'3 import { Plus, BookOpen, Heart, MessageCircle, TrendingUp, Bell } from 'lucide-react' 4 4 import { useAuthStore } from '../../store/authStore' 5 5 import { useStoryStore } from '../../store/storyStore' … … 22 22 const navigate = useNavigate() 23 23 const { currentUser } = useAuthStore() 24 const { stories, collaborations } = useStoryStore()24 const { stories, collaborations, fetchStories } = useStoryStore() 25 25 const { notifications } = useNotificationStore() 26 27 useEffect(() => { fetchStories() }, []) 26 28 27 29 if (!currentUser) return null … … 34 36 const published = myStories.filter(s => s.status === 'published') 35 37 const drafts = myStories.filter(s => s.status === 'draft') 36 const totalViews = myStories.reduce((acc, s) => acc + s.total_views, 0)37 38 const totalLikes = myStories.reduce((acc, s) => acc + s.total_likes, 0) 38 39 … … 56 57 57 58 {/* Stats */} 58 <div className="grid grid-cols-2 lg:grid-cols- 4gap-4 mb-8">59 <div className="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-8"> 59 60 {[ 60 61 { 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' },62 62 { icon: <Heart size={20} className="text-rose-300" />, label: 'Total Likes', value: totalLikes.toLocaleString(), sub: 'Across all stories', color: 'bg-rose-500/20' }, 63 63 { icon: <TrendingUp size={20} className="text-emerald-300" />, label: 'Drafts', value: drafts.length, sub: 'In progress', color: 'bg-emerald-500/20' }, … … 108 108 <div className="flex items-center gap-3 text-slate-500 text-xs"> 109 109 {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>111 110 <span className="flex items-center gap-1"><Heart size={11} /> {story.total_likes}</span> 112 111 <span className="flex items-center gap-1"><MessageCircle size={11} /> {story.total_comments}</span> … … 155 154 <TrendingUp size={18} className="text-indigo-400" /> 156 155 <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>158 156 </div> 159 <StoryAnalytics />157 <StoryAnalytics stories={myStories} /> 160 158 </div> 161 159 )} -
chapterx-frontend/src/store/authStore.ts
r0b502c2 r99c1e45 2 2 import { persist } from 'zustand/middleware' 3 3 import { User, UserRole } from '../types' 4 import { mockUsers } from '../data/mockData'5 4 import axios from 'axios' 6 5 … … 153 152 const res = await axios.get(`${API_BASE}/users`) 154 153 const data: any[] = res.data?.users ?? res.data ?? [] 154 const existing = get().allUsers 155 155 const users: User[] = data.map((u: any) => ({ 156 156 user_id: u.id, … … 163 163 follower_count: 0, 164 164 following_count: 0, 165 bio: existing.find(e => e.user_id === u.id)?.bio, 165 166 })) 166 167 set({ allUsers: users }) -
chapterx-frontend/src/store/storyStore.ts
r0b502c2 r99c1e45 138 138 story_id: s.id, 139 139 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, 143 144 mature_content: s.matureContent, 144 145 status: 'published' as StoryStatus, … … 203 204 addStory: async (story) => { 204 205 set(state => ({ stories: [...state.stories, story] })) 206 const imageUrl = story.cover_image?.startsWith('http') ? story.cover_image : null 205 207 const res = await axios.post(`${API}/stories`, { 206 208 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, 209 212 content: story.content, 210 213 userId: story.user_id, … … 233 236 const story = get().stories.find(s => s.story_id === id) 234 237 if (!story) return 238 const rawImage = partial.cover_image ?? story.cover_image ?? null 239 const imageUrl = rawImage?.startsWith('http') ? rawImage : null 235 240 await axios.put(`${API}/stories/${id}`, { 236 241 id, 237 242 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, 240 246 content: partial.content ?? story.content, 241 247 }, { headers: getAuthHeaders() }) … … 335 341 }, 336 342 337 incrementViewCount: (chapterId) => 343 incrementViewCount: (chapterId) => { 344 const chapter = get().chapters.find(c => c.chapter_id === chapterId) 338 345 set(state => ({ 339 346 chapters: state.chapters.map(c => 340 347 c.chapter_id === chapterId ? { ...c, view_count: c.view_count + 1 } : c 341 348 ), 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 }, 343 355 344 356 addComment: (comment) =>
Note:
See TracChangeset
for help on using the changeset viewer.
