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:
Hördle Bot
2025-12-03 22:46:02 +01:00
parent 863539a5e9
commit cd564b5d8c
9 changed files with 740 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import type { ExternalPuzzle } from '@/lib/externalPuzzles';
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
import { sendGotifyNotification, submitRating } from '../app/actions';
import { getOrCreatePlayerId } from '@/lib/playerId';
// Plausible Analytics
declare global {
@@ -60,6 +61,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
const [commentText, setCommentText] = useState('');
const [commentSending, setCommentSending] = useState(false);
const [commentSent, setCommentSent] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null);
useEffect(() => {
const updateCountdown = () => {
@@ -134,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
} else {
setHasRated(false);
}
// Check if comment already sent for this puzzle
const playerIdentifier = getOrCreatePlayerId();
if (playerIdentifier) {
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
if (commentedPuzzles.includes(dailyPuzzle.id)) {
setCommentSent(true);
}
}
}
}, [dailyPuzzle]);
@@ -300,6 +314,59 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
const handleCommentSubmit = async () => {
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) {
return;
}
setCommentSending(true);
setCommentError(null);
try {
const playerIdentifier = getOrCreatePlayerId();
if (!playerIdentifier) {
throw new Error('Could not get player identifier');
}
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
const genreId = isSpecial ? null : null; // API will determine from puzzle
const response = await fetch('/api/curator-comment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
puzzleId: dailyPuzzle.id,
genreId: genreId,
message: commentText.trim(),
playerIdentifier: playerIdentifier
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send comment');
}
setCommentSent(true);
setCommentText('');
// Store in localStorage that comment was sent
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
commentedPuzzles.push(dailyPuzzle.id);
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
}
} catch (error) {
console.error('Error sending comment:', error);
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
} finally {
setCommentSending(false);
}
};
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
const handleShare = async () => {
@@ -532,6 +599,66 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</button>
</div>
{/* Comment Form */}
{!commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{t('sendComment')}
</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
{t('commentHelp')}
</p>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('commentPlaceholder')}
maxLength={2000}
style={{
width: '100%',
minHeight: '100px',
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid var(--border)',
fontSize: '0.9rem',
fontFamily: 'inherit',
resize: 'vertical',
marginBottom: '0.5rem'
}}
disabled={commentSending}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/2000
</span>
{commentError && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
{commentError}
</span>
)}
</div>
<button
onClick={handleCommentSubmit}
disabled={!commentText.trim() || commentSending || commentSent}
className="btn-primary"
style={{
width: '100%',
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
}}
>
{commentSending ? t('sending') : t('sendComment')}
</button>
</div>
)}
{commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}>
{t('commentSent')}
</p>
</div>
)}
{statistics && <Statistics statistics={statistics} />}
</div>
)}