Implementiere Kurator-Kommentar-System
- Benutzer können nach Rätsel-Abschluss optional Nachricht an Kuratoren senden - Kommentare werden in Datenbank gespeichert und in /curator angezeigt - Neue Datenbank-Modelle: CuratorComment und CuratorCommentRecipient - API-Routen für Kommentar-Versand, Abfrage und Markierung als gelesen - Rate-Limiting: 1 Kommentar pro Spieler pro Rätsel (persistent in DB) - Sicherheitsschutz: PlayerIdentifier-Validierung, Puzzle-Validierung - Automatische Zuordnung zu Kuratoren (Genre-basiert + globale Kuratoren) - Frontend: Kommentar-Formular in Game-Komponente - Frontend: Kommentare-Anzeige in Kuratoren-Seite mit Markierung als gelesen - Übersetzungen für DE und EN hinzugefügt
This commit is contained in:
@@ -37,6 +37,25 @@ interface CuratorInfo {
|
||||
specialIds: number[];
|
||||
}
|
||||
|
||||
interface CuratorComment {
|
||||
id: number;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
readAt: string | null;
|
||||
puzzle: {
|
||||
id: number;
|
||||
date: string;
|
||||
song: {
|
||||
title: string;
|
||||
artist: string;
|
||||
};
|
||||
genre: {
|
||||
id: number;
|
||||
name: any;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
function getCuratorAuthHeaders() {
|
||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||||
@@ -98,6 +117,11 @@ export default function CuratorPageClient() {
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||
|
||||
// Comments state
|
||||
const [comments, setComments] = useState<CuratorComment[]>([]);
|
||||
const [loadingComments, setLoadingComments] = useState(false);
|
||||
const [showComments, setShowComments] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||
@@ -109,6 +133,12 @@ export default function CuratorPageClient() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComments && isAuthenticated) {
|
||||
fetchComments();
|
||||
}
|
||||
}, [showComments, isAuthenticated]);
|
||||
|
||||
const bootstrapCuratorData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -118,6 +148,42 @@ export default function CuratorPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
setLoadingComments(true);
|
||||
const res = await fetch('/api/curator-comments', {
|
||||
headers: getCuratorAuthHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: CuratorComment[] = await res.json();
|
||||
setComments(data);
|
||||
} else {
|
||||
setMessage(t('loadCommentsError'));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(t('loadCommentsError'));
|
||||
} finally {
|
||||
setLoadingComments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markCommentAsRead = async (commentId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/curator-comments/${commentId}/read`, {
|
||||
method: 'POST',
|
||||
headers: getCuratorAuthHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Update local state
|
||||
setComments(comments.map(c =>
|
||||
c.id === commentId ? { ...c, readAt: new Date().toISOString() } : c
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking comment as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCuratorInfo = async () => {
|
||||
const res = await fetch('/api/curator/me', {
|
||||
headers: getCuratorAuthHeaders(),
|
||||
@@ -1325,6 +1391,109 @@ export default function CuratorPageClient() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Comments Section */}
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
|
||||
{t('commentsTitle')} ({comments.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowComments(!showComments);
|
||||
if (!showComments && comments.length === 0) {
|
||||
fetchComments();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '0.4rem 0.8rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #d1d5db',
|
||||
background: showComments ? '#3b82f6' : '#fff',
|
||||
color: showComments ? '#fff' : '#000',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{showComments ? t('hideComments') : t('showComments')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showComments && (
|
||||
<>
|
||||
{loadingComments ? (
|
||||
<p>{t('loadingComments')}</p>
|
||||
) : comments.length === 0 ? (
|
||||
<p>{t('noComments')}</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{comments.map(comment => {
|
||||
const genreName = comment.puzzle.genre
|
||||
? typeof comment.puzzle.genre.name === 'string'
|
||||
? comment.puzzle.genre.name
|
||||
: comment.puzzle.genre.name?.de ?? comment.puzzle.genre.name?.en
|
||||
: null;
|
||||
const isRead = comment.readAt !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: `1px solid ${isRead ? '#d1d5db' : '#3b82f6'}`,
|
||||
background: isRead ? '#f9fafb' : '#eff6ff',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isRead) {
|
||||
markCommentAsRead(comment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isRead && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
right: '0.5rem',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: '#3b82f6',
|
||||
}}
|
||||
title={t('unreadComment')}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', alignItems: 'center' }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: '0.9rem' }}>
|
||||
{t('commentFromPuzzle')} #{comment.puzzle.id}
|
||||
</strong>
|
||||
{genreName && (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
({t('commentGenre')}: {genreName})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.8rem', color: '#6b7280' }}>
|
||||
{new Date(comment.createdAt).toLocaleDateString()} {new Date(comment.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.5rem', fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
{comment.puzzle.song.title} - {comment.puzzle.song.artist}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>
|
||||
{comment.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user