diff --git a/apps/web/src/features/tracks/components/CommentSection.tsx b/apps/web/src/features/tracks/components/CommentSection.tsx index b883276e2..f833aac3a 100644 --- a/apps/web/src/features/tracks/components/CommentSection.tsx +++ b/apps/web/src/features/tracks/components/CommentSection.tsx @@ -1,267 +1,5 @@ -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { - getComments, - createComment, - type CommentListResponse, -} from '../services/commentService'; -import { useUser } from '@/features/auth/hooks/useUser'; -import { useToast } from '@/hooks/useToast'; -import { useIsRateLimited } from '@/hooks/useIsRateLimited'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { MessageCircle, Send } from 'lucide-react'; -import { Spinner } from '@/components/ui/Spinner'; -import { LoadingSpinner } from '@/components/ui/loading-spinner'; -import { CommentThread } from './CommentThread'; -import type { TrackComment } from '../services/commentService'; -import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; - -// FE-PAGE-007: Complete Track Detail page implementation - Comments Section - -interface CommentSectionProps { - trackId: string; -} - -export function CommentSection({ trackId }: CommentSectionProps) { - const { data: user } = useUser(); - const toast = useToast(); - const queryClient = useQueryClient(); - const isRateLimited = useIsRateLimited(); - const [newComment, setNewComment] = useState(''); - const [mutationError, setMutationError] = useState(null); - const [page, setPage] = useState(1); - const limit = 20; - - const { - data: commentsData, - isLoading, - error, - } = useQuery({ - queryKey: ['trackComments', trackId, page], - queryFn: () => getComments(trackId, page, limit), - enabled: !!trackId, - }); - - // Action 3.4.1.3: Store last comment content for retry - const [lastCommentContent, setLastCommentContent] = useState(''); - const [retryCount, setRetryCount] = useState(0); - - // Action 4.4.1.5: Add optimistic update - const createCommentMutation = useMutation({ - mutationFn: (content: string) => createComment(trackId, content), - onMutate: async (content: string) => { - // Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] }); - - // Snapshot previous value - const previousComments = queryClient.getQueryData([ - 'trackComments', - trackId, - page, - ]); - - // Optimistically add new comment - if (previousComments && user) { - const optimisticComment: TrackComment = { - id: `temp-${Date.now()}`, - track_id: trackId, - user_id: user.id, - content: content.trim(), - is_edited: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - user: { - id: user.id, - username: user.username || '', - avatar: user.avatar_url, - }, - }; - - queryClient.setQueryData( - ['trackComments', trackId, page], - { - ...previousComments, - comments: [optimisticComment, ...previousComments.comments], - total: (previousComments.total || 0) + 1, - }, - ); - } - - return { previousComments }; - }, - onError: (error: any, _content, context) => { - // Rollback on error - if (context?.previousComments) { - queryClient.setQueryData( - ['trackComments', trackId, page], - context.previousComments, - ); - } - setMutationError( - new Error(error.message || 'Erreur lors de la publication'), - ); - // Store content for retry - setLastCommentContent(newComment.trim()); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] }); - setNewComment(''); - setMutationError(null); - setRetryCount(0); - setLastCommentContent(''); - toast.success('Commentaire publié'); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!newComment.trim() || !user) return; - setLastCommentContent(newComment.trim()); - createCommentMutation.mutate(newComment.trim()); - }; - - // Action 3.4.1.3: Retry handler for failed mutations - const handleRetry = async () => { - if (!lastCommentContent || retryCount >= 3) return; - - setRetryCount((prev) => prev + 1); - try { - await createCommentMutation.mutateAsync(lastCommentContent); - } catch (error) { - // Error will be handled by mutation's onError - } - }; - - // Filter to show only top-level comments (no parent_id) - const topLevelComments = - commentsData?.comments?.filter((c: TrackComment) => !c.parent_id) || []; - const total = commentsData?.total || 0; - const totalPages = Math.ceil(total / limit); - - return ( - - - - - Commentaires ({commentsData?.total || 0}) - - - - {mutationError && ( - { - setMutationError(null); - setRetryCount(0); - setLastCommentContent(''); - }} - /> - )} - {/* Comment Form */} - {user ? ( -
- setNewComment(e.target.value)} - placeholder="Écrire un commentaire..." - maxLength={500} - /> - -
- ) : ( -

- Connectez-vous pour commenter -

- )} - - {/* Comments List */} - {isLoading ? ( -
- -
- ) : error ? ( - - queryClient.invalidateQueries({ - queryKey: ['trackComments', trackId], - }) - } - /> - ) : topLevelComments.length === 0 ? ( -
- Aucun commentaire pour le moment. Soyez le premier à commenter ! -
- ) : ( -
- {topLevelComments.map((comment: TrackComment) => ( - - ))} - - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {page} sur {totalPages} - - -
- )} -
- )} -
-
- ); -} +/** + * Re-export from comments module. + * FE-PAGE-007: Comment section — implementation in ./comments + */ +export { CommentSection } from './comments'; diff --git a/apps/web/src/features/tracks/components/comments/CommentEditor.tsx b/apps/web/src/features/tracks/components/comments/CommentEditor.tsx new file mode 100644 index 000000000..1fa76358a --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentEditor.tsx @@ -0,0 +1,59 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Send } from 'lucide-react'; +import { Spinner } from '@/components/ui/Spinner'; +import { cn } from '@/lib/utils'; + +export interface CommentEditorProps { + value: string; + onChange: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + placeholder?: string; + maxLength?: number; + isPending?: boolean; + isRateLimited?: boolean; + hasUser: boolean; + className?: string; +} + +export function CommentEditor({ + value, + onChange, + onSubmit, + placeholder = 'Écrire un commentaire...', + maxLength = 500, + isPending = false, + isRateLimited = false, + hasUser, + className, +}: CommentEditorProps) { + const disabled = + !value.trim() || isPending || isRateLimited; + const showForm = hasUser; + + return ( +
+ {showForm ? ( +
+ onChange(e.target.value)} + placeholder={placeholder} + maxLength={maxLength} + /> + +
+ ) : ( +

+ Connectez-vous pour commenter +

+ )} +
+ ); +} diff --git a/apps/web/src/features/tracks/components/comments/CommentList.tsx b/apps/web/src/features/tracks/components/comments/CommentList.tsx new file mode 100644 index 000000000..0de16e0b2 --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentList.tsx @@ -0,0 +1,50 @@ +import { motion } from 'framer-motion'; +import { CommentThread } from '../comment-thread'; +import type { TrackComment } from '../../services/commentService'; +import { cn } from '@/lib/utils'; + +const listVariants = { + visible: { + transition: { + staggerChildren: 0.05, + delayChildren: 0.02, + }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 8 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, +}; + +export interface CommentListProps { + comments: TrackComment[]; + trackId: string; + className?: string; +} + +export function CommentList({ + comments, + trackId, + className, +}: CommentListProps) { + return ( + + {comments.map((comment) => ( + + + + ))} + + ); +} diff --git a/apps/web/src/features/tracks/components/comments/CommentSection.tsx b/apps/web/src/features/tracks/components/comments/CommentSection.tsx new file mode 100644 index 000000000..f94449a53 --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentSection.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + getComments, + createComment, + type CommentListResponse, +} from '../../services/commentService'; +import { useUser } from '@/features/auth/hooks/useUser'; +import { useToast } from '@/hooks/useToast'; +import { useIsRateLimited } from '@/hooks/useIsRateLimited'; +import { Card, CardContent } from '@/components/ui/card'; +import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; +import type { TrackComment } from '../../services/commentService'; +import { CommentSectionHeader } from './CommentSectionHeader'; +import { CommentSectionSkeleton } from './CommentSectionSkeleton'; +import { CommentSectionEmpty } from './CommentSectionEmpty'; +import { CommentSectionError } from './CommentSectionError'; +import { CommentEditor } from './CommentEditor'; +import { CommentList } from './CommentList'; +import { CommentSectionPagination } from './CommentSectionPagination'; + +export interface CommentSectionProps { + trackId: string; +} + +const LIMIT = 20; + +export function CommentSection({ trackId }: CommentSectionProps) { + const { data: user } = useUser(); + const toast = useToast(); + const queryClient = useQueryClient(); + const isRateLimited = useIsRateLimited(); + const [newComment, setNewComment] = useState(''); + const [mutationError, setMutationError] = useState(null); + const [page, setPage] = useState(1); + const [lastCommentContent, setLastCommentContent] = useState(''); + const [retryCount, setRetryCount] = useState(0); + + const { + data: commentsData, + isLoading, + error, + } = useQuery({ + queryKey: ['trackComments', trackId, page], + queryFn: () => getComments(trackId, page, LIMIT), + enabled: !!trackId, + }); + + const createCommentMutation = useMutation({ + mutationFn: (content: string) => createComment(trackId, content), + onMutate: async (content: string) => { + await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] }); + const previousComments = queryClient.getQueryData([ + 'trackComments', + trackId, + page, + ]); + + if (previousComments && user) { + const optimisticComment: TrackComment = { + id: `temp-${Date.now()}`, + track_id: trackId, + user_id: user.id, + content: content.trim(), + is_edited: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + user: { + id: user.id, + username: user.username || '', + avatar: user.avatar_url, + }, + }; + + queryClient.setQueryData( + ['trackComments', trackId, page], + { + ...previousComments, + comments: [optimisticComment, ...previousComments.comments], + total: (previousComments.total || 0) + 1, + }, + ); + } + + return { previousComments }; + }, + onError: (err: Error, _content, context) => { + if (context?.previousComments) { + queryClient.setQueryData( + ['trackComments', trackId, page], + context.previousComments, + ); + } + setMutationError( + new Error(err.message || 'Erreur lors de la publication'), + ); + setLastCommentContent(newComment.trim()); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] }); + setNewComment(''); + setMutationError(null); + setRetryCount(0); + setLastCommentContent(''); + toast.success('Commentaire publié'); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!newComment.trim() || !user) return; + setLastCommentContent(newComment.trim()); + createCommentMutation.mutate(newComment.trim()); + }; + + const handleRetry = async () => { + if (!lastCommentContent || retryCount >= 3) return; + setRetryCount((prev) => prev + 1); + try { + await createCommentMutation.mutateAsync(lastCommentContent); + } catch { + // Handled by mutation onError + } + }; + + const topLevelComments = + commentsData?.comments?.filter((c: TrackComment) => !c.parent_id) || []; + const total = commentsData?.total || 0; + const totalPages = Math.ceil(total / LIMIT); + + return ( + + + + {mutationError && ( + { + setMutationError(null); + setRetryCount(0); + setLastCommentContent(''); + }} + /> + )} + + + + {isLoading ? ( + + ) : error ? ( + + queryClient.invalidateQueries({ + queryKey: ['trackComments', trackId], + }) + } + /> + ) : topLevelComments.length === 0 ? ( + + ) : ( + <> + + + + )} + + + ); +} diff --git a/apps/web/src/features/tracks/components/comments/CommentSectionEmpty.tsx b/apps/web/src/features/tracks/components/comments/CommentSectionEmpty.tsx new file mode 100644 index 000000000..524756c3d --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentSectionEmpty.tsx @@ -0,0 +1,19 @@ +import { cn } from '@/lib/utils'; + +export interface CommentSectionEmptyProps { + className?: string; +} + +export function CommentSectionEmpty({ className }: CommentSectionEmptyProps) { + return ( +
+ Aucun commentaire pour le moment. Soyez le premier à commenter ! +
+ ); +} diff --git a/apps/web/src/features/tracks/components/comments/CommentSectionError.tsx b/apps/web/src/features/tracks/components/comments/CommentSectionError.tsx new file mode 100644 index 000000000..f088e7fa1 --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentSectionError.tsx @@ -0,0 +1,32 @@ +import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; +import { cn } from '@/lib/utils'; + +export interface CommentSectionErrorProps { + error: Error; + resourceId?: string; + onRetry?: () => void; + className?: string; +} + +export function CommentSectionError({ + error, + resourceId, + onRetry, + className, +}: CommentSectionErrorProps) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/features/tracks/components/comments/CommentSectionHeader.tsx b/apps/web/src/features/tracks/components/comments/CommentSectionHeader.tsx new file mode 100644 index 000000000..25770d9fa --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentSectionHeader.tsx @@ -0,0 +1,22 @@ +import { MessageCircle } from 'lucide-react'; +import { CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +export interface CommentSectionHeaderProps { + count: number; + className?: string; +} + +export function CommentSectionHeader({ + count, + className, +}: CommentSectionHeaderProps) { + return ( + + + + Commentaires ({count}) + + + ); +} diff --git a/apps/web/src/features/tracks/components/comments/CommentSectionPagination.tsx b/apps/web/src/features/tracks/components/comments/CommentSectionPagination.tsx new file mode 100644 index 000000000..5614852b0 --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentSectionPagination.tsx @@ -0,0 +1,48 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export interface CommentSectionPaginationProps { + page: number; + totalPages: number; + onPageChange: (page: number) => void; + className?: string; +} + +export function CommentSectionPagination({ + page, + totalPages, + onPageChange, + className, +}: CommentSectionPaginationProps) { + if (totalPages <= 1) return null; + + return ( +
+ + + Page {page} sur {totalPages} + + +
+ ); +} diff --git a/apps/web/src/features/tracks/components/comments/CommentSectionSkeleton.tsx b/apps/web/src/features/tracks/components/comments/CommentSectionSkeleton.tsx new file mode 100644 index 000000000..0ab6744f0 --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/CommentSectionSkeleton.tsx @@ -0,0 +1,28 @@ +import { CommentThreadSkeleton } from '../comment-thread'; +import { cn } from '@/lib/utils'; + +export interface CommentSectionSkeletonProps { + /** Number of skeleton rows (default 4) */ + rows?: number; + className?: string; +} + +/** + * Skeleton for the full comment section: mimics 3–4 comment rows. + * Uses only Tailwind spacing scale and layout primitives. + */ +export function CommentSectionSkeleton({ + rows = 4, + className, +}: CommentSectionSkeletonProps) { + return ( +
+ {Array.from({ length: Math.min(Math.max(rows, 1), 6) }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/features/tracks/components/comments/index.ts b/apps/web/src/features/tracks/components/comments/index.ts new file mode 100644 index 000000000..b0b825d9b --- /dev/null +++ b/apps/web/src/features/tracks/components/comments/index.ts @@ -0,0 +1,8 @@ +export { CommentSection } from './CommentSection'; +export { CommentSectionHeader } from './CommentSectionHeader'; +export { CommentSectionSkeleton } from './CommentSectionSkeleton'; +export { CommentSectionEmpty } from './CommentSectionEmpty'; +export { CommentSectionError } from './CommentSectionError'; +export { CommentEditor } from './CommentEditor'; +export { CommentList } from './CommentList'; +export { CommentSectionPagination } from './CommentSectionPagination';