11import { useState , useEffect , useMemo , useCallback } from 'react' ;
2- import { MessageSquare , Send , Trash2 } from 'lucide-react' ;
2+ import { MessageSquare , Send , Trash2 , ChevronDown } from 'lucide-react' ;
33import { useAuth } from '../context/AuthContext' ;
44import { api } from '../api/client' ;
55import PropTypes from 'prop-types' ;
6+
67const VALID_TUTORIAL_ID = / ^ [ a - z A - Z 0 - 9 _ - ] + $ / ;
8+ const COMMENTS_PER_PAGE = 20 ;
79
810const Comments = ( { tutorialId } ) => {
911 const [ comments , setComments ] = useState ( [ ] ) ;
1012 const [ newComment , setNewComment ] = useState ( '' ) ;
1113 const [ isLoading , setIsLoading ] = useState ( false ) ;
1214 const [ loadingComments , setLoadingComments ] = useState ( false ) ;
1315 const [ loadError , setLoadError ] = useState ( null ) ;
16+ const [ offset , setOffset ] = useState ( 0 ) ;
17+ const [ hasMore , setHasMore ] = useState ( true ) ;
18+
1419 const { isAuthenticated, user } = useAuth ( ) ;
1520 const isAdmin = Boolean ( user && user . role === 'admin' ) ;
21+
1622 const normalizedTutorialId = useMemo ( ( ) => {
1723 if ( typeof tutorialId !== 'string' ) {
1824 return null ;
@@ -24,79 +30,87 @@ const Comments = ({ tutorialId }) => {
2430 return trimmed ;
2531 } , [ tutorialId ] ) ;
2632
27- const loadComments = useCallback ( async ( ) => {
33+ const loadComments = useCallback ( async ( shouldReset = false ) => {
2834 if ( ! normalizedTutorialId ) {
2935 setComments ( [ ] ) ;
3036 setLoadError ( new Error ( 'Kommentare für diese Ressource sind deaktiviert.' ) ) ;
3137 return ;
3238 }
39+
3340 setLoadingComments ( true ) ;
3441 setLoadError ( null ) ;
42+
3543 try {
36- // Fetch comments from the API using the tutorial ID
37- const data = await api . listTutorialComments ( normalizedTutorialId ) ;
38- // Ensure we always have an array, even if API returns unexpected data
39- setComments ( Array . isArray ( data ) ? data : [ ] ) ;
44+ const currentOffset = shouldReset ? 0 : offset ;
45+ const data = await api . listTutorialComments ( normalizedTutorialId , {
46+ limit : COMMENTS_PER_PAGE ,
47+ offset : currentOffset
48+ } ) ;
49+
50+ const newComments = Array . isArray ( data ) ? data : [ ] ;
51+
52+ setComments ( prev => shouldReset ? newComments : [ ...prev , ...newComments ] ) ;
53+ setOffset ( prev => shouldReset ? newComments . length : prev + newComments . length ) ;
54+ setHasMore ( newComments . length === COMMENTS_PER_PAGE ) ;
55+
4056 } catch ( error ) {
41- // Log error for debugging but don't expose to user
4257 console . error ( 'Failed to load comments:' , error ) ;
43- // Set empty array to maintain consistent state
44- setComments ( [ ] ) ;
58+ if ( shouldReset ) setComments ( [ ] ) ;
4559 setLoadError ( error ) ;
60+ } finally {
61+ setLoadingComments ( false ) ;
4662 }
47- setLoadingComments ( false ) ;
48- } , [ normalizedTutorialId ] ) ;
63+ } , [ normalizedTutorialId , offset ] ) ;
4964
65+ // Initial load
5066 useEffect ( ( ) => {
51- loadComments ( ) ;
52- } , [ loadComments ] ) ;
67+ loadComments ( true ) ;
68+ // eslint-disable-next-line react-hooks/exhaustive-deps
69+ } , [ normalizedTutorialId ] ) ;
70+
71+ const handleLoadMore = ( ) => {
72+ loadComments ( false ) ;
73+ } ;
74+
5375 const handleSubmit = async ( e ) => {
54- // Prevent default form submission behavior
5576 e . preventDefault ( ) ;
56- // Validate form data and user permissions
5777 if ( ! canManageComments || ! newComment . trim ( ) ) return ;
58- // Set loading state to prevent duplicate submissions
78+
5979 setIsLoading ( true ) ;
6080 try {
61- // Submit comment to API
6281 await api . createComment ( normalizedTutorialId , newComment ) ;
63- // Clear form for next comment
6482 setNewComment ( '' ) ;
65- // Refresh comments list to show new comment
66- await loadComments ( ) ;
83+ // Reset and reload to show the new comment at the top (assuming backend sorts by newest)
84+ await loadComments ( true ) ;
6785 } catch ( error ) {
68- // Log error for debugging but don't expose sensitive info to user
6986 console . error ( 'Failed to post comment:' , error ) ;
7087 } finally {
71- // Always reset loading state
7288 setIsLoading ( false ) ;
7389 }
7490 } ;
91+
7592 const handleDelete = async ( commentId ) => {
76- // Double-check admin permissions
7793 if ( ! canManageComments ) return ;
78- // Show confirmation dialog to prevent accidental deletion
7994 if ( typeof window !== 'undefined' && ! window . confirm ( 'Kommentar wirklich löschen?' ) ) return ;
95+
8096 try {
81- // Delete comment from API
8297 await api . deleteComment ( commentId ) ;
83- // Refresh comments list to remove deleted comment
84- await loadComments ( ) ;
98+ // Remove from local state to avoid full reload
99+ setComments ( prev => prev . filter ( c => c . id !== commentId ) ) ;
85100 } catch ( error ) {
86- // Log error for debugging
87101 console . error ( 'Failed to delete comment:' , error ) ;
88102 }
89103 } ;
90- const canManageComments = isAdmin && normalizedTutorialId ;
104+
105+ const canManageComments = isAuthenticated && normalizedTutorialId ;
91106
92107 return (
93108 < div className = "mt-12 pt-8 border-t border-gray-200 dark:border-gray-700" >
94- { }
95109 < h2 className = "text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6 flex items-center gap-2" >
96110 < MessageSquare className = "w-6 h-6" />
97- Kommentare ( { comments . length } )
111+ Kommentare
98112 </ h2 >
99- { }
113+
100114 { canManageComments && (
101115 < form onSubmit = { handleSubmit } className = "mb-8" >
102116 < textarea
@@ -107,13 +121,10 @@ const Comments = ({ tutorialId }) => {
107121 rows = { 4 }
108122 maxLength = { 1000 }
109123 />
110- { }
111124 < div className = "mt-2 flex justify-between items-center" >
112- { }
113125 < span className = "text-sm text-gray-500 dark:text-gray-400" >
114126 { newComment . length } /1000
115127 </ span >
116- { }
117128 < button
118129 type = "submit"
119130 disabled = { isLoading || ! newComment . trim ( ) }
@@ -125,34 +136,19 @@ const Comments = ({ tutorialId }) => {
125136 </ div >
126137 </ form >
127138 ) }
128- { }
139+
129140 { ! isAuthenticated && (
130141 < div className = "mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-xl text-center" >
131142 < p className = "text-gray-600 dark:text-gray-400" >
132143 Bitte melde dich an, um Kommentare zu schreiben.
133144 </ p >
134145 </ div >
135146 ) }
136- { }
137- { isAuthenticated && ! isAdmin && (
138- < div className = "mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-xl text-center" >
139- < p className = "text-gray-600 dark:text-gray-400" >
140- Nur Administratoren können Kommentare hinzufügen oder löschen.
141- </ p >
142- </ div >
143- ) }
144- { }
145- { loadingComments && (
146- < div className = "mb-6 text-sm text-gray-500 dark:text-gray-400" > Kommentare werden geladen…</ div >
147- ) }
148- { loadError && ! loadingComments && (
149- < div className = "mb-6 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200" >
150- Kommentare konnten nicht geladen werden.
151- </ div >
152- ) }
153- { }
147+
148+
149+
154150 < div className = "space-y-4" >
155- { comments . length === 0 ? (
151+ { comments . length === 0 && ! loadingComments ? (
156152 < p className = "text-center text-gray-500 dark:text-gray-400 py-8" >
157153 Noch keine Kommentare. Sei der Erste!
158154 </ p >
@@ -162,19 +158,15 @@ const Comments = ({ tutorialId }) => {
162158 key = { comment . id }
163159 className = "p-4 bg-gray-50 dark:bg-gray-800 rounded-xl"
164160 >
165- { }
166161 < div className = "flex justify-between items-start mb-2" >
167162 < div >
168- { }
169163 < span className = "font-semibold text-gray-900 dark:text-gray-100" >
170164 { comment . author }
171165 </ span >
172- { }
173166 < span className = "text-sm text-gray-500 dark:text-gray-400 ml-2" >
174167 { new Date ( comment . created_at ) . toLocaleDateString ( 'de-DE' ) }
175168 </ span >
176169 </ div >
177- { }
178170 { isAdmin && (
179171 < button
180172 onClick = { ( ) => handleDelete ( comment . id ) }
@@ -185,18 +177,44 @@ const Comments = ({ tutorialId }) => {
185177 </ button >
186178 ) }
187179 </ div >
188- { }
189180 < p className = "text-gray-700 dark:text-gray-300 whitespace-pre-wrap" >
190181 { comment . content }
191182 </ p >
192183 </ div >
193184 ) )
194185 ) }
195186 </ div >
187+
188+ { loadError && (
189+ < div className = "mt-6 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200" >
190+ Kommentare konnten nicht geladen werden.
191+ </ div >
192+ ) }
193+
194+ { hasMore && (
195+ < div className = "mt-8 text-center" >
196+ < button
197+ onClick = { handleLoadMore }
198+ disabled = { loadingComments }
199+ className = "inline-flex items-center gap-2 px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
200+ >
201+ { loadingComments ? (
202+ 'Laden...'
203+ ) : (
204+ < >
205+ < ChevronDown className = "w-4 h-4" />
206+ Mehr Kommentare laden
207+ </ >
208+ ) }
209+ </ button >
210+ </ div >
211+ ) }
196212 </ div >
197213 ) ;
198214} ;
215+
199216Comments . propTypes = {
200217 tutorialId : PropTypes . string . isRequired ,
201218} ;
219+
202220export default Comments ;
0 commit comments