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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user