Skip to content

Commit 7d37950

Browse files
committed
feat: Implement comment and search functionality with a new frontend component, backend handlers, API client, and Dockerfile.
1 parent 279470a commit 7d37950

5 files changed

Lines changed: 102 additions & 74 deletions

File tree

backend/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
# Use latest stable Rust for building
3939
# Provides access to latest Rust features and optimizations
40-
FROM rust:latest AS builder
40+
FROM rust:1.82-bookworm AS builder
4141

4242
# Set working directory for all subsequent commands
4343
WORKDIR /app

backend/src/handlers/comments.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,8 @@ pub async fn create_comment(
181181
Path(tutorial_id): Path<String>,
182182
Json(payload): Json<CreateCommentRequest>,
183183
) -> Result<Json<Comment>, (StatusCode, Json<ErrorResponse>)> {
184-
if claims.role != "admin" {
185-
return Err((
186-
StatusCode::FORBIDDEN,
187-
Json(ErrorResponse {
188-
error: "Insufficient permissions".to_string(),
189-
}),
190-
));
191-
}
184+
// Allow any authenticated user to comment
185+
// if claims.role != "admin" { ... } check removed
192186

193187
if let Err(e) = validate_tutorial_id(&tutorial_id) {
194188
return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })));

backend/src/handlers/search.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ fn sanitize_fts_query(raw: &str) -> Result<String, String> {
5757
// Keep only safe characters for FTS5 queries
5858
let sanitized: String = token
5959
.chars()
60-
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '*' | '-' | '_'))
60+
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '*' | '-' | '_' | '.' | '+' | '#' | '@'))
6161
.collect();
6262
if sanitized.is_empty() {
6363
None
@@ -71,7 +71,18 @@ fn sanitize_fts_query(raw: &str) -> Result<String, String> {
7171
if tokens.is_empty() {
7272
Err("Search query must contain at least one searchable character".to_string())
7373
} else {
74-
Ok(tokens.join(" "))
74+
// Join tokens with AND (implicit in FTS5)
75+
// Append * to the last token for prefix matching
76+
let mut query_parts = Vec::new();
77+
for (i, token) in tokens.iter().enumerate() {
78+
if i == tokens.len() - 1 {
79+
// Last token: add prefix matching
80+
query_parts.push(format!("\"{}\"*", token));
81+
} else {
82+
query_parts.push(format!("\"{}\"", token));
83+
}
84+
}
85+
Ok(query_parts.join(" "))
7586
}
7687
}
7788

src/api/client.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const getApiBaseUrl = () => {
1717
}
1818
}
1919
}
20-
return 'http://localhost:8489/api'
20+
return typeof window !== 'undefined' ? '/api' : 'http://localhost:8489/api'
2121
}
2222
const API_BASE_URL = getApiBaseUrl()
2323
const isBinaryBody = (body) => {
@@ -280,12 +280,17 @@ class ApiClient {
280280
...options,
281281
})
282282
}
283-
async listTutorialComments(tutorialId, options = {}) {
283+
async listTutorialComments(tutorialId, { limit, offset, ...options } = {}) {
284284
if (!tutorialId) {
285285
throw new Error('tutorialId is required')
286286
}
287287
const encodedTutorialId = encodeURIComponent(tutorialId)
288-
return this.request(`/tutorials/${encodedTutorialId}/comments`, options)
288+
const params = new URLSearchParams()
289+
if (limit !== undefined) params.append('limit', limit)
290+
if (offset !== undefined) params.append('offset', offset)
291+
const queryString = params.toString()
292+
const endpoint = `/tutorials/${encodedTutorialId}/comments${queryString ? `?${queryString}` : ''}`
293+
return this.request(endpoint, options)
289294
}
290295
async createComment(tutorialId, content, options = {}) {
291296
if (!tutorialId) {

src/components/Comments.jsx

Lines changed: 78 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
import { useState, useEffect, useMemo, useCallback } from 'react';
2-
import { MessageSquare, Send, Trash2 } from 'lucide-react';
2+
import { MessageSquare, Send, Trash2, ChevronDown } from 'lucide-react';
33
import { useAuth } from '../context/AuthContext';
44
import { api } from '../api/client';
55
import PropTypes from 'prop-types';
6+
67
const VALID_TUTORIAL_ID = /^[a-zA-Z0-9_-]+$/;
8+
const COMMENTS_PER_PAGE = 20;
79

810
const 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+
199216
Comments.propTypes = {
200217
tutorialId: PropTypes.string.isRequired,
201218
};
219+
202220
export default Comments;

0 commit comments

Comments
 (0)